diff --git a/.craft.yml b/.craft.yml index c5055acf329c..cf86175ca43d 100644 --- a/.craft.yml +++ b/.craft.yml @@ -153,6 +153,7 @@ targets: - nodejs18.x - nodejs20.x - nodejs22.x + - nodejs24.x license: MIT # CDN Bundle Target diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index d70f36ff6c94..891b91d50f90 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -32,7 +32,11 @@ Do not flag the issues below if they appear in tests. - When calling any `startSpan` API (`startInactiveSpan`, `startSpanManual`, etc), always ensure that the following span attributes are set: - `SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN` (`'sentry.origin'`) with a proper span origin + - a proper origin must only contain [a-z], [A-Z], [0-9], `_` and `.` characters. + - flag any non-conforming origin values as invalid and link to the trace origin specification (https://develop.sentry.dev/sdk/telemetry/traces/trace-origin/) - `SEMANTIC_ATTRIBUTE_SENTRY_OP` (`'sentry.op'`) with a proper span op + - Span ops should be lower case only, and use snake_case. The `.` character is used to delimit op parts. + - flag any non-conforming origin values as invalid and link to the span op specification (https://develop.sentry.dev/sdk/telemetry/traces/span-operations/) - When calling `captureException`, always make sure that the `mechanism` is set: - `handled`: must be set to `true` or `false` - `type`: must be set to a proper origin (i.e. identify the integration and part in the integration that caught the exception). diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index cfaf6db8abef..f693c62d765d 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -20,7 +20,7 @@ jobs: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 881b5f4b6580..5575f81c9e4a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,7 +71,7 @@ jobs: pull-requests: read steps: - name: Check out current commit - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} # We need to check out not only the fake merge commit between the PR and the base branch which GH creates, but @@ -131,13 +131,13 @@ jobs: (needs.job_get_metadata.outputs.is_gitflow_sync == 'false' && needs.job_get_metadata.outputs.has_gitflow_label == 'false') steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 if: github.event_name == 'pull_request' with: ref: ${{ github.event.pull_request.base.sha }} - name: 'Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})' - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} @@ -238,7 +238,7 @@ jobs: needs.job_get_metadata.outputs.is_release == 'true' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -267,7 +267,7 @@ jobs: needs.job_get_metadata.outputs.is_base_branch == 'true' || needs.job_get_metadata.outputs.is_release == 'true' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -296,7 +296,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -325,7 +325,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} @@ -348,7 +348,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -370,7 +370,7 @@ jobs: if: needs.job_get_metadata.outputs.is_release == 'true' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -405,13 +405,13 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 if: github.event_name == 'pull_request' with: ref: ${{ github.event.pull_request.base.sha }} - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -452,7 +452,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -477,7 +477,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -509,12 +509,12 @@ jobs: node: [18, 20, 22, 24] steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 if: github.event_name == 'pull_request' with: ref: ${{ github.event.pull_request.base.sha }} - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -603,7 +603,7 @@ jobs: steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -667,7 +667,7 @@ jobs: steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -715,7 +715,7 @@ jobs: timeout-minutes: 5 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -753,7 +753,7 @@ jobs: typescript: '3.8' steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -789,7 +789,7 @@ jobs: timeout-minutes: 15 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -810,14 +810,14 @@ jobs: needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_remix == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 15 strategy: fail-fast: false matrix: node: [18, 20, 22, 24] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -864,12 +864,12 @@ jobs: matrix-optional: ${{ steps.matrix-optional.outputs.matrix }} steps: - name: Check out base commit (${{ github.event.pull_request.base.sha }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 if: github.event_name == 'pull_request' with: ref: ${{ github.event.pull_request.base.sha }} - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -934,7 +934,7 @@ jobs: matrix: ${{ fromJson(needs.job_e2e_prepare.outputs.matrix) }} steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - uses: pnpm/action-setup@v4 @@ -1064,7 +1064,7 @@ jobs: steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - uses: pnpm/action-setup@v4 diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 57290080c8de..1e71125ddad2 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -31,7 +31,7 @@ jobs: timeout-minutes: 30 steps: - name: Check out current commit - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node @@ -117,7 +117,7 @@ jobs: steps: - name: Check out current commit - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ env.HEAD_COMMIT }} - uses: pnpm/action-setup@v4 diff --git a/.github/workflows/cleanup-pr-caches.yml b/.github/workflows/cleanup-pr-caches.yml index 2c9bba513605..eb65d9a642c1 100644 --- a/.github/workflows/cleanup-pr-caches.yml +++ b/.github/workflows/cleanup-pr-caches.yml @@ -14,7 +14,7 @@ jobs: contents: read steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Cleanup run: | diff --git a/.github/workflows/clear-cache.yml b/.github/workflows/clear-cache.yml index 0f5f2241b34a..3c76486cdbe2 100644 --- a/.github/workflows/clear-cache.yml +++ b/.github/workflows/clear-cache.yml @@ -23,7 +23,7 @@ jobs: name: Delete all caches runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Node uses: actions/setup-node@v6 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 6d6b67201d5e..00e6203b6b55 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/create-issue-for-unreferenced-prs.yml b/.github/workflows/create-issue-for-unreferenced-prs.yml new file mode 100644 index 000000000000..725a2f8244b0 --- /dev/null +++ b/.github/workflows/create-issue-for-unreferenced-prs.yml @@ -0,0 +1,116 @@ +# This GitHub Action workflow checks if a new or updated pull request +# references a GitHub issue in its title or body. If no reference is found, +# it automatically creates a new issue. This helps ensure all work is +# tracked, especially when syncing with tools like Linear. + +name: Create issue for unreferenced PR + +# This action triggers on pull request events +on: + pull_request: + types: [opened, edited, reopened, synchronize, ready_for_review] + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + check_for_issue_reference: + runs-on: ubuntu-latest + steps: + - name: Check PR Body and Title for Issue Reference + uses: actions/github-script@v8 + with: + script: | + const pr = context.payload.pull_request; + if (!pr) { + core.setFailed('Could not get PR from context.'); + return; + } + + // Don't create an issue for draft PRs + if (pr.draft) { + console.log(`PR #${pr.number} is a draft, skipping issue creation.`); + return; + } + + // Check if the PR is already approved + const reviewsResponse = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + }); + + if (reviewsResponse.data.some(review => review.state === 'APPROVED')) { + console.log(`PR #${pr.number} is already approved, skipping issue creation.`); + return; + } + + const prBody = pr.body || ''; + const prTitle = pr.title || ''; + const prAuthor = pr.user.login; + const prUrl = pr.html_url; + const prNumber = pr.number; + + // Regex for GitHub issue references (e.g., #123, fixes #456) + // https://regex101.com/r/eDiGrQ/1 + const issueRegexGitHub = /(?:(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?\s*)?(#\d+|https:\/\/github\.com\/getsentry\/[\w-]+\/issues\/\d+)/i; + + // Regex for Linear issue references (e.g., ENG-123, resolves ENG-456) + const issueRegexLinear = /(?:(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?\s*)?[A-Z]+-\d+/i; + + const contentToCheck = `${prTitle} ${prBody}`; + const hasIssueReference = issueRegexGitHub.test(contentToCheck) || issueRegexLinear.test(contentToCheck); + + if (hasIssueReference) { + console.log(`PR #${prNumber} contains a valid issue reference.`); + return; + } + + // Check if there's already an issue created by this automation for this PR + // Search for issues that mention this PR and were created by github-actions bot + const existingIssuesResponse = await github.rest.search.issuesAndPullRequests({ + q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open author:app/github-actions "${prUrl}" in:title in:body`, + }); + + if (existingIssuesResponse.data.total_count > 0) { + const existingIssue = existingIssuesResponse.data.items[0]; + console.log(`An issue (#${existingIssue.number}) already exists for PR #${prNumber}, skipping creation.`); + return; + } + + core.warning(`PR #${prNumber} does not have an issue reference. Creating a new issue so it can be tracked in Linear.`); + + // Construct the title and body for the new issue + const issueTitle = `${prTitle}`; + const issueBody = `> [!NOTE] + > The pull request "[${prTitle}](${prUrl})" was created by @${prAuthor} but did not reference an issue. Therefore this issue was created for better visibility in external tools like Linear. + + ${prBody} + `; + + // Create the issue using the GitHub API + const newIssue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + assignees: [prAuthor] + }); + + const issueID = newIssue.data.number; + console.log(`Created issue #${issueID}.`); + + // Update the PR body to reference the new issue + const updatedPrBody = `${prBody}\n\nCloses #${issueID}`; + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + body: updatedPrBody + }); + + console.log(`Updated PR #${prNumber} to reference newly created issue #${issueID}.`); diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index c085f9958452..1566299d67e9 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -20,7 +20,7 @@ jobs: && github.event.pull_request.author_association != 'OWNER' && endsWith(github.event.pull_request.user.login, '[bot]') == false steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Node uses: actions/setup-node@v6 with: @@ -36,7 +36,7 @@ jobs: author_association: ${{ github.event.pull_request.author_association }} - name: Create PR with changes - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e + uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 with: # This token is scoped to Daniel Griesser # If we used the default GITHUB_TOKEN, the resulting PR would not trigger CI :( diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index a6ed22e04f6a..bb3169ecb410 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -30,7 +30,7 @@ jobs: if: ${{ github.base_ref != 'master' && github.ref != 'refs/heads/master' }} steps: - name: Check out current branch - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Node uses: actions/setup-node@v6 with: diff --git a/.github/workflows/gitflow-merge-conflict.yml b/.github/workflows/gitflow-merge-conflict.yml new file mode 100644 index 000000000000..c2ad1f42ad1d --- /dev/null +++ b/.github/workflows/gitflow-merge-conflict.yml @@ -0,0 +1,107 @@ +name: 'Gitflow: Merge Conflict Issue' + +on: + pull_request: + types: [opened] + branches: + - develop + +jobs: + check-merge-conflicts: + name: Detect merge conflicts in gitflow PRs + runs-on: ubuntu-24.04 + if: | + ${{ contains(github.event.pull_request.labels.*.name, 'Dev: Gitflow') }} + permissions: + issues: write + steps: + - name: Check for merge conflicts with retry + uses: actions/github-script@v8 + with: + script: | + const retryInterval = 30_000; + const maxRetries = 10; // (30 seconds * 10 retries) = 5 minutes + + async function isMergeable() { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + + return pr.mergeable; + } + + async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + let attempt = 0; + let mergeable = null; + + while (attempt < maxRetries) { + attempt++; + console.log(`Attempt ${attempt}/${maxRetries}: Checking if PR is mergeable...`); + + mergeable = await isMergeable(); + console.log(`Mergeable: ${mergeable}`); + + // If mergeable is not null, GitHub has finished computing merge state + if (mergeable !== null) { + break; + } + + if (attempt < maxRetries) { + console.log(`Waiting ${retryInterval/1000} seconds before retry...`); + await sleep(retryInterval); + } + } + + // Check if we have merge conflicts + if (mergeable === false) { + const issueTitle = '[Gitflow] Merge Conflict'; + + // Check for existing open issues with the same title + const { data: existingIssues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'Dev: Gitflow' + }); + + const existingOpenIssue = existingIssues.find(issue => + issue.title === issueTitle && !issue.pull_request + ); + + if (!existingOpenIssue) { + const issueBody = [ + '## Gitflow Merge Conflict Detected', + '', + `The automated gitflow PR #${context.payload.pull_request.number} has merge conflicts and cannot be merged automatically.`, + '', + '### How to resolve', + '', + `Follow the steps documented in [docs/gitflow.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/develop/docs/gitflow.md#what-to-do-if-there-is-a-merge-conflict):`, + '', + `1. Close the automated PR #${context.payload.pull_request.number}`, + '2. Create a new branch on top of `master` (e.g., `manual-develop-sync`)', + '3. Merge `develop` into this branch with a **merge commit** (fix any merge conflicts)', + '4. Create a PR against `develop` from your branch', + '5. Merge that PR with a **merge commit**' + ].join('\n'); + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + labels: ['Dev: Gitflow'] + }); + + console.log('Created new issue for merge conflict'); + } + } else if (mergeable === null) { + console.log('Could not determine mergeable state after maximum retries'); + } else { + console.log('No merge conflicts detected - PR can be merged'); + } diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml index 96c69d952264..ff649d6ee204 100644 --- a/.github/workflows/gitflow-sync-develop.yml +++ b/.github/workflows/gitflow-sync-develop.yml @@ -23,7 +23,7 @@ jobs: contents: write steps: - name: git checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 # https://github.com/marketplace/actions/github-pull-request-action - name: Create Pull Request diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2cb3fcd9600..0ff10040dc97 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: token: ${{ steps.token.outputs.token }} fetch-depth: 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e2cf7bd830..9f745ef5afe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,107 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.28.0 + +### Important Changes + +- **feat(core): Make `matcher` parameter optional in `makeMultiplexedTransport` ([#10798](https://github.com/getsentry/sentry-javascript/pull/10798))** + +The `matcher` parameter in `makeMultiplexedTransport` is now optional with a sensible default. This makes it much easier to use the multiplexed transport for sending events to multiple DSNs based on runtime configuration. + +**Before:** + +```javascript +import { makeFetchTransport, makeMultiplexedTransport } from '@sentry/browser'; + +const EXTRA_KEY = 'ROUTE_TO'; + +const transport = makeMultiplexedTransport(makeFetchTransport, args => { + const event = args.getEvent(); + if (event?.extra?.[EXTRA_KEY] && Array.isArray(event.extra[EXTRA_KEY])) { + return event.extra[EXTRA_KEY]; + } + return []; +}); + +Sentry.init({ + transport, + // ... other options +}); + +// Capture events with routing info +Sentry.captureException(error, { + extra: { + [EXTRA_KEY]: [ + { dsn: 'https://key1@sentry.io/project1', release: 'v1.0.0' }, + { dsn: 'https://key2@sentry.io/project2' }, + ], + }, +}); +``` + +**After:** + +```javascript +import { makeFetchTransport, makeMultiplexedTransport, MULTIPLEXED_TRANSPORT_EXTRA_KEY } from '@sentry/browser'; + +// Just pass the transport generator - the default matcher handles the rest! +Sentry.init({ + transport: makeMultiplexedTransport(makeFetchTransport), + // ... other options +}); + +// Capture events with routing info using the exported constant +Sentry.captureException(error, { + extra: { + [MULTIPLEXED_TRANSPORT_EXTRA_KEY]: [ + { dsn: 'https://key1@sentry.io/project1', release: 'v1.0.0' }, + { dsn: 'https://key2@sentry.io/project2' }, + ], + }, +}); +``` + +The default matcher looks for routing information in `event.extra[MULTIPLEXED_TRANSPORT_EXTRA_KEY]`. You can still provide a custom matcher function for advanced use cases. + +- **feat(nextjs): Support cacheComponents on turbopack ([#18304](https://github.com/getsentry/sentry-javascript/pull/18304))** + +This release adds support for `cacheComponents` on turbopack builds. We are working on adding support for this feature in webpack builds as well. + +### Other Changes + +- feat: Publish AWS Lambda Layer for Node 24 ([#18327](https://github.com/getsentry/sentry-javascript/pull/18327)) +- feat(browser): Expose langchain instrumentation ([#18342](https://github.com/getsentry/sentry-javascript/pull/18342)) +- feat(browser): Expose langgraph instrumentation ([#18345](https://github.com/getsentry/sentry-javascript/pull/18345)) +- feat(cloudflare): Allow specifying a custom fetch in Cloudflare transport options ([#18335](https://github.com/getsentry/sentry-javascript/pull/18335)) +- feat(core): Add `isolateTrace` option to `Sentry.withMonitor()` ([#18079](https://github.com/getsentry/sentry-javascript/pull/18079)) +- feat(deps): bump @sentry/webpack-plugin from 4.3.0 to 4.6.1 ([#18272](https://github.com/getsentry/sentry-javascript/pull/18272)) +- feat(nextjs): Add cloudflare `waitUntil` detection ([#18336](https://github.com/getsentry/sentry-javascript/pull/18336)) +- feat(node): Add LangChain v1 support ([#18306](https://github.com/getsentry/sentry-javascript/pull/18306)) +- feat(remix): Add parameterized transaction naming for routes ([#17951](https://github.com/getsentry/sentry-javascript/pull/17951)) +- fix(cloudflare): Keep http root span alive until streaming responses are consumed ([#18087](https://github.com/getsentry/sentry-javascript/pull/18087)) +- fix(cloudflare): Wait for async events to finish ([#18334](https://github.com/getsentry/sentry-javascript/pull/18334)) +- fix(core): `continueTrace` doesn't propagate given trace ID if active span exists ([#18328](https://github.com/getsentry/sentry-javascript/pull/18328)) +- fix(node-core): Handle custom scope in log messages without parameters ([#18322](https://github.com/getsentry/sentry-javascript/pull/18322)) +- fix(opentelemetry): Ensure Sentry spans don't leak when tracing is disabled ([#18337](https://github.com/getsentry/sentry-javascript/pull/18337)) +- fix(react-router): Use underscores in trace origin values ([#18351](https://github.com/getsentry/sentry-javascript/pull/18351)) +- chore(tanstackstart-react): Export custom inits from tanstackstart-react ([#18369](https://github.com/getsentry/sentry-javascript/pull/18369)) +- chore(tanstackstart-react)!: Remove empty placeholder implementations ([#18338](https://github.com/getsentry/sentry-javascript/pull/18338)) + +
+ Internal Changes + +- chore: Allow URLs as issue ([#18372](https://github.com/getsentry/sentry-javascript/pull/18372)) +- chore(changelog): Add entry for [#18304](https://github.com/getsentry/sentry-javascript/pull/18304) ([#18329](https://github.com/getsentry/sentry-javascript/pull/18329)) +- chore(ci): Add action to track all PRs as issues ([#18363](https://github.com/getsentry/sentry-javascript/pull/18363)) +- chore(github): Adjust `BUGBOT.md` rules to flag invalid op and origin values during review ([#18352](https://github.com/getsentry/sentry-javascript/pull/18352)) +- ci: Add action to create issue on gitflow merge conflicts ([#18319](https://github.com/getsentry/sentry-javascript/pull/18319)) +- ci(deps): bump actions/checkout from 5 to 6 ([#18268](https://github.com/getsentry/sentry-javascript/pull/18268)) +- ci(deps): bump peter-evans/create-pull-request from 7.0.8 to 7.0.9 ([#18361](https://github.com/getsentry/sentry-javascript/pull/18361)) +- test(cloudflare): Add typechecks for cloudflare-worker e2e test ([#18321](https://github.com/getsentry/sentry-javascript/pull/18321)) + +
+ ## 10.27.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/mocks.js new file mode 100644 index 000000000000..e7d4dbf00961 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/mocks.js @@ -0,0 +1,76 @@ +// Mock LangChain Chat Model for browser testing +export class MockChatAnthropic { + constructor(params) { + this._model = params.model; + this._temperature = params.temperature; + this._maxTokens = params.maxTokens; + } + + async invoke(messages, config = { callbacks: [] }) { + const callbacks = config.callbacks; + const runId = 'mock-run-id-123'; + + const invocationParams = { + model: this._model, + temperature: this._temperature, + max_tokens: this._maxTokens, + }; + + const serialized = { + lc: 1, + type: 'constructor', + id: ['langchain', 'anthropic', 'anthropic'], + kwargs: invocationParams, + }; + + // Call handleChatModelStart + for (const callback of callbacks) { + if (callback.handleChatModelStart) { + await callback.handleChatModelStart( + serialized, + messages, + runId, + undefined, + undefined, + { invocation_params: invocationParams }, + { ls_model_name: this._model, ls_provider: 'anthropic' }, + ); + } + } + + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + // Create mock result + const result = { + generations: [ + [ + { + text: 'Mock response from Anthropic!', + generationInfo: { + finish_reason: 'stop', + }, + }, + ], + ], + llmOutput: { + tokenUsage: { + promptTokens: 10, + completionTokens: 15, + totalTokens: 25, + }, + model_name: this._model, + id: 'msg_mock123', + }, + }; + + // Call handleLLMEnd + for (const callback of callbacks) { + if (callback.handleLLMEnd) { + await callback.handleLLMEnd(result, runId); + } + } + + return result; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/subject.js new file mode 100644 index 000000000000..3df04acd505b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/subject.js @@ -0,0 +1,22 @@ +import { createLangChainCallbackHandler } from '@sentry/browser'; +import { MockChatAnthropic } from './mocks.js'; + +const callbackHandler = createLangChainCallbackHandler({ + recordInputs: false, + recordOutputs: false, +}); + +const chatModel = new MockChatAnthropic({ + model: 'claude-3-haiku-20240307', + temperature: 0.7, + maxTokens: 100, +}); + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +// We can provide callbacks in the config object:https://docs.langchain.com/oss/python/langchain/models#invocation-config +const response = await chatModel.invoke('What is the capital of France?', { + callbacks: [callbackHandler], +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/test.ts new file mode 100644 index 000000000000..9cc1cc9ff98b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langchain/test.ts @@ -0,0 +1,37 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForTransactionRequest } from '../../../../utils/helpers'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not crash in the browser +// and that gen_ai transactions are sent. + +sentryTest('manual LangChain instrumentation sends gen_ai transactions', async ({ getLocalTestUrl, page }) => { + const transactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('claude-3-haiku-20240307'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const req = await transactionPromise; + + const eventData = envelopeRequestParser(req); + + // Verify it's a gen_ai transaction + expect(eventData.transaction).toBe('chat claude-3-haiku-20240307'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.chat'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.langchain'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_mock123', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/mocks.js new file mode 100644 index 000000000000..54792b827a43 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/mocks.js @@ -0,0 +1,29 @@ +// Mock LangGraph graph for browser testing +export class MockStateGraph { + compile(options = {}) { + const compiledGraph = { + name: options.name, + graph_name: options.name, + lc_kwargs: { + name: options.name, + }, + builder: { + nodes: {}, + }, + invoke: async input => { + const messages = input?.messages; + return { + messages: [ + ...messages, + { + role: 'assistant', + content: 'Mock response from LangGraph', + }, + ], + }; + }, + }; + + return compiledGraph; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/subject.js new file mode 100644 index 000000000000..70741f5d111f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/subject.js @@ -0,0 +1,16 @@ +import { MockStateGraph } from './mocks.js'; +import { instrumentLangGraph } from '@sentry/browser'; + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +// Test both agent creation and invocation + +const graph = new MockStateGraph(); +instrumentLangGraph(graph, { recordInputs: false, recordOutputs: false }); +const compiledGraph = graph.compile({ name: 'mock-graph' }); + +const response = await compiledGraph.invoke({ + messages: [{ role: 'user', content: 'What is the capital of France?' }], +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/test.ts new file mode 100644 index 000000000000..1feabd48c8d2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/langgraph/test.ts @@ -0,0 +1,45 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForTransactionRequest } from '../../../../utils/helpers'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not crash in the browser +// and that gen_ai transactions are sent. + +sentryTest('manual LangGraph instrumentation sends gen_ai transactions', async ({ getLocalTestUrl, page }) => { + const createTransactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('create_agent mock-graph'); + }); + + const invokeTransactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('invoke_agent mock-graph'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const createReq = await createTransactionPromise; + const invokeReq = await invokeTransactionPromise; + + const createEventData = envelopeRequestParser(createReq); + const invokeEventData = envelopeRequestParser(invokeReq); + + // Verify create_agent transaction + expect(createEventData.transaction).toBe('create_agent mock-graph'); + expect(createEventData.contexts?.trace?.op).toBe('gen_ai.create_agent'); + expect(createEventData.contexts?.trace?.origin).toBe('auto.ai.langgraph'); + expect(createEventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'create_agent', + 'gen_ai.agent.name': 'mock-graph', + }); + + // Verify invoke_agent transaction + expect(invokeEventData.transaction).toBe('invoke_agent mock-graph'); + expect(invokeEventData.contexts?.trace?.op).toBe('gen_ai.invoke_agent'); + expect(invokeEventData.contexts?.trace?.origin).toBe('auto.ai.langgraph'); + expect(invokeEventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'invoke_agent', + 'gen_ai.agent.name': 'mock-graph', + }); +}); diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index 6e3ef99aa7ea..b1b1df410ca3 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -40,6 +40,8 @@ const IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS: Record = { instrumentAnthropicAiClient: 'instrumentanthropicaiclient', instrumentOpenAiClient: 'instrumentopenaiclient', instrumentGoogleGenAIClient: 'instrumentgooglegenaiclient', + instrumentLangGraph: 'instrumentlanggraph', + createLangChainCallbackHandler: 'createlangchaincallbackhandler', // technically, this is not an integration, but let's add it anyway for simplicity makeMultiplexedTransport: 'multiplexedtransport', }; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.gitignore new file mode 100644 index 000000000000..e71378008bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.gitignore @@ -0,0 +1 @@ +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.npmrc b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/.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/cloudflare-mcp/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json new file mode 100644 index 000000000000..686e747422fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -0,0 +1,41 @@ +{ + "name": "cloudflare-mcp", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "build": "wrangler deploy --dry-run", + "test": "vitest --run", + "typecheck": "tsc --noEmit", + "cf-typegen": "wrangler types", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.22.0", + "@sentry/cloudflare": "latest || *", + "agents": "^0.2.23", + "zod": "^3.25.76" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.8.19", + "@cloudflare/workers-types": "^4.20240725.0", + "@playwright/test": "~1.50.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.5.2", + "vitest": "~3.2.0", + "wrangler": "^4.23.0", + "ws": "^8.18.3" + }, + "volta": { + "extends": "../../package.json" + }, + "pnpm": { + "overrides": { + "strip-literal": "~2.0.0" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/playwright.config.ts new file mode 100644 index 000000000000..73abbd951b90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/playwright.config.ts @@ -0,0 +1,22 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm dev --port ${APP_PORT}`, + port: APP_PORT, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/worker-configuration.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/env.d.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/cloudflare-workers/worker-configuration.d.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/env.d.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts new file mode 100644 index 000000000000..5528ccffd3de --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts @@ -0,0 +1,83 @@ +/** + * Welcome to Cloudflare Workers! This is your first worker. + * + * - Run `npm run dev` in your terminal to start a development server + * - Open a browser tab at http://localhost:8787/ to see your worker in action + * - Run `npm run deploy` to publish your worker + * + * Bind resources to your worker in `wrangler.toml`. After adding bindings, a type definition for the + * `Env` object can be regenerated with `npm run cf-typegen`. + * + * Learn more at https://developers.cloudflare.com/workers/ + */ +import * as Sentry from '@sentry/cloudflare'; +import { createMcpHandler } from 'agents/mcp'; +import * as z from 'zod'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, + debug: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + }), + { + async fetch(request, env, ctx) { + const server = new McpServer({ + name: 'cloudflare-mcp', + version: '1.0.0', + }); + + const span = Sentry.getActiveSpan(); + + if (span) { + span.setAttribute('mcp.server.extra', ' /|\ ^._.^ /|\ '); + } + + server.registerTool( + 'my-tool', + { + title: 'My Tool', + description: 'My Tool Description', + inputSchema: { + message: z.string(), + }, + }, + async ({ message }) => { + const span = Sentry.getActiveSpan(); + + // simulate a long running tool + await new Promise(resolve => setTimeout(resolve, 500)); + + if (span) { + span.setAttribute('mcp.tool.name', 'my-tool'); + span.setAttribute('mcp.tool.extra', 'ƸӜƷ'); + span.setAttribute('mcp.tool.input', JSON.stringify({ message })); + } + + return { + content: [ + { + type: 'text' as const, + text: `Tool my-tool: ${message}`, + }, + ], + }; + }, + ); + + const handler = createMcpHandler(Sentry.wrapMcpServerWithSentry(server), { + route: '/mcp', + }); + + return handler(request, env, ctx); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/start-event-proxy.mjs new file mode 100644 index 000000000000..da5101c5275f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-mcp', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts new file mode 100644 index 000000000000..8ce8b693499e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; + +test('sends spans for MCP tool calls', async ({ baseURL }) => { + const spanRequestWaiter = waitForRequest('cloudflare-mcp', event => { + const transaction = event.envelope[1][0][1]; + return typeof transaction !== 'string' && 'transaction' in transaction && transaction.transaction === 'POST /mcp'; + }); + + const spanMcpWaiter = waitForRequest('cloudflare-mcp', event => { + const transaction = event.envelope[1][0][1]; + return ( + typeof transaction !== 'string' && + 'transaction' in transaction && + transaction.transaction === 'tools/call my-tool' + ); + }); + + const response = await fetch(`${baseURL}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'my-tool', + arguments: { + message: 'ʕっ•ᴥ•ʔっ', + }, + }, + }), + }); + + expect(response.status).toBe(200); + + const requestData = await spanRequestWaiter; + const mcpData = await spanMcpWaiter; + + const requestEvent = requestData.envelope[1][0][1]; + const mcpEvent = mcpData.envelope[1][0][1]; + + // Check that the events have contexts + // this is for TypeScript type safety + if ( + typeof mcpEvent === 'string' || + !('contexts' in mcpEvent) || + typeof requestEvent === 'string' || + !('contexts' in requestEvent) + ) { + throw new Error("Events don't have contexts"); + } + + expect(mcpEvent.contexts?.trace?.trace_id).toBe((mcpData.envelope[0].trace as any).trace_id); + expect(requestData.envelope[0].event_id).not.toBe(mcpData.envelope[0].event_id); + + expect(requestEvent.contexts?.trace).toEqual({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + 'sentry.op': 'http.server', + 'sentry.source': 'url', + 'sentry.sample_rate': 1, + 'http.request.method': 'POST', + 'url.path': '/mcp', + 'url.full': 'http://localhost:38787/mcp', + 'url.port': '38787', + 'url.scheme': 'http:', + 'server.address': 'localhost', + 'http.request.body.size': 120, + 'user_agent.original': 'node', + 'http.request.header.content_type': 'application/json', + 'network.protocol.name': 'HTTP/1.1', + 'mcp.server.extra': ' /|\ ^._.^ /|\ ', + 'http.response.status_code': 200, + }), + op: 'http.server', + status: 'ok', + origin: 'auto.http.cloudflare', + }); + + expect(mcpEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + parent_span_id: requestEvent.contexts?.trace?.span_id, + span_id: expect.any(String), + op: 'mcp.server', + origin: 'auto.function.mcp_server', + data: { + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.op': 'mcp.server', + 'sentry.source': 'route', + 'mcp.transport': 'WorkerTransport', + 'network.transport': 'unknown', + 'network.protocol.version': '2.0', + 'mcp.method.name': 'tools/call', + 'mcp.request.id': '1', + 'mcp.tool.name': 'my-tool', + 'mcp.request.argument.message': '"ʕっ•ᴥ•ʔっ"', + 'mcp.tool.extra': 'ƸӜƷ', + 'mcp.tool.input': '{"message":"ʕっ•ᴥ•ʔっ"}', + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', + 'mcp.tool.result.content': 'Tool my-tool: ʕっ•ᴥ•ʔっ', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/tsconfig.json new file mode 100644 index 000000000000..9a3b10f6cdc8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/vitest-pool-workers"] + }, + "include": ["./**/*.ts", "../worker-configuration.d.ts"], + "exclude": [] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tsconfig.json new file mode 100644 index 000000000000..87d4bbd5fab8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2021", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["es2021"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "Bundler", + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ + "allowJs": true, + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, + "types": ["@cloudflare/workers-types/experimental"] + }, + "exclude": ["test"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/vitest.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/vitest.config.mts new file mode 100644 index 000000000000..931e5113e0c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; + +export default defineWorkersConfig({ + test: { + poolOptions: { + workers: { + wrangler: { configPath: './wrangler.toml' }, + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/wrangler.toml new file mode 100644 index 000000000000..69c10ce7cf01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/wrangler.toml @@ -0,0 +1,98 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-mcp" +main = "src/index.ts" +compatibility_date = "2025-03-21" +compatibility_flags = ["nodejs_compat"] + +# [vars] +# E2E_TEST_DSN = "" + +# Automatically place your workloads in an optimal location to minimize latency. +# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure +# rather than the end user may result in better performance. +# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement +# [placement] +# mode = "smart" + +# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) +# Docs: +# - https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables +# Note: Use secrets to store sensitive data. +# - https://developers.cloudflare.com/workers/configuration/secrets/ +# [vars] +# MY_VARIABLE = "production_value" + +# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#workers-ai +# [ai] +# binding = "AI" + +# Bind an Analytics Engine dataset. Use Analytics Engine to write analytics within your Pages Function. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#analytics-engine-datasets +# [[analytics_engine_datasets]] +# binding = "MY_DATASET" + +# Bind a headless browser instance running on Cloudflare's global network. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#browser-rendering +# [browser] +# binding = "MY_BROWSER" + +# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#d1-databases +# [[d1_databases]] +# binding = "MY_DB" +# database_name = "my-database" +# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a dispatch namespace. Use Workers for Platforms to deploy serverless functions programmatically on behalf of your customers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#dispatch-namespace-bindings-workers-for-platforms +# [[dispatch_namespaces]] +# binding = "MY_DISPATCHER" +# namespace = "my-namespace" + +# Bind a Hyperdrive configuration. Use to accelerate access to your existing databases from Cloudflare Workers. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#hyperdrive +# [[hyperdrive]] +# binding = "MY_HYPERDRIVE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces +# [[kv_namespaces]] +# binding = "MY_KV_NAMESPACE" +# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# Bind an mTLS certificate. Use to present a client certificate when communicating with another service. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#mtls-certificates +# [[mtls_certificates]] +# binding = "MY_CERTIFICATE" +# certificate_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.producers]] +# binding = "MY_QUEUE" +# queue = "my-queue" + +# Bind a Queue consumer. Queue Consumers can retrieve tasks scheduled by Producers to act on them. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#queues +# [[queues.consumers]] +# queue = "my-queue" + +# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#r2-buckets +# [[r2_buckets]] +# binding = "MY_BUCKET" +# bucket_name = "my-bucket" + +# Bind another Worker service. Use this binding to call another Worker without network overhead. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings +# [[services]] +# binding = "MY_SERVICE" +# service = "my-service" + +# Bind a Vectorize index. Use to store and query vector embeddings for semantic search, classification and other vector search use-cases. +# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#vectorize-indexes +# [[vectorize]] +# binding = "MY_INDEX" +# index_name = "my-index" diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json index 91a49e0788f4..5d7cfc35e469 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit", "cf-typegen": "wrangler types", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:dev && pnpm test:prod", + "test:assert": "pnpm typecheck && pnpm test:dev && pnpm test:prod", "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test" }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/env.d.ts new file mode 100644 index 000000000000..1701ed9f621a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/env.d.ts @@ -0,0 +1,7 @@ +// Generated by Wrangler on Mon Jul 29 2024 21:44:31 GMT-0400 (Eastern Daylight Time) +// by running `wrangler types` + +interface Env { + E2E_TEST_DSN: ''; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/tsconfig.json index 9a3b10f6cdc8..80bfbd97acc1 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "types": ["@cloudflare/vitest-pool-workers"] }, - "include": ["./**/*.ts", "../worker-configuration.d.ts"], + "include": ["./**/*.ts"], "exclude": [] } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tsconfig.json index f42019fb0915..87d4bbd5fab8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tsconfig.json @@ -36,8 +36,8 @@ /* Skip type checking all .d.ts files. */ "skipLibCheck": true, - "types": ["./worker-configuration.d.ts"] + "types": ["@cloudflare/workers-types/experimental"] }, "exclude": ["test"], - "include": ["worker-configuration.d.ts", "src/**/*.ts"] + "include": ["src/**/*.ts"] } diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts index 9a5caf46fd66..d85d9d82747d 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/client-transactions.test.ts @@ -3,7 +3,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('Sends a pageload transaction to Sentry', async ({ page }) => { const transactionPromise = waitForTransaction('create-remix-app-express-vite-dev', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === 'routes/_index'; + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/'; }); await page.goto('/'); @@ -15,7 +15,7 @@ test('Sends a pageload transaction to Sentry', async ({ page }) => { test('Sends a navigation transaction to Sentry', async ({ page }) => { const transactionPromise = waitForTransaction('create-remix-app-express-vite-dev', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === 'routes/user.$id'; + return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/user/:id'; }); await page.goto('/'); @@ -28,6 +28,22 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => { expect(transactionEvent).toBeDefined(); }); +test('Sends a navigation transaction with parameterized route to Sentry', async ({ page }) => { + const transactionPromise = waitForTransaction('create-remix-app-express-vite-dev', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + + const linkElement = page.locator('id=navigation'); + await linkElement.click(); + + const transactionEvent = await transactionPromise; + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent.transaction).toBeTruthy(); +}); + test('Renders `sentry-trace` and `baggage` meta tags for the root route', async ({ page }) => { await page.goto('/'); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts index 58e81eeee529..4213aae3e3de 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/tests/server-transactions.test.ts @@ -48,7 +48,7 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id; expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/'); - expect(pageloadTransaction.transaction).toBe('routes/_index'); + expect(pageloadTransaction.transaction).toBe('/'); expect(httpServerTraceId).toBeDefined(); expect(httpServerSpanId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts index 13de9243b22a..6ebb8eacc6a5 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express-vite-dev/vite.config.ts @@ -1,14 +1,15 @@ +import { installGlobals } from '@remix-run/node'; import { vitePlugin as remix } from '@remix-run/dev'; +import { sentryRemixVitePlugin } from '@sentry/remix'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; -import { installGlobals } from '@remix-run/node'; - installGlobals(); export default defineConfig({ plugins: [ remix(), + sentryRemixVitePlugin(), tsconfigPaths({ // The dev server config errors are not relevant to this test app // https://github.com/aleclarson/vite-tsconfig-paths?tab=readme-ov-file#options diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts index a06aa02ceb9c..68237301b635 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/client-transactions.test.ts @@ -3,7 +3,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; test('Sends a pageload transaction to Sentry', async ({ page }) => { const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === 'routes/_index'; + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/'; }); await page.goto('/'); @@ -15,7 +15,7 @@ test('Sends a pageload transaction to Sentry', async ({ page }) => { test('Sends a navigation transaction to Sentry', async ({ page }) => { const transactionPromise = waitForTransaction('create-remix-app-express', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === 'routes/user.$id'; + return transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.transaction === '/user/:id'; }); await page.goto('/'); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts index d57c45545caf..ddb866e5dbaa 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts @@ -90,7 +90,7 @@ test('Propagates trace when ErrorBoundary is triggered', async ({ page }) => { const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id; expect(httpServerTransaction.transaction).toBe('GET client-error'); - expect(pageloadTransaction.transaction).toBe('routes/client-error'); + expect(pageloadTransaction.transaction).toBe('/client-error'); expect(httpServerTraceId).toBeDefined(); expect(httpServerSpanId).toBeDefined(); @@ -132,7 +132,7 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id; expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/'); - expect(pageloadTransaction.transaction).toBe('routes/_index'); + expect(pageloadTransaction.transaction).toBe('/'); expect(httpServerTraceId).toBeDefined(); expect(httpServerSpanId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts index 13de9243b22a..6ebb8eacc6a5 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/vite.config.ts @@ -1,14 +1,15 @@ +import { installGlobals } from '@remix-run/node'; import { vitePlugin as remix } from '@remix-run/dev'; +import { sentryRemixVitePlugin } from '@sentry/remix'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; -import { installGlobals } from '@remix-run/node'; - installGlobals(); export default defineConfig({ plugins: [ remix(), + sentryRemixVitePlugin(), tsconfigPaths({ // The dev server config errors are not relevant to this test app // https://github.com/aleclarson/vite-tsconfig-paths?tab=readme-ov-file#options diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.eslintrc.js b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.eslintrc.js new file mode 100644 index 000000000000..f2faf1470fd8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.eslintrc.js @@ -0,0 +1,4 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node'], +}; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.gitignore b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.gitignore new file mode 100644 index 000000000000..3f7bf98da3e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.npmrc b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/.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/create-remix-app-v2-non-vite/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.client.tsx new file mode 100644 index 000000000000..f540f3c35c1d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.client.tsx @@ -0,0 +1,48 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +// Extend the Window interface to include ENV +declare global { + interface Window { + ENV: { + SENTRY_DSN: string; + [key: string]: unknown; + }; + } +} + +import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; +import * as Sentry from '@sentry/remix'; +import { StrictMode, startTransition, useEffect } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: window.ENV.SENTRY_DSN, + integrations: [ + Sentry.browserTracingIntegration({ + useEffect, + useLocation, + useMatches, + }), + Sentry.replayIntegration(), + ], + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! + // Session Replay + replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. + replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. + tunnel: 'http://localhost:3032/', // proxy server +}); + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx new file mode 100644 index 000000000000..41974897eeae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/entry.server.tsx @@ -0,0 +1,136 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import { PassThrough } from 'node:stream'; + +import type { AppLoadContext, EntryContext } from '@remix-run/node'; +import { createReadableStreamFromReadable } from '@remix-run/node'; +import { installGlobals } from '@remix-run/node'; +import { RemixServer } from '@remix-run/react'; +import isbot from 'isbot'; +import { renderToPipeableStream } from 'react-dom/server'; +import * as Sentry from '@sentry/remix'; + +installGlobals(); + +const ABORT_DELAY = 5_000; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! +}); + +const handleErrorImpl = () => { + Sentry.setTag('remix-test-tag', 'remix-test-value'); +}; + +export const handleError = Sentry.wrapHandleErrorWithSentry(handleErrorImpl); + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext, +) { + return isbot(request.headers.get('user-agent')) + ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set('Content-Type', 'text/html'); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }), + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + }, + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/root.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/root.tsx new file mode 100644 index 000000000000..517a37a9d76b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2-non-vite/app/root.tsx @@ -0,0 +1,80 @@ +import { cssBundleHref } from '@remix-run/css-bundle'; +import { LinksFunction, MetaFunction, json } from '@remix-run/node'; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useRouteError, +} from '@remix-run/react'; +import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix'; +import type { SentryMetaArgs } from '@sentry/remix'; + +export const links: LinksFunction = () => [...(cssBundleHref ? [{ rel: 'stylesheet', href: cssBundleHref }] : [])]; + +export const loader = () => { + return json({ + ENV: { + SENTRY_DSN: process.env.E2E_TEST_DSN, + }, + }); +}; + +export const meta = ({ data }: SentryMetaArgs>) => { + return [ + { + env: data.ENV, + }, + { + name: 'sentry-trace', + content: data.sentryTrace, + }, + { + name: 'baggage', + content: data.sentryBaggage, + }, + ]; +}; + +export function ErrorBoundary() { + const error = useRouteError(); + const eventId = captureRemixErrorBoundaryError(error); + + return ( +
+ ErrorBoundary Error + {eventId} +
+ ); +} + +function App() { + const { ENV } = useLoaderData(); + + return ( + + + + + or