diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 0ac58c1503ec..f4b4bd287271 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -58,6 +58,11 @@ Do not flag the issues below if they appear in tests. - Flag usage of `expect.objectContaining` and other relaxed assertions, when a test expects something NOT to be included in a payload but there's no respective assertion. - Flag usage of conditionals in one test and recommend splitting up the test for the different paths. - Flag usage of loops testing multiple scenarios in one test and recommend using `(it)|(test).each` instead. +- Flag tests that are likely to introduce flakes. In our case this usually means we wait for some telemetry requests sent from an SDK. Patterns to look out for: + - Only waiting for a request, after an action is performed. Instead, start waiting, perform action, await request promise. + - Race conditions when waiting on multiple requests. Ensure that waiting checks are unique enough and don't depend on a hard order when there's a chance that telemetry can be sent in arbitrary order. + - Timeouts or sleeps in tests. Instead suggest concrete events or other signals to wait on. +- Flag usage of `getFirstEnvelope*`, `getMultipleEnvelope*` or related test helpers. These are NOT reliable anymore. Instead suggest helpers like `waitForTransaction`, `waitForError`, `waitForSpans`, etc. ## Platform-safe code diff --git a/.github/FLAKY_CI_FAILURE_TEMPLATE.md b/.github/FLAKY_CI_FAILURE_TEMPLATE.md new file mode 100644 index 000000000000..c105f6928d27 --- /dev/null +++ b/.github/FLAKY_CI_FAILURE_TEMPLATE.md @@ -0,0 +1,24 @@ +--- +title: '[Flaky CI]: {{ env.JOB_NAME }} - {{ env.TEST_NAME }}' +labels: Tests +--- + +### Flakiness Type + +Other / Unknown + +### Name of Job + +{{ env.JOB_NAME }} + +### Name of Test + +{{ env.TEST_NAME }} + +### Link to Test Run + +{{ env.RUN_LINK }} + +--- + +_This issue was automatically created._ diff --git a/.github/actions/install-playwright/action.yml b/.github/actions/install-playwright/action.yml index 8ca47ce04081..ec6ae171e925 100644 --- a/.github/actions/install-playwright/action.yml +++ b/.github/actions/install-playwright/action.yml @@ -18,7 +18,7 @@ runs: working-directory: ${{ inputs.cwd }} - name: Restore cached playwright binaries - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 id: playwright-cache with: path: | @@ -43,7 +43,7 @@ runs: # Only store cache on develop branch - name: Store cached playwright binaries - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 if: github.event_name == 'push' && github.ref == 'refs/heads/develop' with: path: | diff --git a/.github/actions/restore-cache/action.yml b/.github/actions/restore-cache/action.yml index 7e7a3971cd7e..1d5126fbe952 100644 --- a/.github/actions/restore-cache/action.yml +++ b/.github/actions/restore-cache/action.yml @@ -11,13 +11,13 @@ runs: steps: - name: Check dependency cache id: dep-cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ${{ env.CACHED_DEPENDENCY_PATHS }} key: ${{ inputs.dependency_cache_key }} - name: Restore build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: build-output diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cf00b5d00435..544bb7900008 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -152,15 +152,15 @@ jobs: changed_node: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry/node') }} - changed_node_overhead_action: - ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, - '@sentry-internal/node-overhead-gh-action') }} changed_deno: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry/deno') }} changed_bun: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry/bun') }} + changed_bun_integration: + ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, + '@sentry-internal/bun-integration-tests') }} changed_browser_integration: ${{ needs.job_get_metadata.outputs.changed_ci == 'true' || contains(steps.checkForAffected.outputs.affected, '@sentry-internal/browser-integration-tests') }} @@ -208,37 +208,6 @@ jobs: # Only run comparison against develop if this is a PR comparison_branch: ${{ (github.event_name == 'pull_request' && github.base_ref) || ''}} - job_node_overhead_check: - name: Node Overhead Check - needs: [job_get_metadata, job_build] - timeout-minutes: 15 - runs-on: ubuntu-24.04 - if: - (needs.job_build.outputs.changed_node == 'true' && github.event_name == 'pull_request') || - (needs.job_build.outputs.changed_node_overhead_action == 'true' && github.event_name == 'pull_request') || - needs.job_get_metadata.outputs.is_base_branch == 'true' || needs.job_get_metadata.outputs.is_release == 'true' - steps: - - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v6 - with: - ref: ${{ env.HEAD_COMMIT }} - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version-file: 'package.json' - - name: Restore caches - uses: ./.github/actions/restore-cache - with: - dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - - name: Check node overhead - uses: ./dev-packages/node-overhead-gh-action - env: - DEBUG: '1' - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - # Only run comparison against develop if this is a PR - comparison_branch: ${{ (github.event_name == 'pull_request' && github.base_ref) || ''}} - job_lint: name: Lint # Even though the linter only checks source code, not built code, it needs the built code in order check that all @@ -605,6 +574,7 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} directory: dev-packages/browser-integration-tests + enable-coverage: false name: browser-playwright-${{ matrix.bundle }}-${{ matrix.project }}${{ matrix.shard && format('-{0}', matrix.shard) || '' }} @@ -669,6 +639,7 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} directory: dev-packages/browser-integration-tests + enable-coverage: false name: browser-loader-${{ matrix.bundle }} job_check_for_faulty_dts: @@ -737,15 +708,6 @@ jobs: working-directory: dev-packages/node-integration-tests run: yarn test - - name: Parse and Upload Coverage - if: cancelled() == false - continue-on-error: true - uses: getsentry/codecov-action@main - with: - token: ${{ secrets.GITHUB_TOKEN }} - directory: dev-packages/node-integration-tests - name: node-integration-${{ matrix.node }}${{ matrix.typescript && format('-ts{0}', matrix.typescript) || '' }} - job_node_core_integration_tests: name: Node (${{ matrix.node }})${{ (matrix.typescript && format(' (TS {0})', matrix.typescript)) || '' }} Node-Core @@ -787,16 +749,6 @@ jobs: working-directory: dev-packages/node-core-integration-tests run: yarn test - - name: Parse and Upload Coverage - if: cancelled() == false - continue-on-error: true - uses: getsentry/codecov-action@main - with: - token: ${{ secrets.GITHUB_TOKEN }} - directory: dev-packages/node-core-integration-tests - name: - node-core-integration-${{ matrix.node }}${{ matrix.typescript && format('-ts{0}', matrix.typescript) || ''}} - job_cloudflare_integration_tests: name: Cloudflare Integration Tests needs: [job_get_metadata, job_build] @@ -820,6 +772,32 @@ jobs: working-directory: dev-packages/cloudflare-integration-tests run: yarn test + job_bun_integration_tests: + name: Bun Integration Tests + needs: [job_get_metadata, job_build] + if: needs.job_build.outputs.changed_bun_integration == 'true' || github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v6 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version-file: 'package.json' + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + - name: Restore caches + uses: ./.github/actions/restore-cache + with: + dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + + - name: Run integration tests + working-directory: dev-packages/bun-integration-tests + run: yarn test + job_remix_integration_tests: name: Remix (Node ${{ matrix.node }}) Tests needs: [job_get_metadata, job_build] @@ -856,15 +834,6 @@ jobs: cd packages/remix yarn test:integration:ci - - name: Parse and Upload Coverage - if: cancelled() == false - continue-on-error: true - uses: getsentry/codecov-action@main - with: - directory: packages/remix - token: ${{ secrets.GITHUB_TOKEN }} - name: ${{ matrix.node }} - job_e2e_prepare: name: Prepare E2E tests # We want to run this if: @@ -970,7 +939,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: matrix.test-application == 'deno' + if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' uses: denoland/setup-deno@v2.0.3 with: deno-version: v2.1.5 @@ -990,11 +959,6 @@ jobs: if: steps.restore-tarball-cache.outputs.cache-hit != 'true' run: yarn build:tarball - - name: Get node version - id: versions - run: | - echo "echo node=$(jq -r '.volta.node' dev-packages/e2e-tests/package.json)" >> $GITHUB_OUTPUT - - name: Validate Verdaccio run: yarn test:validate working-directory: dev-packages/e2e-tests @@ -1002,8 +966,6 @@ jobs: - name: Prepare Verdaccio run: yarn test:prepare working-directory: dev-packages/e2e-tests - env: - E2E_TEST_PUBLISH_SCRIPT_NODE_VERSION: ${{ steps.versions.outputs.node }} - name: Copy to temp run: yarn ci:copy-to-temp ./test-applications/${{ matrix.test-application }} ${{ runner.temp }}/test-application @@ -1053,15 +1015,6 @@ jobs: retention-days: 7 if-no-files-found: ignore - - name: Parse and Upload Coverage - if: cancelled() == false - continue-on-error: true - uses: getsentry/codecov-action@main - with: - directory: dev-packages/e2e-tests - token: ${{ secrets.GITHUB_TOKEN }} - name: e2e-${{ matrix.test-application }} - # - We skip optional tests on release branches job_optional_e2e_tests: name: E2E ${{ matrix.label || matrix.test-application }} Test (optional) @@ -1116,11 +1069,6 @@ jobs: if: steps.restore-tarball-cache.outputs.cache-hit != 'true' run: yarn build:tarball - - name: Get node version - id: versions - run: | - echo "echo node=$(jq -r '.volta.node' dev-packages/e2e-tests/package.json)" >> $GITHUB_OUTPUT - - name: Validate Verdaccio run: yarn test:validate working-directory: dev-packages/e2e-tests @@ -1128,8 +1076,6 @@ jobs: - name: Prepare Verdaccio run: yarn test:prepare working-directory: dev-packages/e2e-tests - env: - E2E_TEST_PUBLISH_SCRIPT_NODE_VERSION: ${{ steps.versions.outputs.node }} - name: Copy to temp run: yarn ci:copy-to-temp ./test-applications/${{ matrix.test-application }} ${{ runner.temp }}/test-application @@ -1178,6 +1124,7 @@ jobs: job_node_integration_tests, job_node_core_integration_tests, job_cloudflare_integration_tests, + job_bun_integration_tests, job_browser_playwright_tests, job_browser_loader_tests, job_remix_integration_tests, @@ -1192,7 +1139,28 @@ jobs: # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-24.04 + permissions: + issues: write + checks: read steps: + - name: Check out current commit + if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') + uses: actions/checkout@v6 + with: + sparse-checkout: | + .github + scripts + + - name: Create issues for failed jobs + if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') + uses: actions/github-script@v7 + with: + script: | + const { default: run } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/report-ci-failures.mjs` + ); + await run({ github, context, core }); + - name: Check for failures if: cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: | diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index f9c74b5f344f..ac4e1df08841 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -140,11 +140,6 @@ jobs: path: ${{ env.CACHED_BUILD_PATHS }} key: canary-${{ env.HEAD_COMMIT }} - - name: Get node version - id: versions - run: | - echo "echo node=$(jq -r '.volta.node' dev-packages/e2e-tests/package.json)" >> $GITHUB_OUTPUT - - name: Validate Verdaccio run: yarn test:validate working-directory: dev-packages/e2e-tests @@ -152,8 +147,6 @@ jobs: - name: Prepare Verdaccio run: yarn test:prepare working-directory: dev-packages/e2e-tests - env: - E2E_TEST_PUBLISH_SCRIPT_NODE_VERSION: ${{ steps.versions.outputs.node }} - name: Copy to temp run: yarn ci:copy-to-temp ./test-applications/${{ matrix.test-application }} ${{ runner.temp }}/test-application diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml deleted file mode 100644 index 9aabf51e1070..000000000000 --- a/.github/workflows/changelog-preview.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Changelog Preview -on: - pull_request_target: - types: - - opened - - synchronize - - reopened - - edited - - labeled - - unlabeled -permissions: - contents: write - pull-requests: write - statuses: write - -jobs: - changelog-preview: - uses: getsentry/craft/.github/workflows/changelog-preview.yml@2.25.2 - secrets: inherit diff --git a/.github/workflows/ci-metadata.yml b/.github/workflows/ci-metadata.yml index c4fca988d724..0f39590bf167 100644 --- a/.github/workflows/ci-metadata.yml +++ b/.github/workflows/ci-metadata.yml @@ -51,14 +51,17 @@ jobs: id: get_metadata # We need to try a number of different options for finding the head commit, because each kind of trigger event # stores it in a different location + env: + COMMIT_SHA_EXPR: + ${{ github.event.pull_request.head.sha || github.event.head_commit.id || inputs.head_commit }} run: | - COMMIT_SHA=$(git rev-parse --short ${{ github.event.pull_request.head.sha || github.event.head_commit.id || inputs.head_commit }}) + COMMIT_SHA=$(git rev-parse --short "$COMMIT_SHA_EXPR") echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV echo "COMMIT_MESSAGE=$(git log -n 1 --pretty=format:%s $COMMIT_SHA)" >> $GITHUB_ENV # Most changed packages are determined in job_build via Nx - name: Determine changed packages - uses: dorny/paths-filter@v3.0.1 + uses: dorny/paths-filter@v4.0.1 id: changed with: filters: | @@ -67,10 +70,6 @@ jobs: any_code: - '!**/*.md' - - name: Get PR labels - id: pr-labels - uses: mydea/pr-labels-action@fn/bump-node20 - outputs: commit_label: '${{ env.COMMIT_SHA }}: ${{ env.COMMIT_MESSAGE }}' # Note: These next three have to be checked as strings ('true'/'false')! @@ -83,7 +82,8 @@ jobs: # When merging into master, or from master is_gitflow_sync: ${{ github.head_ref == 'master' || github.ref == 'refs/heads/master' }} has_gitflow_label: - ${{ github.event_name == 'pull_request' && contains(steps.pr-labels.outputs.labels, ' Gitflow ') }} + ${{ github.event_name == 'pull_request' && contains(toJSON(github.event.pull_request.labels.*.name), 'Gitflow') + }} force_skip_cache: ${{ github.event_name == 'schedule' || (github.event_name == 'pull_request' && - contains(steps.pr-labels.outputs.labels, ' ci-skip-cache ')) }} + contains(toJSON(github.event.pull_request.labels.*.name), 'ci-skip-cache')) }} diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index aa2c33336bd2..c0a8f1f720b1 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -55,7 +55,7 @@ jobs: browsers: 'chromium' - name: Determine changed tests - uses: dorny/paths-filter@v3.0.1 + uses: dorny/paths-filter@v4.0.1 id: changed with: list-files: json diff --git a/.github/workflows/pr-review-reminder.yml b/.github/workflows/pr-review-reminder.yml new file mode 100644 index 000000000000..3eda72221948 --- /dev/null +++ b/.github/workflows/pr-review-reminder.yml @@ -0,0 +1,39 @@ +name: 'PR: Review Reminder' + +on: + workflow_dispatch: + schedule: + # Run on weekdays at 10:00 AM UTC. No new reminders can fire on weekends because + # Saturday/Sunday are never counted as business days. + - cron: '0 10 * * 1-5' + +# pulls.* list + listRequestedReviewers → pull-requests: read +# issues timeline + comments + createComment → issues: write +# repos.listCollaborators (outside) → Metadata read on the token (see GitHub App permission map) +# checkout → contents: read +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + remind-reviewers: + # `schedule` has no `repository` on github.event; forks must be skipped only for workflow_dispatch. + if: github.event_name == 'schedule' || github.event.repository.fork != true + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Remind pending reviewers + uses: actions/github-script@v7 + with: + script: | + const { default: run } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/pr-review-reminder.mjs` + ); + await run({ github, context, core }); diff --git a/.size-limit.js b/.size-limit.js index 1e6e8d951464..86f3ef5ed87d 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,21 +38,28 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43 KB', + limit: '44 KB', + }, + { + name: '@sentry/browser (incl. Tracing + Span Streaming)', + path: 'packages/browser/build/npm/esm/prod/index.js', + import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), + gzip: true, + limit: '48 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', + limit: '49 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '82 KB', + limit: '83 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -82,14 +89,14 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '87 KB', + limit: '88 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '99 KB', + limit: '100 KB', }, { name: '@sentry/browser (incl. Feedback)', @@ -163,7 +170,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '45 KB', + limit: '46 KB', }, // Svelte SDK (ESM) { @@ -184,7 +191,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '44 KB', + limit: '45 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics)', @@ -196,7 +203,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '45 KB', + limit: '46 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics)', @@ -208,25 +215,25 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '81 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '82 KB', + limit: '83 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '87 KB', + limit: '88 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '88 KB', + limit: '89 KB', }, // browser CDN bundles (non-gzipped) { @@ -234,14 +241,14 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '83 KB', + limit: '83.5 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '130 KB', + limit: '134 KB', }, { name: 'CDN Bundle (incl. Logs, Metrics) - uncompressed', @@ -255,7 +262,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '134 KB', + limit: '138 KB', }, { name: 'CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed', @@ -269,14 +276,14 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '247 KB', + limit: '251 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '250 KB', + limit: '255 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed', @@ -290,7 +297,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '264 KB', + limit: '268 KB', }, // Next.js SDK (ESM) { @@ -299,7 +306,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '48 KB', + limit: '49 KB', }, // SvelteKit SDK (ESM) { @@ -308,7 +315,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '44 KB', + limit: '45 KB', }, // Node-Core SDK (ESM) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 8141da2e6276..7ba7c69b14dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,104 @@ ## Unreleased +## 10.49.0 + +### Important Changes + +- **feat(browser): Add View Hierarchy integration ([#14981](https://github.com/getsentry/sentry-javascript/pull/14981))** + + A new `viewHierarchyIntegration` captures the DOM structure when an error occurs, providing a snapshot of the page state for debugging. Enable it in your Sentry configuration: + + ```javascript + import * as Sentry from '@sentry/browser'; + + Sentry.init({ + dsn: '__DSN__', + integrations: [Sentry.viewHierarchyIntegration()], + }); + ``` + +- **feat(cloudflare): Split alarms into multiple traces and link them ([#19373](https://github.com/getsentry/sentry-javascript/pull/19373))** + + Durable Object alarms now create separate traces for each alarm invocation, with proper linking between related alarms for better observability. + +- **feat(cloudflare): Enable RPC trace propagation with `enableRpcTracePropagation` ([#19991](https://github.com/getsentry/sentry-javascript/pull/19991), [#20345](https://github.com/getsentry/sentry-javascript/pull/20345))** + + A new `enableRpcTracePropagation` option enables automatic trace propagation for Cloudflare RPC calls via `.fetch()`, ensuring distributed traces flow correctly across service bindings. + +- **feat(core): Add `enableTruncation` option to AI integrations ([#20167](https://github.com/getsentry/sentry-javascript/pull/20167), [#20181](https://github.com/getsentry/sentry-javascript/pull/20181), [#20182](https://github.com/getsentry/sentry-javascript/pull/20182), [#20183](https://github.com/getsentry/sentry-javascript/pull/20183), [#20184](https://github.com/getsentry/sentry-javascript/pull/20184))** + + All AI integrations (OpenAI, Anthropic, Google GenAI, LangChain, LangGraph) now support an `enableTruncation` option to control whether large AI inputs/outputs are truncated. + +- **feat(opentelemetry): Vendor `AsyncLocalStorageContextManager` ([#20243](https://github.com/getsentry/sentry-javascript/pull/20243))** + + The OpenTelemetry context manager is now vendored internally, reducing external dependencies and ensuring consistent behavior across environments. + +### Other Changes + +- feat(core): Export a reusable function to add tracing headers ([#20076](https://github.com/getsentry/sentry-javascript/pull/20076)) +- feat(core): Expose `rewriteSources` top level option ([#20142](https://github.com/getsentry/sentry-javascript/pull/20142)) +- feat(deps): bump defu from 6.1.4 to 6.1.6 ([#20104](https://github.com/getsentry/sentry-javascript/pull/20104)) +- feat(node-native): Add support for V8 v14 (Node v25+) ([#20125](https://github.com/getsentry/sentry-javascript/pull/20125)) +- feat(node): Include global scope for `eventLoopBlockIntegration` ([#20108](https://github.com/getsentry/sentry-javascript/pull/20108)) +- fix(core, node): Support loading Express options lazily ([#20211](https://github.com/getsentry/sentry-javascript/pull/20211)) +- fix(core): Set `conversation_id` only on `gen_ai` spans ([#20274](https://github.com/getsentry/sentry-javascript/pull/20274)) +- fix(core): Use `ai.operationId` for Vercel AI V6 operation name mapping ([#20285](https://github.com/getsentry/sentry-javascript/pull/20285)) +- fix(deno): Avoid inferring invalid span op from Deno tracer ([#20128](https://github.com/getsentry/sentry-javascript/pull/20128)) +- fix(deno): Handle `reader.closed` rejection from `releaseLock()` in streaming ([#20187](https://github.com/getsentry/sentry-javascript/pull/20187)) +- fix(nextjs): Preserve directive prologues in turbopack loaders ([#20103](https://github.com/getsentry/sentry-javascript/pull/20103)) +- fix(nextjs): Skip custom browser tracing setup for bot user agents ([#20263](https://github.com/getsentry/sentry-javascript/pull/20263)) +- fix(opentelemetry): Use WeakRef for context stored on scope to prevent memory leak ([#20328](https://github.com/getsentry/sentry-javascript/pull/20328)) +- fix(replay): Use live click attributes in breadcrumbs ([#20262](https://github.com/getsentry/sentry-javascript/pull/20262)) + +
+ Internal Changes + +- chore: Add PR review reminder workflow ([#20175](https://github.com/getsentry/sentry-javascript/pull/20175)) +- chore: Fix lint warnings ([#20250](https://github.com/getsentry/sentry-javascript/pull/20250)) +- chore(bugbot): Add rules to flag test-flake-provoking patterns ([#20192](https://github.com/getsentry/sentry-javascript/pull/20192)) +- chore(ci): Bump actions/cache to v5 and actions/download-artifact to v7 ([#20249](https://github.com/getsentry/sentry-javascript/pull/20249)) +- chore(ci): Bump dorny/paths-filter from v3.0.1 to v4.0.1 ([#20251](https://github.com/getsentry/sentry-javascript/pull/20251)) +- chore(ci): Remove codecov steps from jobs that produce no coverage/JUnit data ([#20244](https://github.com/getsentry/sentry-javascript/pull/20244)) +- chore(ci): Remove craft changelog preview ([#20271](https://github.com/getsentry/sentry-javascript/pull/20271)) +- chore(ci): Remove node-overhead GitHub Action ([#20246](https://github.com/getsentry/sentry-javascript/pull/20246)) +- chore(ci): Replace pr-labels-action with native GitHub expressions ([#20252](https://github.com/getsentry/sentry-javascript/pull/20252)) +- chore(ci): Skip flaky issue creation for optional tests ([#20288](https://github.com/getsentry/sentry-javascript/pull/20288)) +- chore(deps-dev): Bump @sveltejs/kit from 2.53.3 to 2.57.1 ([#20216](https://github.com/getsentry/sentry-javascript/pull/20216)) +- chore(deps-dev): Bump vite from 7.2.0 to 7.3.2 in /dev-packages/e2e-tests/test-applications/tanstackstart-react ([#20107](https://github.com/getsentry/sentry-javascript/pull/20107)) +- chore(deps): Bump axios from 1.13.5 to 1.15.0 ([#20180](https://github.com/getsentry/sentry-javascript/pull/20180)) +- chore(deps): Bump axios from 1.13.5 to 1.15.0 in /dev-packages/e2e-tests/test-applications/nestjs-basic ([#20179](https://github.com/getsentry/sentry-javascript/pull/20179)) +- chore(deps): Bump hono from 4.12.7 to 4.12.12 ([#20118](https://github.com/getsentry/sentry-javascript/pull/20118)) +- chore(deps): Bump hono from 4.12.7 to 4.12.12 in /dev-packages/e2e-tests/test-applications/cloudflare-hono ([#20119](https://github.com/getsentry/sentry-javascript/pull/20119)) +- chore(deps): Bump next from 16.1.7 to 16.2.3 in nextjs-16-cf-workers ([#20289](https://github.com/getsentry/sentry-javascript/pull/20289)) +- chore(size-limit): Bump failing size limit scenario ([#20186](https://github.com/getsentry/sentry-javascript/pull/20186)) +- ci: Add automatic flaky test detector ([#18684](https://github.com/getsentry/sentry-javascript/pull/18684)) +- ci: Extract test names for flaky test issues ([#20298](https://github.com/getsentry/sentry-javascript/pull/20298)) +- ci: Remove Docker container for Verdaccio package publishing ([#20329](https://github.com/getsentry/sentry-javascript/pull/20329)) +- fix(ci): Prevent command injection in ci-metadata workflow ([#19899](https://github.com/getsentry/sentry-javascript/pull/19899)) +- fix(e2e-tests): Remove flaky navigation breadcrumb assertions from parameterized-routes tests ([#20202](https://github.com/getsentry/sentry-javascript/pull/20202)) +- fix(e2e): Add op check to waitForTransaction in React Router e2e tests ([#20193](https://github.com/getsentry/sentry-javascript/pull/20193)) +- fix(node-integration-tests): Fix flaky kafkajs test race condition ([#20189](https://github.com/getsentry/sentry-javascript/pull/20189)) +- ref(core): Add registry in Vercel ai integration ([#20098](https://github.com/getsentry/sentry-javascript/pull/20098)) +- ref(core): Automatically disable truncation when span streaming is enabled in Anthropic AI integration ([#20228](https://github.com/getsentry/sentry-javascript/pull/20228)) +- ref(core): Automatically disable truncation when span streaming is enabled in Google GenAI integration ([#20229](https://github.com/getsentry/sentry-javascript/pull/20229)) +- ref(core): Automatically disable truncation when span streaming is enabled in LangChain integration ([#20230](https://github.com/getsentry/sentry-javascript/pull/20230)) +- ref(core): Automatically disable truncation when span streaming is enabled in LangGraph integration ([#20231](https://github.com/getsentry/sentry-javascript/pull/20231)) +- ref(core): Automatically disable truncation when span streaming is enabled in OpenAI integration ([#20227](https://github.com/getsentry/sentry-javascript/pull/20227)) +- ref(core): Automatically disable truncation when span streaming is enabled in Vercel AI integration ([#20232](https://github.com/getsentry/sentry-javascript/pull/20232)) +- ref(core): Merge embeddings operations constants ([#20095](https://github.com/getsentry/sentry-javascript/pull/20095)) +- ref(core): Remove unused constants from vercel-ai-attributes.ts ([#20096](https://github.com/getsentry/sentry-javascript/pull/20096)) +- ref(nextjs): Refactor `findInjectionIndexAfterDirectives` for better readability ([#20310](https://github.com/getsentry/sentry-javascript/pull/20310)) +- ref(opentelemetry): Replace `@opentelemetry/resources` with inline `getSentryResource()` ([#20327](https://github.com/getsentry/sentry-javascript/pull/20327)) +- test: Fix flaky ANR test by increasing blocking duration ([#20239](https://github.com/getsentry/sentry-javascript/pull/20239)) +- test(bun): Add bun integration test folder ([#20286](https://github.com/getsentry/sentry-javascript/pull/20286)) +- test(cloudflare): Skip flaky durableobject-spans test ([#20282](https://github.com/getsentry/sentry-javascript/pull/20282)) +- test(openai): Use multi-message scenario in no-truncation test ([#20194](https://github.com/getsentry/sentry-javascript/pull/20194)) +- test(react): Remove duplicated test mock ([#20200](https://github.com/getsentry/sentry-javascript/pull/20200)) +- tests(ai): Fix streaming+truncation integration tests across AI integrations ([#20326](https://github.com/getsentry/sentry-javascript/pull/20326)) + +
+ ## 10.48.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 2803a03c8eec..0c28fbfabce5 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -62,7 +62,7 @@ "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "10.48.0", "@supabase/supabase-js": "2.49.3", - "axios": "1.13.5", + "axios": "1.15.0", "babel-loader": "^10.1.1", "fflate": "0.8.2", "html-webpack-plugin": "^5.5.0", diff --git a/dev-packages/browser-integration-tests/playwright.config.ts b/dev-packages/browser-integration-tests/playwright.config.ts index 821c0291ccfb..681de57d4e59 100644 --- a/dev-packages/browser-integration-tests/playwright.config.ts +++ b/dev-packages/browser-integration-tests/playwright.config.ts @@ -30,7 +30,7 @@ const config: PlaywrightTestConfig = { }, ], - reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', + reporter: process.env.CI ? [['list'], ['github'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', globalSetup: require.resolve('./playwright.setup.ts'), globalTeardown: require.resolve('./playwright.teardown.ts'), diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js new file mode 100644 index 000000000000..16e92edb9230 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; +import { viewHierarchyIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [viewHierarchyIntegration()], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js new file mode 100644 index 000000000000..f7060a33f05c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/subject.js @@ -0,0 +1 @@ +throw new Error('Some error'); diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html new file mode 100644 index 000000000000..9e600d2a7e60 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/template.html @@ -0,0 +1,11 @@ + + + + + + + +

Some title

+

Some text

+ + diff --git a/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts new file mode 100644 index 000000000000..d3caf6ff9b3e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/viewHierarchy/test.ts @@ -0,0 +1,39 @@ +import { expect } from '@playwright/test'; +import type { ViewHierarchyData } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, envelopeParser } from '../../../utils/helpers'; + +sentryTest('Captures view hierarchy as attachment', 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 [, events] = await Promise.all([ + page.goto(url), + getMultipleSentryEnvelopeRequests( + page, + 1, + {}, + req => envelopeParser(req)?.[4] as ViewHierarchyData, + ), + ]); + + expect(events).toHaveLength(1); + const event: ViewHierarchyData = events[0]; + + expect(event.rendering_system).toBe('DOM'); + expect(event.positioning).toBe('absolute'); + expect(event.windows).toHaveLength(2); + expect(event.windows[0].type).toBe('h1'); + expect(event.windows[0].visible).toBe(true); + expect(event.windows[0].alpha).toBe(1); + expect(event.windows[0].children).toHaveLength(0); + + expect(event.windows[1].type).toBe('p'); + expect(event.windows[1].visible).toBe(true); + expect(event.windows[1].alpha).toBe(1); + expect(event.windows[1].children).toHaveLength(0); +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts index 08aad51de3ff..1373f78b3a5c 100644 --- a/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts @@ -56,6 +56,66 @@ sentryTest('mutation after threshold results in slow click', async ({ forceFlush expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3501); }); +sentryTest( + 'uses updated attributes for click breadcrumbs after mutation', + async ({ forceFlushReplay, getLocalTestUrl, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const replayRequestPromise = waitForReplayRequest(page, 0); + const segmentReqWithClickBreadcrumbPromise = waitForReplayRequest(page, (_event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); + + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }); + + await page.goto(url); + await replayRequestPromise; + + await forceFlushReplay(); + + await page.evaluate(() => { + const target = document.getElementById('next-question-button'); + if (!target) { + throw new Error('Could not find target button'); + } + + target.id = 'save-note-button'; + target.setAttribute('data-testid', 'save-note-button'); + }); + + await page.getByRole('button', { name: 'Next question' }).click(); + await forceFlushReplay(); + + const segmentReqWithClickBreadcrumb = await segmentReqWithClickBreadcrumbPromise; + + const { breadcrumbs } = getCustomRecordingEvents(segmentReqWithClickBreadcrumb); + const updatedClickBreadcrumb = breadcrumbs.find(breadcrumb => breadcrumb.category === 'ui.click'); + + expect(updatedClickBreadcrumb).toEqual({ + category: 'ui.click', + data: { + node: { + attributes: { + id: 'save-note-button', + testId: 'save-note-button', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '**** ********', + }, + nodeId: expect.any(Number), + }, + message: 'body > button#save-note-button', + timestamp: expect.any(Number), + type: 'default', + }); + }, +); + sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); diff --git a/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html b/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html index 030401479a6b..2e0558870e1e 100644 --- a/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html +++ b/dev-packages/browser-integration-tests/suites/replay/slowClick/template.html @@ -6,6 +6,7 @@
Trigger mutation
+ + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts new file mode 100644 index 000000000000..30dd4f92dbfc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-streamed-spans/test.ts @@ -0,0 +1,79 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ browserName }) => { + if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') { + sentryTest.skip(); + } +}); + +sentryTest('captures INP click as a streamed span', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + const inpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.interaction.click'); + + await page.goto(url); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + await hidePage(page); + + const inpSpan = await inpSpanPromise; + const pageloadSpan = await pageloadSpanPromise; + + expect(inpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.interaction.click' }); + expect(inpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.inp' }); + expect(inpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); + + const inpValue = inpSpan.attributes?.['browser.web_vital.inp.value']?.value as number; + expect(inpValue).toBeGreaterThan(0); + + expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toBeGreaterThan(0); + + expect(inpSpan.name).toBe('body > NormalButton'); + + expect(inpSpan.end_timestamp).toBeGreaterThan(inpSpan.start_timestamp); + + expect(inpSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(inpSpan.trace_id).toMatch(/^[\da-f]{32}$/); + + expect(inpSpan.parent_span_id).toBe(pageloadSpan.span_id); + expect(inpSpan.trace_id).toBe(pageloadSpan.trace_id); +}); + +sentryTest('captures the slowest interaction as streamed INP span', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + const inpSpanPromise = waitForStreamedSpan(page, span => { + const op = getSpanOp(span); + return op === 'ui.interaction.click'; + }); + + await page.locator('[data-test-id=slow-button]').click(); + await page.locator('.clicked[data-test-id=slow-button]').isVisible(); + + await page.waitForTimeout(500); + + await hidePage(page); + + const inpSpan = await inpSpanPromise; + + expect(inpSpan.name).toBe('body > SlowButton'); + expect(inpSpan.attributes?.['sentry.exclusive_time']?.value).toBeGreaterThan(400); + + const inpValue = inpSpan.attributes?.['browser.web_vital.inp.value']?.value as number; + expect(inpValue).toBeGreaterThan(400); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png new file mode 100644 index 000000000000..353b7233d6bf Binary files /dev/null and b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/assets/sentry-logo-600x179.png differ diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js new file mode 100644 index 000000000000..bd3b6ed17872 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/init.js @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html new file mode 100644 index 000000000000..b613a556aca4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/template.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts new file mode 100644 index 000000000000..1f71cb8d76a7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-lcp-streamed-spans/test.ts @@ -0,0 +1,65 @@ +import type { Route } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ browserName, page }) => { + if (shouldSkipTracingTest() || testingCdnBundle() || browserName !== 'chromium') { + sentryTest.skip(); + } + + await page.setViewportSize({ width: 800, height: 1200 }); +}); + +sentryTest('captures LCP as a streamed span with element attributes', async ({ getLocalTestUrl, page }) => { + page.route('**', route => route.continue()); + page.route('**/my/image.png', async (route: Route) => { + return route.fulfill({ + path: `${__dirname}/assets/sentry-logo-600x179.png`, + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const lcpSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'ui.webvital.lcp'); + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + + // Wait for LCP to be captured + await page.waitForTimeout(1000); + + await hidePage(page); + + const lcpSpan = await lcpSpanPromise; + const pageloadSpan = await pageloadSpanPromise; + + expect(lcpSpan.attributes?.['sentry.op']).toEqual({ type: 'string', value: 'ui.webvital.lcp' }); + expect(lcpSpan.attributes?.['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.browser.lcp' }); + expect(lcpSpan.attributes?.['sentry.exclusive_time']).toEqual({ type: 'integer', value: 0 }); + expect(lcpSpan.attributes?.['user_agent.original']?.value).toEqual(expect.stringContaining('Chrome')); + + // Check browser.web_vital.lcp.* attributes + expect(lcpSpan.attributes?.['browser.web_vital.lcp.element']?.value).toEqual(expect.stringContaining('body > img')); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.url']?.value).toBe( + 'https://sentry-test-site.example/my/image.png', + ); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.size']?.value).toEqual(expect.any(Number)); + + // Check web vital value attribute + expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.type).toMatch(/^(double)|(integer)$/); + expect(lcpSpan.attributes?.['browser.web_vital.lcp.value']?.value).toBeGreaterThan(0); + + // Check pageload span id is present + expect(lcpSpan.attributes?.['sentry.pageload.span_id']?.value).toBe(pageloadSpan.span_id); + + // Span should have meaningful duration (navigation start -> LCP event) + expect(lcpSpan.end_timestamp).toBeGreaterThan(lcpSpan.start_timestamp); + + expect(lcpSpan.span_id).toMatch(/^[\da-f]{16}$/); + expect(lcpSpan.trace_id).toMatch(/^[\da-f]{32}$/); + + expect(lcpSpan.parent_span_id).toBe(pageloadSpan.span_id); + expect(lcpSpan.trace_id).toBe(pageloadSpan.trace_id); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js new file mode 100644 index 000000000000..d8da96d88a64 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/init.js @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window._testBaseTimestamp = performance.timeOrigin / 1000; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration()], + traceLifecycle: 'stream', + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts new file mode 100644 index 000000000000..73f37f07a291 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-ttfb-streamed/test.ts @@ -0,0 +1,36 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { hidePage, shouldSkipTracingTest, testingCdnBundle } from '../../../../utils/helpers'; +import { getSpanOp, waitForStreamedSpan } from '../../../../utils/spanUtils'; + +sentryTest.beforeEach(async ({ page }) => { + if (shouldSkipTracingTest() || testingCdnBundle()) { + sentryTest.skip(); + } + + await page.setViewportSize({ width: 800, height: 1200 }); +}); + +sentryTest( + 'captures TTFB and TTFB request time as attributes on the streamed pageload span', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const pageloadSpanPromise = waitForStreamedSpan(page, span => getSpanOp(span) === 'pageload'); + + await page.goto(url); + await hidePage(page); + + const pageloadSpan = await pageloadSpanPromise; + + // If responseStart === 0, TTFB is not reported. + // This seems to happen somewhat randomly, so we handle it. + const responseStart = await page.evaluate("performance.getEntriesByType('navigation')[0].responseStart;"); + if (responseStart !== 0) { + expect(pageloadSpan.attributes?.['browser.web_vital.ttfb.value']?.type).toMatch(/^(double)|(integer)$/); + expect(pageloadSpan.attributes?.['browser.web_vital.ttfb.value']?.value).toBeGreaterThan(0); + } + + expect(pageloadSpan.attributes?.['browser.web_vital.ttfb.request_time']?.type).toMatch(/^(double)|(integer)$/); + }, +); diff --git a/dev-packages/bun-integration-tests/.oxlintrc.json b/dev-packages/bun-integration-tests/.oxlintrc.json new file mode 100644 index 000000000000..dd67e1db9801 --- /dev/null +++ b/dev-packages/bun-integration-tests/.oxlintrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "../../node_modules/oxlint/configuration_schema.json", + "extends": ["../.oxlintrc.json"], + "env": { + "node": true + }, + "overrides": [ + { + "files": ["suites/**/*.ts"], + "globals": { + "Bun": "readonly", + "fetch": "readonly" + }, + "rules": { + "typescript/ban-ts-comment": [ + "error", + { + "ts-ignore": "allow-with-description", + "ts-expect-error": true + } + ], + "import/first": "off" + } + } + ] +} diff --git a/dev-packages/bun-integration-tests/expect.ts b/dev-packages/bun-integration-tests/expect.ts new file mode 100644 index 000000000000..599caaa9e5be --- /dev/null +++ b/dev-packages/bun-integration-tests/expect.ts @@ -0,0 +1,91 @@ +import type { Contexts, Envelope, Event, SdkInfo } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import { expect } from 'vitest'; + +export const UUID_MATCHER = expect.stringMatching(/^[\da-f]{32}$/); +export const UUID_V4_MATCHER = expect.stringMatching( + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, +); +export const SHORT_UUID_MATCHER = expect.stringMatching(/^[\da-f]{16}$/); +export const ISO_DATE_MATCHER = expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + +function dropUndefinedKeys>(obj: T): T { + for (const [key, value] of Object.entries(obj)) { + if (value === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete obj[key]; + } + } + return obj; +} + +function getSdk(sdk: 'bun' | 'hono'): SdkInfo { + return { + integrations: expect.any(Array), + name: `sentry.javascript.${sdk}`, + packages: [ + { + name: `npm:@sentry/${sdk}`, + version: SDK_VERSION, + }, + ...(sdk === 'hono' ? [{ name: 'npm:@sentry/bun', version: SDK_VERSION }] : []), + ], + version: SDK_VERSION, + }; +} + +function defaultContexts(eventContexts: Contexts = {}): Contexts { + return dropUndefinedKeys({ + app: { app_memory: expect.any(Number), app_start_time: expect.any(String), free_memory: expect.any(Number) }, + cloud_resource: expect.any(Object), + trace: { + trace_id: UUID_MATCHER, + span_id: SHORT_UUID_MATCHER, + }, + culture: { locale: expect.any(String), timezone: expect.any(String) }, + device: expect.any(Object), + os: expect.any(Object), + runtime: { name: 'bun', version: expect.any(String) }, + ...eventContexts, + }); +} + +export function expectedEvent(event: Event, { sdk }: { sdk: 'bun' | 'hono' }): Event { + return dropUndefinedKeys({ + event_id: UUID_MATCHER, + timestamp: expect.any(Number), + environment: 'production', + platform: 'node', + modules: expect.any(Object), + sdk: getSdk(sdk), + server_name: expect.any(String), + // release is auto-detected from GitHub CI env vars, so only expect it if we know it will be there + ...(process.env.GITHUB_SHA ? { release: expect.any(String) } : {}), + ...event, + contexts: defaultContexts(event.contexts), + }); +} + +export function eventEnvelope( + event: Event, + { includeSampleRand = false, sdk = 'bun' }: { includeSampleRand?: boolean; sdk?: 'bun' | 'hono' } = {}, +): Envelope { + return [ + { + event_id: UUID_MATCHER, + sent_at: ISO_DATE_MATCHER, + sdk: { name: `sentry.javascript.${sdk}`, version: SDK_VERSION }, + trace: { + environment: event.environment || 'production', + public_key: 'public', + trace_id: UUID_MATCHER, + sample_rate: expect.any(String), + sampled: expect.any(String), + // release is auto-detected from GitHub CI env vars, so only expect it if we know it will be there + ...(process.env.GITHUB_SHA ? { release: expect.any(String) } : {}), + ...(includeSampleRand && { sample_rand: expect.stringMatching(/^[01](\.\d+)?$/) }), + }, + }, + [[{ type: 'event' }, expectedEvent(event, { sdk })]], + ]; +} diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json new file mode 100644 index 000000000000..c711045d1ce1 --- /dev/null +++ b/dev-packages/bun-integration-tests/package.json @@ -0,0 +1,28 @@ +{ + "name": "@sentry-internal/bun-integration-tests", + "version": "10.48.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "private": true, + "scripts": { + "lint": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --type-aware", + "lint:fix": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --fix --type-aware", + "test": "vitest run", + "test:watch": "yarn test --watch" + }, + "dependencies": { + "@sentry/bun": "10.48.0", + "@sentry/hono": "10.48.0", + "hono": "^4.12.12" + }, + "devDependencies": { + "@sentry-internal/test-utils": "10.48.0", + "bun-types": "^1.2.9", + "vitest": "^3.2.4" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/bun-integration-tests/runner.ts b/dev-packages/bun-integration-tests/runner.ts new file mode 100644 index 000000000000..b9831768f78a --- /dev/null +++ b/dev-packages/bun-integration-tests/runner.ts @@ -0,0 +1,280 @@ +import type { Envelope, EnvelopeItemType } from '@sentry/core'; +import { normalize } from '@sentry/core'; +import { createBasicSentryServer } from '@sentry-internal/test-utils'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { inspect } from 'util'; +import { expect } from 'vitest'; + +const CLEANUP_STEPS = new Set<() => void>(); + +export function cleanupChildProcesses(): void { + for (const step of CLEANUP_STEPS) { + step(); + } + CLEANUP_STEPS.clear(); +} + +process.on('exit', cleanupChildProcesses); + +function deferredPromise( + done?: () => void, +): { resolve: (val: T) => void; reject: (reason?: unknown) => void; promise: Promise } { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = (val: T) => { + res(val); + }; + reject = (reason: Error) => { + rej(reason); + }; + }); + if (!resolve || !reject) { + throw new Error('Failed to create deferred promise'); + } + return { + resolve, + reject, + promise: promise.finally(() => done?.()), + }; +} + +type Expected = Envelope | ((envelope: Envelope) => void); + +type StartResult = { + completed(): Promise; + makeRequest( + method: 'get' | 'post', + path: string, + options?: { headers?: Record; data?: BodyInit; expectError?: boolean }, + ): Promise; +}; + +/** Creates a test runner that spawns a Bun child process */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createRunner(...paths: string[]) { + const testPath = join(...paths); + + let unordered = false; + + if (!existsSync(testPath)) { + throw new Error(`Test scenario not found: ${testPath}`); + } + + const expectedEnvelopes: Expected[] = []; + const ignored: Set = new Set(['session', 'sessions', 'client_report']); + const envVars: Record = {}; + + return { + withEnv: function (env: Record) { + Object.assign(envVars, env); + return this; + }, + expect: function (expected: Expected) { + expectedEnvelopes.push(expected); + return this; + }, + expectN: function (n: number, expected: Expected) { + for (let i = 0; i < n; i++) { + expectedEnvelopes.push(expected); + } + return this; + }, + unordered: function () { + unordered = true; + return this; + }, + ignore: function (...types: EnvelopeItemType[]) { + types.forEach(t => ignored.add(t)); + return this; + }, + unignore: function (...types: EnvelopeItemType[]) { + for (const t of types) { + ignored.delete(t); + } + return this; + }, + start: function (signal?: AbortSignal): StartResult { + const { resolve, reject, promise: isComplete } = deferredPromise(cleanupChildProcesses); + const expectedEnvelopeCount = expectedEnvelopes.length; + + let envelopeCount = 0; + const { resolve: setServerPort, promise: serverPortPromise } = deferredPromise(); + let child: ReturnType | undefined; + + function expectCallbackCalled(): void { + envelopeCount++; + if (envelopeCount === expectedEnvelopeCount) { + resolve(); + } + } + + function assertEnvelopeMatches(expected: Expected, envelope: Envelope): void { + if (typeof expected === 'function') { + expected(envelope); + } else { + expect(envelope).toEqual(expected); + } + } + + function newEnvelope(envelope: Envelope): void { + if (process.env.DEBUG) log('newEnvelope', inspect(envelope, false, null, true)); + + const envelopeItemType = envelope[1][0][0].type; + + if (ignored.has(envelopeItemType)) { + return; + } + + try { + if (unordered) { + const matchIndex = expectedEnvelopes.findIndex(candidate => { + try { + assertEnvelopeMatches(candidate, envelope); + return true; + } catch { + return false; + } + }); + + if (matchIndex < 0) { + return; + } + + expectedEnvelopes.splice(matchIndex, 1); + } else { + const expected = expectedEnvelopes.shift(); + + if (!expected) { + return; + } + + assertEnvelopeMatches(expected, envelope); + } + + expectCallbackCalled(); + } catch (e) { + reject(e); + } + } + + createBasicSentryServer(newEnvelope) + .then(([mockServerPort, mockServerClose]) => { + if (mockServerClose) { + CLEANUP_STEPS.add(() => { + mockServerClose(); + }); + } + + if (process.env.DEBUG) log('Starting scenario', testPath); + + const entryFile = join(testPath, 'index.ts'); + if (!existsSync(entryFile)) { + reject(new Error(`Entry file not found: ${entryFile}`)); + return; + } + + const stdio: ('inherit' | 'ipc' | 'ignore' | 'pipe')[] = process.env.DEBUG + ? ['inherit', 'inherit', 'inherit', 'ipc'] + : ['ignore', 'pipe', 'pipe', 'ipc']; + + child = spawn('bun', ['run', entryFile], { + stdio, + signal, + env: { + ...process.env, + SENTRY_DSN: `http://public@localhost:${mockServerPort}/1337`, + ...envVars, + }, + }); + + CLEANUP_STEPS.add(() => { + child?.kill(); + }); + + child.on('error', e => { + // eslint-disable-next-line no-console + console.error('Error starting Bun child process:', e); + reject(e); + }); + + if (!process.env.DEBUG && child.stderr) { + let stderrData = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderrData += chunk.toString(); + }); + child.on('exit', code => { + if (code !== 0 && code !== null && stderrData) { + // eslint-disable-next-line no-console + console.error('Bun process stderr:', stderrData); + } + }); + } + + child.on('message', (message: string) => { + const msg = JSON.parse(message) as { event: string; port?: number }; + if (msg.event === 'READY' && typeof msg.port === 'number') { + if (process.env.DEBUG) log('Bun server ready on port', msg.port); + setServerPort(msg.port); + } + }); + }) + .catch(e => reject(e)); + + return { + completed: async function (): Promise { + return isComplete; + }, + makeRequest: async function ( + method: 'get' | 'post', + path: string, + options: { headers?: Record; data?: BodyInit; expectError?: boolean } = {}, + ): Promise { + const url = `http://localhost:${await serverPortPromise}${path}`; + const body = options.data; + const headers = options.headers || {}; + const expectError = options.expectError || false; + + if (process.env.DEBUG) log('making request', method, url, headers, body); + + try { + const res = await fetch(url, { headers, method, body }); + + if (!res.ok) { + if (!expectError) { + reject(new Error(`Expected request to "${path}" to succeed, but got a ${res.status} response`)); + } + + return; + } + + if (expectError) { + reject(new Error(`Expected request to "${path}" to fail, but got a ${res.status} response`)); + return; + } + + if (res.headers.get('content-type')?.includes('application/json')) { + return await res.json(); + } + + return (await res.text()) as T; + } catch (e) { + if (expectError) { + return; + } + + reject(e); + return; + } + }, + }; + }, + }; +} + +function log(...args: unknown[]): void { + // eslint-disable-next-line no-console + console.log(...args.map(arg => normalize(arg))); +} diff --git a/dev-packages/bun-integration-tests/suites/basic/index.ts b/dev-packages/bun-integration-tests/suites/basic/index.ts new file mode 100644 index 000000000000..c760e61d6ba5 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/basic/index.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/bun'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1.0, +}); + +const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + + if (url.pathname === '/error') { + throw new Error('This is a test error from the Bun integration tests'); + } + + if (url.pathname === '/message') { + Sentry.captureMessage('Hello from Bun'); + return new Response('OK'); + } + + return new Response('Hello from Bun!'); + }, + error(_err) { + return new Response('Internal Server Error', { status: 500 }); + }, +}); + +process.send?.(JSON.stringify({ event: 'READY', port: server.port })); diff --git a/dev-packages/bun-integration-tests/suites/basic/test.ts b/dev-packages/bun-integration-tests/suites/basic/test.ts new file mode 100644 index 000000000000..673464f0c81a --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/basic/test.ts @@ -0,0 +1,54 @@ +import { expect, it } from 'vitest'; +import { eventEnvelope } from '../../expect'; +import { createRunner } from '../../runner'; + +it('captures an error thrown in Bun.serve fetch handler', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope( + { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'This is a test error from the Bun integration tests', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }, + ], + }, + request: expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/error'), + }), + }, + { includeSampleRand: true }, + ), + ) + .ignore('transaction') + .start(signal); + await runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); +}); + +it('captures a manually sent message', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const [, envelopeItems] = envelope; + const [itemHeader, itemPayload] = envelopeItems[0]; + + expect(itemHeader.type).toBe('event'); + + expect(itemPayload).toMatchObject({ + level: 'info', + message: 'Hello from Bun', + }); + }) + .ignore('transaction') + .start(signal); + await runner.makeRequest('get', '/message'); + await runner.completed(); +}); diff --git a/dev-packages/bun-integration-tests/tsconfig.json b/dev-packages/bun-integration-tests/tsconfig.json new file mode 100644 index 000000000000..8fe804d8c50d --- /dev/null +++ b/dev-packages/bun-integration-tests/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["suites/**/*.ts", "*.ts"], + + "compilerOptions": { + "lib": ["ES2020"], + "esModuleInterop": true, + "types": ["bun-types"] + } +} diff --git a/dev-packages/bun-integration-tests/vite.config.mts b/dev-packages/bun-integration-tests/vite.config.mts new file mode 100644 index 000000000000..23ea00e8c30a --- /dev/null +++ b/dev-packages/bun-integration-tests/vite.config.mts @@ -0,0 +1,32 @@ +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vite/vite.config'; + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + coverage: { + enabled: false, + }, + isolate: false, + include: ['./suites/**/test.ts'], + testTimeout: 20_000, + ...(process.env.DEBUG + ? { + disableConsoleIntercept: true, + silent: false, + } + : {}), + pool: 'threads', + poolOptions: { + threads: { + singleThread: true, + }, + }, + reporters: process.env.DEBUG + ? ['default', { summary: false }] + : process.env.GITHUB_ACTIONS + ? ['dot', 'github-actions'] + : ['verbose'], + }, +}); diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 105634d818cb..b5f0eeb7de32 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -16,7 +16,7 @@ "@langchain/langgraph": "^1.0.1", "@sentry/cloudflare": "10.48.0", "@sentry/hono": "10.48.0", - "hono": "^4.12.7" + "hono": "^4.12.12" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index b0b439eb122a..e0a48dd33ff5 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -68,12 +68,17 @@ export function createRunner(...paths: string[]) { // By default, we ignore session & sessions const ignored: Set = new Set(['session', 'sessions', 'client_report']); let serverUrl: string | undefined; + const extraWranglerArgs: string[] = []; return { withServerUrl: function (url: string) { serverUrl = url; return this; }, + withWranglerArgs: function (...args: string[]) { + extraWranglerArgs.push(...args); + return this; + }, expect: function (expected: Expected) { expectedEnvelopes.push(expected); return this; @@ -237,6 +242,7 @@ export function createRunner(...paths: string[]) { `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, '--var', `SERVER_URL:${serverUrl}`, + ...extraWranglerArgs, ], { stdio, signal }, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts new file mode 100644 index 000000000000..76039b6892ee --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + traceLifecycle: 'stream', + release: '1.0.0', + }), + { + async fetch(_request, _env, _ctx) { + Sentry.startSpan({ name: 'test-span', op: 'test' }, segmentSpan => { + Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => { + // noop + }); + + const inactiveSpan = Sentry.startInactiveSpan({ name: 'test-inactive-span' }); + inactiveSpan.addLink({ + context: segmentSpan.spanContext(), + attributes: { 'sentry.link.type': 'some_relation' }, + }); + inactiveSpan.end(); + + Sentry.startSpanManual({ name: 'test-manual-span' }, span => { + span.end(); + }); + }); + + return new Response('OK'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts new file mode 100644 index 000000000000..090142714d5b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts @@ -0,0 +1,264 @@ +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID, + SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +const CLOUDFLARE_SDK = 'sentry.javascript.cloudflare'; + +function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + expect(spanItem).toBeDefined(); + return spanItem![1] as SerializedStreamedSpanContainer; +} + +it('sends a streamed span envelope with correct envelope header', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + expect(getSpanContainer(envelope).items.length).toBeGreaterThan(0); + + expect(envelope[0]).toEqual( + expect.objectContaining({ + sent_at: expect.any(String), + sdk: { + name: CLOUDFLARE_SDK, + version: SDK_VERSION, + }, + trace: expect.objectContaining({ + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }), + }), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); + +it('sends a streamed span envelope with correct spans for a manually started span with children', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const container = getSpanContainer(envelope); + const spans = container.items; + + // Cloudflare `withSentry` wraps fetch in an http.server span (segment) around the scenario. + expect(spans.length).toBe(5); + + const segmentSpan = spans.find(s => !!s.is_segment); + expect(segmentSpan).toBeDefined(); + + const segmentSpanId = segmentSpan!.span_id; + const traceId = segmentSpan!.trace_id; + const segmentName = segmentSpan!.name; + + const parentTestSpan = spans.find(s => s.name === 'test-span'); + expect(parentTestSpan).toBeDefined(); + expect(parentTestSpan!.parent_span_id).toBe(segmentSpanId); + + const childSpan = spans.find(s => s.name === 'test-child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test-child', + }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + }, + name: 'test-child-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const inactiveSpan = spans.find(s => s.name === 'test-inactive-span'); + expect(inactiveSpan).toBeDefined(); + expect(inactiveSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'some_relation', + }, + }, + sampled: true, + span_id: parentTestSpan!.span_id, + trace_id: traceId, + }, + ], + name: 'test-inactive-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const manualSpan = spans.find(s => s.name === 'test-manual-span'); + expect(manualSpan).toBeDefined(); + expect(manualSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + }, + name: 'test-manual-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(parentTestSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + }, + name: 'test-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: parentTestSpan!.span_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(segmentSpan).toEqual({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.server' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'route' }, + 'sentry.span.source': { type: 'string', value: 'route' }, + 'server.address': { + type: 'string', + value: 'localhost', + }, + 'url.full': { + type: 'string', + value: expect.stringMatching(/^http:\/\/localhost:.+$/), + }, + 'url.path': { + type: 'string', + value: '/', + }, + 'url.port': { + type: 'string', + value: '8787', + }, + 'url.scheme': { + type: 'string', + value: 'http:', + }, + 'user_agent.original': { + type: 'string', + value: 'node', + }, + 'http.request.header.accept': { + type: 'string', + value: '*/*', + }, + 'http.request.header.accept_encoding': { + type: 'string', + value: 'br, gzip', + }, + 'http.request.header.accept_language': { + type: 'string', + value: '*', + }, + 'http.request.header.cf_connecting_ip': { + type: 'string', + value: '::1', + }, + 'http.request.header.host': { + type: 'string', + value: expect.stringMatching(/^localhost:.+$/), + }, + 'http.request.header.sec_fetch_mode': { + type: 'string', + value: 'cors', + }, + 'http.request.header.user_agent': { + type: 'string', + value: 'node', + }, + 'http.request.method': { + type: 'string', + value: 'GET', + }, + 'http.response.status_code': { + type: 'integer', + value: 200, + }, + 'network.protocol.name': { + type: 'string', + value: 'HTTP/1.1', + }, + }, + is_segment: true, + trace_id: traceId, + span_id: segmentSpanId, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + name: 'GET /', + }); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc new file mode 100644 index 000000000000..b247aa82fb26 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "start-span-streamed", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts index 795eb03e27c2..1415950208cc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts @@ -6,7 +6,8 @@ import { createRunner } from '../../../runner'; // must appear as children of the DO transaction. The first invocation always worked; // the second invocation on the same DO instance previously lost its child spans // because the client was disposed after the first call. -it('sends child spans on repeated Durable Object calls', async ({ signal }) => { +// TODO: unskip - this test is flaky, timing out in CI +it.skip('sends child spans on repeated Durable Object calls', async ({ signal }) => { function assertDoWorkEnvelope(envelope: unknown): void { const transactionEvent = (envelope as any)[1]?.[0]?.[1]; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts new file mode 100644 index 000000000000..cbb6de4f514c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts @@ -0,0 +1,95 @@ +import { instrumentDurableObjectWithSentry, withSentry } from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + ECHO_HEADERS_DO: DurableObjectNamespace; +} + +class EchoHeadersDurableObjectBase extends DurableObject { + async fetch(incoming: Request): Promise { + return Response.json({ + sentryTrace: incoming.headers.get('sentry-trace'), + baggage: incoming.headers.get('baggage'), + authorization: incoming.headers.get('authorization'), + xFromInit: incoming.headers.get('x-from-init'), + xExtra: incoming.headers.get('x-extra'), + xMergeProbe: incoming.headers.get('x-merge-probe'), + }); + } +} + +export const EchoHeadersDurableObject = instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + EchoHeadersDurableObjectBase, +); + +export default withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + const id = env.ECHO_HEADERS_DO.idFromName('instrument-fetcher-echo'); + const stub = env.ECHO_HEADERS_DO.get(id); + const doUrl = new URL(request.url); + + let subResponse: Response; + + if (url.pathname === '/via-init') { + subResponse = await stub.fetch(doUrl, { + headers: { + Authorization: 'Bearer from-init', + 'X-Extra': 'init-extra', + 'X-Merge-Probe': 'via-init-probe', + }, + }); + } else if (url.pathname === '/via-request') { + subResponse = await stub.fetch( + new Request(doUrl, { + headers: { + Authorization: 'Bearer from-request', + 'X-Extra': 'request-extra', + 'X-Merge-Probe': 'via-request-probe', + }, + }), + ); + } else if (url.pathname === '/via-request-and-init') { + subResponse = await stub.fetch( + new Request(doUrl, { + headers: { + Authorization: 'Bearer from-request', + 'X-Extra': 'request-extra', + 'X-Merge-Probe': 'dropped-from-request', + }, + }), + { + headers: { + 'X-From-Init': '1', + 'X-Merge-Probe': 'via-init-wins', + }, + }, + ); + } else if (url.pathname === '/with-preset-sentry-baggage') { + subResponse = await stub.fetch( + new Request(doUrl, { + headers: { + baggage: 'sentry-environment=preset,acme=vendor', + }, + }), + ); + } else { + return new Response('not found', { status: 404 }); + } + + const payload: unknown = await subResponse.json(); + return Response.json(payload); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts new file mode 100644 index 000000000000..ae38568e34ab --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts @@ -0,0 +1,131 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +type EchoedHeaders = { + sentryTrace: string | null; + baggage: string | null; + authorization: string | null; + xFromInit: string | null; + xExtra: string | null; + xMergeProbe: string | null; +}; + +const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/; + +type ScenarioPath = '/via-init' | '/via-request' | '/via-request-and-init' | '/with-preset-sentry-baggage'; + +function startStubFetchScenario(path: ScenarioPath, signal: AbortSignal) { + let mainTraceId: string | undefined; + let mainSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const traceBase = { + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }; + + const { makeRequest, completed } = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining(traceBase), + }), + transaction: `GET ${path}`, + }), + ); + expect(parentSpanId).toBeUndefined(); + + mainTraceId = transactionEvent.contexts?.trace?.trace_id as string; + mainSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining(traceBase), + }), + transaction: `GET ${path}`, + }), + ); + expect(parentSpanId).toBeDefined(); + + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = parentSpanId as string; + }) + .unordered() + .start(signal); + + return { + makeRequest, + async completedWithTraceCheck(): Promise { + await completed(); + expect(mainTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(mainTraceId).toBe(doTraceId); + expect(mainSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(mainSpanId); + }, + }; +} + +it('stub.fetch: headers in init (URL string + init)', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-init', signal); + const body = await makeRequest('get', '/via-init'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id='); + expect(body?.authorization).toBe('Bearer from-init'); + expect(body?.xExtra).toBe('init-extra'); + expect(body?.xMergeProbe).toBe('via-init-probe'); + expect(body?.xFromInit).toBeNull(); +}); + +it('stub.fetch: headers on Request (URL from incoming request)', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request', signal); + const body = await makeRequest('get', '/via-request'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id='); + expect(body?.authorization).toBe('Bearer from-request'); + expect(body?.xExtra).toBe('request-extra'); + expect(body?.xMergeProbe).toBe('via-request-probe'); + expect(body?.xFromInit).toBeNull(); +}); + +it('stub.fetch: Request + init — only init headers are sent', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request-and-init', signal); + const body = await makeRequest('get', '/via-request-and-init'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id='); + expect(body?.authorization).toBeNull(); + expect(body?.xExtra).toBeNull(); + expect(body?.xMergeProbe).toBe('via-init-wins'); + expect(body?.xFromInit).toBe('1'); +}); + +it('stub.fetch: does not append SDK baggage when the Request already includes Sentry baggage', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/with-preset-sentry-baggage', signal); + const body = await makeRequest('get', '/with-preset-sentry-baggage'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + // Dynamic SDK baggage includes `sentry-trace_id=…`; appending it again would change this string. + expect(body?.baggage).toBe('sentry-environment=preset,acme=vendor'); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc new file mode 100644 index 000000000000..28d4a0a81f19 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-instrument-fetcher", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["EchoHeadersDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "EchoHeadersDurableObject", + "name": "ECHO_HEADERS_DO", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/index.ts new file mode 100644 index 000000000000..6ec278fc9ace --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/index.ts @@ -0,0 +1,60 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; + MY_QUEUE: Queue; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(_request: Request) { + return new Response('DO is fine'); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/queue/send') { + await env.MY_QUEUE.send({ action: 'test' }); + return new Response('Queued'); + } + + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const response = await stub.fetch(new Request('http://fake-host/hello')); + const text = await response.text(); + return new Response(text); + }, + + async queue(batch, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + for (const message of batch.messages) { + await stub.fetch(new Request('http://fake-host/hello')); + message.ack(); + } + }, + + async scheduled(controller, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + await stub.fetch(new Request('http://fake-host/hello')); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/test.ts new file mode 100644 index 000000000000..287d62b25c1f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/test.ts @@ -0,0 +1,209 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('does not propagate trace from worker to durable object when enableRpcTracePropagation is disabled', async ({ + signal, +}) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).not.toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeUndefined(); +}); + +it('does not propagate trace from queue handler to durable object when enableRpcTracePropagation is disabled', async ({ + signal, +}) => { + let queueTraceId: string | undefined; + let queueSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'queue.process', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.queue', + }), + origin: 'auto.faas.cloudflare.queue', + }), + }), + transaction: 'process my-queue', + }), + ); + queueTraceId = transactionEvent.contexts?.trace?.trace_id as string; + queueSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + // Also expect the fetch transaction from the /queue/send request + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /queue/send', + }), + ); + }) + .unordered() + .start(signal); + // The fetch handler sends a message to the queue, which triggers the queue consumer + await runner.makeRequest('get', '/queue/send'); + await runner.completed(); + + expect(queueTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(queueTraceId).not.toBe(doTraceId); + + expect(queueSpanId).toBeDefined(); + expect(doParentSpanId).toBeUndefined(); +}); + +it('does not propagate trace from scheduled handler to durable object when enableRpcTracePropagation is disabled', async ({ + signal, +}) => { + let scheduledTraceId: string | undefined; + let scheduledSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .withWranglerArgs('--test-scheduled') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'faas.cron', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.scheduled', + }), + origin: 'auto.faas.cloudflare.scheduled', + }), + }), + }), + ); + scheduledTraceId = transactionEvent.contexts?.trace?.trace_id as string; + scheduledSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/__scheduled?cron=*+*+*+*+*'); + await runner.completed(); + + expect(scheduledTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(scheduledTraceId).not.toBe(doTraceId); + + expect(scheduledSpanId).toBeDefined(); + expect(doParentSpanId).toBeUndefined(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/wrangler.jsonc new file mode 100644 index 000000000000..b6dc58439427 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "name": "cloudflare-durable-objects", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, + "queues": { + "producers": [ + { + "binding": "MY_QUEUE", + "queue": "my-queue", + }, + ], + "consumers": [ + { + "queue": "my-queue", + }, + ], + }, + "triggers": { + "crons": ["* * * * *"], + }, + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts new file mode 100644 index 000000000000..32464998b413 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts @@ -0,0 +1,62 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; + MY_QUEUE: Queue; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(_request: Request) { + return new Response('DO is fine'); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/queue/send') { + await env.MY_QUEUE.send({ action: 'test' }); + return new Response('Queued'); + } + + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const response = await stub.fetch(new Request('http://fake-host/hello')); + const text = await response.text(); + return new Response(text); + }, + + async queue(batch, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + for (const message of batch.messages) { + await stub.fetch(new Request('http://fake-host/hello')); + message.ack(); + } + }, + + async scheduled(controller, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + await stub.fetch(new Request('http://fake-host/hello')); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts new file mode 100644 index 000000000000..d1c5385f8fbf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts @@ -0,0 +1,206 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to durable object', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace from queue handler to durable object', async ({ signal }) => { + let queueTraceId: string | undefined; + let queueSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'queue.process', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.queue', + }), + origin: 'auto.faas.cloudflare.queue', + }), + }), + transaction: 'process my-queue', + }), + ); + queueTraceId = transactionEvent.contexts?.trace?.trace_id as string; + queueSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + // Also expect the fetch transaction from the /queue/send request + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /queue/send', + }), + ); + }) + .unordered() + .start(signal); + // The fetch handler sends a message to the queue, which triggers the queue consumer + await runner.makeRequest('get', '/queue/send'); + await runner.completed(); + + expect(queueTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(queueTraceId).toBe(doTraceId); + + expect(queueSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(queueSpanId); +}); + +it('propagates trace from scheduled handler to durable object', async ({ signal }) => { + let scheduledTraceId: string | undefined; + let scheduledSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .withWranglerArgs('--test-scheduled') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'faas.cron', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.scheduled', + }), + origin: 'auto.faas.cloudflare.scheduled', + }), + }), + }), + ); + scheduledTraceId = transactionEvent.contexts?.trace?.trace_id as string; + scheduledSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/__scheduled?cron=*+*+*+*+*'); + await runner.completed(); + + expect(scheduledTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(scheduledTraceId).toBe(doTraceId); + + expect(scheduledSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(scheduledSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc new file mode 100644 index 000000000000..b6dc58439427 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "name": "cloudflare-durable-objects", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, + "queues": { + "producers": [ + { + "binding": "MY_QUEUE", + "queue": "my-queue", + }, + ], + "consumers": [ + { + "queue": "my-queue", + }, + ], + }, + "triggers": { + "crons": ["* * * * *"], + }, + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts similarity index 89% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts index 06c79931b880..95de55198929 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts @@ -5,7 +5,7 @@ interface Env { } const myWorker = { - async fetch(request: Request) { + async fetch(_request: Request) { return new Response('Hello from another worker!'); }, }; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts similarity index 92% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts index dc178759f51d..a049a1c796b3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts @@ -9,6 +9,7 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, + enableRpcTracePropagation: true, }), { async fetch(request, env) { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts similarity index 59% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts index fd64c0d31d27..878b307ca5f4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts @@ -1,8 +1,13 @@ import { expect, it } from 'vitest'; import type { Event } from '@sentry/core'; -import { createRunner } from '../../../runner'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to worker via service binding', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let subWorkerTraceId: string | undefined; + let subWorkerParentSpanId: string | undefined; -it('adds a trace to a worker via service binding', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; @@ -20,6 +25,8 @@ it('adds a trace to a worker via service binding', async ({ signal }) => { transaction: 'GET /', }), ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; }) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; @@ -37,9 +44,19 @@ it('adds a trace to a worker via service binding', async ({ signal }) => { transaction: 'GET /hello', }), ); + subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; }) .unordered() .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(subWorkerTraceId).toBeDefined(); + expect(workerTraceId).toBe(subWorkerTraceId); + + expect(workerSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBe(workerSpanId); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler-sub-worker.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler-sub-worker.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts new file mode 100644 index 000000000000..3eb2eb4331ed --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts @@ -0,0 +1,77 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; + MY_WORKFLOW: Workflow; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(_request: Request) { + return new Response('DO is fine'); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +class MyWorkflowBase extends WorkflowEntrypoint { + async run(_event: WorkflowEvent, step: WorkflowStep): Promise { + await step.do('workflow-env-test', async () => { + const id = this.env.MY_DURABLE_OBJECT.idFromName('workflow-test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + const response = await stub.fetch(new Request('http://fake-host/workflow-test')); + return response.text(); + }); + } +} + +export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyWorkflowBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === '/workflow/trigger') { + const instance = await env.MY_WORKFLOW.create(); + // Poll until workflow completes (or timeout after 15s) + for (let i = 0; i < 15; i++) { + try { + const s = await instance.status(); + if (s.status === 'complete' || s.status === 'errored') { + return new Response(JSON.stringify({ id: instance.id, ...s }), { + headers: { 'content-type': 'application/json' }, + }); + } + } catch { + // status() may not be available in local dev + } + await new Promise(r => setTimeout(r, 1000)); + } + return new Response(JSON.stringify({ id: instance.id, status: 'timeout' }), { + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('Hello World!'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts new file mode 100644 index 000000000000..818e92d8d677 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts @@ -0,0 +1,63 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('traces a workflow that calls a durable object with the same trace id', async ({ signal }) => { + let workflowTraceId: string | undefined; + let workflowSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'function.step.do', + data: expect.objectContaining({ + 'sentry.op': 'function.step.do', + 'sentry.origin': 'auto.faas.cloudflare.workflow', + }), + origin: 'auto.faas.cloudflare.workflow', + }), + }), + transaction: 'workflow-env-test', + }), + ); + workflowTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workflowSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /workflow-test', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/workflow/trigger'); + await runner.completed(); + + expect(workflowTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workflowTraceId).toBe(doTraceId); + + expect(workflowSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workflowSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc new file mode 100644 index 000000000000..fd8a63daf3f5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc @@ -0,0 +1,30 @@ +{ + "name": "cloudflare-durable-objects", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, + "workflows": [ + { + "name": "my-workflow", + "binding": "MY_WORKFLOW", + "class_name": "MyWorkflow", + }, + ], + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} diff --git a/dev-packages/e2e-tests/Dockerfile.publish-packages b/dev-packages/e2e-tests/Dockerfile.publish-packages deleted file mode 100644 index 88fd7f116728..000000000000 --- a/dev-packages/e2e-tests/Dockerfile.publish-packages +++ /dev/null @@ -1,6 +0,0 @@ -# This Dockerfile exists for the purpose of using a specific node/npm version (ie. the same we use in CI) to run npm publish with -ARG NODE_VERSION=18.18.0 -FROM node:${NODE_VERSION} - -WORKDIR /sentry-javascript/dev-packages/e2e-tests -CMD [ "yarn", "ts-node", "publish-packages.ts", "--transpile-only" ] diff --git a/dev-packages/e2e-tests/lib/constants.ts b/dev-packages/e2e-tests/lib/constants.ts index bdc550009148..b5476ba4614d 100644 --- a/dev-packages/e2e-tests/lib/constants.ts +++ b/dev-packages/e2e-tests/lib/constants.ts @@ -2,4 +2,3 @@ export const TEST_REGISTRY_CONTAINER_NAME = 'verdaccio-e2e-test-registry'; export const DEFAULT_BUILD_TIMEOUT_SECONDS = 60 * 5; export const DEFAULT_TEST_TIMEOUT_SECONDS = 60 * 2; export const VERDACCIO_VERSION = '5.22.1'; -export const PUBLISH_PACKAGES_DOCKER_IMAGE_NAME = 'publish-packages'; diff --git a/dev-packages/e2e-tests/lib/publishPackages.ts b/dev-packages/e2e-tests/lib/publishPackages.ts new file mode 100644 index 000000000000..5aed3a76e77a --- /dev/null +++ b/dev-packages/e2e-tests/lib/publishPackages.ts @@ -0,0 +1,41 @@ +/* eslint-disable no-console */ +import * as childProcess from 'child_process'; +import { readFileSync } from 'fs'; +import { globSync } from 'glob'; +import * as path from 'path'; + +const repositoryRoot = path.resolve(__dirname, '../../..'); + +/** + * Publishes all built Sentry package tarballs to the local Verdaccio test registry. + */ +export function publishPackages(): void { + const version = (JSON.parse(readFileSync(path.join(__dirname, '../package.json'), 'utf8')) as { version: string }) + .version; + + // Get absolute paths of all the packages we want to publish to the fake registry + // Only include the current versions, to avoid getting old tarballs published as well + const packageTarballPaths = globSync(`packages/*/sentry-*-${version}.tgz`, { + cwd: repositoryRoot, + absolute: true, + }); + + if (packageTarballPaths.length === 0) { + throw new Error(`No packages to publish for version ${version}, did you run "yarn build:tarballs"?`); + } + + const npmrc = path.join(__dirname, '../test-registry.npmrc'); + + for (const tarballPath of packageTarballPaths) { + console.log(`Publishing tarball ${tarballPath} ...`); + const result = childProcess.spawnSync('npm', ['--userconfig', npmrc, 'publish', tarballPath], { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: 'inherit', + }); + + if (result.status !== 0) { + throw new Error(`Error publishing tarball ${tarballPath}`); + } + } +} diff --git a/dev-packages/e2e-tests/publish-packages.ts b/dev-packages/e2e-tests/publish-packages.ts deleted file mode 100644 index cbe9a37de0a7..000000000000 --- a/dev-packages/e2e-tests/publish-packages.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as childProcess from 'child_process'; -import { readFileSync } from 'fs'; -import { globSync } from 'glob'; -import * as path from 'path'; - -const repositoryRoot = path.resolve(__dirname, '../..'); - -const version = (JSON.parse(readFileSync(path.join(__dirname, './package.json'), 'utf8')) as { version: string }) - .version; - -// Get absolute paths of all the packages we want to publish to the fake registry -// Only include the current versions, to avoid getting old tarballs published as well -const packageTarballPaths = globSync(`packages/*/sentry-*-${version}.tgz`, { - cwd: repositoryRoot, - absolute: true, -}); - -if (packageTarballPaths.length === 0) { - // eslint-disable-next-line no-console - console.log(`No packages to publish for version ${version}, did you run "yarn build:tarballs"?`); - process.exit(1); -} - -// Publish built packages to the fake registry -packageTarballPaths.forEach(tarballPath => { - // eslint-disable-next-line no-console - console.log(`Publishing tarball ${tarballPath} ...`); - // `--userconfig` flag needs to be before `publish` - childProcess.exec( - `npm --userconfig ${__dirname}/test-registry.npmrc publish ${tarballPath}`, - { - cwd: repositoryRoot, // Can't use __dirname here because npm would try to publish `@sentry-internal/e2e-tests` - encoding: 'utf8', - }, - err => { - if (err) { - // eslint-disable-next-line no-console - console.error(`Error publishing tarball ${tarballPath}`, err); - process.exit(1); - } - }, - ); -}); diff --git a/dev-packages/e2e-tests/registrySetup.ts b/dev-packages/e2e-tests/registrySetup.ts index 80cbcd10d384..6c521e619f76 100644 --- a/dev-packages/e2e-tests/registrySetup.ts +++ b/dev-packages/e2e-tests/registrySetup.ts @@ -1,10 +1,7 @@ /* eslint-disable no-console */ import * as childProcess from 'child_process'; -import * as path from 'path'; -import { PUBLISH_PACKAGES_DOCKER_IMAGE_NAME, TEST_REGISTRY_CONTAINER_NAME, VERDACCIO_VERSION } from './lib/constants'; - -const publishScriptNodeVersion = process.env.E2E_TEST_PUBLISH_SCRIPT_NODE_VERSION; -const repositoryRoot = path.resolve(__dirname, '../..'); +import { TEST_REGISTRY_CONTAINER_NAME, VERDACCIO_VERSION } from './lib/constants'; +import { publishPackages } from './lib/publishPackages'; // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#grouping-log-lines function groupCIOutput(groupTitle: string, fn: () => void): void { @@ -45,56 +42,7 @@ export function registrySetup(): void { throw new Error('Start Registry Process failed.'); } - // Build container image that is uploading our packages to fake registry with specific Node.js/npm version - const buildPublishImageProcessResult = childProcess.spawnSync( - 'docker', - [ - 'build', - '--tag', - PUBLISH_PACKAGES_DOCKER_IMAGE_NAME, - '--file', - './Dockerfile.publish-packages', - ...(publishScriptNodeVersion ? ['--build-arg', `NODE_VERSION=${publishScriptNodeVersion}`] : []), - '.', - ], - { - encoding: 'utf8', - stdio: 'inherit', - }, - ); - - if (buildPublishImageProcessResult.status !== 0) { - throw new Error('Build Publish Image failed.'); - } - - // Run container that uploads our packages to fake registry - const publishImageContainerRunProcess = childProcess.spawnSync( - 'docker', - [ - 'run', - '--rm', - '-v', - `${repositoryRoot}:/sentry-javascript`, - '--network', - 'host', - PUBLISH_PACKAGES_DOCKER_IMAGE_NAME, - ], - { - encoding: 'utf8', - stdio: 'inherit', - }, - ); - - const statusCode = publishImageContainerRunProcess.status; - - if (statusCode !== 0) { - if (statusCode === 137) { - throw new Error( - `Publish Image Container failed with exit code ${statusCode}, possibly due to memory issues. Consider increasing the memory limit for the container.`, - ); - } - throw new Error(`Publish Image Container failed with exit code ${statusCode}`); - } + publishPackages(); }); console.log(''); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index a8e6c9d538ae..3d536e6fbabe 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@sentry/cloudflare": "latest || *", - "hono": "4.12.7" + "hono": "4.12.12" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts index cc71748c44f8..6828f6ee02d6 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts @@ -19,6 +19,13 @@ class MyDurableObjectBase extends DurableObject { throw new Error('Should be recorded in Sentry.'); } + async alarm(): Promise { + const action = await this.ctx.storage.get('alarm-action'); + if (action === 'throw') { + throw new Error('Alarm error captured by Sentry'); + } + } + async fetch(request: Request) { const url = new URL(request.url); switch (url.pathname) { @@ -32,6 +39,12 @@ class MyDurableObjectBase extends DurableObject { this.ctx.acceptWebSocket(server); return new Response(null, { status: 101, webSocket: client }); } + case '/setAlarm': { + const action = url.searchParams.get('action') || 'succeed'; + await this.ctx.storage.put('alarm-action', action); + await this.ctx.storage.setAlarm(Date.now() + 500); + return new Response('Alarm set'); + } case '/storage/put': { await this.ctx.storage.put('test-key', 'test-value'); return new Response('Stored'); @@ -72,7 +85,7 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - instrumentPrototypeMethods: true, + enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -88,6 +101,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, + enableRpcTracePropagation: true, }), { async fetch(request, env) { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts index 4235ca7d17cc..d43cb21770a0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts @@ -99,3 +99,47 @@ test('Storage operations create spans in Durable Object transactions', async ({ expect(putSpan?.data?.['db.system.name']).toBe('cloudflare.durable_object.storage'); expect(putSpan?.data?.['db.operation.name']).toBe('put'); }); + +test.describe('Alarm instrumentation', () => { + test.describe.configure({ mode: 'serial' }); + + test('captures error from alarm handler', async ({ baseURL }) => { + const errorWaiter = waitForError('cloudflare-workers', event => { + return event.exception?.values?.[0]?.value === 'Alarm error captured by Sentry'; + }); + + const response = await fetch(`${baseURL}/pass-to-object/setAlarm?action=throw`); + expect(response.status).toBe(200); + + const event = await errorWaiter; + expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); + }); + + test('creates a transaction for alarm with new trace linked to setAlarm', async ({ baseURL }) => { + const setAlarmTransactionWaiter = waitForTransaction('cloudflare-workers', event => { + return event.spans?.some(span => span.description?.includes('storage_setAlarm')) ?? false; + }); + + const alarmTransactionWaiter = waitForTransaction('cloudflare-workers', event => { + return event.transaction === 'alarm' && event.contexts?.trace?.op === 'function'; + }); + + const response = await fetch(`${baseURL}/pass-to-object/setAlarm`); + expect(response.status).toBe(200); + + const setAlarmTransaction = await setAlarmTransactionWaiter; + const alarmTransaction = await alarmTransactionWaiter; + + // Alarm creates a transaction with correct attributes + expect(alarmTransaction.contexts?.trace?.op).toBe('function'); + expect(alarmTransaction.contexts?.trace?.origin).toBe('auto.faas.cloudflare.durable_object'); + + // Alarm starts a new trace (different trace ID from the request that called setAlarm) + expect(alarmTransaction.contexts?.trace?.trace_id).not.toBe(setAlarmTransaction.contexts?.trace?.trace_id); + + // Alarm links to the trace that called setAlarm via sentry.previous_trace attribute + const previousTrace = alarmTransaction.contexts?.trace?.data?.['sentry.previous_trace']; + expect(previousTrace).toBeDefined(); + expect(previousTrace).toContain(setAlarmTransaction.contexts?.trace?.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts index 9c0159b26327..c82f324e3c75 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts @@ -72,7 +72,7 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - instrumentPrototypeMethods: true, + enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -118,6 +118,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, + enableRpcTracePropagation: true, }), MyWorker, ); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc b/dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json b/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json new file mode 100644 index 000000000000..35242c740171 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/deno.json @@ -0,0 +1,11 @@ +{ + "imports": { + "@sentry/deno": "npm:@sentry/deno", + "@sentry/core": "npm:@sentry/core", + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "ai": "npm:ai@^3.0.0", + "ai/test": "npm:ai@^3.0.0/test", + "zod": "npm:zod@^3.22.4" + }, + "nodeModulesDir": "manual" +} diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/package.json b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json new file mode 100644 index 000000000000..70a20db2de05 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/package.json @@ -0,0 +1,25 @@ +{ + "name": "deno-streamed-app", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "deno run --allow-net --allow-env --allow-read src/app.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/deno": "latest || *", + "@opentelemetry/api": "^1.9.0", + "ai": "^3.0.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs new file mode 100644 index 000000000000..3d3ab7d8df02 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts new file mode 100644 index 000000000000..206eb7f6f387 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts @@ -0,0 +1,66 @@ +import { trace } from '@opentelemetry/api'; + +// Simulate a pre-existing OTel provider (like Supabase Edge Runtime registers +// before user code runs). Without trace.disable() in Sentry's setup, this would +// cause setGlobalTracerProvider to be a no-op, silently dropping all OTel spans. +const fakeProvider = { + getTracer: () => ({ + startSpan: () => ({ end: () => {}, setAttributes: () => {} }), + startActiveSpan: (_name: string, fn: Function) => fn({ end: () => {}, setAttributes: () => {} }), + }), +}; +trace.setGlobalTracerProvider(fakeProvider as any); + +// Sentry.init() must call trace.disable() to clear the fake provider above +import * as Sentry from '@sentry/deno'; + +Sentry.init({ + environment: 'qa', + dsn: Deno.env.get('E2E_TEST_DSN'), + debug: !!Deno.env.get('DEBUG'), + tunnel: 'http://localhost:3031/', + traceLifecycle: 'stream', + tracesSampleRate: 1, + sendDefaultPii: true, + enableLogs: true, +}); + +const port = 3030; + +function flushDeferred() { + setTimeout(() => { + Sentry.flush(); + }, 100); +} + +Deno.serve({ port }, async (req: Request) => { + const url = new URL(req.url); + + // Test Sentry.startSpan — uses Sentry's internal pipeline + if (url.pathname === '/test-sentry-span') { + Sentry.startSpan({ name: 'test-sentry-span' }, () => { + // noop + }); + flushDeferred(); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test interop: OTel span inside a Sentry span + if (url.pathname === '/test-interop') { + Sentry.startSpan({ name: 'sentry-parent' }, () => { + const tracer = trace.getTracer('test-tracer'); + const span = tracer.startSpan('otel-child'); + span.end(); + }); + flushDeferred(); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response('Not found', { status: 404 }); +}); + +console.log(`Deno test app listening on port ${port}`); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs new file mode 100644 index 000000000000..a0c7bfc7222f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'deno-streamed', +}); diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts new file mode 100644 index 000000000000..023429b07f41 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -0,0 +1,284 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +const SEGMENT_SPAN = { + attributes: { + 'client.address': { + type: 'string', + value: expect.any(String), + }, + 'client.port': { + type: 'integer', + value: expect.any(Number), + }, + 'http.request.header.accept': { + type: 'string', + value: '*/*', + }, + 'http.request.header.accept_encoding': { + type: 'string', + value: 'gzip, deflate', + }, + 'http.request.header.accept_language': { + type: 'string', + value: '*', + }, + 'http.request.header.connection': { + type: 'string', + value: 'keep-alive', + }, + 'http.request.header.host': { + type: 'string', + value: expect.stringMatching(/^localhost:\d+$/), + }, + 'http.request.header.sec_fetch_mode': { + type: 'string', + value: 'cors', + }, + 'http.request.header.user_agent': { + type: 'string', + value: 'node', + }, + 'http.request.method': { + type: 'string', + value: 'GET', + }, + 'http.response.header.content_type': { + type: 'string', + value: 'application/json', + }, + 'http.response.status_code': { + type: 'integer', + value: expect.any(Number), + }, + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.op': { + type: 'string', + value: 'http.server', + }, + 'sentry.origin': { + type: 'string', + value: 'auto.http.deno', + }, + 'sentry.sample_rate': { + type: 'integer', + value: 1, + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-sentry-span', + }, + 'sentry.source': { + type: 'string', + value: 'url', + }, + 'sentry.span.source': { + type: 'string', + value: 'url', + }, + 'server.address': { + type: 'string', + value: expect.any(String), + }, + 'url.full': { + type: 'string', + value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-sentry-span$/), + }, + 'url.path': { + type: 'string', + value: '/test-sentry-span', + }, + 'url.port': { + type: 'string', + value: expect.any(String), + }, + 'url.scheme': { + type: 'string', + value: 'http:', + }, + 'user_agent.original': { + type: 'string', + value: 'node', + }, + }, + end_timestamp: expect.any(Number), + is_segment: true, + name: 'GET /test-sentry-span', + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), +}; + +test('Sends streamed spans (http.server and manual with Sentry.startSpan)', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('deno-streamed', spans => { + return spans.some(span => span.name === 'test-sentry-span'); + }); + + await fetch(`${baseURL}/test-sentry-span`); + + const spans = await spansPromise; + expect(spans).toHaveLength(2); + + expect(spans).toEqual([ + { + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-sentry-span', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'test-sentry-span', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }, + SEGMENT_SPAN, + ]); +}); + +test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { + const spansPromise = waitForStreamedSpans('deno-streamed', spans => { + return spans.some(span => span.name === 'sentry-parent'); + }); + + await fetch(`${baseURL}/test-interop`); + + const spans = await spansPromise; + + expect(spans).toHaveLength(3); + + const httpServerSpan = spans.find(span => getSpanOp(span) === 'http.server'); + expect(httpServerSpan).toEqual({ + ...SEGMENT_SPAN, + name: 'GET /test-interop', + attributes: { + ...SEGMENT_SPAN.attributes, + 'sentry.segment.name': { type: 'string', value: 'GET /test-interop' }, + 'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-interop$/) }, + 'url.path': { type: 'string', value: '/test-interop' }, + }, + }); + // Verify the OTel span is a child of the Sentry span + const sentrySpan = spans.find(span => span.name === 'sentry-parent'); + const otelSpan = spans.find(span => span.name === 'otel-child'); + + expect(otelSpan!.parent_span_id).toBe(sentrySpan!.span_id); + + expect(sentrySpan).toEqual({ + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-interop', + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'sentry-parent', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: httpServerSpan!.trace_id, + }); + + expect(otelSpan).toEqual({ + attributes: { + 'sentry.environment': { + type: 'string', + value: 'qa', + }, + 'sentry.origin': { + type: 'string', + value: 'manual', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.deno', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'sentry.segment.id': { + type: 'string', + value: expect.stringMatching(/^[\da-f]{16}$/), + }, + 'sentry.segment.name': { + type: 'string', + value: 'GET /test-interop', + }, + 'sentry.deno_tracer': { + type: 'boolean', + value: true, + }, + }, + end_timestamp: expect.any(Number), + is_segment: false, + name: 'otel-child', + parent_span_id: expect.stringMatching(/^[\da-f]{16}$/), + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + status: 'ok', + trace_id: httpServerSpan!.trace_id, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts index d0b824bf9b2f..9d849a33224b 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts @@ -28,13 +28,27 @@ test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) = // Due to the AI SDK monkey-patching limitation (https://github.com/vercel/ai/pull/6716), // only explicitly opted-in calls produce telemetry spans. // The explicitly enabled call (experimental_telemetry: { isEnabled: true }) should produce spans. - const aiSpans = spans.filter( - (span: any) => + const aiSpans = spans.filter((span: any) => { + if ( span.op === 'gen_ai.invoke_agent' || span.op === 'gen_ai.generate_content' || - span.op === 'otel.span' || - span.description?.includes('ai.generateText'), - ); + span.op === 'gen_ai.execute_tool' + ) { + return true; + } + // Processed Vercel AI spans (incl. cases where OTel kind no longer maps to a generic `op`) + if (span.origin === 'auto.vercelai.otel') { + return true; + } + // Raw Vercel AI OTel span names / attributes before or without full Sentry mapping + if (typeof span.description === 'string' && span.description.startsWith('ai.')) { + return true; + } + if (span.data?.['ai.operationId'] != null || span.data?.['ai.pipeline.name'] != null) { + return true; + } + return false; + }); // We expect at least one AI-related span from the explicitly enabled call expect(aiSpans.length).toBeGreaterThanOrEqual(1); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts index 3cd0892cebdc..19077bb76b75 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts @@ -33,11 +33,15 @@ test('Sends transaction with OTel tracer.startSpan despite pre-existing provider expect.arrayContaining([ expect.objectContaining({ description: 'test-otel-span', - op: 'otel.span', origin: 'manual', }), ]), ); + + const otelSpan = transaction.spans!.find((s: any) => s.description === 'test-otel-span'); + expect(otelSpan).toBeDefined(); + // INTERNAL (and other unmapped) kinds must not get a synthetic `otel.span` op + expect(otelSpan!.op).toBeUndefined(); }); test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) => { @@ -53,11 +57,14 @@ test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) = expect.arrayContaining([ expect.objectContaining({ description: 'test-otel-active-span', - op: 'otel.span', origin: 'manual', }), ]), ); + + const otelSpan = transaction.spans!.find((s: any) => s.description === 'test-otel-active-span'); + expect(otelSpan).toBeDefined(); + expect(otelSpan!.op).toBeUndefined(); }); test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { @@ -77,7 +84,6 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) }), expect.objectContaining({ description: 'otel-child', - op: 'otel.span', origin: 'manual', }), ]), @@ -87,4 +93,5 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) const sentrySpan = transaction.spans!.find((s: any) => s.description === 'sentry-parent'); const otelSpan = transaction.spans!.find((s: any) => s.description === 'otel-child'); expect(otelSpan!.parent_span_id).toBe(sentrySpan!.span_id); + expect(otelSpan!.op).toBeUndefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json b/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json index 46beea570042..6917e546a383 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/package.json @@ -21,7 +21,7 @@ "@nestjs/platform-express": "^10.0.0", "@sentry/nestjs": "latest || *", "reflect-metadata": "^0.2.0", - "axios": "1.13.5", + "axios": "1.15.0", "rxjs": "^7.8.1" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts index b53cda3ac968..ba446f2e7c4e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts index 2a5e2910050a..55ac655dfc5a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts index fb93e77aaf8b..ab2086622ca3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts index dc16f1590aa3..07e5f007efad 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/tests/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json index 9695657cbd3f..5be3e1b9a9d2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json @@ -20,7 +20,7 @@ "@opennextjs/cloudflare": "^1.14.9", "@sentry/nextjs": "latest || *", "@sentry/core": "latest || *", - "next": "16.1.7", + "next": "16.2.3", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts index 3c9ab427b3de..30faebe69548 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/parameterized-routes.test.ts @@ -14,13 +14,6 @@ test.skip('should create a parameterized transaction when the `app` directory is const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -63,13 +56,6 @@ test.skip('should create a static transaction when the `app` directory is used a const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -110,13 +96,6 @@ test.skip('should create a partially parameterized transaction when the `app` di const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -158,13 +137,6 @@ test.skip('should create a nested parameterized transaction when the `app` direc const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts index 4078ded5734d..43a2aa6191de 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts @@ -13,13 +13,6 @@ test('should create a parameterized transaction when the `app` directory is used const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -61,13 +54,6 @@ test('should create a static transaction when the `app` directory is used and th const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/static', to: '/parameterized/static' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -107,13 +93,6 @@ test('should create a partially parameterized transaction when the `app` directo const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { @@ -154,13 +133,6 @@ test('should create a nested parameterized transaction when the `app` directory const transaction = await transactionPromise; expect(transaction).toMatchObject({ - breadcrumbs: expect.arrayContaining([ - { - category: 'navigation', - data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, - timestamp: expect.any(Number), - }, - ]), contexts: { react: { version: expect.any(String) }, trace: { diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json index d1957655916b..6f379575019b 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/instrumentation-http": "^0.57.1", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json index 69decb891620..b7d9b06647b3 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.2", "@opentelemetry/instrumentation-http": "^0.57.2", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json index abb49f748d96..28d17064a5ff 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1/package.json @@ -14,7 +14,6 @@ "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", "@opentelemetry/core": "^1.30.1", "@opentelemetry/instrumentation": "^0.57.1", "@opentelemetry/instrumentation-http": "^0.57.1", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json index 974d0711acc8..f79c0894bfc9 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-http": "^0.214.0", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json index 00e1ab056be6..dd294c205b32 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-http": "^0.214.0", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json index 77b6006ee947..7c1ea4377070 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json @@ -14,7 +14,6 @@ "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-http": "^0.214.0", diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts index 9e9891bd9306..3e44c612b462 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts @@ -5,7 +5,9 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should create navigation transaction', async ({ page }) => { const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/ssr'; + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload @@ -56,7 +58,10 @@ test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts index b374c0ce4642..3095f720eb71 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); @@ -105,7 +108,9 @@ test.describe('client - pageload performance', () => { test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/static'; + return ( + transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/static`); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts index 9e9891bd9306..3e44c612b462 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts @@ -5,7 +5,9 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should create navigation transaction', async ({ page }) => { const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/ssr'; + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload @@ -56,7 +58,10 @@ test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts index b374c0ce4642..3095f720eb71 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); @@ -105,7 +108,9 @@ test.describe('client - pageload performance', () => { test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/static'; + return ( + transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/static`); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/navigation.client.test.ts index c30be5a32564..eccddaf77f04 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/navigation.client.test.ts @@ -5,7 +5,10 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts index 224a466ece66..d32fd24c75a6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts index c30be5a32564..eccddaf77f04 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/navigation.client.test.ts @@ -5,7 +5,10 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts index 224a466ece66..d32fd24c75a6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts index c273b5b55195..a31d716f7120 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts @@ -5,7 +5,9 @@ import { APP_NAME } from '../constants'; test.describe('client - navigation performance', () => { test('should create navigation transaction', async ({ page }) => { const navigationPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/ssr'; + return ( + transactionEvent.transaction === '/performance/ssr' && transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload @@ -56,7 +58,10 @@ test.describe('client - navigation performance', () => { test('should create navigation transaction when navigating with object `to` prop', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload @@ -106,7 +111,10 @@ test.describe('client - navigation performance', () => { test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); }); await page.goto(`/performance`); // pageload diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts index b374c0ce4642..3095f720eb71 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance'; + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'pageload'; }); await page.goto(`/performance`); @@ -55,7 +55,10 @@ test.describe('client - pageload performance', () => { test('should update pageload transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/with/:param'; + return ( + transactionEvent.transaction === '/performance/with/:param' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/with/sentry`); @@ -105,7 +108,9 @@ test.describe('client - pageload performance', () => { test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/static'; + return ( + transactionEvent.transaction === '/performance/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); }); await page.goto(`/performance/static`); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 97629d7d259b..bcfb3279f684 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -28,7 +28,7 @@ "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react-swc": "^3.5.0", "typescript": "^5.9.0", - "vite": "7.2.0", + "vite": "7.3.2", "vite-tsconfig-paths": "^5.1.4", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils" diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 038cb87dc03d..e46fe5825af8 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -27,7 +27,6 @@ "@nestjs/core": "^11", "@nestjs/platform-express": "^11", "@opentelemetry/api": "^1.9.1", - "@opentelemetry/context-async-hooks": "^2.6.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/instrumentation-http": "0.214.0", diff --git a/dev-packages/node-core-integration-tests/suites/anr/app-path.mjs b/dev-packages/node-core-integration-tests/suites/anr/app-path.mjs index 2cf1cff1ea32..28f245851b01 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/app-path.mjs +++ b/dev-packages/node-core-integration-tests/suites/anr/app-path.mjs @@ -25,7 +25,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/basic-multiple.mjs b/dev-packages/node-core-integration-tests/suites/anr/basic-multiple.mjs index 9c8b17b590bc..1caf96d3abdb 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/basic-multiple.mjs +++ b/dev-packages/node-core-integration-tests/suites/anr/basic-multiple.mjs @@ -21,7 +21,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/basic-session.js b/dev-packages/node-core-integration-tests/suites/anr/basic-session.js index 541c5ee25e36..e89a65e79ad2 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/basic-session.js +++ b/dev-packages/node-core-integration-tests/suites/anr/basic-session.js @@ -20,7 +20,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/basic.js b/dev-packages/node-core-integration-tests/suites/anr/basic.js index 738810f2fa2f..9010fb296c48 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/basic.js +++ b/dev-packages/node-core-integration-tests/suites/anr/basic.js @@ -22,7 +22,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/basic.mjs b/dev-packages/node-core-integration-tests/suites/anr/basic.mjs index 5902394e8109..109a201ecb98 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/basic.mjs +++ b/dev-packages/node-core-integration-tests/suites/anr/basic.mjs @@ -21,7 +21,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/forked.js b/dev-packages/node-core-integration-tests/suites/anr/forked.js index be4848abee5c..90148e549ce1 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/forked.js +++ b/dev-packages/node-core-integration-tests/suites/anr/forked.js @@ -21,7 +21,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/isolated.mjs b/dev-packages/node-core-integration-tests/suites/anr/isolated.mjs index 37e804d01b71..8d24c4cbd021 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/isolated.mjs +++ b/dev-packages/node-core-integration-tests/suites/anr/isolated.mjs @@ -18,7 +18,7 @@ setupOtel(client); async function longWork() { await new Promise(resolve => setTimeout(resolve, 1000)); - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-core-integration-tests/suites/anr/stop-and-start.js b/dev-packages/node-core-integration-tests/suites/anr/stop-and-start.js index c377c8716814..bec3d83b7d39 100644 --- a/dev-packages/node-core-integration-tests/suites/anr/stop-and-start.js +++ b/dev-packages/node-core-integration-tests/suites/anr/stop-and-start.js @@ -23,7 +23,7 @@ Sentry.setUser({ email: 'person@home.com' }); Sentry.addBreadcrumb({ message: 'important message!' }); function longWorkIgnored() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); @@ -31,7 +31,7 @@ function longWorkIgnored() { } function longWork() { - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); assert.ok(hash); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 5960e95ef018..0d9f63ca1974 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -58,7 +58,7 @@ "generic-pool": "^3.9.0", "graphql": "^16.11.0", "graphql-tag": "^2.12.6", - "hono": "^4.12.7", + "hono": "^4.12.12", "http-terminator": "^3.2.0", "ioredis": "^5.4.1", "kafkajs": "2.2.4", diff --git a/dev-packages/node-integration-tests/suites/express/late-init/instrument.mjs b/dev-packages/node-integration-tests/suites/express/late-init/instrument.mjs new file mode 100644 index 000000000000..eea577d83ebc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/late-init/instrument.mjs @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// First: preload the express instrumentation without calling Sentry.init(). +// registers OTel module hook, patches the Express module with no config. +Sentry.preloadOpenTelemetry({ integrations: ['Express'] }); + +// call Sentry.init() with express integration config. +// instrumentExpress is already registered, so this calls setConfig() on the +// existing instrumentation to update its options. The lazy getOptions() +// in patchLayer ensures the updated options are read at request time. +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + // suppress the middleware layer that the cors module generates + integrations: [Sentry.expressIntegration({ ignoreLayersType: ['middleware'] })], +}); diff --git a/dev-packages/node-integration-tests/suites/express/late-init/scenario.mjs b/dev-packages/node-integration-tests/suites/express/late-init/scenario.mjs new file mode 100644 index 000000000000..faea295143ef --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/late-init/scenario.mjs @@ -0,0 +1,18 @@ +import cors from 'cors'; +import express from 'express'; +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; + +const app = express(); + +// cors() would normally create a 'middleware' type span, but the +// ignoreLayersType: ['middleware'] option set via Sentry.init() suppresses it. +app.use(cors()); + +app.get('/test/express', (_req, res) => { + res.send({ response: 'response 1' }); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/late-init/test.ts b/dev-packages/node-integration-tests/suites/express/late-init/test.ts new file mode 100644 index 000000000000..d7aeba94fd2b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/late-init/test.ts @@ -0,0 +1,49 @@ +import { afterAll, describe, expect } from 'vitest'; +import { assertSentryTransaction } from '../../../utils/assertions'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('express late init', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('applies expressIntegration config set via Sentry.init() called after instrumentExpress()', async () => { + const runner = createRunner() + .expect({ + transaction: transaction => { + assertSentryTransaction(transaction, { + transaction: 'GET /test/express', + contexts: { + trace: { + op: 'http.server', + status: 'ok', + }, + }, + }); + // request_handler span IS present + // confirms the express patch was applied. + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'express.type': 'request_handler', + }), + }), + ); + // Middleware spans NOT present, ignoreLayersType: ['middleware'] + // configured via the Sentry.init() AFTER instrumentExpress(). + expect(transaction.spans).not.toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'express.type': 'middleware', + }), + }), + ); + }, + }) + .start(); + runner.makeRequest('get', '/test/express'); + await runner.completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs index c2c0f39fc44e..992a07c083da 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs @@ -24,6 +24,8 @@ const fns = [ neverResolve, ]; +Sentry.getGlobalScope().setUser({ email: 'something@gmail.com' }); + setTimeout(() => { for (let id = 0; id < 10; id++) { Sentry.withIsolationScope(async () => { diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index 75f957f07af5..8dd49d126b67 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -249,7 +249,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { message: 'Starting task 5', }, ], - user: { id: 5 }, + user: { id: 5, email: 'something@gmail.com' }, threads: { values: [ { diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs new file mode 100644 index 000000000000..ce15aad4e8e1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-no-truncation.mjs @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.anthropicAIIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/anthropic/v1/')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..9d8360708ab3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.anthropicAIIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs new file mode 100644 index 000000000000..36f2ffe8c35c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-no-truncation.mjs @@ -0,0 +1,54 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + this.messages = { + create: this._messagesCreate.bind(this), + }; + } + + async _messagesCreate(params) { + await new Promise(resolve => setTimeout(resolve, 10)); + return { + id: 'msg-no-truncation-test', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Response' }], + model: params.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 5 }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ apiKey: 'mock-api-key' }); + const client = instrumentAnthropicAiClient(mockClient, { enableTruncation: false, recordInputs: true }); + + // Multiple messages with long content (would normally be truncated and popped to last message only) + const longContent = 'A'.repeat(50_000); + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ], + }); + + // Long string input (messagesFromParams wraps it in an array) + const longStringInput = 'B'.repeat(50_000); + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + input: longStringInput, + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-span-streaming.mjs new file mode 100644 index 000000000000..53594bb60058 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-span-streaming.mjs @@ -0,0 +1,53 @@ +import Anthropic from '@anthropic-ai/sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/anthropic/v1/messages', (req, res) => { + res.send({ + id: 'msg_streaming_test', + type: 'message', + model: req.body.model, + role: 'assistant', + content: [{ type: 'text', text: 'Response' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Anthropic({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}/anthropic`, + }); + + // Long content that would normally be truncated + const longContent = 'A'.repeat(50_000); + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [{ role: 'user', content: longContent }], + }); + }); + + // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 3241adfc161d..e740c24071fd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -802,4 +802,94 @@ describe('Anthropic integration', () => { }); }, ); + + const longContent = 'A'.repeat(50_000); + const longStringInput = 'B'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + // Multiple messages should all be preserved (no popping to last message only) + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + // Long string input should not be truncated (messagesFromParams wraps it in an array) + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([longStringInput]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 1, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-span-streaming.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + // Find the chat span by matching the start of the truncated content (the 'A' repeated messages). + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs new file mode 100644 index 000000000000..be5288b429d6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-no-truncation.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.googleGenAIIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..e706163aea04 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.googleGenAIIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs new file mode 100644 index 000000000000..13b271a23878 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-no-truncation.mjs @@ -0,0 +1,47 @@ +import { instrumentGoogleGenAIClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockGoogleGenerativeAI { + constructor(config) { + this.apiKey = config.apiKey; + this.models = { + generateContent: this._generateContent.bind(this), + }; + } + + async _generateContent() { + await new Promise(resolve => setTimeout(resolve, 10)); + return { + response: { + text: () => 'Response', + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 }, + candidates: [ + { + content: { parts: [{ text: 'Response' }], role: 'model' }, + finishReason: 'STOP', + }, + ], + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockGoogleGenerativeAI({ apiKey: 'mock-api-key' }); + const client = instrumentGoogleGenAIClient(mockClient, { enableTruncation: false, recordInputs: true }); + + // Long content that would normally be truncated + const longContent = 'A'.repeat(50_000); + await client.models.generateContent({ + model: 'gemini-1.5-flash', + contents: [ + { role: 'user', parts: [{ text: longContent }] }, + { role: 'model', parts: [{ text: 'Some reply' }] }, + { role: 'user', parts: [{ text: 'Follow-up question' }] }, + ], + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs new file mode 100644 index 000000000000..f5b2656b5cb0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-span-streaming.mjs @@ -0,0 +1,51 @@ +import { GoogleGenAI } from '@google/genai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockGoogleGenAIServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/v1beta/models/:model\\:generateContent', (req, res) => { + res.json({ + candidates: [ + { + content: { parts: [{ text: 'Response' }], role: 'model' }, + finishReason: 'STOP', + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockGoogleGenAIServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new GoogleGenAI({ + apiKey: 'mock-api-key', + httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, + }); + + // Long content that would normally be truncated + const longContent = 'A'.repeat(50_000); + await client.models.generateContent({ + model: 'gemini-1.5-flash', + contents: [{ role: 'user', parts: [{ text: longContent }] }], + }); + }); + + // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 5d79cdf94202..9839ef5fa2c0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -653,4 +653,87 @@ describe('Google GenAI integration', () => { .completed(); }); }); + + const longContent = 'A'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', parts: [{ text: longContent }] }, + { role: 'model', parts: [{ text: 'Some reply' }] }, + { role: 'user', parts: [{ text: 'Follow-up question' }] }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-span-streaming.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + // Find the chat span by matching the start of the truncated content (the 'A' repeated messages). + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith( + '[{"role":"user","parts":[{"text":"AAAA', + ), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts index 176d947e1ecf..84e8d4a5612e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts @@ -1,3 +1,4 @@ +import type { TransactionEvent } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; @@ -8,16 +9,50 @@ describe('kafkajs', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('traces producers and consumers', { timeout: 60_000 }, async () => { + // The producer and consumer transactions can arrive in any order, + // so we collect them and assert after both have been received. + const receivedTransactions: TransactionEvent[] = []; + await createRunner() .withDockerCompose({ workingDirectory: [__dirname], readyMatches: ['9092'], }) .expect({ - transaction: { - transaction: 'send test-topic', - contexts: { - trace: expect.objectContaining({ + transaction: (transaction: TransactionEvent) => { + receivedTransactions.push(transaction); + }, + }) + .expect({ + transaction: (transaction: TransactionEvent) => { + receivedTransactions.push(transaction); + + const producer = receivedTransactions.find( + t => t.contexts?.trace?.data?.['sentry.origin'] === 'auto.kafkajs.otel.producer', + ); + const consumer = receivedTransactions.find( + t => t.contexts?.trace?.data?.['sentry.origin'] === 'auto.kafkajs.otel.consumer', + ); + + expect(producer).toBeDefined(); + expect(consumer).toBeDefined(); + + for (const t of [producer, consumer]) { + // just to assert on the basic shape (for more straight-forward tests, this is usually done by the runner) + expect(t).toMatchObject({ + event_id: expect.any(String), + timestamp: expect.anything(), + start_timestamp: expect.anything(), + spans: expect.any(Array), + type: 'transaction', + }); + } + + expect(producer!.transaction).toBe('send test-topic'); + expect(consumer!.transaction).toBe('process test-topic'); + + expect(producer!.contexts?.trace).toMatchObject( + expect.objectContaining({ op: 'message', status: 'ok', data: expect.objectContaining({ @@ -28,14 +63,10 @@ describe('kafkajs', () => { 'sentry.origin': 'auto.kafkajs.otel.producer', }), }), - }, - }, - }) - .expect({ - transaction: { - transaction: 'process test-topic', - contexts: { - trace: expect.objectContaining({ + ); + + expect(consumer!.contexts?.trace).toMatchObject( + expect.objectContaining({ op: 'message', status: 'ok', data: expect.objectContaining({ @@ -46,7 +77,7 @@ describe('kafkajs', () => { 'sentry.origin': 'auto.kafkajs.otel.consumer', }), }), - }, + ); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs new file mode 100644 index 000000000000..027299eeacad --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-no-truncation.mjs @@ -0,0 +1,24 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [ + Sentry.langChainIntegration({ + enableTruncation: false, + recordInputs: true, + recordOutputs: true, + }), + ], + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1/messages') || event.transaction.includes('/v1/embeddings')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..cdfebbf845fc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.langChainIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs new file mode 100644 index 000000000000..bb8f5fc35325 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-no-truncation.mjs @@ -0,0 +1,56 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_no_truncation_test', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Response' }], + model: req.body.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const model = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + // Long content that would normally be truncated + const longContent = 'A'.repeat(50_000); + await model.invoke([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs new file mode 100644 index 000000000000..0d049d346e98 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-span-streaming.mjs @@ -0,0 +1,52 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_span_streaming_test', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Response' }], + model: req.body.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const model = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + // Single long message so truncation must crop it + const longContent = 'A'.repeat(50_000); + await model.invoke([{ role: 'user', content: longContent }]); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index 39127c7e3055..f85e3187ac78 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -549,4 +549,84 @@ describe('LangChain integration', () => { .completed(); }); }); + + const longContent = 'A'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-span-streaming.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs new file mode 100644 index 000000000000..91b4e4b1bae5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-no-truncation.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [ + Sentry.langGraphIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..2d8d986a2cd1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.langGraphIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs new file mode 100644 index 000000000000..982e7a69de53 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-no-truncation.mjs @@ -0,0 +1,46 @@ +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/node'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'langgraph-test' }, async () => { + const mockLlm = () => { + return { + messages: [ + { + role: 'assistant', + content: 'Mock LLM response', + response_metadata: { + model_name: 'mock-model', + finish_reason: 'stop', + tokenUsage: { + promptTokens: 20, + completionTokens: 10, + totalTokens: 30, + }, + }, + }, + ], + }; + }; + + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addEdge(START, 'agent') + .addEdge('agent', END) + .compile({ name: 'weather_assistant' }); + + // Multiple messages with long content (would normally be truncated and popped to last message only) + const longContent = 'A'.repeat(50_000); + await graph.invoke({ + messages: [ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ], + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs new file mode 100644 index 000000000000..bfba2d1fcd7f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-span-streaming.mjs @@ -0,0 +1,42 @@ +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/node'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'langgraph-test' }, async () => { + const mockLlm = () => { + return { + messages: [ + { + role: 'assistant', + content: 'Mock LLM response', + response_metadata: { + model_name: 'mock-model', + finish_reason: 'stop', + tokenUsage: { + promptTokens: 20, + completionTokens: 10, + totalTokens: 30, + }, + }, + }, + ], + }; + }; + + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addEdge(START, 'agent') + .addEdge('agent', END) + .compile({ name: 'weather_assistant' }); + + // Single long message so truncation must crop it + const longContent = 'A'.repeat(50_000); + await graph.invoke({ + messages: [{ role: 'user', content: longContent }], + }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 0b03e59bbfbf..0837efb63c2f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -4,6 +4,7 @@ import { GEN_AI_AGENT_NAME_ATTRIBUTE, GEN_AI_CONVERSATION_ID_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_PIPELINE_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, @@ -364,4 +365,84 @@ describe('LangGraph integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_RESUME }).start().completed(); }); }); + + const longContent = 'A'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'langgraph-test', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-span-streaming.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs new file mode 100644 index 000000000000..0dd039762f1f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-no-truncation.mjs @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.openAIIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], + beforeSendTransaction: event => { + if (event.transaction.includes('/openai/')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..097c7adcf087 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.openAIIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs new file mode 100644 index 000000000000..c5fe61c1ab66 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-no-truncation.mjs @@ -0,0 +1,87 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/openai/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.model, + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + }); + + app.post('/openai/responses', (req, res) => { + res.send({ + id: 'resp_mock456', + object: 'response', + created_at: 1677652290, + model: req.body.model, + output: [ + { + type: 'message', + id: 'msg_mock_output_1', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'Response text', annotations: [] }], + }, + ], + output_text: 'Response text', + status: 'completed', + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // Multiple messages with long content (would normally be truncated and popped to last message only) + const longContent = 'A'.repeat(50_000); + await client.chat.completions.create({ + model: 'gpt-4', + messages: [ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ], + }); + + // Responses API with long string input (would normally be truncated) + const longStringInput = 'B'.repeat(50_000); + await client.responses.create({ + model: 'gpt-4', + input: longStringInput, + }); + }); + + // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits + await Sentry.flush(); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-span-streaming.mjs new file mode 100644 index 000000000000..33b8fd2e555b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-span-streaming.mjs @@ -0,0 +1,83 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.post('/openai/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.model, + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + }); + + app.post('/openai/responses', (req, res) => { + res.send({ + id: 'resp_mock456', + object: 'response', + created_at: 1677652290, + model: req.body.model, + output: [ + { + type: 'message', + id: 'msg_mock_output_1', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'Response text', annotations: [] }], + }, + ], + output_text: 'Response text', + status: 'completed', + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // Single long message for chat completions + const longContent = 'A'.repeat(50_000); + await client.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: longContent }], + }); + + // Responses API with long string input + const longStringInput = 'B'.repeat(50_000); + await client.responses.create({ + model: 'gpt-4', + input: longStringInput, + }); + }); + + // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits + await Sentry.flush(); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index ae7715e9852c..e3ecc4f80ae0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -345,6 +345,47 @@ describe('OpenAI integration', () => { }); }); + const longContent = 'A'.repeat(50_000); + + const EXPECTED_TRANSACTION_NO_TRUNCATION = { + transaction: 'main', + spans: expect.arrayContaining([ + // Multiple messages should all be preserved (no popping to last message only) + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + // Responses API long string input should not be truncated or wrapped in quotes + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: 'B'.repeat(50_000), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 1, + }), + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_NO_TRUNCATION }) + .start() + .completed(); + }); + }, + ); + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { transaction: 'main', spans: expect.arrayContaining([ @@ -982,4 +1023,67 @@ describe('OpenAI integration', () => { .completed(); }); }); + + const streamingLongContent = 'A'.repeat(50_000); + const streamingLongString = 'B'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + + const responsesSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongString), + ); + expect(responsesSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-span-streaming.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + // Truncation keeps only the last message (50k 'A's) and crops it to the byte limit. + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + + // The responses API string input (50k 'B's) should also be truncated. + const responsesSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('BBB'), + ); + expect(responsesSpan).toBeDefined(); + expect(responsesSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongString.length, + ); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs new file mode 100644 index 000000000000..0593d975c8d7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-no-truncation.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [ + Sentry.vercelAIIntegration({ + recordInputs: true, + recordOutputs: true, + enableTruncation: false, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs new file mode 100644 index 000000000000..39c60d69dacb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming-with-truncation.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', + integrations: [ + Sentry.vercelAIIntegration({ + enableTruncation: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs new file mode 100644 index 000000000000..48a860c510c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs new file mode 100644 index 000000000000..415c13ef9acf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-no-truncation.mjs @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + // Multiple messages with long content (would normally be truncated and popped to last message only) + const longContent = 'A'.repeat(50_000); + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 5 }, + text: 'Response', + }), + }), + messages: [ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ], + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-span-streaming.mjs new file mode 100644 index 000000000000..ebe0becaad35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-span-streaming.mjs @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + // Single long message so truncation must crop it + const longContent = 'A'.repeat(50_000); + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 5 }, + text: 'Response', + }), + }), + messages: [{ role: 'user', content: longContent }], + }); + }); + + // Flush is required when span streaming is enabled to ensure streamed spans are sent before the process exits + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 673887737ee6..5aa1dc8342a5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -950,4 +950,84 @@ describe('Vercel AI integration', () => { .completed(); }); }); + + const longContent = 'A'.repeat(50_000); + + createEsmAndCjsTests( + __dirname, + 'scenario-no-truncation.mjs', + 'instrument-no-truncation.mjs', + (createRunner, test) => { + test('does not truncate input messages when enableTruncation is false', async () => { + await createRunner() + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + // Multiple messages should all be preserved (no popping to last message only) + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: JSON.stringify([ + { role: 'user', content: longContent }, + { role: 'assistant', content: 'Some reply' }, + { role: 'user', content: 'Follow-up question' }, + ]), + [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: 3, + }), + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); + + const streamingLongContent = 'A'.repeat(50_000); + + createEsmAndCjsTests(__dirname, 'scenario-span-streaming.mjs', 'instrument-streaming.mjs', (createRunner, test) => { + test('automatically disables truncation when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.includes(streamingLongContent), + ); + expect(chatSpan).toBeDefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-span-streaming.mjs', + 'instrument-streaming-with-truncation.mjs', + (createRunner, test) => { + test('respects explicit enableTruncation: true even when span streaming is enabled', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + // With explicit enableTruncation: true, content should be truncated despite streaming. + const chatSpan = spans.find(s => + s.attributes?.[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]?.value?.startsWith('[{"role":"user","content":"AAAA'), + ); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES_ATTRIBUTE].value.length).toBeLessThan( + streamingLongContent.length, + ); + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-tool-loop-agent.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-tool-loop-agent.mjs index fe485ce29a90..6967ec2efe94 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-tool-loop-agent.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-tool-loop-agent.mjs @@ -8,7 +8,7 @@ async function run() { let callCount = 0; const agent = new ToolLoopAgent({ - experimental_telemetry: { isEnabled: true }, + experimental_telemetry: { isEnabled: true, functionId: 'weather_agent' }, model: new MockLanguageModelV3({ doGenerate: async () => { if (callCount++ === 0) { diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts index 2c07366423cf..1b030804f8d2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts @@ -619,6 +619,7 @@ describe('Vercel AI integration (V6)', () => { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.vercelai.otel', [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'mock-model-id', }), + description: 'invoke_agent weather_agent', op: 'gen_ai.invoke_agent', origin: 'auto.vercelai.otel', status: 'ok', @@ -633,6 +634,7 @@ describe('Vercel AI integration (V6)', () => { [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 20, [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: ['tool-calls'], }), + description: 'generate_content mock-model-id', op: 'gen_ai.generate_content', origin: 'auto.vercelai.otel', status: 'ok', @@ -662,6 +664,7 @@ describe('Vercel AI integration (V6)', () => { [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 25, [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: ['stop'], }), + description: 'generate_content mock-model-id', op: 'gen_ai.generate_content', origin: 'auto.vercelai.otel', status: 'ok', diff --git a/dev-packages/node-overhead-gh-action/.oxlintrc.json b/dev-packages/node-overhead-gh-action/.oxlintrc.json deleted file mode 100644 index 5bffa72a1a08..000000000000 --- a/dev-packages/node-overhead-gh-action/.oxlintrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "../../node_modules/oxlint/configuration_schema.json", - "extends": ["../.oxlintrc.json"], - "env": { - "node": true - } -} diff --git a/dev-packages/node-overhead-gh-action/README.md b/dev-packages/node-overhead-gh-action/README.md deleted file mode 100644 index 1759ab7bd7c3..000000000000 --- a/dev-packages/node-overhead-gh-action/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# node-overhead-gh-action - -Capture the overhead of Sentry in a node app. diff --git a/dev-packages/node-overhead-gh-action/action.yml b/dev-packages/node-overhead-gh-action/action.yml deleted file mode 100644 index e90aef2e4342..000000000000 --- a/dev-packages/node-overhead-gh-action/action.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: 'node-overhead-gh-action' -description: 'Run node overhead comparison' -inputs: - github_token: - required: true - description: 'a github access token' - comparison_branch: - required: false - default: '' - description: 'If set, compare the current branch with this branch' - threshold: - required: false - default: '3' - description: 'The percentage threshold for size changes before posting a comment' -runs: - using: 'node24' - main: 'index.mjs' diff --git a/dev-packages/node-overhead-gh-action/db/init/init.sql b/dev-packages/node-overhead-gh-action/db/init/init.sql deleted file mode 100644 index 44071266aab5..000000000000 --- a/dev-packages/node-overhead-gh-action/db/init/init.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE DATABASE mydb; -USE mydb - --- SQL script to create the 'users' table and insert initial data. - --- 1. Create the 'users' table --- This table stores basic user information. --- 'id' is the primary key and will automatically increment for each new record. --- 'name' stores the user's name, up to 255 characters. --- 'age' stores the user's age as an integer. - -CREATE TABLE users ( - id INT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(255) NOT NULL, - age INT -); - --- 2. Insert 5 rows into the 'users' table --- Populating the table with some sample data. - -INSERT INTO users (name, age) VALUES ('Alice Johnson', 28); -INSERT INTO users (name, age) VALUES ('Bob Smith', 45); -INSERT INTO users (name, age) VALUES ('Charlie Brown', 32); -INSERT INTO users (name, age) VALUES ('Diana Prince', 25); -INSERT INTO users (name, age) VALUES ('Ethan Hunt', 41); diff --git a/dev-packages/node-overhead-gh-action/docker-compose.yml b/dev-packages/node-overhead-gh-action/docker-compose.yml deleted file mode 100644 index a929dd5c5c88..000000000000 --- a/dev-packages/node-overhead-gh-action/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -services: - db: - image: mysql:8 - restart: always - container_name: node-overhead-gh-action-mysql - ports: - - '3306:3306' - environment: - MYSQL_ROOT_PASSWORD: password - volumes: - # - ./db/data:/var/lib/mysql - - ./db/init:/docker-entrypoint-initdb.d/:ro diff --git a/dev-packages/node-overhead-gh-action/index.mjs b/dev-packages/node-overhead-gh-action/index.mjs deleted file mode 100644 index 8c3e2c56873b..000000000000 --- a/dev-packages/node-overhead-gh-action/index.mjs +++ /dev/null @@ -1,236 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { DefaultArtifactClient } from '@actions/artifact'; -import * as core from '@actions/core'; -import { exec } from '@actions/exec'; -import { context, getOctokit } from '@actions/github'; -import * as glob from '@actions/glob'; -import * as io from '@actions/io'; -import { markdownTable } from 'markdown-table'; -import { getArtifactsForBranchAndWorkflow } from './lib/getArtifactsForBranchAndWorkflow.mjs'; -import { getAveragedOverheadMeasurements } from './lib/getOverheadMeasurements.mjs'; -import { formatResults, hasChanges } from './lib/markdown-table-formatter.mjs'; - -const NODE_OVERHEAD_HEADING = '## node-overhead report 🧳'; -const ARTIFACT_NAME = 'node-overhead-action'; -const RESULTS_FILE = 'node-overhead-results.json'; - -function getResultsFilePath() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - return path.resolve(__dirname, RESULTS_FILE); -} - -const { getInput, setFailed } = core; - -async function fetchPreviousComment(octokit, repo, pr) { - const { data: commentList } = await octokit.rest.issues.listComments({ - ...repo, - issue_number: pr.number, - }); - - return commentList.find(comment => comment.body.startsWith(NODE_OVERHEAD_HEADING)); -} - -async function run() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - - try { - const { payload, repo } = context; - const pr = payload.pull_request; - - const comparisonBranch = getInput('comparison_branch'); - const githubToken = getInput('github_token'); - const threshold = getInput('threshold') || 1; - - if (comparisonBranch && !pr) { - throw new Error('No PR found. Only pull_request workflows are supported.'); - } - - const octokit = getOctokit(githubToken); - const resultsFilePath = getResultsFilePath(); - - // If we have no comparison branch, we just run overhead check & store the result as artifact - if (!comparisonBranch) { - return runNodeOverheadOnComparisonBranch(); - } - - // Else, we run overhead check for the current branch, AND fetch it for the comparison branch - let base; - let current; - let baseIsNotLatest = false; - let baseWorkflowRun; - - try { - const workflowName = `${process.env.GITHUB_WORKFLOW || ''}`; - core.startGroup(`getArtifactsForBranchAndWorkflow - workflow:"${workflowName}", branch:"${comparisonBranch}"`); - const artifacts = await getArtifactsForBranchAndWorkflow(octokit, { - ...repo, - artifactName: ARTIFACT_NAME, - branch: comparisonBranch, - workflowName, - }); - core.endGroup(); - - if (!artifacts) { - throw new Error('No artifacts found'); - } - - baseWorkflowRun = artifacts.workflowRun; - - await downloadOtherWorkflowArtifact(octokit, { - ...repo, - artifactName: ARTIFACT_NAME, - artifactId: artifacts.artifact.id, - downloadPath: __dirname, - }); - - base = JSON.parse(await fs.readFile(resultsFilePath, { encoding: 'utf8' })); - - if (!artifacts.isLatest) { - baseIsNotLatest = true; - core.info('Base artifact is not the latest one. This may lead to incorrect results.'); - } - } catch (error) { - core.startGroup('Warning, unable to find base results'); - core.error(error); - core.endGroup(); - } - - core.startGroup('Getting current overhead measurements'); - try { - current = await getAveragedOverheadMeasurements(); - } catch (error) { - core.error('Error getting current overhead measurements'); - core.endGroup(); - throw error; - } - core.debug(`Current overhead measurements: ${JSON.stringify(current, null, 2)}`); - core.endGroup(); - - const thresholdNumber = Number(threshold); - - const nodeOverheadComment = await fetchPreviousComment(octokit, repo, pr); - - if (nodeOverheadComment) { - core.debug('Found existing node overhead comment, updating it instead of creating a new one...'); - } - - const shouldComment = isNaN(thresholdNumber) || hasChanges(base, current, thresholdNumber) || nodeOverheadComment; - - if (shouldComment) { - const bodyParts = [ - NODE_OVERHEAD_HEADING, - 'Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.', - ]; - - if (baseIsNotLatest) { - bodyParts.push( - '⚠️ **Warning:** Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.', - ); - } - try { - bodyParts.push(markdownTable(formatResults(base, current))); - } catch (error) { - core.error('Error generating markdown table'); - throw error; - } - - if (baseWorkflowRun) { - bodyParts.push(''); - bodyParts.push(`[View base workflow run](${baseWorkflowRun.html_url})`); - } - - const body = bodyParts.join('\r\n'); - - try { - if (!nodeOverheadComment) { - await octokit.rest.issues.createComment({ - ...repo, - issue_number: pr.number, - body, - }); - } else { - await octokit.rest.issues.updateComment({ - ...repo, - comment_id: nodeOverheadComment.id, - body, - }); - } - } catch { - core.error( - "Error updating comment. This can happen for PR's originating from a fork without write permissions.", - ); - } - } else { - core.debug('Skipping comment because there are no changes.'); - } - } catch (error) { - core.error(error); - setFailed(error.message); - } -} - -async function runNodeOverheadOnComparisonBranch() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const resultsFilePath = getResultsFilePath(); - - const artifactClient = new DefaultArtifactClient(); - - const result = await getAveragedOverheadMeasurements(); - - try { - await fs.writeFile(resultsFilePath, JSON.stringify(result), 'utf8'); - } catch (error) { - core.error('Error parsing node overhead output. The output should be a json.'); - throw error; - } - - const globber = await glob.create(resultsFilePath, { - followSymbolicLinks: false, - }); - const files = await globber.glob(); - - await artifactClient.uploadArtifact(ARTIFACT_NAME, files, __dirname); -} - -run(); - -/** - * Use GitHub API to fetch artifact download url, then - * download and extract artifact to `downloadPath` - */ -async function downloadOtherWorkflowArtifact(octokit, { owner, repo, artifactId, artifactName, downloadPath }) { - const artifact = await octokit.rest.actions.downloadArtifact({ - owner, - repo, - artifact_id: artifactId, - archive_format: 'zip', - }); - - // Make sure output path exists - try { - await io.mkdirP(downloadPath); - } catch { - // ignore errors - } - - const downloadFile = path.resolve(downloadPath, `${artifactName}.zip`); - - await exec('wget', [ - '-nv', - '--retry-connrefused', - '--waitretry=1', - '--read-timeout=20', - '--timeout=15', - '-t', - '0', - '-O', - downloadFile, - artifact.url, - ]); - - await exec('unzip', ['-q', '-d', downloadPath, downloadFile], { - silent: true, - }); -} diff --git a/dev-packages/node-overhead-gh-action/lib/getArtifactsForBranchAndWorkflow.mjs b/dev-packages/node-overhead-gh-action/lib/getArtifactsForBranchAndWorkflow.mjs deleted file mode 100644 index ca7e4e20e9e5..000000000000 --- a/dev-packages/node-overhead-gh-action/lib/getArtifactsForBranchAndWorkflow.mjs +++ /dev/null @@ -1,122 +0,0 @@ -import * as core from '@actions/core'; - -// max pages of workflows to pagination through -const DEFAULT_MAX_PAGES = 50; -// max results per page -const DEFAULT_PAGE_LIMIT = 10; - -/** - * Fetch artifacts from a workflow run from a branch - * - * This is a bit hacky since GitHub Actions currently does not directly - * support downloading artifacts from other workflows - */ -export async function getArtifactsForBranchAndWorkflow(octokit, { owner, repo, workflowName, branch, artifactName }) { - let repositoryWorkflow = null; - - // For debugging - const allWorkflows = []; - - // - // Find workflow id from `workflowName` - // - for await (const response of octokit.paginate.iterator(octokit.rest.actions.listRepoWorkflows, { - owner, - repo, - })) { - const targetWorkflow = response.data.find(({ name }) => name === workflowName); - - allWorkflows.push(...response.data.map(({ name }) => name)); - - // If not found in responses, continue to search on next page - if (!targetWorkflow) { - continue; - } - - repositoryWorkflow = targetWorkflow; - break; - } - - if (!repositoryWorkflow) { - core.info( - `Unable to find workflow with name "${workflowName}" in the repository. Found workflows: ${allWorkflows.join( - ', ', - )}`, - ); - return null; - } - - const workflow_id = repositoryWorkflow.id; - - let currentPage = 0; - let latestWorkflowRun = null; - - for await (const response of octokit.paginate.iterator(octokit.rest.actions.listWorkflowRuns, { - owner, - repo, - workflow_id, - branch, - per_page: DEFAULT_PAGE_LIMIT, - event: 'push', - })) { - if (!response.data.length) { - core.warning(`Workflow ${workflow_id} not found in branch ${branch}`); - return null; - } - - // Do not allow downloading artifacts from a fork. - const filtered = response.data.filter(workflowRun => workflowRun.head_repository.full_name === `${owner}/${repo}`); - - // Sort to ensure the latest workflow run is the first - filtered.sort((a, b) => { - return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); - }); - - // Store the first workflow run, to determine if this is the latest one... - if (!latestWorkflowRun) { - latestWorkflowRun = filtered[0]; - } - - // Search through workflow artifacts until we find a workflow run w/ artifact name that we are looking for - for (const workflowRun of filtered) { - core.info(`Checking artifacts for workflow run: ${workflowRun.html_url}`); - - const { - data: { artifacts }, - } = await octokit.rest.actions.listWorkflowRunArtifacts({ - owner, - repo, - run_id: workflowRun.id, - }); - - if (!artifacts) { - core.warning( - `Unable to fetch artifacts for branch: ${branch}, workflow: ${workflow_id}, workflowRunId: ${workflowRun.id}`, - ); - } else { - const foundArtifact = artifacts.find(({ name }) => name === artifactName); - if (foundArtifact) { - core.info(`Found suitable artifact: ${foundArtifact.url}`); - return { - artifact: foundArtifact, - workflowRun, - isLatest: latestWorkflowRun.id === workflowRun.id, - }; - } else { - core.info(`No artifact found for ${artifactName}, trying next workflow run...`); - } - } - } - - if (currentPage > DEFAULT_MAX_PAGES) { - core.warning(`Workflow ${workflow_id} not found in branch: ${branch}`); - return null; - } - - currentPage++; - } - - core.warning(`Artifact not found: ${artifactName}`); - core.endGroup(); - return null; -} diff --git a/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs b/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs deleted file mode 100644 index 266b62cd7742..000000000000 --- a/dev-packages/node-overhead-gh-action/lib/getOverheadMeasurements.mjs +++ /dev/null @@ -1,250 +0,0 @@ -import { execSync, spawn } from 'child_process'; -import { dirname, join } from 'path'; -import treeKill from 'tree-kill'; -import { fileURLToPath } from 'url'; - -const DEBUG = !!process.env.DEBUG; - -const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); - -async function getMeasurements(instrumentFile, autocannonCommand = 'yarn test:get') { - const args = [join(packageRoot, './src/app.mjs')]; - - if (instrumentFile) { - args.unshift('--import', join(packageRoot, instrumentFile)); - } - - const cmd = `node ${args.join(' ')}`; - - log('--------------------------------'); - log(`Getting measurements for "${cmd}"`); - - const killAppProcess = await startAppProcess(cmd); - - log('Example app listening, running autocannon...'); - - try { - const result = await startAutocannonProcess(autocannonCommand); - await killAppProcess(); - return result; - } catch (error) { - //oxlint-disable-next-line restrict-template-expressions - log(`Error running autocannon: ${error}`); - await killAppProcess(); - throw error; - } -} - -async function startAppProcess(cmd) { - const appProcess = spawn(cmd, { shell: true }); - - log('Child process started, waiting for example app...'); - - // Promise to keep track of the app process being closed - let resolveAppClose, rejectAppClose; - const appClosePromise = new Promise((resolve, reject) => { - resolveAppClose = resolve; - rejectAppClose = reject; - }); - - appProcess.on('close', code => { - if (code && code !== 0) { - rejectAppClose(new Error(`App process exited with code ${code}`)); - } else { - resolveAppClose(); - } - }); - - await new Promise((resolve, reject) => { - appProcess.stdout.on('data', data => { - log(`appProcess: ${data}`); - if (`${data}`.includes('Example app listening on port')) { - resolve(); - } - }); - - appProcess.stderr.on('data', data => { - log(`appProcess stderr: ${data}`); - killProcess(appProcess); - reject(data); - }); - }); - - return async () => { - log('Killing app process...'); - appProcess.stdin.end(); - appProcess.stdout.end(); - appProcess.stderr.end(); - - await killProcess(appProcess); - await appClosePromise; - log('App process killed'); - }; -} - -async function startAutocannonProcess(autocannonCommand) { - const autocannon = spawn(autocannonCommand, { - shell: true, - cwd: packageRoot, - }); - - let lastJson = undefined; - autocannon.stdout.on('data', data => { - log(`autocannon: ${data}`); - try { - lastJson = JSON.parse(data); - } catch { - // do nothing - } - }); - - return new Promise((resolve, reject) => { - autocannon.stderr.on('data', data => { - log(`autocannon stderr: ${data}`); - lastJson = undefined; - killProcess(autocannon); - }); - - autocannon.on('close', code => { - log(`autocannon closed with code ${code}`); - log(`Average requests: ${lastJson?.requests.average}`); - - if ((code && code !== 0) || !lastJson?.requests.average) { - reject(new Error(`Autocannon process exited with code ${code}`)); - } else { - resolve(Math.floor(lastJson.requests.average)); - } - }); - }); -} - -function startDb() { - const closeDb = () => { - execSync('yarn db:down', { - shell: true, - cwd: packageRoot, - }); - }; - - // Ensure eventually open DB is closed fist - closeDb(); - - return new Promise((resolve, reject) => { - const child = spawn('yarn db:up', { - shell: true, - cwd: packageRoot, - }); - - const timeout = setTimeout(() => { - closeDb(); - reject(new Error('Timed out waiting for docker-compose')); - }, 60000); - - const readyMatch = 'port: 3306'; - - function newData(data) { - const text = data.toString('utf8'); - log(text); - - if (text.includes(readyMatch)) { - child.stdout.removeAllListeners(); - child.stderr.removeAllListeners(); - clearTimeout(timeout); - resolve(closeDb); - } - } - - child.stdout.on('data', newData); - child.stderr.on('data', newData); - }); -} - -async function getOverheadMeasurements() { - const GET = { - baseline: await getMeasurements(undefined, 'yarn test:get'), - withInstrument: await getMeasurements('./src/instrument.mjs', 'yarn test:get'), - withInstrumentErrorOnly: await getMeasurements('./src/instrument-error-only.mjs', 'yarn test:get'), - }; - - const POST = { - baseline: await getMeasurements(undefined, 'yarn test:post'), - withInstrument: await getMeasurements('./src/instrument.mjs', 'yarn test:post'), - withInstrumentErrorOnly: await getMeasurements('./src/instrument-error-only.mjs', 'yarn test:post'), - }; - - const MYSQL = { - baseline: await getMeasurements(undefined, 'yarn test:mysql'), - withInstrument: await getMeasurements('./src/instrument.mjs', 'yarn test:mysql'), - withInstrumentErrorOnly: await getMeasurements('./src/instrument-error-only.mjs', 'yarn test:mysql'), - }; - - return { - GET, - POST, - MYSQL, - }; -} - -export async function getAveragedOverheadMeasurements() { - const closeDb = await startDb(); - const repeat = process.env.REPEAT ? parseInt(process.env.REPEAT) : 1; - - const results = []; - for (let i = 0; i < repeat; i++) { - const result = await getOverheadMeasurements(); - results.push(result); - } - - closeDb(); - - // Calculate averages for each scenario - const averaged = { - GET: { - baseline: Math.floor(results.reduce((sum, r) => sum + r.GET.baseline, 0) / results.length), - withInstrument: Math.floor(results.reduce((sum, r) => sum + r.GET.withInstrument, 0) / results.length), - withInstrumentErrorOnly: Math.floor( - results.reduce((sum, r) => sum + r.GET.withInstrumentErrorOnly, 0) / results.length, - ), - }, - POST: { - baseline: Math.floor(results.reduce((sum, r) => sum + r.POST.baseline, 0) / results.length), - withInstrument: Math.floor(results.reduce((sum, r) => sum + r.POST.withInstrument, 0) / results.length), - withInstrumentErrorOnly: Math.floor( - results.reduce((sum, r) => sum + r.POST.withInstrumentErrorOnly, 0) / results.length, - ), - }, - MYSQL: { - baseline: Math.floor(results.reduce((sum, r) => sum + r.MYSQL.baseline, 0) / results.length), - withInstrument: Math.floor(results.reduce((sum, r) => sum + r.MYSQL.withInstrument, 0) / results.length), - withInstrumentErrorOnly: Math.floor( - results.reduce((sum, r) => sum + r.MYSQL.withInstrumentErrorOnly, 0) / results.length, - ), - }, - }; - - return averaged; -} - -function log(message) { - if (DEBUG) { - // eslint-disable-next-line no-console - console.log(message); - } -} - -function killProcess(process) { - return new Promise(resolve => { - const pid = process.pid; - - if (!pid) { - log('Process has no PID, fallback killing process...'); - process.kill(); - resolve(); - return; - } - - treeKill(pid, () => { - resolve(); - }); - }); -} diff --git a/dev-packages/node-overhead-gh-action/lib/markdown-table-formatter.mjs b/dev-packages/node-overhead-gh-action/lib/markdown-table-formatter.mjs deleted file mode 100644 index 3119d6ad0edd..000000000000 --- a/dev-packages/node-overhead-gh-action/lib/markdown-table-formatter.mjs +++ /dev/null @@ -1,112 +0,0 @@ -const NODE_OVERHEAD_RESULTS_HEADER = ['Scenario', 'Requests/s', '% of Baseline', 'Prev. Requests/s', 'Change %']; - -const ROUND_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { - style: 'decimal', - minimumFractionDigits: 0, - maximumFractionDigits: 0, -}); - -export function formatResults(baseScenarios, currentScenarios) { - const headers = NODE_OVERHEAD_RESULTS_HEADER; - - const scenarios = getScenarios(baseScenarios, currentScenarios); - const rows = [headers]; - - scenarios.forEach(scenario => { - const base = baseScenarios?.[scenario]; - const current = currentScenarios?.[scenario]; - const baseline = current?.baseline; - - rows.push(formatResult(`${scenario} Baseline`, base?.baseline, current?.baseline)); - rows.push(formatResult(`${scenario} With Sentry`, base?.withInstrument, current?.withInstrument, baseline)); - rows.push( - formatResult( - `${scenario} With Sentry (error only)`, - base?.withInstrumentErrorOnly, - current?.withInstrumentErrorOnly, - baseline, - ), - ); - }); - - return rows; -} -export function hasChanges(baseScenarios, currentScenarios, threshold = 0) { - if (!baseScenarios || !currentScenarios) { - return true; - } - - const names = ['baseline', 'withInstrument', 'withInstrumentErrorOnly']; - const scenarios = getScenarios(baseScenarios, currentScenarios); - - return scenarios.some(scenario => { - const base = baseScenarios?.[scenario]; - const current = currentScenarios?.[scenario]; - - return names.some(name => { - const baseResult = base[name]; - const currentResult = current[name]; - - if (!baseResult || !currentResult) { - return true; - } - - return Math.abs((currentResult - baseResult) / baseResult) * 100 > threshold; - }); - }); -} - -function formatResult(name, base, current, baseline) { - const currentValue = current ? ROUND_NUMBER_FORMATTER.format(current) : '-'; - const baseValue = base ? ROUND_NUMBER_FORMATTER.format(base) : '-'; - - return [ - name, - currentValue, - baseline != null ? formatPercentageDecrease(baseline, current) : '-', - baseValue, - formatPercentageChange(base, current), - ]; -} - -function formatPercentageChange(baseline, value) { - if (!baseline) { - return 'added'; - } - - if (!value) { - return 'removed'; - } - - const percentage = ((value - baseline) / baseline) * 100; - return formatChange(percentage); -} - -function formatPercentageDecrease(baseline, value) { - if (!baseline) { - return 'added'; - } - - if (!value) { - return 'removed'; - } - - const percentage = (value / baseline) * 100; - return `${ROUND_NUMBER_FORMATTER.format(percentage)}%`; -} - -function formatChange(value) { - if (value === 0) { - return '-'; - } - - if (value > 0) { - return `+${ROUND_NUMBER_FORMATTER.format(value)}%`; - } - - return `${ROUND_NUMBER_FORMATTER.format(value)}%`; -} - -function getScenarios(baseScenarios = {}, currentScenarios = {}) { - return Array.from(new Set([...Object.keys(baseScenarios), ...Object.keys(currentScenarios)])); -} diff --git a/dev-packages/node-overhead-gh-action/package.json b/dev-packages/node-overhead-gh-action/package.json deleted file mode 100644 index 54a10b24f543..000000000000 --- a/dev-packages/node-overhead-gh-action/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@sentry-internal/node-overhead-gh-action", - "version": "10.48.0", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "private": true, - "type": "module", - "main": "index.mjs", - "scripts": { - "dev": "node ./run-local.mjs", - "start": "node ./src/app.mjs", - "start:sentry": "node --import ./src/instrument.mjs ./src/app.mjs", - "start:sentry-error-only": "node --import ./src/instrument-error-only.mjs ./src/app.mjs", - "test:get": "autocannon --json -c 100 -p 10 -d 10 -W [ -c 100 -d 5] http://localhost:3030/test-get", - "test:mysql": "autocannon --json -c 100 -p 10 -d 10 -W [ -c 100 -d 5] http://localhost:3030/test-mysql", - "test:post": "autocannon --json -m POST -b \"{\\\"data\\\":\\\"test\\\"}\" --headers \"Content-type: application/json\" -c 100 -p 10 -d 10 -W [ -c 100 -d 5] http://localhost:3030/test-post", - "clean": "rimraf -g **/node_modules", - "db:up": "docker compose up", - "db:down": "docker compose down --volumes", - "lint": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --type-aware", - "lint:fix": "OXLINT_TSGOLINT_DANGEROUSLY_SUPPRESS_PROGRAM_DIAGNOSTICS=true oxlint . --fix --type-aware" - }, - "dependencies": { - "@sentry/node": "10.48.0", - "express": "^4.21.2", - "mysql2": "^3.19.1" - }, - "devDependencies": { - "@actions/artifact": "5.0.3", - "@actions/core": "1.10.1", - "@actions/exec": "1.1.1", - "@actions/github": "^5.0.0", - "@actions/glob": "0.6.1", - "@actions/io": "1.1.3", - "autocannon": "^8.0.0", - "eslint-plugin-regexp": "^1.15.0", - "markdown-table": "3.0.3", - "tree-kill": "1.2.2" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/node-overhead-gh-action/run-local.mjs b/dev-packages/node-overhead-gh-action/run-local.mjs deleted file mode 100644 index fd890d559b5b..000000000000 --- a/dev-packages/node-overhead-gh-action/run-local.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import { getAveragedOverheadMeasurements } from './lib/getOverheadMeasurements.mjs'; -import { formatResults } from './lib/markdown-table-formatter.mjs'; - -async function run() { - const measurements = await getAveragedOverheadMeasurements(); - - // eslint-disable-next-line no-console - console.log(formatResults(undefined, measurements)); -} - -run(); diff --git a/dev-packages/node-overhead-gh-action/src/app.mjs b/dev-packages/node-overhead-gh-action/src/app.mjs deleted file mode 100644 index 185340837aeb..000000000000 --- a/dev-packages/node-overhead-gh-action/src/app.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import * as Sentry from '@sentry/node'; -import express from 'express'; -import mysql from 'mysql2/promise'; - -const app = express(); -const port = 3030; - -const pool = mysql.createPool({ - user: 'root', - password: 'password', - host: 'localhost', - database: 'mydb', - port: 3306, - waitForConnections: true, - connectionLimit: 10, - maxIdle: 10, // max idle connections, the default value is the same as `connectionLimit` - idleTimeout: 60000, // idle connections timeout, in milliseconds, the default value 60000 - queueLimit: 0, - enableKeepAlive: true, - keepAliveInitialDelay: 0, -}); - -app.use(express.json()); - -app.get('/test-get', function (req, res) { - res.send({ version: 'v1' }); -}); - -app.post('/test-post', function (req, res) { - const body = req.body; - res.send(generateResponse(body)); -}); - -app.get('/test-mysql', function (_req, res) { - pool.query('SELECT * from users').then(([users]) => { - res.send({ version: 'v1', users }); - }); -}); - -Sentry.setupExpressErrorHandler(app); - -app.listen(port, () => { - // eslint-disable-next-line no-console - console.log(`Example app listening on port ${port}`); -}); - -// This is complicated on purpose to simulate a real-world response -function generateResponse(body) { - const bodyStr = JSON.stringify(body); - const RES_BODY_SIZE = 10000; - - const bodyLen = bodyStr.length; - let resBody = ''; - for (let i = 0; i < RES_BODY_SIZE; i++) { - resBody += `${i}${bodyStr[i % bodyLen]}-`; - } - return { version: 'v1', length: bodyLen, resBody }; -} diff --git a/dev-packages/node-overhead-gh-action/src/instrument-error-only.mjs b/dev-packages/node-overhead-gh-action/src/instrument-error-only.mjs deleted file mode 100644 index 6476a071226a..000000000000 --- a/dev-packages/node-overhead-gh-action/src/instrument-error-only.mjs +++ /dev/null @@ -1,5 +0,0 @@ -import * as Sentry from '@sentry/node'; - -Sentry.init({ - dsn: process.env.E2E_TEST_DSN || 'https://1234567890@sentry.io/1234567890', -}); diff --git a/dev-packages/node-overhead-gh-action/src/instrument.mjs b/dev-packages/node-overhead-gh-action/src/instrument.mjs deleted file mode 100644 index 8a49ebb67a7e..000000000000 --- a/dev-packages/node-overhead-gh-action/src/instrument.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import * as Sentry from '@sentry/node'; - -Sentry.init({ - dsn: process.env.E2E_TEST_DSN || 'https://1234567890@sentry.io/1234567890', - tracesSampleRate: 1, -}); diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 9c411c3fc015..77effa924ff4 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -6,6 +6,8 @@ import type { SerializedMetric, SerializedMetricContainer, SerializedSession, + SerializedStreamedSpan, + StreamedSpanEnvelope, } from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; import * as fs from 'fs'; @@ -427,6 +429,197 @@ export function waitForMetric( }); } +/** + * Check if an envelope item is a Span V2 container item. + */ +function isStreamedSpanEnvelopeItem( + envelopeItem: EnvelopeItem, +): envelopeItem is [ + { type: 'span'; content_type: 'application/vnd.sentry.items.span.v2+json'; item_count: number }, + { items: SerializedStreamedSpan[] }, +] { + const [header] = envelopeItem; + return ( + header.type === 'span' && + 'content_type' in header && + header.content_type === 'application/vnd.sentry.items.span.v2+json' + ); +} + +/** + * Wait for a Span V2 envelope to be sent. + * Returns the first Span V2 envelope that is sent that matches the callback. + * If no callback is provided, returns the first Span V2 envelope that is sent. + * + * @example + * ```ts + * const envelope = await waitForSpanV2Envelope(PROXY_SERVER_NAME); + * const spans = envelope[1][0][1].items; + * expect(spans.length).toBeGreaterThan(0); + * ``` + * + * @example + * ```ts + * // With a filter callback + * const envelope = await waitForSpanV2Envelope(PROXY_SERVER_NAME, envelope => { + * return envelope[1][0][1].items.length > 5; + * }); + * ``` + */ +export function waitForStreamedSpanEnvelope( + proxyServerName: string, + callback?: (spanEnvelope: StreamedSpanEnvelope) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelope = eventData.envelope; + const envelopeItems = envelope[1]; + + // Check if this is a Span V2 envelope by looking for a Span V2 item + const hasSpanV2Item = envelopeItems.some(item => isStreamedSpanEnvelopeItem(item)); + if (!hasSpanV2Item) { + return false; + } + + const spanV2Envelope = envelope as StreamedSpanEnvelope; + + if (callback) { + return callback(spanV2Envelope); + } + + return true; + }, + timestamp, + ) + .then(eventData => resolve(eventData.envelope as StreamedSpanEnvelope)) + .catch(reject); + }); +} + +/** + * Wait for a single Span V2 to be sent that matches the callback. + * Returns the first Span V2 that is sent that matches the callback. + * If no callback is provided, returns the first Span V2 that is sent. + * + * @example + * ```ts + * const span = await waitForSpanV2(PROXY_SERVER_NAME, span => { + * return span.name === 'GET /api/users'; + * }); + * expect(span.status).toBe('ok'); + * ``` + * + * @example + * ```ts + * // Using the getSpanV2Op helper + * const span = await waitForSpanV2(PROXY_SERVER_NAME, span => { + * return getSpanV2Op(span) === 'http.client'; + * }); + * ``` + */ +export function waitForStreamedSpan( + proxyServerName: string, + callback: (span: SerializedStreamedSpan) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelope = eventData.envelope; + const envelopeItems = envelope[1]; + + for (const envelopeItem of envelopeItems) { + if (!isStreamedSpanEnvelopeItem(envelopeItem)) { + continue; + } + + const spans = envelopeItem[1].items; + + for (const span of spans) { + if (await callback(span)) { + resolve(span); + return true; + } + } + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + +/** + * Wait for Span V2 spans to be sent. Returns all spans from the envelope for which the callback returns true. + * If no callback is provided, returns all spans from the first Span V2 envelope. + * + * @example + * ```ts + * // Get all spans from the first envelope + * const spans = await waitForSpansV2(PROXY_SERVER_NAME); + * expect(spans.length).toBeGreaterThan(0); + * ``` + * + * @example + * ```ts + * // Filter for specific spans (same callback style as waitForSpanV2) + * const httpSpans = await waitForSpansV2(PROXY_SERVER_NAME, spans => { + * return spans.some(span => getSpanV2Op(span) === 'http.client'); + * }); + * expect(httpSpans.length).toBe(2); + * ``` + */ +export function waitForStreamedSpans( + proxyServerName: string, + callback?: (spans: SerializedStreamedSpan[]) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForRequest( + proxyServerName, + async eventData => { + const envelope = eventData.envelope; + const envelopeItems = envelope[1]; + + for (const envelopeItem of envelopeItems) { + if (isStreamedSpanEnvelopeItem(envelopeItem)) { + const spans = envelopeItem[1].items; + if (callback) { + if (await callback(spans)) { + resolve(spans); + return true; + } + } else { + resolve(spans); + return true; + } + } + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + +/** + * Helper to get the span operation from a Span V2 JSON object. + * + * @example + * ```ts + * const span = await waitForSpanV2(PROXY_SERVER_NAME, span => { + * return getSpanV2Op(span) === 'http.client'; + * }); + * ``` + */ +export function getSpanOp(span: SerializedStreamedSpan): string | undefined { + return span.attributes?.['sentry.op']?.type === 'string' ? span.attributes['sentry.op'].value : undefined; +} + const TEMP_FILE_PREFIX = 'event-proxy-server-'; async function registerCallbackServerPort(serverName: string, port: string): Promise { diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 749cbdbdd663..54e5d11749b4 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -8,6 +8,10 @@ export { waitForSession, waitForPlainRequest, waitForMetric, + waitForStreamedSpan, + waitForStreamedSpans, + waitForStreamedSpanEnvelope, + getSpanOp, } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; diff --git a/dev-packages/test-utils/src/playwright-config.ts b/dev-packages/test-utils/src/playwright-config.ts index fb15fc325232..823f47380c05 100644 --- a/dev-packages/test-utils/src/playwright-config.ts +++ b/dev-packages/test-utils/src/playwright-config.ts @@ -37,7 +37,7 @@ export function getPlaywrightConfig( /* In dev mode some apps are flaky, so we allow retry there... */ retries: testEnv === 'development' ? 3 : 0, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: process.env.CI ? [['line'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', + reporter: process.env.CI ? [['line'], ['github'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ diff --git a/package.json b/package.json index d05b71e7fdbc..3d211ca42067 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,11 @@ "dedupe-deps:check": "yarn-deduplicate yarn.lock --list --fail", "dedupe-deps:fix": "yarn-deduplicate yarn.lock", "postpublish": "nx run-many -t postpublish --parallel=1", - "test": "nx run-many -t test --exclude \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", + "test": "nx run-many -t test --exclude \"@sentry-internal/{browser-integration-tests,bun-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", "test:scripts": "vitest run scripts/bump-version.test.ts", - "test:unit": "nx run-many -t test:unit --exclude \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", + "test:unit": "nx run-many -t test:unit --exclude \"@sentry-internal/{browser-integration-tests,bun-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", "test:update-snapshots": "nx run-many -t test:update-snapshots", - "test:pr": "nx affected -t test --exclude \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", + "test:pr": "nx affected -t test --exclude \"@sentry-internal/{browser-integration-tests,bun-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", "test:pr:browser": "UNIT_TEST_ENV=browser ts-node ./scripts/ci-unit-tests.ts --affected", "test:pr:node": "UNIT_TEST_ENV=node ts-node ./scripts/ci-unit-tests.ts --affected", "test:ci:browser": "UNIT_TEST_ENV=browser ts-node ./scripts/ci-unit-tests.ts", @@ -98,6 +98,7 @@ "dev-packages/bundle-analyzer-scenarios", "dev-packages/e2e-tests", "dev-packages/node-integration-tests", + "dev-packages/bun-integration-tests", "dev-packages/cloudflare-integration-tests", "dev-packages/node-core-integration-tests", "dev-packages/test-utils", @@ -105,7 +106,6 @@ "dev-packages/clear-cache-gh-action", "dev-packages/external-contributor-gh-action", "dev-packages/rollup-utils", - "dev-packages/node-overhead-gh-action", "dev-packages/bundler-tests" ], "devDependencies": { diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 2b2d4b7f9397..888524ed7c21 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -20,6 +20,8 @@ export { elementTimingIntegration, startTrackingElementTiming } from './metrics/ export { extractNetworkProtocol } from './metrics/utils'; +export { trackClsAsSpan, trackInpAsSpan, trackLcpAsSpan } from './metrics/webVitalSpans'; + export { addClickKeypressInstrumentationHandler } from './instrument/dom'; export { addHistoryInstrumentationHandler } from './instrument/history'; diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 28d1f2bfaec8..9a00ab322e16 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -2,6 +2,7 @@ import type { Client, Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core'; import { browserPerformanceTimeOrigin, + debug, getActiveSpan, getComponentName, htmlTreeAsString, @@ -27,7 +28,7 @@ import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan import { getActivationStart } from './web-vitals/lib/getActivationStart'; import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry'; import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher'; - +import { DEBUG_BUILD } from '../debug-build'; interface NavigatorNetworkInformation { readonly connection?: NetworkInformation; } @@ -75,8 +76,18 @@ let _lcpEntry: LargestContentfulPaint | undefined; let _clsEntry: LayoutShift | undefined; interface StartTrackingWebVitalsOptions { - recordClsStandaloneSpans: boolean; - recordLcpStandaloneSpans: boolean; + /** + * When `true`, CLS is tracked as a standalone span. When `false`, CLS is + * recorded as a measurement on the pageload span. When `undefined`, CLS + * tracking is skipped entirely (e.g. because span streaming handles it). + */ + recordClsStandaloneSpans: boolean | undefined; + /** + * When `true`, LCP is tracked as a standalone span. When `false`, LCP is + * recorded as a measurement on the pageload span. When `undefined`, LCP + * tracking is skipped entirely (e.g. because span streaming handles it). + */ + recordLcpStandaloneSpans: boolean | undefined; client: Client; } @@ -84,6 +95,7 @@ interface StartTrackingWebVitalsOptions { * Start tracking web vitals. * The callback returned by this function can be used to stop tracking & ensure all measurements are final & captured. * + * @deprecated this function will be removed and streamlined once we stop supporting standalone v1 * @returns A function that forces web vitals collection */ export function startTrackingWebVitals({ @@ -97,13 +109,24 @@ export function startTrackingWebVitals({ if (performance.mark) { WINDOW.performance.mark('sentry-tracing-init'); } - const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP(); + + const lcpCleanupCallback = recordLcpStandaloneSpans + ? trackLcpAsStandaloneSpan(client) + : recordLcpStandaloneSpans === false + ? _trackLCP() + : undefined; + + const clsCleanupCallback = recordClsStandaloneSpans + ? trackClsAsStandaloneSpan(client) + : recordClsStandaloneSpans === false + ? _trackCLS() + : undefined; + const ttfbCleanupCallback = _trackTtfb(); - const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS(); return (): void => { - lcpCleanupCallback?.(); ttfbCleanupCallback(); + lcpCleanupCallback?.(); clsCleanupCallback?.(); }; } @@ -314,6 +337,11 @@ interface AddPerformanceEntriesOptions { * Default: [] */ ignorePerformanceApiSpans: Array; + + /** + * Whether span streaming is enabled. + */ + spanStreamingEnabled?: boolean; } /** Add performance related spans to a transaction */ @@ -325,6 +353,14 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries return; } + const { + spanStreamingEnabled, + ignorePerformanceApiSpans, + ignoreResourceSpans, + recordClsOnPageloadSpan, + recordLcpOnPageloadSpan, + } = options; + const timeOrigin = msToSec(origin); const performanceEntries = performance.getEntries(); @@ -353,7 +389,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries case 'mark': case 'paint': case 'measure': { - _addMeasureSpans(span, entry, startTime, duration, timeOrigin, options.ignorePerformanceApiSpans); + _addMeasureSpans(span, entry, startTime, duration, timeOrigin, ignorePerformanceApiSpans); // capture web vitals const firstHidden = getVisibilityWatcher(); @@ -376,7 +412,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries startTime, duration, timeOrigin, - options.ignoreResourceSpans, + ignoreResourceSpans, ); break; } @@ -386,28 +422,50 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries _performanceCursor = Math.max(performanceEntries.length - 1, 0); - _trackNavigator(span); + _trackNavigator(span, spanStreamingEnabled); // Measurements are only available for pageload transactions if (op === 'pageload') { _addTtfbRequestTimeToMeasurements(_measurements); - // If CLS standalone spans are enabled, don't record CLS as a measurement - if (!options.recordClsOnPageloadSpan) { - delete _measurements.cls; - } + if (spanStreamingEnabled) { + const setAttr = (shortWebVitalName: string, value: number, customAttrName?: string) => { + const attrKey = customAttrName ?? `browser.web_vital.${shortWebVitalName}.value`; + span.setAttribute(attrKey, value); + DEBUG_BUILD && debug.log('Setting web vital attribute', { [attrKey]: value }, 'on pageload span'); + }; + // for streamed pageload spans, we add the web vital measurements as attributes. + // We omit LCP, CLS and INP because they're tracked separately as spans + ['ttfb', 'fp', 'fcp'].forEach(measurementName => { + if (_measurements[measurementName]) { + setAttr(measurementName, _measurements[measurementName].value); + } + }); + if (_measurements['ttfb.requestTime']) { + setAttr('ttfb.requestTime', _measurements['ttfb.requestTime'].value, 'browser.web_vital.ttfb.request_time'); + } + } else { + // TODO (V11): Remove this else branch once we remove v1 standalone spans and transactions - // If LCP standalone spans are enabled, don't record LCP as a measurement - if (!options.recordLcpOnPageloadSpan) { - delete _measurements.lcp; - } + // If CLS standalone spans are enabled, don't record CLS as a measurement + if (!recordClsOnPageloadSpan) { + delete _measurements.cls; + } - Object.entries(_measurements).forEach(([measurementName, measurement]) => { - setMeasurement(measurementName, measurement.value, measurement.unit); - }); + // If LCP standalone spans are enabled, don't record LCP as a measurement + if (!recordLcpOnPageloadSpan) { + delete _measurements.lcp; + } + + Object.entries(_measurements).forEach(([measurementName, measurement]) => { + setMeasurement(measurementName, measurement.value, measurement.unit); + }); + + _setWebVitalAttributes(span, options); + } // Set timeOrigin which denotes the timestamp which to base the LCP/FCP/FP/TTFB measurements on - span.setAttribute('performance.timeOrigin', timeOrigin); + span.setAttribute(spanStreamingEnabled ? 'browser.performance.time_origin' : 'performance.timeOrigin', timeOrigin); // In prerendering scenarios, where a page might be prefetched and pre-rendered before the user clicks the link, // the navigation starts earlier than when the user clicks it. Web Vitals should always be based on the @@ -415,9 +473,10 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries // time where the user actively started the navigation, for example by clicking a link. // This is user action is called "activation" and the time between navigation and activation is stored in // the `activationStart` attribute of the "navigation" PerformanceEntry. - span.setAttribute('performance.activationStart', getActivationStart()); - - _setWebVitalAttributes(span, options); + span.setAttribute( + spanStreamingEnabled ? 'browser.performance.navigation.activation_start' : 'performance.activationStart', + getActivationStart(), + ); } _lcpEntry = undefined; @@ -712,8 +771,9 @@ export function _addResourceSpans( /** * Capture the information of the user agent. + * TODO v11: Remove non-span-streaming attributes and measurements once we removed transactions */ -function _trackNavigator(span: Span): void { +function _trackNavigator(span: Span, spanStreamingEnabled: boolean | undefined): void { const navigator = WINDOW.navigator as null | (Navigator & NavigatorNetworkInformation & NavigatorDeviceMemory); if (!navigator) { return; @@ -723,24 +783,38 @@ function _trackNavigator(span: Span): void { const connection = navigator.connection; if (connection) { if (connection.effectiveType) { - span.setAttribute('effectiveConnectionType', connection.effectiveType); + span.setAttribute( + spanStreamingEnabled ? 'network.connection.effective_type' : 'effectiveConnectionType', + connection.effectiveType, + ); } if (connection.type) { - span.setAttribute('connectionType', connection.type); + span.setAttribute(spanStreamingEnabled ? 'network.connection.type' : 'connectionType', connection.type); } if (isMeasurementValue(connection.rtt)) { _measurements['connection.rtt'] = { value: connection.rtt, unit: 'millisecond' }; + if (spanStreamingEnabled) { + span.setAttribute('network.connection.rtt', connection.rtt); + } } } if (isMeasurementValue(navigator.deviceMemory)) { - span.setAttribute('deviceMemory', `${navigator.deviceMemory} GB`); + if (spanStreamingEnabled) { + span.setAttribute('device.memory.estimated_capacity', navigator.deviceMemory); + } else { + span.setAttribute('deviceMemory', `${navigator.deviceMemory} GB`); + } } if (isMeasurementValue(navigator.hardwareConcurrency)) { - span.setAttribute('hardwareConcurrency', String(navigator.hardwareConcurrency)); + if (spanStreamingEnabled) { + span.setAttribute('device.processor_count', navigator.hardwareConcurrency); + } else { + span.setAttribute('hardwareConcurrency', String(navigator.hardwareConcurrency)); + } } } diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 831565f07408..3eb0b2920a75 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -37,7 +37,7 @@ const ELEMENT_NAME_TIMESTAMP_MAP = new Map(); * 60 seconds is the maximum for a plausible INP value * (source: Me) */ -const MAX_PLAUSIBLE_INP_DURATION = 60; +export const MAX_PLAUSIBLE_INP_DURATION = 60; /** * Start tracking INP webvital events. */ @@ -54,7 +54,7 @@ export function startTrackingINP(): () => void { return () => undefined; } -const INP_ENTRY_MAP: Record = { +export const INP_ENTRY_MAP: Record = { click: 'click', pointerdown: 'click', pointerup: 'click', @@ -155,6 +155,14 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => { } }; +/** + * Look up a cached interaction context (element name + root span) by interactionId. + * Returns undefined if no context was cached for this interaction. + */ +export function getCachedInteractionContext(interactionId: number | undefined): InteractionContext | undefined { + return interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined; +} + /** * Register a listener to cache route information for INP interactions. */ diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 4c461ec6776c..608a5fd11511 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -27,7 +27,7 @@ interface PerformanceEntry { readonly startTime: number; toJSON(): Record; } -interface PerformanceEventTiming extends PerformanceEntry { +export interface PerformanceEventTiming extends PerformanceEntry { processingStart: number; processingEnd: number; duration: number; diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 084d17becb8d..a3f3ea0e2cf8 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -203,17 +203,18 @@ export function supportsWebVital(entryType: 'layout-shift' | 'largest-contentful * @param collectorCallback the callback to be called when the first of these events is triggered. Parameters: * - event: the event that triggered the reporting of the web vital value. * - pageloadSpanId: the span id of the pageload span. This is used to link the web vital span to the pageload span. + * - pageloadSpan: the pageload span instance. This is used for full access to the pageload span for span streaming. */ export function listenForWebVitalReportEvents( client: Client, - collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string) => void, + collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string, pageloadSpan?: Span) => void, ) { - let pageloadSpanId: string | undefined; + let pageloadSpan: Span | undefined; let collected = false; function _runCollectorCallbackOnce(event: WebVitalReportEvent) { - if (!collected && pageloadSpanId) { - collectorCallback(event, pageloadSpanId); + if (!collected && pageloadSpan) { + collectorCallback(event, pageloadSpan.spanContext().spanId, pageloadSpan); } collected = true; } @@ -233,7 +234,7 @@ export function listenForWebVitalReportEvents( }); const unsubscribeAfterStartPageLoadSpan = client.on('afterStartPageLoadSpan', span => { - pageloadSpanId = span.spanContext().spanId; + pageloadSpan = span; unsubscribeAfterStartPageLoadSpan(); }); } diff --git a/packages/browser-utils/src/metrics/webVitalSpans.ts b/packages/browser-utils/src/metrics/webVitalSpans.ts new file mode 100644 index 000000000000..b342b653df97 --- /dev/null +++ b/packages/browser-utils/src/metrics/webVitalSpans.ts @@ -0,0 +1,308 @@ +import type { Client, Span, SpanAttributes } from '@sentry/core'; +import { + browserPerformanceTimeOrigin, + debug, + getActiveSpan, + getCurrentScope, + getRootSpan, + htmlTreeAsString, + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToStreamedSpanJSON, + startInactiveSpan, + timestampInSeconds, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../types'; +import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp'; +import type { InstrumentationHandlerCallback } from './instrument'; +import { addClsInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler } from './instrument'; +import type { WebVitalReportEvent } from './utils'; +import { getBrowserPerformanceAPI, listenForWebVitalReportEvents, msToSec, supportsWebVital } from './utils'; +import type { PerformanceEventTiming } from './instrument'; + +// Locally-defined interfaces to avoid leaking bare global type references into the +// generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these +// available during this package's compilation but are NOT carried to consumers. +// This mirrors the pattern used for PerformanceEventTiming in instrument.ts. +export interface LayoutShift extends PerformanceEntry { + value: number; + sources: Array<{ node: Node | null }>; + hadRecentInput: boolean; +} + +export interface LargestContentfulPaint extends PerformanceEntry { + readonly renderTime: DOMHighResTimeStamp; + readonly loadTime: DOMHighResTimeStamp; + readonly size: number; + readonly id: string; + readonly url: string; + readonly element: Element | null; +} + +interface WebVitalSpanOptions { + name: string; + op: string; + origin: string; + metricName: 'lcp' | 'cls' | 'inp'; + value: number; + attributes?: SpanAttributes; + parentSpan?: Span; + reportEvent?: WebVitalReportEvent; + startTime: number; + endTime?: number; +} + +/** + * Emits a web vital span that flows through the span streaming pipeline. + */ +export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { + const { + name, + op, + origin, + metricName, + value, + attributes: passedAttributes, + parentSpan, + reportEvent, + startTime, + endTime, + } = options; + + const routeName = getCurrentScope().getScopeData().transactionName; + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, + [`browser.web_vital.${metricName}.value`]: value, + 'sentry.transaction': routeName, + // Web vital score calculation relies on the user agent + 'user_agent.original': WINDOW.navigator?.userAgent, + ...passedAttributes, + }; + + if (parentSpan && spanToStreamedSpanJSON(parentSpan).attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'pageload') { + // for LCP and CLS, we collect the pageload span id as an attribute + attributes['sentry.pageload.span_id'] = parentSpan.spanContext().spanId; + } + + if (reportEvent) { + attributes[`browser.web_vital.${metricName}.report_event`] = reportEvent; + } + + const span = startInactiveSpan({ + name, + attributes, + startTime, + // if we have a pageload span, we let the web vital span start as its parent. This ensures that + // it is not started as a segment span, without having to manually set it to a "standalone" v2 span + // that has `segment: false` but no actual parent span. + parentSpan: parentSpan, + }); + + if (span) { + span.end(endTime ?? startTime); + } +} + +/** + * Tracks LCP as a streamed span. + */ +export function trackLcpAsSpan(client: Client): void { + let lcpValue = 0; + let lcpEntry: LargestContentfulPaint | undefined; + + if (!supportsWebVital('largest-contentful-paint')) { + return; + } + + const cleanupLcpHandler = addLcpInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LargestContentfulPaint | undefined; + if (!entry) { + return; + } + lcpValue = metric.value; + lcpEntry = entry; + }, true); + + listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { + _sendLcpSpan(lcpValue, lcpEntry, pageloadSpan, reportEvent); + cleanupLcpHandler(); + }); +} + +/** + * Exported only for testing. + */ +export function _sendLcpSpan( + lcpValue: number, + entry: LargestContentfulPaint | undefined, + pageloadSpan?: Span, + reportEvent?: WebVitalReportEvent, +): void { + DEBUG_BUILD && debug.log(`Sending LCP span (${lcpValue})`); + + const performanceTimeOrigin = browserPerformanceTimeOrigin() || 0; + const timeOrigin = msToSec(performanceTimeOrigin); + const endTime = msToSec(performanceTimeOrigin + (entry?.startTime || 0)); + const name = entry ? htmlTreeAsString(entry.element) : 'Largest contentful paint'; + + const attributes: SpanAttributes = {}; + + entry?.element && (attributes['browser.web_vital.lcp.element'] = htmlTreeAsString(entry.element)); + entry?.id && (attributes['browser.web_vital.lcp.id'] = entry.id); + entry?.url && (attributes['browser.web_vital.lcp.url'] = entry.url); + entry?.loadTime != null && (attributes['browser.web_vital.lcp.load_time'] = entry.loadTime); + entry?.renderTime != null && (attributes['browser.web_vital.lcp.render_time'] = entry.renderTime); + entry?.size != null && (attributes['browser.web_vital.lcp.size'] = entry.size); + + _emitWebVitalSpan({ + name, + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: lcpValue, + attributes, + parentSpan: pageloadSpan, + reportEvent, + startTime: timeOrigin, + endTime, + }); +} + +/** + * Tracks CLS as a streamed span. + */ +export function trackClsAsSpan(client: Client): void { + let clsValue = 0; + let clsEntry: LayoutShift | undefined; + + if (!supportsWebVital('layout-shift')) { + return; + } + + const cleanupClsHandler = addClsInstrumentationHandler(({ metric }) => { + const entry = metric.entries[metric.entries.length - 1] as LayoutShift | undefined; + if (!entry) { + return; + } + clsValue = metric.value; + clsEntry = entry; + }, true); + + listenForWebVitalReportEvents(client, (reportEvent, _, pageloadSpan) => { + _sendClsSpan(clsValue, clsEntry, pageloadSpan, reportEvent); + cleanupClsHandler(); + }); +} + +/** + * Exported only for testing. + */ +export function _sendClsSpan( + clsValue: number, + entry: LayoutShift | undefined, + pageloadSpan?: Span, + reportEvent?: WebVitalReportEvent, +): void { + DEBUG_BUILD && debug.log(`Sending CLS span (${clsValue})`); + + const startTime = entry ? msToSec((browserPerformanceTimeOrigin() || 0) + entry.startTime) : timestampInSeconds(); + const name = entry ? htmlTreeAsString(entry.sources[0]?.node) : 'Layout shift'; + + const attributes: SpanAttributes = {}; + + if (entry?.sources) { + entry.sources.forEach((source, index) => { + attributes[`browser.web_vital.cls.source.${index + 1}`] = htmlTreeAsString(source.node); + }); + } + + _emitWebVitalSpan({ + name, + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + metricName: 'cls', + value: clsValue, + attributes, + parentSpan: pageloadSpan, + reportEvent, + startTime, + }); +} + +/** + * Tracks INP as a streamed span. + * + * This mirrors the standalone INP tracking logic (`startTrackingINP`) but emits + * spans through the streaming pipeline instead of as standalone spans. + * Requires `registerInpInteractionListener()` to be called separately for + * cached element names and root spans per interaction. + */ +export function trackInpAsSpan(): void { + const performance = getBrowserPerformanceAPI(); + if (!performance || !browserPerformanceTimeOrigin()) { + return; + } + + const onInp: InstrumentationHandlerCallback = ({ metric }) => { + if (metric.value == null) { + return; + } + + const duration = msToSec(metric.value); + + if (duration > MAX_PLAUSIBLE_INP_DURATION) { + return; + } + + const entry = metric.entries.find(e => e.duration === metric.value && INP_ENTRY_MAP[e.name]); + + if (!entry) { + return; + } + + _sendInpSpan(metric.value, entry); + }; + + addInpInstrumentationHandler(onInp); +} + +/** + * Exported only for testing. + */ +export function _sendInpSpan(inpValue: number, entry: PerformanceEventTiming): void { + DEBUG_BUILD && debug.log(`Sending INP span (${inpValue})`); + + const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime); + const duration = msToSec(inpValue); + const interactionType = INP_ENTRY_MAP[entry.name]; + + const cachedContext = getCachedInteractionContext(entry.interactionId); + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + + const spanToUse = cachedContext?.span || rootSpan; + const routeName = spanToUse + ? spanToStreamedSpanJSON(spanToUse).name + : getCurrentScope().getScopeData().transactionName; + const name = cachedContext?.elementName || htmlTreeAsString(entry.target); + + _emitWebVitalSpan({ + name, + op: `ui.interaction.${interactionType}`, + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: inpValue, + attributes: { + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration, + 'sentry.transaction': routeName, + }, + startTime, + endTime: startTime + duration, + parentSpan: spanToUse, + }); +} diff --git a/packages/browser-utils/test/metrics/webVitalSpans.test.ts b/packages/browser-utils/test/metrics/webVitalSpans.test.ts new file mode 100644 index 000000000000..733891370fda --- /dev/null +++ b/packages/browser-utils/test/metrics/webVitalSpans.test.ts @@ -0,0 +1,468 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as inpModule from '../../src/metrics/inp'; +import { _emitWebVitalSpan, _sendClsSpan, _sendInpSpan, _sendLcpSpan } from '../../src/metrics/webVitalSpans'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + browserPerformanceTimeOrigin: vi.fn(), + timestampInSeconds: vi.fn(), + getCurrentScope: vi.fn(), + htmlTreeAsString: vi.fn(), + startInactiveSpan: vi.fn(), + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + spanToJSON: vi.fn(), + spanToStreamedSpanJSON: vi.fn(), + }; +}); + +// Mock WINDOW +vi.mock('../../src/types', () => ({ + WINDOW: { + navigator: { userAgent: 'test-user-agent' }, + performance: { + getEntriesByType: vi.fn().mockReturnValue([]), + }, + }, +})); + +function createMockPageloadSpan(spanId: string) { + return { + spanContext: () => ({ spanId, traceId: 'trace-1', traceFlags: 1 }), + end: vi.fn(), + }; +} + +describe('_emitWebVitalSpan', () => { + const mockSpan = { + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-transaction', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ attributes: {} } as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('creates a non-standalone span with correct attributes', () => { + _emitWebVitalSpan({ + name: 'Test Vital', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: 100, + startTime: 1.5, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith({ + name: 'Test Vital', + attributes: { + 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.op': 'ui.webvital.lcp', + 'sentry.exclusive_time': 0, + 'browser.web_vital.lcp.value': 100, + 'sentry.transaction': 'test-transaction', + 'user_agent.original': 'test-user-agent', + }, + startTime: 1.5, + }); + + // No standalone flag + expect(SentryCore.startInactiveSpan).not.toHaveBeenCalledWith( + expect.objectContaining({ experimental: expect.anything() }), + ); + + expect(mockSpan.end).toHaveBeenCalledWith(1.5); + }); + + it('includes pageload span id when parentSpan is a pageload span', () => { + const mockPageloadSpan = createMockPageloadSpan('abc123'); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ + attributes: { 'sentry.op': 'pageload' }, + } as any); + + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: 50, + parentSpan: mockPageloadSpan as any, + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.pageload.span_id': 'abc123', + }), + parentSpan: mockPageloadSpan, + }), + ); + }); + + it('does not include pageload span id when parentSpan is not a pageload span', () => { + const mockNonPageloadSpan = createMockPageloadSpan('xyz789'); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ + attributes: { 'sentry.op': 'ui.interaction.click' }, + } as any); + + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.interaction.click', + origin: 'auto.http.browser.inp', + metricName: 'inp', + value: 50, + parentSpan: mockNonPageloadSpan as any, + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'sentry.pageload.span_id': expect.anything(), + }), + }), + ); + }); + + it('includes reportEvent when provided', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.cls', + origin: 'auto.http.browser.cls', + metricName: 'cls', + value: 0.1, + reportEvent: 'pagehide', + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'browser.web_vital.cls.report_event': 'pagehide', + }), + }), + ); + }); + + it('merges additional attributes', () => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: 50, + attributes: { 'custom.attr': 'value' }, + startTime: 1.0, + }); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'custom.attr': 'value', + }), + }), + ); + }); + + it('handles when startInactiveSpan returns undefined', () => { + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(undefined as any); + + expect(() => { + _emitWebVitalSpan({ + name: 'Test', + op: 'ui.webvital.lcp', + origin: 'auto.http.browser.lcp', + metricName: 'lcp', + value: 50, + startTime: 1.0, + }); + }).not.toThrow(); + }); +}); + +describe('_sendLcpSpan', () => { + const mockSpan = { + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ + attributes: { 'sentry.op': 'pageload' }, + } as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends a streamed LCP span with entry data', () => { + const mockEntry = { + element: { tagName: 'img' } as Element, + id: 'hero', + url: 'https://example.com/hero.jpg', + loadTime: 100, + renderTime: 150, + size: 50000, + startTime: 200, + } as LargestContentfulPaint; + + const mockPageloadSpan = createMockPageloadSpan('pageload-123'); + + _sendLcpSpan(250, mockEntry, mockPageloadSpan as any, 'pagehide'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.browser.lcp', + 'sentry.op': 'ui.webvital.lcp', + 'sentry.exclusive_time': 0, + 'sentry.pageload.span_id': 'pageload-123', + 'browser.web_vital.lcp.element': '', + 'browser.web_vital.lcp.id': 'hero', + 'browser.web_vital.lcp.url': 'https://example.com/hero.jpg', + 'browser.web_vital.lcp.load_time': 100, + 'browser.web_vital.lcp.render_time': 150, + 'browser.web_vital.lcp.size': 50000, + 'browser.web_vital.lcp.report_event': 'pagehide', + 'sentry.transaction': 'test-route', + }), + startTime: 1, // timeOrigin: 1000 / 1000 + parentSpan: mockPageloadSpan, + }), + ); + + // endTime = timeOrigin + entry.startTime = (1000 + 200) / 1000 = 1.2 + expect(mockSpan.end).toHaveBeenCalledWith(1.2); + }); + + it('sends a streamed LCP span without entry data', () => { + _sendLcpSpan(0, undefined); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Largest contentful paint', + startTime: 1, // timeOrigin: 1000 / 1000 + }), + ); + }); +}); + +describe('_sendClsSpan', () => { + const mockSpan = { + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.timestampInSeconds).mockReturnValue(1.5); + vi.mocked(SentryCore.htmlTreeAsString).mockImplementation((node: any) => `<${node?.tagName || 'div'}>`); + vi.mocked(SentryCore.startInactiveSpan).mockReturnValue(mockSpan as any); + vi.mocked(SentryCore.spanToStreamedSpanJSON).mockReturnValue({ + attributes: { 'sentry.op': 'pageload' }, + } as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends a streamed CLS span with entry data and sources', () => { + const mockEntry: LayoutShift = { + name: 'layout-shift', + entryType: 'layout-shift', + startTime: 100, + duration: 0, + value: 0.1, + hadRecentInput: false, + sources: [ + // @ts-expect-error - other properties are irrelevant + { node: { tagName: 'div' } as Element }, + // @ts-expect-error - other properties are irrelevant + { node: { tagName: 'span' } as Element }, + ], + toJSON: vi.fn(), + }; + + vi.mocked(SentryCore.htmlTreeAsString) + .mockReturnValueOnce('
') // for the name + .mockReturnValueOnce('
') // for source 1 + .mockReturnValueOnce(''); // for source 2 + + const mockPageloadSpan = createMockPageloadSpan('pageload-789'); + + _sendClsSpan(0.1, mockEntry, mockPageloadSpan as any, 'navigation'); + + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: '
', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.browser.cls', + 'sentry.op': 'ui.webvital.cls', + 'sentry.pageload.span_id': 'pageload-789', + 'browser.web_vital.cls.source.1': '
', + 'browser.web_vital.cls.source.2': '', + 'browser.web_vital.cls.report_event': 'navigation', + 'sentry.transaction': 'test-route', + }), + parentSpan: mockPageloadSpan, + }), + ); + }); + + it('sends a streamed CLS span without entry data', () => { + _sendClsSpan(0, undefined); + + expect(SentryCore.timestampInSeconds).toHaveBeenCalled(); + expect(SentryCore.startInactiveSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Layout shift', + startTime: 1.5, + }), + ); + }); +}); + +describe('_sendInpSpan', () => { + const mockSpan = { + end: vi.fn(), + }; + + const mockScope = { + getScopeData: vi.fn().mockReturnValue({ + transactionName: 'test-route', + }), + }; + + beforeEach(() => { + vi.mocked(SentryCore.getCurrentScope).mockReturnValue(mockScope as any); + vi.mocked(SentryCore.browserPerformanceTimeOrigin).mockReturnValue(1000); + vi.mocked(SentryCore.htmlTreeAsString).mockReturnValue('