diff --git a/.craft.yml b/.craft.yml index f2ffca132f23..331d065a2ff9 100644 --- a/.craft.yml +++ b/.craft.yml @@ -146,7 +146,7 @@ targets: # AWS Lambda Layer target - name: aws-lambda-layer includeNames: /^sentry-node-serverless-\d+.\d+.\d+(-(beta|alpha|rc)\.\d+)?\.zip$/ - layerName: SentryNodeServerlessSDKv10 + layerName: SentryNodeServerlessSDKv{{{major}}} compatibleRuntimes: - name: node versions: diff --git a/.cursor/rules/sdk_development.mdc b/.cursor/rules/sdk_development.mdc index 088c94f47a23..c997b65f5482 100644 --- a/.cursor/rules/sdk_development.mdc +++ b/.cursor/rules/sdk_development.mdc @@ -121,6 +121,54 @@ Each package typically contains: - Integration tests use Playwright extensively - Never change the volta, yarn, or package manager setup in general unless explicitly asked for +### E2E Testing + +E2E tests are located in `dev-packages/e2e-tests/` and verify SDK behavior in real-world framework scenarios. + +#### How Verdaccio Registry Works + +E2E tests use [Verdaccio](https://verdaccio.org/), a lightweight npm registry running in Docker. Before tests run: + +1. SDK packages are built and packed into tarballs (`yarn build && yarn build:tarball`) +2. Tarballs are published to Verdaccio at `http://127.0.0.1:4873` +3. Test applications install packages from Verdaccio instead of public npm + +#### The `.npmrc` Requirement + +Every E2E test application needs an `.npmrc` file with: + +``` +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +``` + +Without this file, pnpm installs from the public npm registry instead of Verdaccio, so your local changes won't be tested. This is a common cause of "tests pass in CI but fail locally" or vice versa. + +#### Running a Single E2E Test + +```bash +# Build packages first +yarn build && yarn build:tarball + +# Run a specific test app +cd dev-packages/e2e-tests +yarn test:run + +# Run with a specific variant (e.g., Next.js 15) +yarn test:run --variant +``` + +#### Common Pitfalls and Debugging + +1. **Missing `.npmrc`**: Most common issue. Always verify the test app has the correct `.npmrc` file. + +2. **Stale tarballs**: After SDK changes, must re-run `yarn build:tarball`. + +3. **Debugging tips**: + - Check browser console logs for SDK initialization errors + - Use `debug: true` in Sentry config + - Verify installed package version: check `node_modules/@sentry/*/package.json` + ### Notes for Background Tasks - Make sure to use [volta](https://volta.sh/) for development. Volta is used to manage the node, yarn and pnpm version used. diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index e1f22cff2f64..02a1f47b611a 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 94dc3db3942d..25797f31a008 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,7 +159,7 @@ jobs: head: ${{ env.HEAD_COMMIT }} - name: NX cache - uses: actions/cache@v4 + uses: actions/cache@v5 # Disable cache when: # - on release branches # - when PR has `ci-skip-cache` label or on nightly builds @@ -181,7 +181,7 @@ jobs: run: yarn build - name: Upload build artifacts - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: build-output path: ${{ env.CACHED_BUILD_PATHS }} @@ -386,7 +386,7 @@ jobs: run: yarn build:tarball - name: Archive artifacts - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: ${{ github.sha }} retention-days: 90 @@ -629,7 +629,7 @@ jobs: format(' --shard={0}/{1}', matrix.shard, matrix.shards) || '' }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: @@ -692,7 +692,7 @@ jobs: yarn test:loader - name: Upload Playwright Traces - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: playwright-traces-job_browser_loader_tests-${{ matrix.bundle}} @@ -881,7 +881,7 @@ jobs: with: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: NX cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: .nxcache key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} @@ -892,7 +892,7 @@ jobs: run: yarn build:tarball - name: Stores tarballs in cache - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: ${{ github.workspace }}/packages/*/*.tgz key: ${{ env.BUILD_CACHE_TARBALL_KEY }} @@ -959,7 +959,7 @@ jobs: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore tarball cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 id: restore-tarball-cache with: path: ${{ github.workspace }}/packages/*/*.tgz @@ -1009,7 +1009,7 @@ jobs: SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: playwright-traces-job_e2e_playwright_tests-${{ matrix.test-application}} @@ -1023,7 +1023,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) @@ -1084,7 +1084,7 @@ jobs: dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} - name: Restore tarball cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 id: restore-tarball-cache with: path: ${{ github.workspace }}/packages/*/*.tgz @@ -1135,7 +1135,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 1e71125ddad2..36244b3da154 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -39,7 +39,7 @@ jobs: with: node-version-file: 'package.json' - name: Check canary cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ env.CACHED_BUILD_PATHS }} key: canary-${{ env.HEAD_COMMIT }} @@ -114,6 +114,12 @@ jobs: - test-application: 'nuxt-4' build-command: 'test:build-canary' label: 'nuxt-4 (canary)' + - test-application: 'tanstackstart-react' + build-command: 'test:build-latest' + label: 'tanstackstart-react (latest)' + - test-application: 'nestjs-11' + build-command: 'test:build-latest' + label: 'nestjs-11 (latest)' steps: - name: Check out current commit @@ -130,7 +136,7 @@ jobs: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Restore canary cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ${{ env.CACHED_BUILD_PATHS }} key: canary-${{ env.HEAD_COMMIT }} diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 1566299d67e9..b4678af2eb56 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -36,7 +36,7 @@ jobs: author_association: ${{ github.event.pull_request.author_association }} - name: Create PR with changes - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 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 bb3169ecb410..6afed7df214b 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -40,7 +40,7 @@ jobs: run: yarn install --ignore-engines --frozen-lockfile - name: NX cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: .nxcache key: nx-Linux-${{ github.ref }}-${{ env.HEAD_COMMIT }} @@ -71,7 +71,7 @@ jobs: TEST_RUN_COUNT: 'AUTO' - name: Upload Playwright Traces - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 if: failure() && steps.test.outcome == 'failure' with: name: playwright-test-results diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a0278ae85a4..fcb44598c722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} diff --git a/.size-limit.js b/.size-limit.js index aa0d45ce176c..215a40d1bf17 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -82,7 +82,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '85 KB', + limit: '85.5 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -112,6 +112,27 @@ module.exports = [ gzip: true, limit: '35 KB', }, + { + name: '@sentry/browser (incl. Metrics)', + path: 'packages/browser/build/npm/esm/prod/index.js', + import: createImport('init', 'metrics'), + gzip: true, + limit: '27 KB', + }, + { + name: '@sentry/browser (incl. Logs)', + path: 'packages/browser/build/npm/esm/prod/index.js', + import: createImport('init', 'logger'), + gzip: true, + limit: '27 KB', + }, + { + name: '@sentry/browser (incl. Metrics & Logs)', + path: 'packages/browser/build/npm/esm/prod/index.js', + import: createImport('init', 'metrics', 'logger'), + gzip: true, + limit: '28 KB', + }, // React SDK (ESM) { name: '@sentry/react', @@ -222,7 +243,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '42 KB', + limit: '42.5 KB', }, // Node-Core SDK (ESM) { @@ -240,7 +261,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '162 KB', + limit: '162.5 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5c81b9d377..4dc2613d9ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,127 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.33.0 + +### Important Changes + +- **feat(core): Apply scope attributes to metrics ([#18738](https://github.com/getsentry/sentry-javascript/pull/18738))** + + You can now set attributes on the SDK's scopes which will be applied to all metrics as long as the respective scopes are active. For the time being, only `string`, `number` and `boolean` attribute values are supported. + + ```ts + Sentry.getGlobalScope().setAttributes({ is_admin: true, auth_provider: 'google' }); + + Sentry.withScope(scope => { + scope.setAttribute('step', 'authentication'); + + // scope attributes `is_admin`, `auth_provider` and `step` are added + Sentry.metrics.count('clicks', 1, { attributes: { activeSince: 100 } }); + Sentry.metrics.gauge('timeSinceRefresh', 4, { unit: 'hour' }); + }); + + // scope attributes `is_admin` and `auth_provider` are added + Sentry.metrics.count('response_time', 283.33, { unit: 'millisecond' }); + ``` + +- **feat(tracing): Add Vercel AI SDK v6 support ([#18741](https://github.com/getsentry/sentry-javascript/pull/18741))** + + The Sentry SDK now supports the Vercel AI SDK v6. Tracing and error monitoring will work automatically with the new version. + +- **feat(wasm): Add applicationKey option for third-party error filtering ([#18762](https://github.com/getsentry/sentry-javascript/pull/18762))** + + Adds support for applying an application key to WASM stack frames that can be then used in the `thirdPartyErrorFilterIntegration` for detection of first-party code. + + Usage: + + ```js + Sentry.init({ + integrations: [ + // Integration order matters: wasmIntegration needs to be before thirdPartyErrorFilterIntegration + wasmIntegration({ applicationKey: 'your-custom-application-key' }), ←───┐ + thirdPartyErrorFilterIntegration({ │ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', ├─ matching keys + filterKeys: ['your-custom-application-key'] ←─────────────────────────┘ + }), + ], + }); + ``` + +### Other Changes + +- feat(cloudflare): Support `propagateTraceparent` ([#18569](https://github.com/getsentry/sentry-javascript/pull/18569)) +- feat(core): Add `ignoreSentryInternalFrames` option to `thirdPartyErrorFilterIntegration` ([#18632](https://github.com/getsentry/sentry-javascript/pull/18632)) +- feat(core): Add gen_ai.conversation.id attribute to OpenAI and LangGr… ([#18703](https://github.com/getsentry/sentry-javascript/pull/18703)) +- feat(core): Add recordInputs/recordOutputs options to MCP server wrapper ([#18600](https://github.com/getsentry/sentry-javascript/pull/18600)) +- feat(core): Support IPv6 hosts in the DSN ([#2996](https://github.com/getsentry/sentry-javascript/pull/2996)) (#17708) +- feat(deps): Bump bundler plugins to ^4.6.1 ([#17980](https://github.com/getsentry/sentry-javascript/pull/17980)) +- feat(nextjs): Emit warning for conflicting treeshaking / debug settings ([#18638](https://github.com/getsentry/sentry-javascript/pull/18638)) +- feat(nextjs): Print Turbopack note for deprecated webpack options ([#18769](https://github.com/getsentry/sentry-javascript/pull/18769)) +- feat(node-core): Add `isolateTrace` option to `node-cron` instrumentation ([#18416](https://github.com/getsentry/sentry-javascript/pull/18416)) +- feat(node): Use `process.on('SIGTERM')` for flushing in Vercel functions ([#17583](https://github.com/getsentry/sentry-javascript/pull/17583)) +- feat(nuxt): Detect development environment and add dev E2E test ([#18671](https://github.com/getsentry/sentry-javascript/pull/18671)) +- fix(browser): Forward worker metadata for third-party error filtering ([#18756](https://github.com/getsentry/sentry-javascript/pull/18756)) +- fix(browser): Reduce number of `visibilitystate` and `pagehide` listeners ([#18581](https://github.com/getsentry/sentry-javascript/pull/18581)) +- fix(browser): Respect `tunnel` in `diagnoseSdkConnectivity` ([#18616](https://github.com/getsentry/sentry-javascript/pull/18616)) +- fix(cloudflare): Consume body of fetch in the Cloudflare transport ([#18545](https://github.com/getsentry/sentry-javascript/pull/18545)) +- fix(core): Set op on ended Vercel AI spans ([#18601](https://github.com/getsentry/sentry-javascript/pull/18601)) +- fix(core): Subtract `performance.now()` from `browserPerformanceTimeOrigin` fallback ([#18715](https://github.com/getsentry/sentry-javascript/pull/18715)) +- fix(core): Update client options to allow explicit `undefined` ([#18024](https://github.com/getsentry/sentry-javascript/pull/18024)) +- fix(feedback): Fix cases where the outline of inputs were wrong ([#18647](https://github.com/getsentry/sentry-javascript/pull/18647)) +- fix(next): Ensure inline sourcemaps are generated for wrapped modules in Dev ([#18640](https://github.com/getsentry/sentry-javascript/pull/18640)) +- fix(next): Wrap all Random APIs with a safe runner ([#18700](https://github.com/getsentry/sentry-javascript/pull/18700)) +- fix(nextjs): Avoid Edge build warning from OpenTelemetry `process.argv0` ([#18759](https://github.com/getsentry/sentry-javascript/pull/18759)) +- fix(nextjs): Remove polynomial regular expression ([#18725](https://github.com/getsentry/sentry-javascript/pull/18725)) +- fix(node-core): Ignore worker threads in OnUncaughtException ([#18689](https://github.com/getsentry/sentry-javascript/pull/18689)) +- fix(node): relax Fastify's `setupFastifyErrorHandler` argument type ([#18620](https://github.com/getsentry/sentry-javascript/pull/18620)) +- fix(nuxt): Allow overwriting server-side `defaultIntegrations` ([#18717](https://github.com/getsentry/sentry-javascript/pull/18717)) +- fix(pino): Allow custom namespaces for `msg` and `err` ([#18597](https://github.com/getsentry/sentry-javascript/pull/18597)) +- fix(react,solid,vue): Fix parametrization behavior for non-matched routes ([#18735](https://github.com/getsentry/sentry-javascript/pull/18735)) +- fix(replay): Ensure replays contain canvas rendering when resumed after inactivity ([#18714](https://github.com/getsentry/sentry-javascript/pull/18714)) +- fix(tracing): add gen_ai.request.messages.original_length attributes ([#18608](https://github.com/getsentry/sentry-javascript/pull/18608)) +- ref(nextjs): Drop `resolve` dependency ([#18618](https://github.com/getsentry/sentry-javascript/pull/18618)) +- ref(react-router): Use snake_case for span op names ([#18617](https://github.com/getsentry/sentry-javascript/pull/18617)) + +
+ Internal Changes + +- chore(bun): Fix `install-bun.js` version check and improve upgrade feedback ([#18492](https://github.com/getsentry/sentry-javascript/pull/18492)) +- chore(changelog): Fix typo ([#18648](https://github.com/getsentry/sentry-javascript/pull/18648)) +- chore(craft): Use version templating for aws layer ([#18675](https://github.com/getsentry/sentry-javascript/pull/18675)) +- chore(deps): Bump IITM to ^2.0.1 ([#18599](https://github.com/getsentry/sentry-javascript/pull/18599)) +- chore(e2e-tests): Upgrade `@trpc/server` and `@trpc/client` ([#18722](https://github.com/getsentry/sentry-javascript/pull/18722)) +- chore(e2e): Unpin react-router-7-framework-spa to ^7.11.0 ([#18551](https://github.com/getsentry/sentry-javascript/pull/18551)) +- chore(nextjs): Bump next version in dev deps ([#18661](https://github.com/getsentry/sentry-javascript/pull/18661)) +- chore(node-tests): Upgrade `@langchain/core` ([#18720](https://github.com/getsentry/sentry-javascript/pull/18720)) +- chore(react): Inline `hoist-non-react-statics` package ([#18102](https://github.com/getsentry/sentry-javascript/pull/18102)) +- chore(size-limit): Add size checks for metrics and logs ([#18573](https://github.com/getsentry/sentry-javascript/pull/18573)) +- chore(tests): Add unordered mode to cloudflare test runner ([#18596](https://github.com/getsentry/sentry-javascript/pull/18596)) +- ci(deps): bump actions/cache from 4 to 5 ([#18654](https://github.com/getsentry/sentry-javascript/pull/18654)) +- ci(deps): Bump actions/create-github-app-token from 2.2.0 to 2.2.1 ([#18656](https://github.com/getsentry/sentry-javascript/pull/18656)) +- ci(deps): bump actions/upload-artifact from 5 to 6 ([#18655](https://github.com/getsentry/sentry-javascript/pull/18655)) +- ci(deps): bump peter-evans/create-pull-request from 7.0.9 to 8.0.0 ([#18657](https://github.com/getsentry/sentry-javascript/pull/18657)) +- doc: E2E testing documentation updates ([#18649](https://github.com/getsentry/sentry-javascript/pull/18649)) +- ref(core): Extract and reuse `getCombinedScopeData` helper ([#18585](https://github.com/getsentry/sentry-javascript/pull/18585)) +- ref(core): Remove dependence between `performance.timeOrigin` and `performance.timing.navigationStart` ([#18710](https://github.com/getsentry/sentry-javascript/pull/18710)) +- ref(core): Streamline and test `browserPerformanceTimeOrigin` ([#18708](https://github.com/getsentry/sentry-javascript/pull/18708)) +- ref(core): Strengthen `browserPerformanceTimeOrigin` reliability check ([#18719](https://github.com/getsentry/sentry-javascript/pull/18719)) +- ref(core): Use `serializeAttributes` for metric attribute serialization ([#18582](https://github.com/getsentry/sentry-javascript/pull/18582)) +- ref(node): Remove duplicate function `isCjs` ([#18662](https://github.com/getsentry/sentry-javascript/pull/18662)) +- test(core): Improve unit test performance for offline transport tests ([#18628](https://github.com/getsentry/sentry-javascript/pull/18628)) +- test(core): Use fake timers in promisebuffer tests to ensure deterministic behavior ([#18659](https://github.com/getsentry/sentry-javascript/pull/18659)) +- test(e2e): Add e2e metrics tests in Next.js 16 ([#18643](https://github.com/getsentry/sentry-javascript/pull/18643)) +- test(e2e): Pin agents package in cloudflare-mcp test ([#18609](https://github.com/getsentry/sentry-javascript/pull/18609)) +- test(e2e): Pin solid/vue tanstack router to 1.41.8 ([#18610](https://github.com/getsentry/sentry-javascript/pull/18610)) +- test(nestjs): Add canary test for latest ([#18685](https://github.com/getsentry/sentry-javascript/pull/18685)) +- test(node-native): Increase worker block timeout ([#18683](https://github.com/getsentry/sentry-javascript/pull/18683)) +- test(nuxt): Fix nuxt-4 dev E2E test ([#18737](https://github.com/getsentry/sentry-javascript/pull/18737)) +- test(tanstackstart-react): Add canary test for latest ([#18686](https://github.com/getsentry/sentry-javascript/pull/18686)) +- test(vue): Added canary and latest test variants to Vue tests ([#18681](https://github.com/getsentry/sentry-javascript/pull/18681)) + +
+ +Work in this release was contributed by @G-Rath, @gianpaj, @maximepvrt, @Mohataseem89, @sebws, and @xgedev. Thank you for your contributions! + ## 10.32.1 - fix(cloudflare): Add hono transaction name when error is thrown ([#18529](https://github.com/getsentry/sentry-javascript/pull/18529)) @@ -27,7 +148,7 @@ You can now set attributes on the SDK's scopes which will be applied to all logs as long as the respective scopes are active. For the time being, only `string`, `number` and `boolean` attribute values are supported. ```ts - Sentry.geGlobalScope().setAttributes({ is_admin: true, auth_provider: 'google' }); + Sentry.getGlobalScope().setAttributes({ is_admin: true, auth_provider: 'google' }); Sentry.withScope(scope => { scope.setAttribute('step', 'authentication'); diff --git a/CLAUDE.md b/CLAUDE.md index e515c171303e..cae60376d964 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,6 +120,54 @@ Each package typically contains: - Integration tests use Playwright extensively - Never change the volta, yarn, or package manager setup in general unless explicitly asked for +### E2E Testing + +E2E tests are located in `dev-packages/e2e-tests/` and verify SDK behavior in real-world framework scenarios. + +#### How Verdaccio Registry Works + +E2E tests use [Verdaccio](https://verdaccio.org/), a lightweight npm registry running in Docker. Before tests run: + +1. SDK packages are built and packed into tarballs (`yarn build && yarn build:tarball`) +2. Tarballs are published to Verdaccio at `http://127.0.0.1:4873` +3. Test applications install packages from Verdaccio instead of public npm + +#### The `.npmrc` Requirement + +Every E2E test application needs an `.npmrc` file with: + +``` +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +``` + +Without this file, pnpm installs from the public npm registry instead of Verdaccio, so your local changes won't be tested. This is a common cause of "tests pass in CI but fail locally" or vice versa. + +#### Running a Single E2E Test + +```bash +# Build packages first +yarn build && yarn build:tarball + +# Run a specific test app +cd dev-packages/e2e-tests +yarn test:run + +# Run with a specific variant (e.g., Next.js 15) +yarn test:run --variant +``` + +#### Common Pitfalls and Debugging + +1. **Missing `.npmrc`**: Most common issue. Always verify the test app has the correct `.npmrc` file. + +2. **Stale tarballs**: After SDK changes, must re-run `yarn build:tarball`. + +3. **Debugging tips**: + - Check browser console logs for SDK initialization errors + - Use `debug: true` in Sentry config + - Verify installed package version: check `node_modules/@sentry/*/package.json` + ### Notes for Background Tasks - Make sure to use [volta](https://volta.sh/) for development. Volta is used to manage the node, yarn and pnpm version used. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d486d6718c1..70ebd45da74f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,89 @@ the tests in each location. Check out the `scripts` entry of the corresponding ` Note: you must run `yarn build` before `yarn test` will work. +## Running E2E Tests Locally + +E2E tests verify SDK behavior in real-world framework scenarios using a local npm registry (Verdaccio). + +### Prerequisites + +1. **Docker**: Required to run the Verdaccio registry container +2. **Volta with pnpm support**: Enable pnpm in Volta by setting `VOLTA_FEATURE_PNPM=1` in your environment. See [Volta pnpm docs](https://docs.volta.sh/advanced/pnpm). + +### Step-by-Step Instructions + +1. **Build the SDK packages and create tarballs:** + + ```bash + yarn build + yarn build:tarball + ``` + + Note: You must re-run `yarn build:tarball` after any changes to packages. + +2. **Set up environment (optional):** + + ```bash + cd dev-packages/e2e-tests + cp .env.example .env + # Fill in Sentry project auth info if running tests that send data to Sentry + ``` + +3. **Run all E2E tests:** + + ```bash + yarn test:e2e + ``` + +4. **Or run a specific test application:** + + ```bash + yarn test:run + # Example: yarn test:run nextjs-app-dir + ``` + +5. **Run with a specific variant:** + ```bash + yarn test:run --variant + # Example: yarn test:run nextjs-pages-dir --variant 15 + ``` + +### Common Issues and Troubleshooting + +#### Packages install from public npm instead of Verdaccio + +Every E2E test application **must** have an `.npmrc` file with: + +``` +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +``` + +Without this, pnpm will fetch packages from the public npm registry instead of the local Verdaccio instance, causing tests to use outdated/published versions instead of your local changes. + +#### Tests fail after making SDK changes + +Make sure to rebuild tarballs: + +```bash +yarn build +yarn build:tarball +``` + +#### Docker-related issues + +- Ensure Docker daemon is running +- Check that port 4873 is not in use by another process +- Try stopping and removing existing Verdaccio containers + +#### Debugging test failures + +1. Check browser console logs for SDK initialization errors +2. Enable debug mode in the test app's Sentry config: `debug: true` +3. Verify packages are installed from Verdaccio by checking the version in `node_modules/@sentry/*/package.json` + +For more details, see [dev-packages/e2e-tests/README.md](dev-packages/e2e-tests/README.md). + ## Debug Build Flags Throughout the codebase, you will find a `__DEBUG_BUILD__` constant. This flag serves two purposes: diff --git a/dev-packages/browser-integration-tests/suites/ipv6/init.js b/dev-packages/browser-integration-tests/suites/ipv6/init.js new file mode 100644 index 000000000000..de8412c65a86 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/ipv6/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@[2001:db8::1]/1337', + sendClientReports: false, + defaultIntegrations: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/ipv6/subject.js b/dev-packages/browser-integration-tests/suites/ipv6/subject.js new file mode 100644 index 000000000000..8dc99f21ee0e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/ipv6/subject.js @@ -0,0 +1 @@ +Sentry.captureException(new Error('Test error')); diff --git a/dev-packages/browser-integration-tests/suites/ipv6/template.html b/dev-packages/browser-integration-tests/suites/ipv6/template.html new file mode 100644 index 000000000000..39082f45e532 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/ipv6/template.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/ipv6/test.ts b/dev-packages/browser-integration-tests/suites/ipv6/test.ts new file mode 100644 index 000000000000..7c4ed4da876f --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/ipv6/test.ts @@ -0,0 +1,20 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../utils/fixtures'; +import { envelopeRequestParser } from '../../utils/helpers'; + +sentryTest('sends event to an IPv6 DSN', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Technically, we could also use `waitForErrorRequest` but it listens to every POST request, regardless + // of URL. Therefore, waiting on the ipv6 URL request, makes the test a bit more robust. + // We simplify things further by setting up the SDK for errors-only, so that no other request is made. + const requestPromise = page.waitForRequest(req => req.method() === 'POST' && req.url().includes('[2001:db8::1]')); + + await page.goto(url); + + const errorRequest = envelopeRequestParser(await requestPromise); + + expect(errorRequest.exception?.values?.[0]?.value).toBe('Test error'); + + await page.waitForTimeout(1000); +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js index 0b8fced8d6e3..6e1a35009e5f 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/subject.js @@ -9,4 +9,10 @@ Sentry.startSpan({ name: 'test-span', op: 'test' }, () => { Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); +Sentry.withScope(scope => { + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + Sentry.metrics.count('test.scope.attributes.counter', 1, { attributes: { action: 'click' } }); +}); + Sentry.flush(); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts index 3a8ac97f8408..655458c008a1 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts @@ -17,7 +17,7 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) expect(envelopeItems[0]).toEqual([ { type: 'trace_metric', - item_count: 5, + item_count: 6, content_type: 'application/vnd.sentry.items.trace-metric+json', }, { @@ -98,6 +98,60 @@ sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.scope.attributes.counter', + type: 'counter', + value: 1, + attributes: { + action: { + type: 'string', + value: 'click', + }, + scope_attribute_1: { + type: 'integer', + value: 1, + }, + scope_attribute_2: { + type: 'string', + value: 'test', + }, + scope_attribute_3: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.browser', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'user.email': { + type: 'string', + value: 'test@example.com', + }, + 'user.id': { + type: 'string', + value: 'user-123', + }, + 'user.name': { + type: 'string', + value: 'testuser', + }, + }, + }, ], }, ]); diff --git a/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js new file mode 100644 index 000000000000..912a4aafd728 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/init.js @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/browser'; +import { thirdPartyErrorFilterIntegration } from '@sentry/browser'; +import { wasmIntegration } from '@sentry/wasm'; + +// Simulate what the bundler plugin would inject to mark JS code as first-party +var _sentryModuleMetadataGlobal = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata = _sentryModuleMetadataGlobal._sentryModuleMetadata || {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack] = Object.assign( + {}, + _sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack], + { + '_sentryBundlerPluginAppKey:wasm-test-app': true, + }, +); + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + wasmIntegration({ applicationKey: 'wasm-test-app' }), + thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-contains-third-party-frames', + filterKeys: ['wasm-test-app'], + }), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js new file mode 100644 index 000000000000..74d9e73aa6f3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/subject.js @@ -0,0 +1,35 @@ +// Simulate what the bundler plugin would inject to mark this JS file as first-party +var _sentryModuleMetadataGlobal = + typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : typeof self !== 'undefined' + ? self + : {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata = _sentryModuleMetadataGlobal._sentryModuleMetadata || {}; + +_sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack] = Object.assign( + {}, + _sentryModuleMetadataGlobal._sentryModuleMetadata[new Error().stack], + { + '_sentryBundlerPluginAppKey:wasm-test-app': true, + }, +); + +async function runWasm() { + function crash() { + throw new Error('WASM triggered error'); + } + + const { instance } = await WebAssembly.instantiateStreaming(fetch('https://localhost:5887/simple.wasm'), { + env: { + external_func: crash, + }, + }); + + instance.exports.internal_func(); +} + +runWasm(); diff --git a/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts new file mode 100644 index 000000000000..f0ebf27d6aef --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/wasm/thirdPartyFilter/test.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; +import { shouldSkipWASMTests } from '../../../utils/wasmHelpers'; + +const bundle = process.env.PW_BUNDLE || ''; +// We only want to run this in non-CDN bundle mode because both +// wasmIntegration and thirdPartyErrorFilterIntegration are only available in NPM packages +if (bundle.startsWith('bundle')) { + sentryTest.skip(); +} + +sentryTest( + 'WASM frames should be recognized as first-party when applicationKey is configured', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/simple.wasm', route => { + const wasmModule = fs.readFileSync(path.resolve(__dirname, '../simple.wasm')); + + return route.fulfill({ + status: 200, + body: wasmModule, + headers: { + 'Content-Type': 'application/wasm', + }, + }); + }); + + const errorEventPromise = waitForErrorRequest(page, e => { + return e.exception?.values?.[0]?.value === 'WASM triggered error'; + }); + + await page.goto(url); + + const errorEvent = envelopeRequestParser(await errorEventPromise); + + expect(errorEvent.tags?.third_party_code).toBeUndefined(); + + // Verify we have WASM frames in the stacktrace + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filename: expect.stringMatching(/simple\.wasm$/), + platform: 'native', + }), + ]), + ); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index b945bee2eeea..a9fb96b59505 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -57,6 +57,9 @@ type StartResult = { export function createRunner(...paths: string[]) { const testPath = join(...paths); + // controls whether envelopes are expected in predefined order or not + let unordered = false; + if (!existsSync(testPath)) { throw new Error(`Test scenario not found: ${testPath}`); } @@ -64,8 +67,13 @@ export function createRunner(...paths: string[]) { const expectedEnvelopes: Expected[] = []; // By default, we ignore session & sessions const ignored: Set = new Set(['session', 'sessions', 'client_report']); + let serverUrl: string | undefined; return { + withServerUrl: function (url: string) { + serverUrl = url; + return this; + }, expect: function (expected: Expected) { expectedEnvelopes.push(expected); return this; @@ -76,6 +84,10 @@ export function createRunner(...paths: string[]) { } return this; }, + unordered: function () { + unordered = true; + return this; + }, ignore: function (...types: EnvelopeItemType[]) { types.forEach(t => ignored.add(t)); return this; @@ -102,6 +114,14 @@ export function createRunner(...paths: string[]) { } } + 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)); @@ -111,19 +131,36 @@ export function createRunner(...paths: string[]) { return; } - const expected = expectedEnvelopes.shift(); - - // Catch any error or failed assertions and pass them to done to end the test quickly try { - if (!expected) { - return; - } + if (unordered) { + // find any matching expected envelope + const matchIndex = expectedEnvelopes.findIndex(candidate => { + try { + assertEnvelopeMatches(candidate, envelope); + return true; + } catch { + return false; + } + }); + + // no match found + if (matchIndex < 0) { + return; + } - if (typeof expected === 'function') { - expected(envelope); + // remove the matching expected envelope + expectedEnvelopes.splice(matchIndex, 1); } else { - expect(envelope).toEqual(expected); + // in ordered mode we just look at the next expected envelope + const expected = expectedEnvelopes.shift(); + + if (!expected) { + return; + } + + assertEnvelopeMatches(expected, envelope); } + expectCallbackCalled(); } catch (e) { reject(e); @@ -154,6 +191,8 @@ export function createRunner(...paths: string[]) { 'false', '--var', `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, + '--var', + `SERVER_URL:${serverUrl}`, ], { stdio, signal }, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts index 9d7eb264f76e..727d61cca130 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono/basic/test.ts @@ -47,6 +47,7 @@ it('Hono app captures errors', async ({ signal }) => { }), ); }) + .unordered() .start(signal); await runner.makeRequest('get', '/error', { expectError: true }); await runner.completed(); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts new file mode 100644 index 000000000000..973f54053571 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SERVER_URL: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + propagateTraceparent: true, + }), + { + async fetch(_request, env, _ctx) { + await fetch(env.SERVER_URL); + throw new Error('Test error to capture trace headers'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts new file mode 100644 index 000000000000..d92fde438eb8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/test.ts @@ -0,0 +1,59 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, it } from 'vitest'; +import { eventEnvelope } from '../../../expect'; +import { createRunner } from '../../../runner'; + +it('Tracing headers', async ({ signal }) => { + expect.assertions(5); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/', headers => { + expect(headers['baggage']).toEqual(expect.any(String)); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); + expect(headers['traceparent']).toEqual(expect.stringMatching(/^00-([a-f\d]{32})-([a-f\d]{16})-00$/)); + }) + .start(); + + const runner = createRunner(__dirname) + .withServerUrl(SERVER_URL) + .expect( + eventEnvelope({ + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'Test error to capture trace headers', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, + }, + ], + }, + breadcrumbs: [ + { + category: 'fetch', + data: { + method: 'GET', + status_code: 200, + url: expect.stringContaining('http://localhost:'), + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), + }, + }), + ) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); + closeTestServer(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/wrangler.jsonc new file mode 100644 index 000000000000..24fb2861023d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"] +} diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 133b53268d52..ffe06dd91aaf 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -88,6 +88,81 @@ EOF Make sure to add a `test:build` and `test:assert` command to the new app's `package.json` file. +### The `.npmrc` File + +Every test application needs an `.npmrc` file (as shown above) to tell pnpm to fetch `@sentry/*` and `@sentry-internal/*` packages from the local Verdaccio registry. Without it, pnpm will install from the public npm registry and your local changes won't be tested - this is one of the most common causes of confusing test failures. + +To verify packages are being installed from Verdaccio, check the version in `node_modules/@sentry/*/package.json`. If it shows something like `0.0.0-pr.12345`, Verdaccio is working. If it shows a released version (e.g., `8.0.0`), the `.npmrc` is missing or incorrect. + +## Troubleshooting + +### Common Issues + +#### Tests fail with "Cannot find module '@sentry/...'" or use wrong package version + +1. Verify the test application has an `.npmrc` file (see above) +2. Rebuild tarballs: `yarn build && yarn build:tarball` +3. Delete `node_modules` in the test application and re-run the test + +#### Docker/Verdaccio issues + +- Ensure Docker daemon is running +- Check that port 4873 is not already in use: `lsof -i :4873` +- Stop any existing Verdaccio containers: `docker ps` and `docker stop ` +- Check Verdaccio logs for errors + +#### Tests pass locally but fail in CI (or vice versa) + +- Most likely cause: missing `.npmrc` file +- Verify all `@sentry/*` dependencies use `latest || *` version specifier +- Check if the test relies on environment-specific behavior + +### Debugging Tips + +1. **Enable Sentry debug mode**: Add `debug: true` to the Sentry init config to see detailed SDK logs +2. **Check browser console**: Look for SDK initialization errors or warnings +3. **Inspect network requests**: Verify events are being sent to the expected endpoint +4. **Check installed versions**: `cat node_modules/@sentry/browser/package.json | grep version` + +## Bundler-Specific Behavior + +Different bundlers handle environment variables and code replacement differently. This is important when writing tests or SDK code that relies on build-time constants. + +### Webpack + +- `DefinePlugin` replaces variables in your application code +- **Does NOT replace values inside `node_modules`** +- Environment variables must be explicitly defined + +### Vite + +- `define` option replaces variables in your application code +- **Does NOT replace values inside `node_modules`** +- `import.meta.env.VITE_*` variables are replaced at build time +- For replacing values in dependencies, use `@rollup/plugin-replace` + +### Next.js + +- Automatically injects `process.env` via webpack/turbopack +- Handles environment variables more seamlessly than raw webpack/Vite +- Server and client bundles may have different environment variable access + +### `import.meta.env` Considerations + +- Only available in Vite and ES modules +- Webpack and Turbopack do not have `import.meta.env` +- SDK code accessing `import.meta.env` must use try-catch to handle environments where it doesn't exist + +```typescript +// Safe pattern for SDK code +let envValue: string | undefined; +try { + envValue = import.meta.env.VITE_SOME_VAR; +} catch { + // import.meta.env not available in this bundler +} +``` + Test apps in the folder `test-applications` will be automatically picked up by CI in the job `job_e2e_tests` (in `.github/workflows/build.yml`). The test matrix for CI is generated in `dev-packages/e2e-tests/lib/getTestMatrix.ts`. diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json index 37954bd3cbbc..f6eddbbdeb58 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@sentry/browser": "latest || *", - "@sentry/vite-plugin": "^4.0.0" + "@sentry/vite-plugin": "^4.6.1" }, "volta": { "node": "20.19.2", diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts index b017c1bfdc4d..238ec062663a 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/main.ts @@ -7,7 +7,13 @@ Sentry.init({ environment: import.meta.env.MODE || 'development', tracesSampleRate: 1.0, debug: true, - integrations: [Sentry.browserTracingIntegration()], + integrations: [ + Sentry.browserTracingIntegration(), + Sentry.thirdPartyErrorFilterIntegration({ + behaviour: 'apply-tag-if-contains-third-party-frames', + filterKeys: ['browser-webworker-vite'], + }), + ], tunnel: 'http://localhost:3031/', // proxy server }); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts index e298fa525efb..d12e61111c85 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tests/errors.test.ts @@ -171,3 +171,19 @@ test('captures an error from the third lazily added worker', async ({ page }) => ], }); }); + +test('worker errors are not tagged as third-party when module metadata is present', async ({ page }) => { + const errorEventPromise = waitForError('browser-webworker-vite', async event => { + return !event.type && event.exception?.values?.[0]?.value === 'Uncaught Error: Uncaught error in worker'; + }); + + await page.goto('/'); + + await page.locator('#trigger-error').click(); + + await page.waitForTimeout(1000); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.tags?.third_party_code).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts index df010d9b426c..190aa3749e3f 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ org: process.env.E2E_TEST_SENTRY_ORG_SLUG, project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, + applicationKey: 'browser-webworker-vite', }), ], @@ -21,6 +22,7 @@ export default defineConfig({ org: process.env.E2E_TEST_SENTRY_ORG_SLUG, project: process.env.E2E_TEST_SENTRY_PROJECT, authToken: process.env.E2E_TEST_AUTH_TOKEN, + applicationKey: 'browser-webworker-vite', }), ], }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json index 93404de22833..7aad0d2966ac 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -17,7 +17,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.24.0", "@sentry/cloudflare": "latest || *", - "agents": "^0.2.23", + "agents": "0.2.32", "zod": "^3.25.76" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json index 68973f3ffd72..0230683d8e5d 100644 --- a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json @@ -15,7 +15,7 @@ "devDependencies": { "rollup": "^4.35.0", "vitest": "^0.34.6", - "@sentry/rollup-plugin": "^4.0.0" + "@sentry/rollup-plugin": "^4.6.1" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/package.json b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json index b59ad9b2245e..48e2525de321 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json @@ -11,6 +11,7 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml", "test": "playwright test", "test:build": "pnpm install", + "test:build-latest": "pnpm install && pnpm add @nestjs/common@latest @nestjs/core@latest @nestjs/platform-express@latest @nestjs/microservices@latest && pnpm add -D @nestjs/cli@latest @nestjs/testing@latest && pnpm build", "test:assert": "pnpm test" }, "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx new file mode 100644 index 000000000000..03201cdccf60 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata-async/page.tsx @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/nextjs'; + +function fetchPost() { + return Promise.resolve({ id: '1', title: 'Post 1' }); +} + +export async function generateMetadata() { + const { id } = await fetchPost(); + const product = `Product: ${id}`; + + return { + title: product, + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx new file mode 100644 index 000000000000..7bcdbd0474e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/metadata/page.tsx @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/nextjs'; + +/** + * Tests generateMetadata function with cache components, this calls the propagation context to be set + * Which will generate and set a trace id in the propagation context, which should trigger the random API error if unpatched + * See: https://github.com/getsentry/sentry-javascript/issues/18392 + */ +export function generateMetadata() { + return { + title: 'Cache Components Metadata Test', + }; +} + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts index 9f7b0ca559be..9a60ac59cd8f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -26,3 +26,29 @@ test('Should render suspense component', async ({ page }) => { expect(serverTx.spans?.filter(span => span.op === 'get.todos').length).toBeGreaterThan(0); await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); }); + +test('Should generate metadata', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/metadata'); + const serverTx = await serverTxPromise; + + expect(serverTx.spans?.filter(span => span.op === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Cache Components Metadata Test'); +}); + +test('Should generate metadata async', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/metadata-async'); + const serverTx = await serverTxPromise; + + expect(serverTx.spans?.filter(span => span.op === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + await expect(page).toHaveTitle('Product: 1'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/page.tsx new file mode 100644 index 000000000000..fdb7bc0a40a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; + +export default function Page() { + const handleClick = async () => { + Sentry.metrics.count('test.page.count', 1, { + attributes: { + page: '/metrics', + 'random.attribute': 'Apples', + }, + }); + Sentry.metrics.distribution('test.page.distribution', 100, { + attributes: { + page: '/metrics', + 'random.attribute': 'Manzanas', + }, + }); + Sentry.metrics.gauge('test.page.gauge', 200, { + attributes: { + page: '/metrics', + 'random.attribute': 'Mele', + }, + }); + await fetch('/metrics/route-handler'); + }; + + return ( +
+

Metrics page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/route-handler/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/route-handler/route.ts new file mode 100644 index 000000000000..84e81960f9c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/metrics/route-handler/route.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/nextjs'; + +export const GET = async () => { + Sentry.metrics.count('test.route.handler.count', 1, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Potatoes', + }, + }); + Sentry.metrics.distribution('test.route.handler.distribution', 100, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Patatas', + }, + }); + Sentry.metrics.gauge('test.route.handler.gauge', 200, { + attributes: { + endpoint: '/metrics/route-handler', + 'random.attribute': 'Patate', + }, + }); + return Response.json({ message: 'Bueno' }); +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts new file mode 100644 index 000000000000..43edb917d526 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/metrics.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +test('Should emit metrics from server and client', async ({ request, page }) => { + const clientCountPromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.page.count'; + }); + + const clientDistributionPromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.page.distribution'; + }); + + const clientGaugePromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.page.gauge'; + }); + + const serverCountPromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.route.handler.count'; + }); + + const serverDistributionPromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.route.handler.distribution'; + }); + + const serverGaugePromise = waitForMetric('nextjs-16', async metric => { + return metric.name === 'test.route.handler.gauge'; + }); + + await page.goto('/metrics'); + await page.getByText('Emit').click(); + const clientCount = await clientCountPromise; + const clientDistribution = await clientDistributionPromise; + const clientGauge = await clientGaugePromise; + const serverCount = await serverCountPromise; + const serverDistribution = await serverDistributionPromise; + const serverGauge = await serverGaugePromise; + + expect(clientCount).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.page.count', + type: 'counter', + value: 1, + attributes: { + page: { value: '/metrics', type: 'string' }, + 'random.attribute': { value: 'Apples', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(clientDistribution).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.page.distribution', + type: 'distribution', + value: 100, + attributes: { + page: { value: '/metrics', type: 'string' }, + 'random.attribute': { value: 'Manzanas', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(clientGauge).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + span_id: expect.any(String), + name: 'test.page.gauge', + type: 'gauge', + value: 200, + attributes: { + page: { value: '/metrics', type: 'string' }, + 'random.attribute': { value: 'Mele', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(serverCount).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.route.handler.count', + type: 'counter', + value: 1, + attributes: { + 'server.address': { value: expect.any(String), type: 'string' }, + 'random.attribute': { value: 'Potatoes', type: 'string' }, + endpoint: { value: '/metrics/route-handler', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(serverDistribution).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.route.handler.distribution', + type: 'distribution', + value: 100, + attributes: { + 'server.address': { value: expect.any(String), type: 'string' }, + 'random.attribute': { value: 'Patatas', type: 'string' }, + endpoint: { value: '/metrics/route-handler', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(serverGauge).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.route.handler.gauge', + type: 'gauge', + value: 200, + attributes: { + 'server.address': { value: expect.any(String), type: 'string' }, + 'random.attribute': { value: 'Patate', type: 'string' }, + endpoint: { value: '/metrics/route-handler', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-incorrect-instrumentation/package.json b/dev-packages/e2e-tests/test-applications/node-express-incorrect-instrumentation/package.json index d60da152faf0..994100e5d7b9 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-incorrect-instrumentation/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-incorrect-instrumentation/package.json @@ -12,8 +12,8 @@ }, "dependencies": { "@sentry/node": "latest || *", - "@trpc/server": "10.45.3", - "@trpc/client": "10.45.2", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", "@types/express": "4.17.17", "@types/node": "^18.19.1", "express": "4.20.0", diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json index e6ca810047a6..f29feda5eea8 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json @@ -13,8 +13,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", "@sentry/node": "latest || *", - "@trpc/server": "10.45.3", - "@trpc/client": "10.45.2", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", "@types/express": "^4.17.21", "@types/node": "^18.19.1", "express": "^5.1.0", diff --git a/dev-packages/e2e-tests/test-applications/node-express/package.json b/dev-packages/e2e-tests/test-applications/node-express/package.json index d0fbe7df5ff0..4305e8593a76 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express/package.json @@ -13,8 +13,8 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.10.2", "@sentry/node": "latest || *", - "@trpc/server": "10.45.2", - "@trpc/client": "10.45.2", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", "@types/express": "^4.17.21", "@types/node": "^18.19.1", "express": "^4.21.2", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash new file mode 100644 index 000000000000..a1831f1e8e76 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash @@ -0,0 +1,46 @@ +#!/bin/bash +# To enable Sentry in Nuxt dev, it needs the sentry.server.config.mjs file from the .nuxt folder. +# First, we need to start 'nuxt dev' to generate the file, and then start 'nuxt dev' again with the NODE_OPTIONS to have Sentry enabled. + +# Using a different port to avoid playwright already starting with the tests for port 3030 +TEMP_PORT=3035 + +# 1. Start dev in background - this generates .nuxt folder +pnpm dev -p $TEMP_PORT & +DEV_PID=$! + +# 2. Wait for the sentry.server.config.mjs file to appear +echo "Waiting for .nuxt/dev/sentry.server.config.mjs file..." +COUNTER=0 +while [ ! -f ".nuxt/dev/sentry.server.config.mjs" ] && [ $COUNTER -lt 30 ]; do + sleep 1 + ((COUNTER++)) +done + +if [ ! -f ".nuxt/dev/sentry.server.config.mjs" ]; then + echo "ERROR: .nuxt/dev/sentry.server.config.mjs file never appeared!" + echo "This usually means the Nuxt dev server failed to start or generate the file. Try to rerun the test." + pkill -P $DEV_PID || kill $DEV_PID + exit 1 +fi + +# 3. Cleanup +echo "Found .nuxt/dev/sentry.server.config.mjs, stopping 'nuxt dev' process..." +pkill -P $DEV_PID || kill $DEV_PID + +# Wait for port to be released +echo "Waiting for port $TEMP_PORT to be released..." +COUNTER=0 +# Check if port is still in use +while lsof -i :$TEMP_PORT > /dev/null 2>&1 && [ $COUNTER -lt 10 ]; do + sleep 1 + ((COUNTER++)) +done + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "WARNING: Port $TEMP_PORT still in use after 10 seconds, proceeding anyway..." +else + echo "Port $TEMP_PORT released successfully" +fi + +echo "Nuxt dev server can now be started with '--import ./.nuxt/dev/sentry.server.config.mjs'" diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index eb28e69b0633..3f25ef7df0e4 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -11,9 +11,11 @@ "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", "clean": "npx nuxi cleanup", "test": "playwright test", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "bash ./nuxt-start-dev-server.bash && TEST_ENV=development playwright test environment", "test:build": "pnpm install && pnpm build", "test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test:prod && pnpm test:dev" }, "dependencies": { "@pinia/nuxt": "^0.5.5", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts index e07fb02e5218..b86690ca086c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/playwright.config.ts @@ -1,7 +1,25 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return "NODE_OPTIONS='--import ./.nuxt/dev/sentry.server.config.mjs' nuxt dev -p 3030"; + } + + if (testEnv === 'production') { + return 'pnpm start:import'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + const config = getPlaywrightConfig({ - startCommand: `pnpm start:import`, + startCommand: getStartCommand(), }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts index 3cbea64827cb..7b5d97ff0b09 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.client.config.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nuxt'; import { usePinia, useRuntimeConfig } from '#imports'; Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions dsn: useRuntimeConfig().public.sentry.dsn, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index 729b2296c683..26519911072b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nuxt'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts new file mode 100644 index 000000000000..b59e4560165b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/environment.test.ts @@ -0,0 +1,77 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test.describe('environment detection', async () => { + test('sets correct environment for client-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-4 E2E test app'; + }); + + // We have to wait for networkidle in dev mode because clicking the button is a no-op otherwise (network requests are blocked during page load) + await page.goto(`/client-error`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for client-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { + return transactionEvent.transaction === '/test-param/:param()'; + }); + + await page.goto(`/test-param/1234`); + + const transaction = await transactionPromise; + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side errors', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Nuxt 4 Server error'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Server API Error', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toBe('GET /api/server-error'); + + if (isDevMode) { + expect(error.environment).toBe('development'); + } else { + expect(error.environment).toBe('production'); + } + }); + + test('sets correct environment for server-side transactions', async ({ page }) => { + const transactionPromise = waitForTransaction('nuxt-4', async transactionEvent => { + return transactionEvent.transaction === 'GET /api/nitro-fetch'; + }); + + await page.goto(`/fetch-server-routes`, isDevMode ? { waitUntil: 'networkidle' } : {}); + await page.getByText('Fetch Nitro $fetch', { exact: true }).click(); + + const transaction = await transactionPromise; + + expect(transaction.contexts.trace.op).toBe('http.server'); + + if (isDevMode) { + expect(transaction.environment).toBe('development'); + } else { + expect(transaction.environment).toBe('production'); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts index 5244ec499d33..18b7ce9f3c6c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts @@ -153,14 +153,14 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.origin': 'auto.http.react_router.loader', - 'sentry.op': 'function.react-router.loader', + 'sentry.op': 'function.react_router.loader', }, description: 'Executing Server Loader', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.loader', + op: 'function.react_router.loader', origin: 'auto.http.react_router.loader', }); }); @@ -213,14 +213,14 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.origin': 'auto.http.react_router.action', - 'sentry.op': 'function.react-router.action', + 'sentry.op': 'function.react_router.action', }, description: 'Executing Server Action', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.action', + op: 'function.react_router.action', origin: 'auto.http.react_router.action', }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts index 6058403bb81e..e0ca27a19e10 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts @@ -153,11 +153,11 @@ test.describe('server - performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.op': 'function.react-router.loader', + 'sentry.op': 'function.react_router.loader', 'sentry.origin': 'auto.http.react_router.server', }, description: 'Executing Server Loader', - op: 'function.react-router.loader', + op: 'function.react_router.loader', origin: 'auto.http.react_router.server', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -213,11 +213,11 @@ test.describe('server - performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.op': 'function.react-router.action', + 'sentry.op': 'function.react_router.action', 'sentry.origin': 'auto.http.react_router.server', }, description: 'Executing Server Action', - op: 'function.react-router.action', + op: 'function.react_router.action', origin: 'auto.http.react_router.server', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx index 223c8e6129dd..7448ebe7bfe2 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx @@ -9,7 +9,7 @@ Sentry.init({ dsn: 'https://username@domain/123', integrations: [Sentry.reactRouterTracingIntegration()], tracesSampleRate: 1.0, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: `http://localhost:3031/`, tracePropagationTargets: [/^\//], }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json index 3421b6e913c3..3d102291ff55 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json @@ -18,16 +18,16 @@ }, "dependencies": { "@sentry/react-router": "latest || *", - "@react-router/node": "7.10.1", - "@react-router/serve": "7.10.1", + "@react-router/node": "^7.11.0", + "@react-router/serve": "^7.11.0", "isbot": "^5.1.27", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router": "7.10.1" + "react-router": "^7.11.0" }, "devDependencies": { "@playwright/test": "~1.56.0", - "@react-router/dev": "7.10.1", + "@react-router/dev": "^7.11.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@tailwindcss/vite": "^4.1.4", "@types/node": "^20", diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts index bc7b44591f30..8cbc4c46a460 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts @@ -122,14 +122,14 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.origin': 'auto.http.react_router', - 'sentry.op': 'function.react-router.loader', + 'sentry.op': 'function.react_router.loader', }, description: 'Executing Server Loader', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.loader', + op: 'function.react_router.loader', origin: 'auto.http.react_router', }); }); @@ -150,14 +150,14 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.origin': 'auto.http.react_router', - 'sentry.op': 'function.react-router.action', + 'sentry.op': 'function.react_router.action', }, description: 'Executing Server Action', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.action', + op: 'function.react_router.action', origin: 'auto.http.react_router', }); }); diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json index 4314393034bb..40da7f5fb859 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json @@ -19,7 +19,7 @@ "@remix-run/cloudflare-pages": "^2.15.2", "@sentry/cloudflare": "latest || *", "@sentry/remix": "latest || *", - "@sentry/vite-plugin": "^4.0.0", + "@sentry/vite-plugin": "^4.6.1", "@shopify/hydrogen": "2025.4.0", "@shopify/remix-oxygen": "^2.0.10", "graphql": "^16.6.0", diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json index 8b2cceecb30a..8082add342d1 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json @@ -15,7 +15,7 @@ "dependencies": { "@sentry/solid": "latest || *", "@tailwindcss/vite": "^4.0.6", - "@tanstack/solid-router": "^1.132.25", + "@tanstack/solid-router": "^1.141.8", "@tanstack/solid-router-devtools": "^1.132.25", "@tanstack/solid-start": "^1.132.25", "solid-js": "^1.9.5", diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx index 4580fa6e8a90..6561d96ce6b1 100644 --- a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx @@ -39,7 +39,7 @@ const indexRoute = createRoute({ const postsRoute = createRoute({ getParentRoute: () => rootRoute, - path: 'posts/', + path: 'posts', }); const postIdRoute = createRoute({ diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/tanstack-router/package.json index 64f92e662ae0..edb4a6cd6707 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@sentry/react": "latest || *", - "@tanstack/react-router": "1.64.0", + "@tanstack/react-router": "^1.64.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx index 3574d4ffb81a..01d867f63c65 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx @@ -41,7 +41,7 @@ const indexRoute = createRoute({ const postsRoute = createRoute({ getParentRoute: () => rootRoute, - path: 'posts/', + path: 'posts', }); const postIdRoute = createRoute({ 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 0076ccf22dc8..d75ebb148639 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -9,6 +9,7 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", "test:assert": "pnpm test" }, "dependencies": { diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/package.json b/dev-packages/e2e-tests/test-applications/tsx-express/package.json index 32c8a5668f63..cd6eeba3f19b 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/package.json +++ b/dev-packages/e2e-tests/test-applications/tsx-express/package.json @@ -13,8 +13,8 @@ "@modelcontextprotocol/sdk": "^1.10.2", "@sentry/core": "latest || *", "@sentry/node": "latest || *", - "@trpc/server": "10.45.2", - "@trpc/client": "10.45.2", + "@trpc/server": "10.45.4", + "@trpc/client": "10.45.4", "@types/express": "^4.17.21", "@types/node": "^18.19.1", "express": "^4.21.2", diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json index 1dc469b50ca1..603f2f0ffc31 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -12,7 +12,11 @@ "type-check": "vue-tsc --build --force", "test": "playwright test", "test:build": "pnpm install && pnpm build", - "test:assert": "playwright test" + "test:assert": "pnpm test:print-version && playwright test", + "test:build-canary": "pnpm install && pnpm test:install-canary && pnpm build", + "test:build-latest": "pnpm install && pnpm add vue@latest && pnpm build", + "test:install-canary": "pnpm add vue@$(git ls-remote --tags --sort='v:refname' https://github.com/vuejs/core.git | tail -n1 | awk -F'/' '{print $NF}')", + "test:print-version": "node -p \"'Vue version: ' + require('vue/package.json').version\"" }, "dependencies": { "@sentry/vue": "latest || *", @@ -36,5 +40,19 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-latest", + "label": "vue-3 (latest)" + } + ], + "optionalVariants": [ + { + "build-command": "pnpm test:build-canary", + "label": "vue-3 (canary)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json index fc99c9ea3c6c..448876ec6d2b 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@sentry/vue": "latest || *", - "@tanstack/vue-router": "^1.64.0", + "@tanstack/vue-router": "^1.141.8", "vue": "^3.4.15" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts index cdeec524fb50..2b44a6297ca7 100644 --- a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts @@ -19,7 +19,7 @@ const indexRoute = createRoute({ const postsRoute = createRoute({ getParentRoute: () => rootRoute, - path: 'posts/', + path: 'posts', }); const postIdRoute = createRoute({ diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/scenario.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/base/scenario.ts similarity index 93% rename from dev-packages/node-core-integration-tests/suites/cron/node-cron/scenario.ts rename to dev-packages/node-core-integration-tests/suites/cron/node-cron/base/scenario.ts index 818bf7b63871..0cfa7d79c135 100644 --- a/dev-packages/node-core-integration-tests/suites/cron/node-cron/scenario.ts +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/base/scenario.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node-core'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as cron from 'node-cron'; -import { setupOtel } from '../../../utils/setupOtel'; +import { setupOtel } from '../../../../utils/setupOtel'; const client = Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/test.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/base/test.ts similarity index 96% rename from dev-packages/node-core-integration-tests/suites/cron/node-cron/test.ts rename to dev-packages/node-core-integration-tests/suites/cron/node-cron/base/test.ts index dcdb1ba4c4d9..6935fb289b16 100644 --- a/dev-packages/node-core-integration-tests/suites/cron/node-cron/test.ts +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/base/test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; afterAll(() => { cleanupChildProcesses(); diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts new file mode 100644 index 000000000000..e06814477bf5 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts @@ -0,0 +1,59 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as cron from 'node-cron'; +import { setupOtel } from '../../../../utils/setupOtel'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +setupOtel(client); + +const cronWithCheckIn = Sentry.cron.instrumentNodeCron(cron, { isolateTrace: true }); + +let closeNext1 = false; +let closeNext2 = false; + +const task = cronWithCheckIn.schedule( + '* * * * * *', + () => { + if (closeNext1) { + // https://github.com/node-cron/node-cron/issues/317 + setImmediate(() => { + task.stop(); + }); + + throw new Error('Error in cron job'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext1 = true; + }, + { name: 'my-cron-job' }, +); + +const task2 = cronWithCheckIn.schedule( + '* * * * * *', + () => { + if (closeNext2) { + // https://github.com/node-cron/node-cron/issues/317 + setImmediate(() => { + task2.stop(); + }); + + throw new Error('Error in cron job 2'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext2 = true; + }, + { name: 'my-2nd-cron-job' }, +); + +setTimeout(() => { + process.exit(); +}, 5000); diff --git a/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/test.ts b/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/test.ts new file mode 100644 index 000000000000..cf469d2e6acd --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/cron/node-cron/isolateTrace/test.ts @@ -0,0 +1,49 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('node-cron instrumentation with isolateTrace creates distinct traces for each cron job', async () => { + let firstErrorTraceId: string | undefined; + + await createRunner(__dirname, 'scenario.ts') + .ignore('check_in') + .expect({ + event: event => { + const traceId = event.contexts?.trace?.trace_id; + const spanId = event.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f\d]{32}/); + expect(spanId).toMatch(/[a-f\d]{16}/); + + firstErrorTraceId = traceId; + + expect(event.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: expect.stringMatching(/^Error in cron job( 2)?$/), + mechanism: { type: 'auto.function.node-cron.instrumentNodeCron', handled: false }, + }); + }, + }) + .expect({ + event: event => { + const traceId = event.contexts?.trace?.trace_id; + const spanId = event.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f\d]{32}/); + expect(spanId).toMatch(/[a-f\d]{16}/); + + expect(traceId).not.toBe(firstErrorTraceId); + + expect(event.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: expect.stringMatching(/^Error in cron job( 2)?$/), + mechanism: { type: 'auto.function.node-cron.instrumentNodeCron', handled: false }, + }); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts b/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts new file mode 100644 index 000000000000..076e0ca02643 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/ipv6/scenario.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@[2001:db8::1]/1337', + defaultIntegrations: false, + sendClientReports: false, + release: '1.0', + transport: loggingTransport, +}); + +Sentry.captureException(new Error(Sentry.getClient()?.getDsn()?.host)); diff --git a/dev-packages/node-core-integration-tests/suites/ipv6/test.ts b/dev-packages/node-core-integration-tests/suites/ipv6/test.ts new file mode 100644 index 000000000000..ef670645c520 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/ipv6/test.ts @@ -0,0 +1,17 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture a simple error with message', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: event => { + expect(event.exception?.values?.[0]?.value).toBe('[2001:db8::1]'); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts index 77adfae79802..86905bae1066 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/scenario.ts @@ -25,6 +25,12 @@ async function run(): Promise { Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); + Sentry.withScope(scope => { + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + Sentry.metrics.count('test.scope.attributes.counter', 1, { attributes: { action: 'click' } }); + }); + await Sentry.flush(); } diff --git a/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts index c89c8fb59e55..9494ce2a99ca 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/metrics/test.ts @@ -87,6 +87,60 @@ describe('metrics', () => { 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.scope.attributes.counter', + type: 'counter', + value: 1, + attributes: { + action: { + type: 'string', + value: 'click', + }, + scope_attribute_1: { + type: 'integer', + value: 1, + }, + scope_attribute_2: { + type: 'string', + value: 'test', + }, + scope_attribute_3: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.node-core', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'user.email': { + type: 'string', + value: 'test@example.com', + }, + 'user.id': { + type: 'string', + value: 'user-123', + }, + 'user.name': { + type: 'string', + value: 'testuser', + }, + }, + }, ], }, }) diff --git a/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts index b9b2327497f5..4592221c286b 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/dsc-txn-name-update/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { conditionalTest } from '../../../utils'; import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; // This test requires Node.js 22+ because it depends on the 'http.client.request.created' // diagnostic channel for baggage header propagation, which only exists since Node 22.12.0+ and 23.2.0+ diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts index 0d1d33bb5fc9..531d66b3f2e6 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts index 61dfe4c4ba88..7781f01f4605 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts index 046763a0b55a..2f0bfd410663 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts index acc1d6c89a25..702a2febd61d 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts index 4507a360006c..8458d25728d0 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts index 318d4628453b..96892353d2dd 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts index 1cad4abf9a99..17393f21a8a4 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts index 55882d18830a..7d863d27ce6e 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts index 8cf07571fe24..e2af51920b0b 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts index a1ac7ca292e4..4aecd4c8dfa1 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-sampled/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts index 63ae25f32a0c..bf6f3fb6e316 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/http-unsampled/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/requests/traceparent/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/requests/traceparent/test.ts index 2cdb4cfd1aa7..517ea314dfc5 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/requests/traceparent/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/requests/traceparent/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing traceparent', () => { createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts index a136eb770a8d..b97f64adace5 100644 --- a/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts +++ b/dev-packages/node-core-integration-tests/suites/tracing/tracePropagationTargets/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { conditionalTest } from '../../../utils'; import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; // This test requires Node.js 22+ because it depends on the 'http.client.request.created' // diagnostic channel for baggage header propagation, which only exists since Node 22.12.0+ and 23.2.0+ diff --git a/dev-packages/node-core-integration-tests/utils/server.ts b/dev-packages/node-core-integration-tests/utils/server.ts index 92e0477c845c..b8941b4b0c32 100644 --- a/dev-packages/node-core-integration-tests/utils/server.ts +++ b/dev-packages/node-core-integration-tests/utils/server.ts @@ -37,49 +37,3 @@ export function createBasicSentryServer(onEnvelope: (env: Envelope) => void): Pr }); }); } - -type HeaderAssertCallback = (headers: Record) => void; - -/** Creates a test server that can be used to check headers */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function createTestServer() { - const gets: Array<[string, HeaderAssertCallback, number]> = []; - let error: unknown | undefined; - - return { - get: function (path: string, callback: HeaderAssertCallback, result = 200) { - gets.push([path, callback, result]); - return this; - }, - start: async (): Promise<[string, () => void]> => { - const app = express(); - - for (const [path, callback, result] of gets) { - app.get(path, (req, res) => { - try { - callback(req.headers); - } catch (e) { - error = e; - } - - res.status(result).send(); - }); - } - - return new Promise(resolve => { - const server = app.listen(0, () => { - const address = server.address() as AddressInfo; - resolve([ - `http://localhost:${address.port}`, - () => { - server.close(); - if (error) { - throw error; - } - }, - ]); - }); - }); - }, - }; -} diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index ed25bcfa1f59..f761c6b7c458 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -30,7 +30,7 @@ "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", "@langchain/anthropic": "^0.3.10", - "@langchain/core": "^0.3.28", + "@langchain/core": "^0.3.80", "@langchain/langgraph": "^0.2.32", "@nestjs/common": "^11", "@nestjs/core": "^11", diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts similarity index 100% rename from dev-packages/node-integration-tests/suites/cron/node-cron/scenario.ts rename to dev-packages/node-integration-tests/suites/cron/node-cron/base/scenario.ts diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts similarity index 96% rename from dev-packages/node-integration-tests/suites/cron/node-cron/test.ts rename to dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts index a986e3f83d92..990af6028235 100644 --- a/dev-packages/node-integration-tests/suites/cron/node-cron/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/base/test.ts @@ -1,5 +1,5 @@ import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; afterAll(() => { cleanupChildProcesses(); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts new file mode 100644 index 000000000000..5a670d9e6cf2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/scenario.ts @@ -0,0 +1,56 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as cron from 'node-cron'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +const cronWithCheckIn = Sentry.cron.instrumentNodeCron(cron, { isolateTrace: true }); + +let closeNext1 = false; +let closeNext2 = false; + +const task = cronWithCheckIn.schedule( + '* * * * * *', + () => { + if (closeNext1) { + // https://github.com/node-cron/node-cron/issues/317 + setImmediate(() => { + task.stop(); + }); + + throw new Error('Error in cron job'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext1 = true; + }, + { name: 'my-cron-job' }, +); + +const task2 = cronWithCheckIn.schedule( + '* * * * * *', + () => { + if (closeNext2) { + // https://github.com/node-cron/node-cron/issues/317 + setImmediate(() => { + task2.stop(); + }); + + throw new Error('Error in cron job 2'); + } + + // eslint-disable-next-line no-console + console.log('You will see this message every second'); + closeNext2 = true; + }, + { name: 'my-2nd-cron-job' }, +); + +setTimeout(() => { + process.exit(); +}, 5000); diff --git a/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts new file mode 100644 index 000000000000..ea044ca22ec6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/cron/node-cron/isolateTrace/test.ts @@ -0,0 +1,49 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('node-cron instrumentation', async () => { + let firstErrorTraceId: string | undefined; + + await createRunner(__dirname, 'scenario.ts') + .ignore('check_in') + .expect({ + event: event => { + const traceId = event.contexts?.trace?.trace_id; + const spanId = event.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f\d]{32}/); + expect(spanId).toMatch(/[a-f\d]{16}/); + + firstErrorTraceId = traceId; + + expect(event.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: expect.stringMatching(/^Error in cron job( 2)?$/), + mechanism: { type: 'auto.function.node-cron.instrumentNodeCron', handled: false }, + }); + }, + }) + .expect({ + event: event => { + const traceId = event.contexts?.trace?.trace_id; + const spanId = event.contexts?.trace?.span_id; + + expect(traceId).toMatch(/[a-f\d]{32}/); + expect(spanId).toMatch(/[a-f\d]{16}/); + + expect(traceId).not.toBe(firstErrorTraceId); + + expect(event.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: expect.stringMatching(/^Error in cron job( 2)?$/), + mechanism: { type: 'auto.function.node-cron.instrumentNodeCron', handled: false }, + }); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/ipv6/scenario.ts b/dev-packages/node-integration-tests/suites/ipv6/scenario.ts new file mode 100644 index 000000000000..0023a1bc4b48 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/ipv6/scenario.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@[2001:db8::1]/1337', + defaultIntegrations: false, + sendClientReports: false, + release: '1.0', + transport: loggingTransport, +}); + +Sentry.captureException(new Error(Sentry.getClient()?.getDsn()?.host)); diff --git a/dev-packages/node-integration-tests/suites/ipv6/test.ts b/dev-packages/node-integration-tests/suites/ipv6/test.ts new file mode 100644 index 000000000000..ef670645c520 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/ipv6/test.ts @@ -0,0 +1,17 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should capture a simple error with message', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: event => { + expect(event.exception?.values?.[0]?.value).toBe('[2001:db8::1]'); + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-custom-keys.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-custom-keys.mjs new file mode 100644 index 000000000000..41bbf6ddc57e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario-custom-keys.mjs @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino'; + +const logger = pino({ + name: 'myapp', + messageKey: 'message', // Custom key instead of 'msg' + errorKey: 'error', // Custom key instead of 'err' +}); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'custom-keys-test' }, () => { + logger.info({ user: 'user-123', action: 'custom-key-test' }, 'Custom message key'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'error-custom-key' }, () => { + logger.error(new Error('Custom error key')); + }); + }); +}, 500); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index a0a16c422dc2..7ae9c0dd5fbf 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -295,4 +295,79 @@ conditionalTest({ min: 20 })('Pino integration', () => { .start() .completed(); }); + + test('captures logs with custom messageKey and errorKey', async () => { + const instrumentPath = join(__dirname, 'instrument.mjs'); + + await createRunner(__dirname, 'scenario-custom-keys.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .ignore('transaction') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'Custom error key', + mechanism: { + type: 'pino', + handled: true, + }, + stacktrace: { + frames: expect.arrayContaining([ + expect.objectContaining({ + function: '?', + in_app: true, + module: 'scenario-custom-keys', + }), + ]), + }, + }, + ], + }, + }, + }) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'Custom message key', + trace_id: expect.any(String), + severity_number: 9, + attributes: { + name: { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 30, type: 'integer' }, + user: { value: 'user-123', type: 'string' }, + action: { value: 'custom-key-test', type: 'string' }, + message: { value: 'Custom message key', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'Custom error key', + trace_id: expect.any(String), + severity_number: 17, + attributes: { + name: { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 50, type: 'integer' }, + message: { value: 'Custom error key', type: 'string' }, + error: { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }, + }, + ], + }, + }) + .start() + .completed(); + }); }); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts index 5a35991bfd4b..10981a84d103 100644 --- a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/test.ts @@ -1,6 +1,7 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { describe, expect, test } from 'vitest'; +import { conditionalTest } from '../../../utils'; import { createRunner } from '../../../utils/runner'; describe('OnUncaughtException integration', () => { @@ -101,4 +102,129 @@ describe('OnUncaughtException integration', () => { .start() .completed(); }); + + conditionalTest({ max: 18 })('Worker thread error handling Node 18', () => { + test('should capture uncaught worker thread errors - without childProcess integration', async () => { + await createRunner(__dirname, 'worker-thread/uncaught-worker.mjs') + .withInstrument(path.join(__dirname, 'worker-thread/instrument.mjs')) + .expect({ + event: { + level: 'fatal', + exception: { + values: [ + { + type: 'Error', + value: 'job failed', + mechanism: { + type: 'auto.node.onuncaughtexception', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + }); + + // childProcessIntegration only exists in Node 20+ + conditionalTest({ min: 20 })('Worker thread error handling Node 20+', () => { + test.each(['mjs', 'js'])('should not interfere with worker thread error handling ".%s"', async extension => { + const runner = createRunner(__dirname, `worker-thread/caught-worker.${extension}`) + .withFlags( + extension === 'mjs' ? '--import' : '--require', + path.join(__dirname, `worker-thread/instrument.${extension}`), + ) + .expect({ + event: { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'job failed', + mechanism: { + type: 'auto.child_process.worker_thread', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start(); + + await runner.completed(); + + const logs = runner.getLogs(); + + expect(logs).toEqual(expect.arrayContaining([expect.stringMatching(/^caught Error: job failed/)])); + }); + + test('should not interfere with worker thread error handling when required inline', async () => { + const runner = createRunner(__dirname, 'worker-thread/caught-worker-inline.js') + .expect({ + event: { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'job failed', + mechanism: { + type: 'auto.child_process.worker_thread', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start(); + + await runner.completed(); + + const logs = runner.getLogs(); + + expect(logs).toEqual(expect.arrayContaining([expect.stringMatching(/^caught Error: job failed/)])); + }); + + test('should capture uncaught worker thread errors', async () => { + await createRunner(__dirname, 'worker-thread/uncaught-worker.mjs') + .withInstrument(path.join(__dirname, 'worker-thread/instrument.mjs')) + .expect({ + event: { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'job failed', + mechanism: { + type: 'auto.child_process.worker_thread', + handled: false, + }, + stacktrace: { + frames: expect.any(Array), + }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker-inline.js b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker-inline.js new file mode 100644 index 000000000000..798d6725308b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker-inline.js @@ -0,0 +1,4 @@ +// reuse the same worker script as the other tests +// just now in one file +require('./instrument.js'); +require('./caught-worker.js'); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.js b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.js new file mode 100644 index 000000000000..a13112e06d92 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.js @@ -0,0 +1,23 @@ +const path = require('path'); +const { Worker } = require('worker_threads'); + +function runJob() { + const worker = new Worker(path.join(__dirname, 'job.js')); + return new Promise((resolve, reject) => { + worker.once('error', reject); + worker.once('exit', code => { + if (code) reject(new Error(`Worker exited with code ${code}`)); + else resolve(); + }); + }); +} + +runJob() + .then(() => { + // eslint-disable-next-line no-console + console.log('Job completed successfully'); + }) + .catch(err => { + // eslint-disable-next-line no-console + console.error('caught', err); + }); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.mjs b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.mjs new file mode 100644 index 000000000000..4e1750e36e71 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/caught-worker.mjs @@ -0,0 +1,24 @@ +import path from 'path'; +import { Worker } from 'worker_threads'; + +const __dirname = new URL('.', import.meta.url).pathname; + +function runJob() { + const worker = new Worker(path.join(__dirname, 'job.js')); + return new Promise((resolve, reject) => { + worker.once('error', reject); + worker.once('exit', code => { + if (code) reject(new Error(`Worker exited with code ${code}`)); + else resolve(); + }); + }); +} + +try { + await runJob(); + // eslint-disable-next-line no-console + console.log('Job completed successfully'); +} catch (err) { + // eslint-disable-next-line no-console + console.error('caught', err); +} diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.js b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.js new file mode 100644 index 000000000000..a2b13b91bce6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.js @@ -0,0 +1,9 @@ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + debug: false, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.mjs b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.mjs new file mode 100644 index 000000000000..9263fe27bce1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + debug: false, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/job.js b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/job.js new file mode 100644 index 000000000000..b904a77813ac --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/job.js @@ -0,0 +1 @@ +throw new Error('job failed'); diff --git a/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/uncaught-worker.mjs b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/uncaught-worker.mjs new file mode 100644 index 000000000000..eceff3cffa77 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/OnUncaughtException/worker-thread/uncaught-worker.mjs @@ -0,0 +1,17 @@ +import path from 'path'; +import { Worker } from 'worker_threads'; + +const __dirname = new URL('.', import.meta.url).pathname; + +function runJob() { + const worker = new Worker(path.join(__dirname, 'job.js')); + return new Promise((resolve, reject) => { + worker.once('error', reject); + worker.once('exit', code => { + if (code) reject(new Error(`Worker exited with code ${code}`)); + else resolve(); + }); + }); +} + +await runJob(); diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts index 8d02a1fcd17c..170d4b96e279 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/scenario.ts @@ -22,6 +22,12 @@ async function run(): Promise { Sentry.setUser({ id: 'user-123', email: 'test@example.com', username: 'testuser' }); Sentry.metrics.count('test.user.counter', 1, { attributes: { action: 'click' } }); + Sentry.withScope(scope => { + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + Sentry.metrics.count('test.scope.attributes.counter', 1, { attributes: { action: 'click' } }); + }); + await Sentry.flush(); } diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts index 471fe114fa1e..ff67b73e9ad3 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/test.ts @@ -86,6 +86,60 @@ describe('metrics', () => { 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, + { + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + name: 'test.scope.attributes.counter', + type: 'counter', + value: 1, + attributes: { + action: { + type: 'string', + value: 'click', + }, + scope_attribute_1: { + type: 'integer', + value: 1, + }, + scope_attribute_2: { + type: 'string', + value: 'test', + }, + scope_attribute_3: { + type: 'integer', + unit: 'gigabyte', + value: 38, + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: '1.0.0', + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.node', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'user.email': { + type: 'string', + value: 'test@example.com', + }, + 'user.id': { + type: 'string', + value: 'user-123', + }, + 'user.name': { + type: 'string', + value: 'testuser', + }, + }, + }, ], }, }) diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs index 274a4ce9e3a9..dfd664fbf01f 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/worker-block.mjs @@ -2,4 +2,4 @@ import { longWork } from './long-work.js'; setTimeout(() => { longWork(); -}, 2000); +}, 5000); diff --git a/dev-packages/node-integration-tests/suites/tracing/dsc-txn-name-update/test.ts b/dev-packages/node-integration-tests/suites/tracing/dsc-txn-name-update/test.ts index aa74bea7d79e..ddac08fe1b21 100644 --- a/dev-packages/node-integration-tests/suites/tracing/dsc-txn-name-update/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/dsc-txn-name-update/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; test('adds current transaction name to baggage when the txn name is high-quality', async () => { expect.assertions(5); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts index 1b599def6be6..1cc06ba6f21e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-basic/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('captures spans for outgoing fetch requests', async () => { expect.assertions(3); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts index 8d0a35a43d05..6092e212df08 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('adds requestHook and responseHook attributes to spans of outgoing fetch requests', async () => { expect.assertions(3); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts index 580a63a52e90..8eea877dc72e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-strip-query/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('strips and handles query params in spans of outgoing fetch requests', async () => { expect.assertions(4); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts index bb21f7def8f0..0549d7e914c0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-basic/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('captures spans for outgoing http requests', async () => { expect.assertions(3); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts index edfac9fe2081..94ccd6c9702a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/http-strip-query/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; test('strips and handles query params in spans of outgoing http requests', async () => { expect.assertions(4); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 15c354e45533..ac0ac3780a38 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; function getCommonHttpRequestHeaders(): Record { return { diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs new file mode 100644 index 000000000000..415d85215278 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-thread-id.mjs @@ -0,0 +1,67 @@ +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/node'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'langgraph-thread-id-test' }, async () => { + // Define a simple mock LLM function + 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, + }, + }, + }, + ], + }; + }; + + // Create and compile the graph + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addEdge(START, 'agent') + .addEdge('agent', END) + .compile({ name: 'thread_test_agent' }); + + // Test 1: Invoke with thread_id in config + await graph.invoke( + { + messages: [{ role: 'user', content: 'Hello with thread ID' }], + }, + { + configurable: { + thread_id: 'thread_abc123_session_1', + }, + }, + ); + + // Test 2: Invoke with different thread_id (simulating different conversation) + await graph.invoke( + { + messages: [{ role: 'user', content: 'Different conversation' }], + }, + { + configurable: { + thread_id: 'thread_xyz789_session_2', + }, + }, + ); + + // Test 3: Invoke without thread_id (should not have gen_ai.conversation.id) + await graph.invoke({ + messages: [{ role: 'user', content: 'No thread ID here' }], + }); + }); + + 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 6a67b5cd1e86..bafcdf49a32c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -205,4 +205,72 @@ describe('LangGraph integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_WITH_TOOLS }).start().completed(); }); }); + + // Test for thread_id (conversation ID) support + const EXPECTED_TRANSACTION_THREAD_ID = { + transaction: 'langgraph-thread-id-test', + spans: expect.arrayContaining([ + // create_agent span + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'create_agent', + 'sentry.op': 'gen_ai.create_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'thread_test_agent', + }, + description: 'create_agent thread_test_agent', + op: 'gen_ai.create_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // First invoke_agent span with thread_id + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'thread_test_agent', + 'gen_ai.pipeline.name': 'thread_test_agent', + // The thread_id should be captured as conversation.id + 'gen_ai.conversation.id': 'thread_abc123_session_1', + }), + description: 'invoke_agent thread_test_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // Second invoke_agent span with different thread_id + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'thread_test_agent', + 'gen_ai.pipeline.name': 'thread_test_agent', + // Different thread_id for different conversation + 'gen_ai.conversation.id': 'thread_xyz789_session_2', + }), + description: 'invoke_agent thread_test_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + // Third invoke_agent span without thread_id (should NOT have gen_ai.conversation.id) + expect.objectContaining({ + data: expect.not.objectContaining({ + 'gen_ai.conversation.id': expect.anything(), + }), + description: 'invoke_agent thread_test_agent', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-thread-id.mjs', 'instrument.mjs', (createRunner, test) => { + test('should capture thread_id as gen_ai.conversation.id', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_THREAD_ID }).start().completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts index 98dab6e77b86..ac40fbe94249 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts @@ -182,6 +182,7 @@ describe('OpenAI Tool Calls integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', @@ -212,6 +213,7 @@ describe('OpenAI Tool Calls integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', @@ -241,6 +243,7 @@ describe('OpenAI Tool Calls integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', @@ -270,6 +273,7 @@ describe('OpenAI Tool Calls integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, 'gen_ai.response.model': 'gpt-4', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs new file mode 100644 index 000000000000..7088a6ca9cbe --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-conversation.mjs @@ -0,0 +1,95 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + // Conversations API endpoint - create conversation + app.post('/openai/conversations', (req, res) => { + res.send({ + id: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + object: 'conversation', + created_at: 1704067200, + metadata: {}, + }); + }); + + // Responses API endpoint - with conversation support + app.post('/openai/responses', (req, res) => { + const { model, conversation, previous_response_id } = req.body; + + res.send({ + id: 'resp_mock_conv_123', + object: 'response', + created_at: 1704067210, + model: model, + output: [ + { + type: 'message', + id: 'msg_mock_output_1', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: `Response with conversation: ${conversation || 'none'}, previous_response_id: ${previous_response_id || 'none'}`, + annotations: [], + }, + ], + }, + ], + output_text: `Response with conversation: ${conversation || 'none'}`, + status: 'completed', + usage: { + input_tokens: 10, + output_tokens: 15, + total_tokens: 25, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'conversation-test' }, async () => { + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // Test 1: Create a conversation + const conversation = await client.conversations.create(); + + // Test 2: Use conversation ID in responses.create + await client.responses.create({ + model: 'gpt-4', + input: 'Hello, this is a conversation test', + conversation: conversation.id, + }); + + // Test 3: Use previous_response_id for chaining (without formal conversation) + const firstResponse = await client.responses.create({ + model: 'gpt-4', + input: 'Tell me a joke', + }); + + await client.responses.create({ + model: 'gpt-4', + input: 'Explain why that is funny', + previous_response_id: firstResponse.id, + }); + }); + + 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 d56bb27f6a24..4d41b34b8c31 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -159,6 +159,7 @@ describe('OpenAI integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -214,6 +215,7 @@ describe('OpenAI integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', }, description: 'chat error-model', @@ -231,6 +233,7 @@ describe('OpenAI integration', () => { 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Tell me about streaming"}]', 'gen_ai.response.text': 'Hello from OpenAI streaming!', @@ -287,6 +290,7 @@ describe('OpenAI integration', () => { 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'error-model', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', @@ -645,4 +649,75 @@ describe('OpenAI integration', () => { }); }, ); + + // Test for conversation ID support (Conversations API and previous_response_id) + const EXPECTED_TRANSACTION_CONVERSATION = { + transaction: 'conversation-test', + spans: expect.arrayContaining([ + // First span - conversations.create returns conversation object with id + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'conversations', + 'sentry.op': 'gen_ai.conversations', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + // The conversation ID should be captured from the response + 'gen_ai.conversation.id': 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + }), + description: 'conversations unknown', + op: 'gen_ai.conversations', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - responses.create with conversation parameter + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + // The conversation ID should be captured from the request + 'gen_ai.conversation.id': 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + }), + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Third span - responses.create without conversation (first in chain, should NOT have gen_ai.conversation.id) + expect.objectContaining({ + data: expect.not.objectContaining({ + 'gen_ai.conversation.id': expect.anything(), + }), + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Fourth span - responses.create with previous_response_id (chaining) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + // The previous_response_id should be captured as conversation.id + 'gen_ai.conversation.id': 'resp_mock_conv_123', + }), + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-conversation.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures conversation ID from Conversations API and previous_response_id', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_CONVERSATION }) + .start() + .completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts index 23520852f070..3784fb7e4631 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts @@ -159,6 +159,7 @@ describe('OpenAI integration (V6)', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -214,6 +215,7 @@ describe('OpenAI integration (V6)', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', }, description: 'chat error-model', @@ -231,6 +233,7 @@ describe('OpenAI integration (V6)', () => { 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.temperature': 0.8, 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 2, 'gen_ai.request.messages': '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Tell me about streaming"}]', 'gen_ai.response.text': 'Hello from OpenAI streaming!', @@ -287,6 +290,7 @@ describe('OpenAI integration (V6)', () => { 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'error-model', 'gen_ai.request.stream': true, + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', 'gen_ai.system': 'openai', 'sentry.op': 'gen_ai.chat', @@ -306,6 +310,7 @@ describe('OpenAI integration (V6)', () => { // Check that custom options are respected expect.objectContaining({ data: expect.objectContaining({ + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true }), @@ -313,6 +318,7 @@ describe('OpenAI integration (V6)', () => { // Check that custom options are respected for streaming expect.objectContaining({ data: expect.objectContaining({ + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true 'gen_ai.request.stream': true, // Should be marked as stream diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts index d3315ae86ece..2691d10294a5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-breadcrumbs/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts index 61dfe4c4ba88..7781f01f4605 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing-no-spans/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts index 046763a0b55a..2f0bfd410663 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts index acc1d6c89a25..702a2febd61d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts index 4507a360006c..8458d25728d0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing fetch', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts index 318d4628453b..96892353d2dd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts index 1cad4abf9a99..17393f21a8a4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing-no-spans/test.ts @@ -1,7 +1,7 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { conditionalTest } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http requests with tracing & spans disabled', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts index d0b13513d1de..4f6593f82e34 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts index 932f379ec23e..b00148c26142 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts index 9a7b13a34332..ebd6198bfd4e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts index 28fb877d0425..1b40af0b6ec3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing http', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/traceparent/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/traceparent/test.ts index 2cdb4cfd1aa7..517ea314dfc5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/traceparent/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/traceparent/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; import { createEsmAndCjsTests } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; describe('outgoing traceparent', () => { createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts index 9fb39a1ec8f2..dc5105c9d1bb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/test.ts @@ -1,6 +1,6 @@ +import { createTestServer } from '@sentry-internal/test-utils'; import { expect, test } from 'vitest'; import { createRunner } from '../../../utils/runner'; -import { createTestServer } from '../../../utils/server'; test('HttpIntegration should instrument correct requests when tracePropagationTargets option is provided', async () => { expect.assertions(11); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-late-model-id.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-late-model-id.mjs new file mode 100644 index 000000000000..05b8190cc0b4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-late-model-id.mjs @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; + +// Custom mock model that doesn't set modelId initially (simulates late model ID setting) +// This tests that the op is correctly set even when model ID is not available at span start. +// The span name update (e.g., 'generate_text gpt-4') is skipped when model ID is missing.t +class LateModelIdMock { + specificationVersion = 'v1'; + provider = 'late-model-provider'; + // modelId is intentionally undefined initially to simulate late setting + modelId = undefined; + defaultObjectGenerationMode = 'json'; + + async doGenerate() { + // Model ID is only "available" during generation, not at span start + this.modelId = 'late-mock-model-id'; + + return { + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 5, completionTokens: 10 }, + text: 'Response from late model!', + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new LateModelIdMock(), + prompt: 'Test prompt for late model ID', + }); + }); +} + +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 de228303ab0e..8112bcadd5f5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -67,6 +67,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -95,6 +96,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.request.messages': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.response.id': expect.any(String), @@ -205,6 +207,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the first span?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -237,6 +240,7 @@ describe('Vercel AI integration', () => { // Second span - doGenerate for first call, should also include input/output fields when sendDefaultPii: true expect.objectContaining({ data: { + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], @@ -275,6 +279,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -308,6 +313,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.request.messages': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.response.id': expect.any(String), @@ -345,6 +351,7 @@ describe('Vercel AI integration', () => { expect.objectContaining({ data: { 'gen_ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in San Francisco?"}]', 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.model': 'mock-model-id', @@ -380,6 +387,7 @@ describe('Vercel AI integration', () => { data: { 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, 'gen_ai.request.messages': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['tool-calls'], 'gen_ai.response.id': expect.any(String), @@ -699,4 +707,40 @@ describe('Vercel AI integration', () => { expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-late-model-id.mjs', 'instrument.mjs', (createRunner, test) => { + test('sets op correctly even when model ID is not available at span start', async () => { + const expectedTransaction = { + transaction: 'main', + spans: expect.arrayContaining([ + // The generateText span should have the correct op even though model ID was not available at span start + expect.objectContaining({ + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + data: expect.objectContaining({ + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + 'gen_ai.operation.name': 'ai.generateText', + }), + }), + // The doGenerate span - name stays as 'generateText.doGenerate' since model ID is missing + expect.objectContaining({ + description: 'generateText.doGenerate', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + data: expect.objectContaining({ + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + }), + }), + ]), + }; + + await createRunner().expect({ transaction: expectedTransaction }).start().completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts index 01aa715bdc77..179644bbcd73 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts @@ -75,6 +75,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.settings.maxRetries': 2, 'vercel.ai.streaming': false, 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'gen_ai.response.model': 'mock-model-id', 'gen_ai.usage.input_tokens': 10, @@ -107,6 +108,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.response.id': expect.any(String), 'gen_ai.response.text': expect.any(String), 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, @@ -205,6 +207,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.operationId': 'ai.generateText', 'vercel.ai.pipeline.name': 'generateText', 'vercel.ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the first span?"}]', 'vercel.ai.response.finishReason': 'stop', 'gen_ai.response.text': 'First span here!', @@ -231,6 +234,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.model.provider': 'mock-provider', 'vercel.ai.operationId': 'ai.generateText.doGenerate', 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', 'vercel.ai.response.finishReason': 'stop', 'vercel.ai.response.id': expect.any(String), @@ -263,6 +267,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.operationId': 'ai.generateText', 'vercel.ai.pipeline.name': 'generateText', 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', 'vercel.ai.response.finishReason': 'stop', 'gen_ai.response.text': expect.any(String), @@ -300,6 +305,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.response.id': expect.any(String), 'gen_ai.response.text': expect.any(String), 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, @@ -321,6 +327,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.operationId': 'ai.generateText', 'vercel.ai.pipeline.name': 'generateText', 'vercel.ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'gen_ai.request.messages.original_length': 1, 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in San Francisco?"}]', 'vercel.ai.response.finishReason': 'tool-calls', 'gen_ai.response.tool_calls': expect.any(String), @@ -347,6 +354,7 @@ describe('Vercel AI integration (V5)', () => { 'vercel.ai.model.provider': 'mock-provider', 'vercel.ai.operationId': 'ai.generateText.doGenerate', 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'gen_ai.request.messages.original_length': expect.any(Number), 'gen_ai.request.messages': expect.any(String), 'vercel.ai.prompt.toolChoice': expect.any(String), 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument-with-pii.mjs new file mode 100644 index 000000000000..b798e21228f5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument-with-pii.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, + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument.mjs new file mode 100644 index 000000000000..5e898ee1949d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/instrument.mjs @@ -0,0 +1,10 @@ +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, + transport: loggingTransport, + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..9ea18401ac35 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario-error-in-tool.mjs @@ -0,0 +1,41 @@ +import * as Sentry from '@sentry/node'; +import { generateText, tool } from 'ai'; +import { MockLanguageModelV3 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 15, noCache: 15, cached: 0 }, + outputTokens: { total: 25, noCache: 25, cached: 0 }, + totalTokens: { total: 40, noCache: 40, cached: 0 }, + }, + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getWeather', + input: JSON.stringify({ location: 'San Francisco' }), + }, + ], + warnings: [], + }), + }), + tools: { + getWeather: tool({ + inputSchema: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }), + }, + prompt: 'What is the weather in San Francisco?', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario.mjs new file mode 100644 index 000000000000..66233d1dabe5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/scenario.mjs @@ -0,0 +1,92 @@ +import * as Sentry from '@sentry/node'; +import { generateText, tool } from 'ai'; +import { MockLanguageModelV3 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, + }, + content: [{ type: 'text', text: 'First span here!' }], + warnings: [], + }), + }), + prompt: 'Where is the first span?', + }); + + // This span should have input and output prompts attached because telemetry is explicitly enabled. + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, + }, + content: [{ type: 'text', text: 'Second span here!' }], + warnings: [], + }), + }), + prompt: 'Where is the second span?', + }); + + // This span should include tool calls and tool results + await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 15, noCache: 15, cached: 0 }, + outputTokens: { total: 25, noCache: 25, cached: 0 }, + totalTokens: { total: 40, noCache: 40, cached: 0 }, + }, + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getWeather', + input: JSON.stringify({ location: 'San Francisco' }), + }, + ], + warnings: [], + }), + }), + tools: { + getWeather: tool({ + inputSchema: z.object({ location: z.string() }), + execute: async ({ location }) => `Weather in ${location}: Sunny, 72°F`, + }), + }, + prompt: 'What is the weather in San Francisco?', + }); + + // This span should not be captured because we've disabled telemetry + await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV3({ + doGenerate: async () => ({ + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 10, noCache: 10, cached: 0 }, + outputTokens: { total: 20, noCache: 20, cached: 0 }, + totalTokens: { total: 30, noCache: 30, cached: 0 }, + }, + content: [{ type: 'text', text: 'Third span here!' }], + warnings: [], + }), + }), + prompt: 'Where is the third span?', + }); + }); +} + +run(); 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 new file mode 100644 index 000000000000..98a16618d77d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6/test.ts @@ -0,0 +1,577 @@ +import type { Event } from '@sentry/node'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('Vercel AI integration (V6)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - no telemetry config, should enable telemetry but not record inputs/outputs when sendDefaultPii: false + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Second span - explicitly enabled telemetry but recordInputs/recordOutputs not set, should not record when sendDefaultPii: false + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_text', + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Third span - explicit telemetry enabled, should record inputs/outputs regardless of sendDefaultPii + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'stop', + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fourth span - doGenerate for explicit telemetry enabled call + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_text', + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fifth span - tool call generateText span + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Sixth span - tool call doGenerate span + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Seventh span - tool call execution span + expect.objectContaining({ + data: expect.objectContaining({ + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'gen_ai.operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + const EXPECTED_AVAILABLE_TOOLS_JSON = + '[{"type":"function","name":"getWeather","inputSchema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"location":{"type":"string"}},"required":["location"],"additionalProperties":false}}]'; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - no telemetry config, should enable telemetry AND record inputs/outputs when sendDefaultPii: true + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the first span?"}', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': '[{"role":"user","content":"Where is the first span?"}]', + 'vercel.ai.response.finishReason': 'stop', + 'gen_ai.response.text': 'First span here!', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Second span - doGenerate for first call, should also include input/output fields when sendDefaultPii: true + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'gen_ai.response.text': 'First span here!', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Third span - explicitly enabled telemetry, should record inputs/outputs regardless of sendDefaultPii + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': '[{"role":"user","content":"Where is the second span?"}]', + 'vercel.ai.response.finishReason': 'stop', + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fourth span - doGenerate for explicitly enabled telemetry call + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_text', + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fifth span - tool call generateText span (should include prompts when sendDefaultPii: true) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in San Francisco?"}]', + 'vercel.ai.response.finishReason': 'tool-calls', + 'gen_ai.response.tool_calls': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Sixth span - tool call doGenerate span (should include prompts when sendDefaultPii: true) + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'gen_ai.request.messages': expect.any(String), + 'vercel.ai.prompt.toolChoice': expect.any(String), + 'gen_ai.request.available_tools': EXPECTED_AVAILABLE_TOOLS_JSON, + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + // 'gen_ai.response.text': 'Tool call completed!', // TODO: look into why this is not being set + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.response.tool_calls': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Seventh span - tool call execution span + expect.objectContaining({ + data: expect.objectContaining({ + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.input': expect.any(String), + 'gen_ai.tool.output': expect.any(String), + 'gen_ai.tool.type': 'function', + 'gen_ai.operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates ai related spans with sendDefaultPii: false', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('creates ai related spans with sendDefaultPii: true', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-error-in-tool.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('captures error in tool', async () => { + const expectedTransaction = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + 'vercel.ai.response.finishReason': 'tool-calls', + }), + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.request.headers.user-agent': expect.any(String), + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'gen_ai.operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + expect.objectContaining({ + data: expect.objectContaining({ + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'gen_ai.operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }), + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'internal_error', + }), + ]), + }; + + const expectedError = { + level: 'error', + tags: expect.objectContaining({ + 'vercel.ai.tool.name': 'getWeather', + 'vercel.ai.tool.callId': 'call-1', + }), + }; + + let transactionEvent: Event | undefined; + let errorEvent: Event | undefined; + + await createRunner() + .expect({ + transaction: transaction => { + transactionEvent = transaction; + }, + }) + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start() + .completed(); + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent).toMatchObject(expectedTransaction); + + expect(errorEvent).toBeDefined(); + expect(errorEvent).toMatchObject(expectedError); + + // Trace id should be the same for the transaction and error event + expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates ai related spans with v6', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^6.0.0', + }, + }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts new file mode 100644 index 000000000000..51e1b4d09ccf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/scenario.ts @@ -0,0 +1,36 @@ +import type { BaseTransportOptions, Envelope, Transport, TransportMakeRequestResponse } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +function bufferedLoggingTransport(_options: BaseTransportOptions): Transport { + const bufferedEnvelopes: Envelope[] = []; + + return { + send(envelope: Envelope): Promise { + bufferedEnvelopes.push(envelope); + return Promise.resolve({ statusCode: 200 }); + }, + flush(_timeout?: number): PromiseLike { + // Print envelopes once flushed to verify they were sent. + for (const envelope of bufferedEnvelopes.splice(0, bufferedEnvelopes.length)) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(envelope)); + } + + return Promise.resolve(true); + }, + }; +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: bufferedLoggingTransport, +}); + +Sentry.captureMessage('SIGTERM flush message'); + +// Signal that we're ready to receive SIGTERM. +// eslint-disable-next-line no-console +console.log('READY'); + +// Keep the process alive so the integration test can send SIGTERM. +setInterval(() => undefined, 1_000); diff --git a/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts new file mode 100644 index 000000000000..d605895555ae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/vercel/sigterm-flush/test.ts @@ -0,0 +1,39 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('flushes buffered events when SIGTERM is received on Vercel', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .withEnv({ VERCEL: '1' }) + .expect({ + event: { + message: 'SIGTERM flush message', + }, + }) + .start(); + + // Wait for the scenario to signal it's ready (SIGTERM handler is registered). + const waitForReady = async (): Promise => { + const maxWait = 10_000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + if (runner.getLogs().some(line => line.includes('READY'))) { + return; + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error('Timed out waiting for scenario to be ready'); + }; + + await waitForReady(); + + runner.sendSignal('SIGTERM'); + + await runner.completed(); + + // Check that the child didn't crash (it may be killed by the runner after completion). + expect(runner.getLogs().join('\n')).not.toMatch(/Error starting child process/i); +}); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index 97c4021ccc89..985db0a80e6c 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -172,6 +172,7 @@ type StartResult = { childHasExited(): boolean; getLogs(): string[]; getPort(): number | undefined; + sendSignal(signal: NodeJS.Signals): void; makeRequest( method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string, @@ -668,6 +669,9 @@ export function createRunner(...paths: string[]) { getPort(): number | undefined { return scenarioServerPort; }, + sendSignal(signal: NodeJS.Signals): void { + child?.kill(signal); + }, makeRequest: async function ( method: 'get' | 'post' | 'put' | 'delete' | 'patch', path: string, diff --git a/dev-packages/node-integration-tests/utils/server.ts b/dev-packages/node-integration-tests/utils/server.ts deleted file mode 100644 index a1ba3f522fb1..000000000000 --- a/dev-packages/node-integration-tests/utils/server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import express from 'express'; -import type { AddressInfo } from 'net'; - -type HeaderAssertCallback = (headers: Record) => void; - -/** Creates a test server that can be used to check headers */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function createTestServer() { - const gets: Array<[string, HeaderAssertCallback, number]> = []; - let error: unknown | undefined; - - return { - get: function (path: string, callback: HeaderAssertCallback, result = 200) { - gets.push([path, callback, result]); - return this; - }, - start: async (): Promise<[string, () => void]> => { - const app = express(); - - for (const [path, callback, result] of gets) { - app.get(path, (req, res) => { - try { - callback(req.headers); - } catch (e) { - error = e; - } - - res.status(result).send(); - }); - } - - return new Promise(resolve => { - const server = app.listen(0, () => { - const address = server.address() as AddressInfo; - resolve([ - `http://localhost:${address.port}`, - () => { - server.close(); - if (error) { - throw error; - } - }, - ]); - }); - }); - }, - }; -} diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 08fa39db950f..9c411c3fc015 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -1,5 +1,12 @@ /* eslint-disable max-lines */ -import type { Envelope, EnvelopeItem, Event, SerializedSession } from '@sentry/core'; +import type { + Envelope, + EnvelopeItem, + Event, + SerializedMetric, + SerializedMetricContainer, + SerializedSession, +} from '@sentry/core'; import { parseEnvelope } from '@sentry/core'; import * as fs from 'fs'; import * as http from 'http'; @@ -391,6 +398,35 @@ export function waitForTransaction( }); } +/** + * Wait for metric items to be sent. + */ +export function waitForMetric( + proxyServerName: string, + callback: (metricEvent: SerializedMetric) => Promise | boolean, +): Promise { + const timestamp = getNanosecondTimestamp(); + return new Promise((resolve, reject) => { + waitForEnvelopeItem( + proxyServerName, + async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + const metricContainer = envelopeItemBody as SerializedMetricContainer; + if (envelopeItemHeader.type === 'trace_metric') { + for (const metric of metricContainer.items) { + if (await callback(metric)) { + resolve(metric); + return true; + } + } + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + 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 e9ae76f592ed..4a3dfcfaa4c8 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -7,7 +7,8 @@ export { waitForTransaction, waitForSession, waitForPlainRequest, + waitForMetric, } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; -export { createBasicSentryServer } from './server'; +export { createBasicSentryServer, createTestServer } from './server'; diff --git a/dev-packages/test-utils/src/server.ts b/dev-packages/test-utils/src/server.ts index b8941b4b0c32..92e0477c845c 100644 --- a/dev-packages/test-utils/src/server.ts +++ b/dev-packages/test-utils/src/server.ts @@ -37,3 +37,49 @@ export function createBasicSentryServer(onEnvelope: (env: Envelope) => void): Pr }); }); } + +type HeaderAssertCallback = (headers: Record) => void; + +/** Creates a test server that can be used to check headers */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createTestServer() { + const gets: Array<[string, HeaderAssertCallback, number]> = []; + let error: unknown | undefined; + + return { + get: function (path: string, callback: HeaderAssertCallback, result = 200) { + gets.push([path, callback, result]); + return this; + }, + start: async (): Promise<[string, () => void]> => { + const app = express(); + + for (const [path, callback, result] of gets) { + app.get(path, (req, res) => { + try { + callback(req.headers); + } catch (e) { + error = e; + } + + res.status(result).send(); + }); + } + + return new Promise(resolve => { + const server = app.listen(0, () => { + const address = server.address() as AddressInfo; + resolve([ + `http://localhost:${address.port}`, + () => { + server.close(); + if (error) { + throw error; + } + }, + ]); + }); + }); + }, + }; +} diff --git a/package.json b/package.json index b0f32aa93a50..c92a18b0dfe1 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,8 @@ "gauge/strip-ansi": "6.0.1", "wide-align/string-width": "4.2.3", "cliui/wrap-ansi": "7.0.0", - "sucrase": "getsentry/sucrase#es2020-polyfills" + "sucrase": "getsentry/sucrase#es2020-polyfills", + "import-in-the-middle": "2.0.1" }, "version": "0.0.0", "name": "sentry-javascript", diff --git a/packages/astro/package.json b/packages/astro/package.json index 3f588bd9c414..cb7ea61b933a 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -59,7 +59,7 @@ "@sentry/browser": "10.32.1", "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", - "@sentry/vite-plugin": "^4.1.0" + "@sentry/vite-plugin": "^4.6.1" }, "devDependencies": { "astro": "^3.5.0", diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index 4012d4118ad3..084d17becb8d 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -218,6 +218,7 @@ export function listenForWebVitalReportEvents( collected = true; } + // eslint-disable-next-line deprecation/deprecation onHidden(() => { _runCollectorCallbackOnce('pagehide'); }); diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts index d9dc2f6718ed..f48346e5e46d 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts @@ -21,16 +21,28 @@ export interface OnHiddenCallback { (event: Event): void; } -// Sentry-specific change: -// This function's logic was NOT updated to web-vitals 4.2.4 or 5.x but we continue -// to use the web-vitals 3.5.2 versiondue to us having stricter browser support. -// PR with context that made the changes: https://github.com/GoogleChrome/web-vitals/pull/442/files#r1530492402 -// The PR removed listening to the `pagehide` event, in favour of only listening to `visibilitychange` event. -// This is "more correct" but some browsers we still support (Safari <14.4) don't fully support `visibilitychange` -// or have known bugs w.r.t the `visibilitychange` event. -// TODO (v11): If we decide to drop support for Safari 14.4, we can use the logic from web-vitals 4.2.4 -// In this case, we also need to update the integration tests that currently trigger the `pagehide` event to -// simulate the page being hidden. +/** + * Sentry-specific change: + * + * This function's logic was NOT updated to web-vitals 4.2.4 or 5.x but we continue + * to use the web-vitals 3.5.2 version due to having stricter browser support. + * + * PR with context that made the changes: + * https://github.com/GoogleChrome/web-vitals/pull/442/files#r1530492402 + * + * The PR removed listening to the `pagehide` event, in favour of only listening to + * the `visibilitychange` event. This is "more correct" but some browsers we still + * support (Safari <14.4) don't fully support `visibilitychange` or have known bugs + * with respect to the `visibilitychange` event. + * + * TODO (v11): If we decide to drop support for Safari 14.4, we can use the logic + * from web-vitals 4.2.4. In this case, we also need to update the integration tests + * that currently trigger the `pagehide` event to simulate the page being hidden. + * + * @param {OnHiddenCallback} cb - Callback to be executed when the page is hidden or unloaded. + * + * @deprecated use `whenIdleOrHidden` or `addPageListener('visibilitychange')` instead + */ export const onHidden = (cb: OnHiddenCallback) => { const onHiddenOrPageHide = (event: Event) => { if (event.type === 'pagehide' || WINDOW.document?.visibilityState === 'hidden') { @@ -38,8 +50,8 @@ export const onHidden = (cb: OnHiddenCallback) => { } }; - addPageListener('visibilitychange', onHiddenOrPageHide, true); + addPageListener('visibilitychange', onHiddenOrPageHide, { capture: true, once: true }); // Some browsers have buggy implementations of visibilitychange, // so we use pagehide in addition, just to be safe. - addPageListener('pagehide', onHiddenOrPageHide, true); + addPageListener('pagehide', onHiddenOrPageHide, { capture: true, once: true }); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts index 008aac8dc4c2..0bf8b2ce5894 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts @@ -16,7 +16,6 @@ import { WINDOW } from '../../../types.js'; import { addPageListener, removePageListener } from './globalListeners.js'; -import { onHidden } from './onHidden.js'; import { runOnce } from './runOnce.js'; /** @@ -34,15 +33,18 @@ export const whenIdleOrHidden = (cb: () => void) => { // eslint-disable-next-line no-param-reassign cb = runOnce(cb); addPageListener('visibilitychange', cb, { once: true, capture: true }); + // sentry: we use pagehide instead of directly listening to visibilitychange + // because some browsers we still support (Safari <14.4) don't fully support + // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. + // TODO(v11): remove this once we drop support for Safari <14.4 + addPageListener('pagehide', cb, { once: true, capture: true }); rIC(() => { cb(); // Remove the above event listener since no longer required. // See: https://github.com/GoogleChrome/web-vitals/issues/622 removePageListener('visibilitychange', cb, { capture: true }); + // TODO(v11): remove this once we drop support for Safari <14.4 + removePageListener('pagehide', cb, { capture: true }); }); - // sentry: we use onHidden instead of directly listening to visibilitychange - // because some browsers we still support (Safari <14.4) don't fully support - // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. - onHidden(cb); } }; diff --git a/packages/browser/src/diagnose-sdk.ts b/packages/browser/src/diagnose-sdk.ts index 0ad4bef69d6c..0a5fdd0da05b 100644 --- a/packages/browser/src/diagnose-sdk.ts +++ b/packages/browser/src/diagnose-sdk.ts @@ -22,23 +22,28 @@ export async function diagnoseSdkConnectivity(): Promise< return 'no-dsn-configured'; } + // Check if a tunnel is configured and use it if available + const tunnel = client.getOptions().tunnel; + + // We are using the + // - "sentry-sdks" org with id 447951 not to pollute any actual organizations. + // - "diagnose-sdk-connectivity" project with id 4509632503087104 + // - the public key of said org/project, which is disabled in the project settings + // => this DSN: https://c1dfb07d783ad5325c245c1fd3725390@o447951.ingest.us.sentry.io/4509632503087104 (i.e. disabled) + const defaultUrl = + 'https://o447951.ingest.sentry.io/api/4509632503087104/envelope/?sentry_version=7&sentry_key=c1dfb07d783ad5325c245c1fd3725390&sentry_client=sentry.javascript.browser%2F1.33.7'; + + const url = tunnel || defaultUrl; + try { await suppressTracing(() => // If fetch throws, there is likely an ad blocker active or there are other connective issues. - fetch( - // We are using the - // - "sentry-sdks" org with id 447951 not to pollute any actual organizations. - // - "diagnose-sdk-connectivity" project with id 4509632503087104 - // - the public key of said org/project, which is disabled in the project settings - // => this DSN: https://c1dfb07d783ad5325c245c1fd3725390@o447951.ingest.us.sentry.io/4509632503087104 (i.e. disabled) - 'https://o447951.ingest.sentry.io/api/4509632503087104/envelope/?sentry_version=7&sentry_key=c1dfb07d783ad5325c245c1fd3725390&sentry_client=sentry.javascript.browser%2F1.33.7', - { - body: '{}', - method: 'POST', - mode: 'cors', - credentials: 'omit', - }, - ), + fetch(url, { + body: '{}', + method: 'POST', + mode: 'cors', + credentials: 'omit', + }), ); } catch { return 'sentry-unreachable'; diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index e95e161e703c..5af6c3b2553a 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -10,6 +10,7 @@ export const INTEGRATION_NAME = 'WebWorker'; interface WebWorkerMessage { _sentryMessage: boolean; _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any _sentryWorkerError?: SerializedWorkerError; } @@ -122,6 +123,18 @@ function listenForSentryMessages(worker: Worker): void { }; } + // Handle module metadata + if (event.data._sentryModuleMetadata) { + DEBUG_BUILD && debug.log('Sentry module metadata web worker message received', event.data); + // Merge worker's raw metadata into the global object + // It will be parsed lazily when needed by getMetadataForUrl + WINDOW._sentryModuleMetadata = { + ...event.data._sentryModuleMetadata, + // Module metadata of the main thread have precedence over the worker's in case of a collision. + ...WINDOW._sentryModuleMetadata, + }; + } + // Handle unhandled rejections forwarded from worker if (event.data._sentryWorkerError) { DEBUG_BUILD && debug.log('Sentry worker rejection message received', event.data._sentryWorkerError); @@ -187,7 +200,10 @@ interface MinimalDedicatedWorkerGlobalScope { } interface RegisterWebWorkerOptions { - self: MinimalDedicatedWorkerGlobalScope & { _sentryDebugIds?: Record }; + self: MinimalDedicatedWorkerGlobalScope & { + _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; // eslint-disable-line @typescript-eslint/no-explicit-any + }; } /** @@ -195,6 +211,7 @@ interface RegisterWebWorkerOptions { * * This function will: * - Send debug IDs to the parent thread + * - Send module metadata to the parent thread (for thirdPartyErrorFilterIntegration) * - Set up a handler for unhandled rejections in the worker * - Forward unhandled rejections to the parent thread for capture * @@ -215,10 +232,12 @@ interface RegisterWebWorkerOptions { * - `self`: The worker instance you're calling this function from (self). */ export function registerWebWorker({ self }: RegisterWebWorkerOptions): void { - // Send debug IDs to parent thread + // Send debug IDs and raw module metadata to parent thread + // The metadata will be parsed lazily on the main thread when needed self.postMessage({ _sentryMessage: true, _sentryDebugIds: self._sentryDebugIds ?? undefined, + _sentryModuleMetadata: self._sentryModuleMetadata ?? undefined, }); // Set up unhandledrejection handler inside the worker @@ -251,11 +270,12 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { return false; } - // Must have at least one of: debug IDs or worker error + // Must have at least one of: debug IDs, module metadata, or worker error const hasDebugIds = '_sentryDebugIds' in eventData; + const hasModuleMetadata = '_sentryModuleMetadata' in eventData; const hasWorkerError = '_sentryWorkerError' in eventData; - if (!hasDebugIds && !hasWorkerError) { + if (!hasDebugIds && !hasModuleMetadata && !hasWorkerError) { return false; } @@ -264,6 +284,14 @@ function isSentryMessage(eventData: unknown): eventData is WebWorkerMessage { return false; } + // Validate module metadata if present + if ( + hasModuleMetadata && + !(isPlainObject(eventData._sentryModuleMetadata) || eventData._sentryModuleMetadata === undefined) + ) { + return false; + } + // Validate worker error if present if (hasWorkerError && !isPlainObject(eventData._sentryWorkerError)) { return false; diff --git a/packages/browser/test/diagnose-sdk.test.ts b/packages/browser/test/diagnose-sdk.test.ts index 5bc05dc6cf56..85b60047361e 100644 --- a/packages/browser/test/diagnose-sdk.test.ts +++ b/packages/browser/test/diagnose-sdk.test.ts @@ -42,6 +42,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns "no-dsn-configured" when client.getDsn() returns undefined', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue(undefined), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); @@ -55,6 +56,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns "sentry-unreachable" when fetch throws an error', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockRejectedValue(new Error('Network error')); @@ -77,6 +79,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns "sentry-unreachable" when fetch throws a TypeError (common for network issues)', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); @@ -91,6 +94,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns undefined when connectivity check succeeds', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); @@ -113,6 +117,7 @@ describe('diagnoseSdkConnectivity', () => { it('returns undefined even when fetch returns an error status (4xx, 5xx)', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); // Mock a 403 response (expected since the DSN is disabled) @@ -129,6 +134,7 @@ describe('diagnoseSdkConnectivity', () => { it('uses the correct test endpoint URL', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); @@ -149,6 +155,7 @@ describe('diagnoseSdkConnectivity', () => { it('uses correct fetch options', async () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); @@ -168,6 +175,7 @@ describe('diagnoseSdkConnectivity', () => { const mockClient: Partial = { getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), }; mockGetClient.mockReturnValue(mockClient); mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); @@ -176,4 +184,72 @@ describe('diagnoseSdkConnectivity', () => { expect(suppressTracingSpy).toHaveBeenCalledTimes(1); }); + + it('uses tunnel URL when tunnel option is configured', async () => { + const tunnelUrl = '/monitor'; + const mockClient: Partial = { + getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({ tunnel: tunnelUrl }), + }; + mockGetClient.mockReturnValue(mockClient); + mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); + + const result = await diagnoseSdkConnectivity(); + + expect(result).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledWith( + tunnelUrl, + expect.objectContaining({ + body: '{}', + method: 'POST', + mode: 'cors', + credentials: 'omit', + }), + ); + }); + + it('uses default URL when tunnel is not configured', async () => { + const mockClient: Partial = { + getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({}), + }; + mockGetClient.mockReturnValue(mockClient); + mockFetch.mockResolvedValue(new Response('{}', { status: 200 })); + + const result = await diagnoseSdkConnectivity(); + + expect(result).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://o447951.ingest.sentry.io/api/4509632503087104/envelope/?sentry_version=7&sentry_key=c1dfb07d783ad5325c245c1fd3725390&sentry_client=sentry.javascript.browser%2F1.33.7', + expect.objectContaining({ + body: '{}', + method: 'POST', + mode: 'cors', + credentials: 'omit', + }), + ); + }); + + it('returns "sentry-unreachable" when tunnel is configured but unreachable', async () => { + const tunnelUrl = '/monitor'; + const mockClient: Partial = { + getDsn: vi.fn().mockReturnValue('https://test@example.com/123'), + getOptions: vi.fn().mockReturnValue({ tunnel: tunnelUrl }), + }; + mockGetClient.mockReturnValue(mockClient); + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await diagnoseSdkConnectivity(); + + expect(result).toBe('sentry-unreachable'); + expect(mockFetch).toHaveBeenCalledWith( + tunnelUrl, + expect.objectContaining({ + body: '{}', + method: 'POST', + mode: 'cors', + credentials: 'omit', + }), + ); + }); }); diff --git a/packages/browser/test/integrations/webWorker.test.ts b/packages/browser/test/integrations/webWorker.test.ts index b72895621339..584f18ee9a75 100644 --- a/packages/browser/test/integrations/webWorker.test.ts +++ b/packages/browser/test/integrations/webWorker.test.ts @@ -209,6 +209,97 @@ describe('webWorkerIntegration', () => { 'main.js': 'main-debug', }); }); + + it('processes module metadata from worker', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; + const moduleMetadata = { + 'Error\n at worker-file1.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + 'Error\n at worker-file2.js:2:2': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect(mockDebugLog).toHaveBeenCalledWith('Sentry module metadata web worker message received', mockEvent.data); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); + }); + + it('handles message with both debug IDs and module metadata', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; + const moduleMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryDebugIds: { 'worker-file.js': 'debug-id-1' }, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); + expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ + 'worker-file.js': 'debug-id-1', + }); + }); + + it('accepts message with only module metadata', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = undefined; + const moduleMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: moduleMetadata, + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).toHaveBeenCalled(); + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual(moduleMetadata); + }); + + it('ignores invalid module metadata', () => { + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: 'not-an-object', + }; + + messageHandler(mockEvent); + + expect(mockEvent.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + + it('gives main thread precedence over worker for conflicting module metadata', () => { + (helpers.WINDOW as any)._sentryModuleMetadata = { + 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' }, + 'Error\n at main-only.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true }, + }; + + mockEvent.data = { + _sentryMessage: true, + _sentryModuleMetadata: { + 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true, source: 'worker' }, + 'Error\n at worker-only.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true }, + }, + }; + + messageHandler(mockEvent); + + expect((helpers.WINDOW as any)._sentryModuleMetadata).toEqual({ + 'Error\n at shared-file.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true, source: 'main' }, // Main thread wins + 'Error\n at main-only.js:1:1': { '_sentryBundlerPluginAppKey:main-app': true }, // Main thread preserved + 'Error\n at worker-only.js:1:1': { '_sentryBundlerPluginAppKey:worker-app': true }, // Worker added + }); + }); }); }); }); @@ -218,6 +309,7 @@ describe('registerWebWorker', () => { postMessage: ReturnType; addEventListener: ReturnType; _sentryDebugIds?: Record; + _sentryModuleMetadata?: Record; }; beforeEach(() => { @@ -236,6 +328,7 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, }); }); @@ -254,6 +347,7 @@ describe('registerWebWorker', () => { 'worker-file1.js': 'debug-id-1', 'worker-file2.js': 'debug-id-2', }, + _sentryModuleMetadata: undefined, }); }); @@ -266,6 +360,57 @@ describe('registerWebWorker', () => { expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, + }); + }); + + it('includes raw module metadata when available', () => { + const rawMetadata = { + 'Error\n at worker-file1.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + 'Error\n at worker-file2.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockWorkerSelf._sentryModuleMetadata = rawMetadata; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + _sentryModuleMetadata: rawMetadata, + }); + }); + + it('sends undefined module metadata when not available', () => { + mockWorkerSelf._sentryModuleMetadata = undefined; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: undefined, + _sentryModuleMetadata: undefined, + }); + }); + + it('includes both debug IDs and module metadata when both available', () => { + const rawMetadata = { + 'Error\n at worker-file.js:1:1': { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + mockWorkerSelf._sentryDebugIds = { + 'worker-file.js': 'debug-id-1', + }; + mockWorkerSelf._sentryModuleMetadata = rawMetadata; + + registerWebWorker({ self: mockWorkerSelf as any }); + + expect(mockWorkerSelf.postMessage).toHaveBeenCalledWith({ + _sentryMessage: true, + _sentryDebugIds: { + 'worker-file.js': 'debug-id-1', + }, + _sentryModuleMetadata: rawMetadata, }); }); }); @@ -335,6 +480,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { expect(mockWorker.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: mockWorker._sentryDebugIds, + _sentryModuleMetadata: undefined, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ @@ -355,6 +501,7 @@ describe('registerWebWorker and webWorkerIntegration', () => { expect(mockWorker3.postMessage).toHaveBeenCalledWith({ _sentryMessage: true, _sentryDebugIds: mockWorker3._sentryDebugIds, + _sentryModuleMetadata: undefined, }); expect((helpers.WINDOW as any)._sentryDebugIds).toEqual({ diff --git a/packages/bun/scripts/install-bun.js b/packages/bun/scripts/install-bun.js index 2f885c4f2b7d..e2221e549d3e 100644 --- a/packages/bun/scripts/install-bun.js +++ b/packages/bun/scripts/install-bun.js @@ -10,13 +10,11 @@ const https = require('https'); const installScriptUrl = 'https://bun.sh/install'; // Check if bun is installed -exec('bun -version', (error, version) => { +exec('bun --version', (error, version) => { if (error) { console.error('bun is not installed. Installing...'); installLatestBun(); } else { - const versionBefore = version.trim(); - exec('bun upgrade', (error, stdout, stderr) => { if (error) { console.error('Failed to upgrade bun:', error); @@ -26,6 +24,7 @@ exec('bun -version', (error, version) => { const out = [stdout, stderr].join('\n'); if (out.includes("You're already on the latest version of Bun")) { + console.log('Bun is already up to date.'); return; } diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts index 66c9f559f29c..8dfff417ff27 100644 --- a/packages/cloudflare/src/integrations/fetch.ts +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -90,6 +90,7 @@ const _fetchIntegration = ((options: Partial = {}) => { setupOnce() { addFetchInstrumentationHandler(handlerData => { const client = getClient(); + const { propagateTraceparent } = client?.getOptions() || {}; if (!client || !HAS_CLIENT_MAP.get(client)) { return; } @@ -100,6 +101,7 @@ const _fetchIntegration = ((options: Partial = {}) => { instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { spanOrigin: 'auto.http.fetch', + propagateTraceparent, }); if (breadcrumbs) { diff --git a/packages/cloudflare/src/transport.ts b/packages/cloudflare/src/transport.ts index 8881e2dd6567..8e0e82aae7e0 100644 --- a/packages/cloudflare/src/transport.ts +++ b/packages/cloudflare/src/transport.ts @@ -89,7 +89,18 @@ export function makeCloudflareTransport(options: CloudflareTransportOptions): Tr }; return suppressTracing(() => { - return (options.fetch ?? fetch)(options.url, requestOptions).then(response => { + return (options.fetch ?? fetch)(options.url, requestOptions).then(async response => { + // Consume the response body to satisfy Cloudflare Workers' fetch requirements. + // The runtime requires all fetch response bodies to be read or explicitly canceled + // to prevent connection stalls and potential deadlocks. We read the body as text + // even though we don't use the content, as Sentry's response information is in the headers. + // See: https://github.com/getsentry/sentry-javascript/issues/18534 + try { + await response.text(); + } catch { + // no-op + } + return { statusCode: response.status, headers: { diff --git a/packages/cloudflare/src/utils/commonjs.ts b/packages/cloudflare/src/utils/commonjs.ts deleted file mode 100644 index 23a9b97f9fc1..000000000000 --- a/packages/cloudflare/src/utils/commonjs.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Detect CommonJS. */ -export function isCjs(): boolean { - try { - return typeof module !== 'undefined' && typeof module.exports !== 'undefined'; - } catch { - return false; - } -} diff --git a/packages/cloudflare/test/transport.test.ts b/packages/cloudflare/test/transport.test.ts index 71b231f542af..fdb9fbc5e30f 100644 --- a/packages/cloudflare/test/transport.test.ts +++ b/packages/cloudflare/test/transport.test.ts @@ -106,6 +106,78 @@ describe('Edge Transport', () => { ...REQUEST_OPTIONS, }); }); + + describe('Response body consumption (issue #18534)', () => { + it('consumes the response body to prevent Cloudflare stalled connection warnings', async () => { + const textMock = vi.fn(() => Promise.resolve('OK')); + const headers = { + get: vi.fn(), + }; + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + text: textMock, + }), + ); + + const transport = makeCloudflareTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + await transport.send(ERROR_ENVELOPE); + await transport.flush(); + + expect(textMock).toHaveBeenCalledTimes(1); + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + + it('handles response body consumption errors gracefully', async () => { + const textMock = vi.fn(() => Promise.reject(new Error('Body read error'))); + const headers = { + get: vi.fn(), + }; + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + text: textMock, + }), + ); + + const transport = makeCloudflareTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + await expect(transport.send(ERROR_ENVELOPE)).resolves.toBeDefined(); + await expect(transport.flush()).resolves.toBeDefined(); + + expect(textMock).toHaveBeenCalledTimes(1); + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + + it('handles a potential never existing use case of a non existing text method', async () => { + const headers = { + get: vi.fn(), + }; + + mockFetch.mockImplementationOnce(() => + Promise.resolve({ + headers, + status: 200, + }), + ); + + const transport = makeCloudflareTransport(DEFAULT_EDGE_TRANSPORT_OPTIONS); + + await expect(transport.send(ERROR_ENVELOPE)).resolves.toBeDefined(); + await expect(transport.flush()).resolves.toBeDefined(); + expect(headers.get).toHaveBeenCalledTimes(2); + expect(headers.get).toHaveBeenCalledWith('X-Sentry-Rate-Limits'); + expect(headers.get).toHaveBeenCalledWith('Retry-After'); + }); + }); }); describe('IsolatedPromiseBuffer', () => { diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index 5a021c016763..5ce5d0f72cd2 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -1,4 +1,15 @@ module.exports = { extends: ['../../.eslintrc.js'], ignorePatterns: ['rollup.npm.config.mjs'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts index f33241be2a1e..d3255d76b0e9 100644 --- a/packages/core/src/attributes.ts +++ b/packages/core/src/attributes.ts @@ -75,7 +75,7 @@ export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObjec */ export function attributeValueToTypedAttributeValue( rawValue: unknown, - useFallback?: boolean, + useFallback?: boolean | 'skip-undefined', ): TypedAttributeValue | void { const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; const attributeValue = getTypedAttributeValue(value); @@ -84,7 +84,7 @@ export function attributeValueToTypedAttributeValue( return { ...attributeValue, ...checkedUnit }; } - if (!useFallback) { + if (!useFallback || (useFallback === 'skip-undefined' && value === undefined)) { return; } @@ -113,9 +113,12 @@ export function attributeValueToTypedAttributeValue( * * @returns The serialized attributes. */ -export function serializeAttributes(attributes: RawAttributes, fallback: boolean = false): Attributes { +export function serializeAttributes( + attributes: RawAttributes | undefined, + fallback: boolean | 'skip-undefined' = false, +): Attributes { const serializedAttributes: Attributes = {}; - for (const [key, value] of Object.entries(attributes)) { + for (const [key, value] of Object.entries(attributes ?? {})) { const typedValue = attributeValueToTypedAttributeValue(value, fallback); if (typedValue) { serializedAttributes[key] = typedValue; diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index aad363905a68..56b382a2860e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -45,6 +45,7 @@ import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; +import { safeMathRandom } from './utils/randomSafeContext'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning } from './utils/spanUtils'; import { rejectedSyncPromise } from './utils/syncpromise'; @@ -1288,7 +1289,7 @@ export abstract class Client { // 0.0 === 0% events are sent // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); - if (isError && typeof parsedSampleRate === 'number' && Math.random() > parsedSampleRate) { + if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { this.recordDroppedEvent('sample_rate', 'error'); return rejectedSyncPromise( _makeDoNotSendEventError( diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 38475b857ace..7fdc380faf0d 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -1 +1,2 @@ export const DEFAULT_ENVIRONMENT = 'production'; +export const DEV_ENVIRONMENT = 'development'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e18ea294f182..28495fed10a4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,7 +61,7 @@ export { _INTERNAL_shouldSkipAiProviderWrapping, _INTERNAL_clearAiProviderSkips, } from './utils/ai/providerSkip'; -export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent'; +export { applyScopeDataToEvent, mergeScopeData, getCombinedScopeData } from './utils/scopeData'; export { prepareEvent } from './utils/prepareEvent'; export type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; @@ -101,7 +101,7 @@ export { headersToDict, httpHeadersToSpanAttributes, } from './utils/request'; -export { DEFAULT_ENVIRONMENT } from './constants'; +export { DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from './constants'; export { addBreadcrumb } from './breadcrumbs'; export { functionToStringIntegration } from './integrations/functiontostring'; // eslint-disable-next-line deprecation/deprecation @@ -322,6 +322,7 @@ export { vercelWaitUntil } from './utils/vercelWaitUntil'; export { flushIfServerless } from './utils/flushIfServerless'; export { SDK_VERSION } from './utils/version'; export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids'; +export { getFilenameToMetadataMap } from './metadata'; export { escapeStringForRegex } from './vendor/escapeStringForRegex'; export type { Attachment } from './types-hoist/attachment'; @@ -449,6 +450,7 @@ export type { MetricType, SerializedMetric, SerializedMetricContainer, + // eslint-disable-next-line deprecation/deprecation SerializedMetricAttributeValue, } from './types-hoist/metric'; export type { TimedEvent } from './types-hoist/timedEvent'; @@ -513,3 +515,9 @@ export type { UnstableRollupPluginOptions, UnstableWebpackPluginOptions, } from './build-time-plugins/buildTimeOptionsBase'; +export { + withRandomSafeContext as _INTERNAL_withRandomSafeContext, + type RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, + safeMathRandom as _INTERNAL_safeMathRandom, + safeDateNow as _INTERNAL_safeDateNow, +} from './utils/randomSafeContext'; diff --git a/packages/core/src/integrations/mcp-server/attributeExtraction.ts b/packages/core/src/integrations/mcp-server/attributeExtraction.ts index 8f1e5a77d94d..75449f43ccc9 100644 --- a/packages/core/src/integrations/mcp-server/attributeExtraction.ts +++ b/packages/core/src/integrations/mcp-server/attributeExtraction.ts @@ -14,15 +14,25 @@ import { import { extractTargetInfo, getRequestArguments } from './methodConfig'; import type { JsonRpcNotification, JsonRpcRequest, McpSpanType } from './types'; +/** + * Formats logging data for span attributes + * @internal + */ +function formatLoggingData(data: unknown): string { + return typeof data === 'string' ? data : JSON.stringify(data); +} + /** * Extracts additional attributes for specific notification types * @param method - Notification method name * @param params - Notification parameters + * @param recordInputs - Whether to include actual content or just metadata * @returns Method-specific attributes for span instrumentation */ export function getNotificationAttributes( method: string, params: Record, + recordInputs?: boolean, ): Record { const attributes: Record = {}; @@ -45,10 +55,8 @@ export function getNotificationAttributes( } if (params?.data !== undefined) { attributes[MCP_LOGGING_DATA_TYPE_ATTRIBUTE] = typeof params.data; - if (typeof params.data === 'string') { - attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = params.data; - } else { - attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = JSON.stringify(params.data); + if (recordInputs) { + attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = formatLoggingData(params.data); } } break; @@ -95,12 +103,14 @@ export function getNotificationAttributes( * @param type - Span type (request or notification) * @param message - JSON-RPC message * @param params - Optional parameters for attribute extraction + * @param recordInputs - Whether to capture input arguments in spans * @returns Type-specific attributes for span instrumentation */ export function buildTypeSpecificAttributes( type: McpSpanType, message: JsonRpcRequest | JsonRpcNotification, params?: Record, + recordInputs?: boolean, ): Record { if (type === 'request') { const request = message as JsonRpcRequest; @@ -109,11 +119,11 @@ export function buildTypeSpecificAttributes( return { ...(request.id !== undefined && { [MCP_REQUEST_ID_ATTRIBUTE]: String(request.id) }), ...targetInfo.attributes, - ...getRequestArguments(request.method, params || {}), + ...(recordInputs ? getRequestArguments(request.method, params || {}) : {}), }; } - return getNotificationAttributes(message.method, params || {}); + return getNotificationAttributes(message.method, params || {}, recordInputs); } // Re-export buildTransportAttributes for spans.ts diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 0985a0927cdd..3567ec382cdf 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -6,14 +6,12 @@ * request ID collisions between different MCP sessions. */ -import { getClient } from '../../currentScopes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types-hoist/span'; import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; -import { filterMcpPiiFromSpanData } from './piiFiltering'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; import { buildServerAttributesFromInfo, extractSessionDataFromInitializeResponse } from './sessionExtraction'; -import type { MCPTransport, RequestId, RequestSpanMapValue } from './types'; +import type { MCPTransport, RequestId, RequestSpanMapValue, ResolvedMcpOptions } from './types'; /** * Transport-scoped correlation system that prevents collisions between different MCP sessions @@ -48,6 +46,7 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI spanMap.set(requestId, { span, method, + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis startTime: Date.now(), }); } @@ -57,8 +56,14 @@ export function storeSpanForRequest(transport: MCPTransport, requestId: RequestI * @param transport - MCP transport instance * @param requestId - Request identifier * @param result - Execution result for attribute extraction + * @param options - Resolved MCP options */ -export function completeSpanWithResults(transport: MCPTransport, requestId: RequestId, result: unknown): void { +export function completeSpanWithResults( + transport: MCPTransport, + requestId: RequestId, + result: unknown, + options: ResolvedMcpOptions, +): void { const spanMap = getOrCreateSpanMap(transport); const spanData = spanMap.get(requestId); if (spanData) { @@ -77,18 +82,10 @@ export function completeSpanWithResults(transport: MCPTransport, requestId: Requ span.setAttributes(initAttributes); } else if (method === 'tools/call') { - const rawToolAttributes = extractToolResultAttributes(result); - const client = getClient(); - const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); - const toolAttributes = filterMcpPiiFromSpanData(rawToolAttributes, sendDefaultPii); - + const toolAttributes = extractToolResultAttributes(result, options.recordOutputs); span.setAttributes(toolAttributes); } else if (method === 'prompts/get') { - const rawPromptAttributes = extractPromptResultAttributes(result); - const client = getClient(); - const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); - const promptAttributes = filterMcpPiiFromSpanData(rawPromptAttributes, sendDefaultPii); - + const promptAttributes = extractPromptResultAttributes(result, options.recordOutputs); span.setAttributes(promptAttributes); } diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index a1eb8815805a..5698cd445834 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -1,7 +1,8 @@ +import { getClient } from '../../currentScopes'; import { fill } from '../../utils/object'; import { wrapAllMCPHandlers } from './handlers'; import { wrapTransportError, wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport'; -import type { MCPServerInstance, MCPTransport } from './types'; +import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions } from './types'; import { validateMcpServerInstance } from './validation'; /** @@ -22,18 +23,26 @@ const wrappedMcpServerInstances = new WeakSet(); * import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; * import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; * + * // Default: inputs/outputs captured based on sendDefaultPii option * const server = Sentry.wrapMcpServerWithSentry( * new McpServer({ name: "my-server", version: "1.0.0" }) * ); * + * // Explicitly control input/output capture + * const server = Sentry.wrapMcpServerWithSentry( + * new McpServer({ name: "my-server", version: "1.0.0" }), + * { recordInputs: true, recordOutputs: false } + * ); + * * const transport = new StreamableHTTPServerTransport(); * await server.connect(transport); * ``` * * @param mcpServerInstance - MCP server instance to instrument + * @param options - Optional configuration for recording inputs and outputs * @returns Instrumented server instance (same reference) */ -export function wrapMcpServerWithSentry(mcpServerInstance: S): S { +export function wrapMcpServerWithSentry(mcpServerInstance: S, options?: McpServerWrapperOptions): S { if (wrappedMcpServerInstances.has(mcpServerInstance)) { return mcpServerInstance; } @@ -43,6 +52,13 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S): } const serverInstance = mcpServerInstance as MCPServerInstance; + const client = getClient(); + const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const resolvedOptions: ResolvedMcpOptions = { + recordInputs: options?.recordInputs ?? sendDefaultPii, + recordOutputs: options?.recordOutputs ?? sendDefaultPii, + }; fill(serverInstance, 'connect', originalConnect => { return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) { @@ -52,8 +68,8 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S): ...restArgs, ); - wrapTransportOnMessage(transport); - wrapTransportSend(transport); + wrapTransportOnMessage(transport, resolvedOptions); + wrapTransportSend(transport, resolvedOptions); wrapTransportOnClose(transport); wrapTransportError(transport); diff --git a/packages/core/src/integrations/mcp-server/piiFiltering.ts b/packages/core/src/integrations/mcp-server/piiFiltering.ts index ff801cbf2a1e..f8715a383f00 100644 --- a/packages/core/src/integrations/mcp-server/piiFiltering.ts +++ b/packages/core/src/integrations/mcp-server/piiFiltering.ts @@ -1,71 +1,37 @@ /** * PII filtering for MCP server spans * - * Removes sensitive data when sendDefaultPii is false. - * Uses configurable attribute filtering to protect user privacy. + * Removes network-level sensitive data when sendDefaultPii is false. + * Input/output data (request arguments, tool/prompt results) is controlled + * separately via recordInputs/recordOutputs options. */ import type { SpanAttributeValue } from '../../types-hoist/span'; -import { - CLIENT_ADDRESS_ATTRIBUTE, - CLIENT_PORT_ATTRIBUTE, - MCP_LOGGING_MESSAGE_ATTRIBUTE, - MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE, - MCP_PROMPT_RESULT_MESSAGE_CONTENT_ATTRIBUTE, - MCP_PROMPT_RESULT_PREFIX, - MCP_REQUEST_ARGUMENT, - MCP_RESOURCE_URI_ATTRIBUTE, - MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, - MCP_TOOL_RESULT_PREFIX, -} from './attributes'; +import { CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE } from './attributes'; /** - * PII attributes that should be removed when sendDefaultPii is false + * Network PII attributes that should be removed when sendDefaultPii is false * @internal */ -const PII_ATTRIBUTES = new Set([ - CLIENT_ADDRESS_ATTRIBUTE, - CLIENT_PORT_ATTRIBUTE, - MCP_LOGGING_MESSAGE_ATTRIBUTE, - MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE, - MCP_PROMPT_RESULT_MESSAGE_CONTENT_ATTRIBUTE, - MCP_RESOURCE_URI_ATTRIBUTE, - MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, -]); +const NETWORK_PII_ATTRIBUTES = new Set([CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE]); /** - * Checks if an attribute key should be considered PII. + * Checks if an attribute key should be considered network PII. * * Returns true for: - * - Explicit PII attributes (client.address, client.port, mcp.logging.message, etc.) - * - All request arguments (mcp.request.argument.*) - * - Tool and prompt result content (mcp.tool.result.*, mcp.prompt.result.*) except metadata - * - * Preserves metadata attributes ending with _count, _error, or .is_error as they don't contain sensitive data. + * - client.address (IP address) + * - client.port (port number) + * - mcp.resource.uri (potentially sensitive URIs) * * @param key - Attribute key to evaluate - * @returns true if the attribute should be filtered out (is PII), false if it should be preserved + * @returns true if the attribute should be filtered out (is network PII), false if it should be preserved * @internal */ -function isPiiAttribute(key: string): boolean { - if (PII_ATTRIBUTES.has(key)) { - return true; - } - - if (key.startsWith(`${MCP_REQUEST_ARGUMENT}.`)) { - return true; - } - - if (key.startsWith(`${MCP_TOOL_RESULT_PREFIX}.`) || key.startsWith(`${MCP_PROMPT_RESULT_PREFIX}.`)) { - if (!key.endsWith('_count') && !key.endsWith('_error') && !key.endsWith('.is_error')) { - return true; - } - } - - return false; +function isNetworkPiiAttribute(key: string): boolean { + return NETWORK_PII_ATTRIBUTES.has(key); } /** - * Removes PII attributes from span data when sendDefaultPii is false + * Removes network PII attributes from span data when sendDefaultPii is false * @param spanData - Raw span attributes * @param sendDefaultPii - Whether to include PII data * @returns Filtered span attributes @@ -80,7 +46,7 @@ export function filterMcpPiiFromSpanData( return Object.entries(spanData).reduce( (acc, [key, value]) => { - if (!isPiiAttribute(key)) { + if (!isNetworkPiiAttribute(key)) { acc[key] = value as SpanAttributeValue; } return acc; diff --git a/packages/core/src/integrations/mcp-server/resultExtraction.ts b/packages/core/src/integrations/mcp-server/resultExtraction.ts index 34dc2be9d09c..58f9ad860083 100644 --- a/packages/core/src/integrations/mcp-server/resultExtraction.ts +++ b/packages/core/src/integrations/mcp-server/resultExtraction.ts @@ -15,9 +15,13 @@ import { isValidContentItem } from './validation'; /** * Build attributes for tool result content items * @param content - Array of content items from tool result - * @returns Attributes extracted from each content item including type, text, mime type, URI, and resource info + * @param includeContent - Whether to include actual content (text, URIs) or just metadata + * @returns Attributes extracted from each content item */ -function buildAllContentItemAttributes(content: unknown[]): Record { +function buildAllContentItemAttributes( + content: unknown[], + includeContent: boolean, +): Record { const attributes: Record = { [MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE]: content.length, }; @@ -29,29 +33,34 @@ function buildAllContentItemAttributes(content: unknown[]): Record { - if (typeof value === 'string') { - attributes[`${prefix}.${key}`] = value; - } - }; + if (typeof item.type === 'string') { + attributes[`${prefix}.content_type`] = item.type; + } - safeSet('content_type', item.type); - safeSet('mime_type', item.mimeType); - safeSet('uri', item.uri); - safeSet('name', item.name); + if (includeContent) { + const safeSet = (key: string, value: unknown): void => { + if (typeof value === 'string') { + attributes[`${prefix}.${key}`] = value; + } + }; - if (typeof item.text === 'string') { - attributes[`${prefix}.content`] = item.text; - } + safeSet('mime_type', item.mimeType); + safeSet('uri', item.uri); + safeSet('name', item.name); - if (typeof item.data === 'string') { - attributes[`${prefix}.data_size`] = item.data.length; - } + if (typeof item.text === 'string') { + attributes[`${prefix}.content`] = item.text; + } - const resource = item.resource; - if (isValidContentItem(resource)) { - safeSet('resource_uri', resource.uri); - safeSet('resource_mime_type', resource.mimeType); + if (typeof item.data === 'string') { + attributes[`${prefix}.data_size`] = item.data.length; + } + + const resource = item.resource; + if (isValidContentItem(resource)) { + safeSet('resource_uri', resource.uri); + safeSet('resource_mime_type', resource.mimeType); + } } } @@ -61,14 +70,18 @@ function buildAllContentItemAttributes(content: unknown[]): Record { +export function extractToolResultAttributes( + result: unknown, + recordOutputs: boolean, +): Record { if (!isValidContentItem(result)) { return {}; } - const attributes = Array.isArray(result.content) ? buildAllContentItemAttributes(result.content) : {}; + const attributes = Array.isArray(result.content) ? buildAllContentItemAttributes(result.content, recordOutputs) : {}; if (typeof result.isError === 'boolean') { attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = result.isError; @@ -80,43 +93,49 @@ export function extractToolResultAttributes(result: unknown): Record { +export function extractPromptResultAttributes( + result: unknown, + recordOutputs: boolean, +): Record { const attributes: Record = {}; if (!isValidContentItem(result)) { return attributes; } - if (typeof result.description === 'string') { + if (recordOutputs && typeof result.description === 'string') { attributes[MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE] = result.description; } if (Array.isArray(result.messages)) { attributes[MCP_PROMPT_RESULT_MESSAGE_COUNT_ATTRIBUTE] = result.messages.length; - const messages = result.messages; - for (const [i, message] of messages.entries()) { - if (!isValidContentItem(message)) { - continue; - } + if (recordOutputs) { + const messages = result.messages; + for (const [i, message] of messages.entries()) { + if (!isValidContentItem(message)) { + continue; + } - const prefix = messages.length === 1 ? 'mcp.prompt.result' : `mcp.prompt.result.${i}`; + const prefix = messages.length === 1 ? 'mcp.prompt.result' : `mcp.prompt.result.${i}`; - const safeSet = (key: string, value: unknown): void => { - if (typeof value === 'string') { - const attrName = messages.length === 1 ? `${prefix}.message_${key}` : `${prefix}.${key}`; - attributes[attrName] = value; - } - }; + const safeSet = (key: string, value: unknown): void => { + if (typeof value === 'string') { + const attrName = messages.length === 1 ? `${prefix}.message_${key}` : `${prefix}.${key}`; + attributes[attrName] = value; + } + }; - safeSet('role', message.role); + safeSet('role', message.role); - if (isValidContentItem(message.content)) { - const content = message.content; - if (typeof content.text === 'string') { - const attrName = messages.length === 1 ? `${prefix}.message_content` : `${prefix}.content`; - attributes[attrName] = content.text; + if (isValidContentItem(message.content)) { + const content = message.content; + if (typeof content.text === 'string') { + const attrName = messages.length === 1 ? `${prefix}.message_content` : `${prefix}.content`; + attributes[attrName] = content.text; + } } } } diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts index fdd4c107ee30..010148faab65 100644 --- a/packages/core/src/integrations/mcp-server/spans.ts +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -24,7 +24,14 @@ import { } from './attributes'; import { extractTargetInfo } from './methodConfig'; import { filterMcpPiiFromSpanData } from './piiFiltering'; -import type { ExtraHandlerData, JsonRpcNotification, JsonRpcRequest, McpSpanConfig, MCPTransport } from './types'; +import type { + ExtraHandlerData, + JsonRpcNotification, + JsonRpcRequest, + McpSpanConfig, + MCPTransport, + ResolvedMcpOptions, +} from './types'; /** * Creates a span name based on the method and target @@ -76,7 +83,7 @@ function buildSentryAttributes(type: McpSpanConfig['type']): Record = { ...buildTransportAttributes(transport, extra), [MCP_METHOD_NAME_ATTRIBUTE]: method, - ...buildTypeSpecificAttributes(type, message, params), + ...buildTypeSpecificAttributes(type, message, params, options?.recordInputs), ...buildSentryAttributes(type), }; @@ -116,6 +123,7 @@ function createMcpSpan(config: McpSpanConfig): unknown { * @param jsonRpcMessage - Notification message * @param transport - MCP transport instance * @param extra - Extra handler data + * @param options - Resolved MCP options * @param callback - Span execution callback * @returns Span execution result */ @@ -123,6 +131,7 @@ export function createMcpNotificationSpan( jsonRpcMessage: JsonRpcNotification, transport: MCPTransport, extra: ExtraHandlerData, + options: ResolvedMcpOptions, callback: () => unknown, ): unknown { return createMcpSpan({ @@ -131,6 +140,7 @@ export function createMcpNotificationSpan( transport, extra, callback, + options, }); } @@ -138,18 +148,21 @@ export function createMcpNotificationSpan( * Creates a span for outgoing MCP notifications * @param jsonRpcMessage - Notification message * @param transport - MCP transport instance + * @param options - Resolved MCP options * @param callback - Span execution callback * @returns Span execution result */ export function createMcpOutgoingNotificationSpan( jsonRpcMessage: JsonRpcNotification, transport: MCPTransport, + options: ResolvedMcpOptions, callback: () => unknown, ): unknown { return createMcpSpan({ type: 'notification-outgoing', message: jsonRpcMessage, transport, + options, callback, }); } @@ -159,12 +172,14 @@ export function createMcpOutgoingNotificationSpan( * @param jsonRpcMessage - Request message * @param transport - MCP transport instance * @param extra - Optional extra handler data + * @param options - Resolved MCP options * @returns Span configuration object */ export function buildMcpServerSpanConfig( jsonRpcMessage: JsonRpcRequest, transport: MCPTransport, extra?: ExtraHandlerData, + options?: ResolvedMcpOptions, ): { name: string; op: string; @@ -180,7 +195,7 @@ export function buildMcpServerSpanConfig( const rawAttributes: Record = { ...buildTransportAttributes(transport, extra), [MCP_METHOD_NAME_ATTRIBUTE]: method, - ...buildTypeSpecificAttributes('request', jsonRpcMessage, params), + ...buildTypeSpecificAttributes('request', jsonRpcMessage, params, options?.recordInputs), ...buildSentryAttributes('request'), }; diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index bb9a1b2b37d2..5e4fd6e75c23 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -22,7 +22,7 @@ import { updateSessionDataForTransport, } from './sessionManagement'; import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; -import type { ExtraHandlerData, MCPTransport, SessionData } from './types'; +import type { ExtraHandlerData, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, isValidContentItem } from './validation'; /** @@ -30,8 +30,9 @@ import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, isValidCont * For "initialize" requests, extracts and stores client info and protocol version * in the session data for the transport. * @param transport - MCP transport instance to wrap + * @param options - Resolved MCP options */ -export function wrapTransportOnMessage(transport: MCPTransport): void { +export function wrapTransportOnMessage(transport: MCPTransport, options: ResolvedMcpOptions): void { if (transport.onmessage) { fill(transport, 'onmessage', originalOnMessage => { return function (this: MCPTransport, message: unknown, extra?: unknown) { @@ -51,7 +52,7 @@ export function wrapTransportOnMessage(transport: MCPTransport): void { const isolationScope = getIsolationScope().clone(); return withIsolationScope(isolationScope, () => { - const spanConfig = buildMcpServerSpanConfig(message, this, extra as ExtraHandlerData); + const spanConfig = buildMcpServerSpanConfig(message, this, extra as ExtraHandlerData, options); const span = startInactiveSpan(spanConfig); // For initialize requests, add client info directly to span (works even for stateless transports) @@ -73,7 +74,7 @@ export function wrapTransportOnMessage(transport: MCPTransport): void { } if (isJsonRpcNotification(message)) { - return createMcpNotificationSpan(message, this, extra as ExtraHandlerData, () => { + return createMcpNotificationSpan(message, this, extra as ExtraHandlerData, options, () => { return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); }); } @@ -89,15 +90,16 @@ export function wrapTransportOnMessage(transport: MCPTransport): void { * For "initialize" responses, extracts and stores protocol version and server info * in the session data for the transport. * @param transport - MCP transport instance to wrap + * @param options - Resolved MCP options */ -export function wrapTransportSend(transport: MCPTransport): void { +export function wrapTransportSend(transport: MCPTransport, options: ResolvedMcpOptions): void { if (transport.send) { fill(transport, 'send', originalSend => { return async function (this: MCPTransport, ...args: unknown[]) { const [message] = args; if (isJsonRpcNotification(message)) { - return createMcpOutgoingNotificationSpan(message, this, () => { + return createMcpOutgoingNotificationSpan(message, this, options, () => { return (originalSend as (...args: unknown[]) => unknown).call(this, ...args); }); } @@ -119,7 +121,7 @@ export function wrapTransportSend(transport: MCPTransport): void { } } - completeSpanWithResults(this, message.id, message.result); + completeSpanWithResults(this, message.id, message.result, options); } } diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 7c25d52167c7..35dbcffcabb0 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -127,6 +127,7 @@ export interface McpSpanConfig { transport: MCPTransport; extra?: ExtraHandlerData; callback: () => unknown; + options?: ResolvedMcpOptions; } export type SessionId = string; @@ -183,3 +184,19 @@ export type SessionData = { protocolVersion?: string; serverInfo?: PartyInfo; }; + +/** + * Options for configuring the MCP server wrapper. + */ +export type McpServerWrapperOptions = { + /** Whether to capture tool/prompt input arguments in spans. Defaults to sendDefaultPii. */ + recordInputs?: boolean; + /** Whether to capture tool/prompt output results in spans. Defaults to sendDefaultPii. */ + recordOutputs?: boolean; +}; + +/** + * Resolved options with defaults applied. Used internally. + * @internal + */ +export type ResolvedMcpOptions = Required; diff --git a/packages/core/src/integrations/third-party-errors-filter.ts b/packages/core/src/integrations/third-party-errors-filter.ts index 53739c9efd2d..f5d4c087eeab 100644 --- a/packages/core/src/integrations/third-party-errors-filter.ts +++ b/packages/core/src/integrations/third-party-errors-filter.ts @@ -2,6 +2,7 @@ import { defineIntegration } from '../integration'; import { addMetadataToStackFrames, stripMetadataFromStackFrames } from '../metadata'; import type { EventItem } from '../types-hoist/envelope'; import type { Event } from '../types-hoist/event'; +import type { StackFrame } from '../types-hoist/stackframe'; import { forEachEnvelopeItem } from '../utils/envelope'; import { getFramesFromEvent } from '../utils/stacktrace'; @@ -32,6 +33,13 @@ interface Options { | 'drop-error-if-exclusively-contains-third-party-frames' | 'apply-tag-if-contains-third-party-frames' | 'apply-tag-if-exclusively-contains-third-party-frames'; + + /** + * @experimental + * If set to true, the integration will ignore frames that are internal to the Sentry SDK from the third-party frame detection. + * Note that enabling this option might lead to errors being misclassified as third-party errors. + */ + ignoreSentryInternalFrames?: boolean; } /** @@ -67,7 +75,7 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti }, processEvent(event) { - const frameKeys = getBundleKeysForAllFramesWithFilenames(event); + const frameKeys = getBundleKeysForAllFramesWithFilenames(event, options.ignoreSentryInternalFrames); if (frameKeys) { const arrayMethod = @@ -98,27 +106,75 @@ export const thirdPartyErrorFilterIntegration = defineIntegration((options: Opti }; }); -function getBundleKeysForAllFramesWithFilenames(event: Event): string[][] | undefined { +/** + * Checks if a stack frame is a Sentry internal frame by strictly matching: + * 1. The frame must be the last frame in the stack + * 2. The filename must indicate the internal helpers file + * 3. The context_line must contain the exact pattern "fn.apply(this, wrappedArguments)" + * 4. The comment pattern "Attempt to invoke user-land function" must be present in pre_context + * + */ +function isSentryInternalFrame(frame: StackFrame, frameIndex: number): boolean { + // Only match the last frame (index 0 in reversed stack) + if (frameIndex !== 0 || !frame.context_line || !frame.filename) { + return false; + } + + if ( + !frame.filename.includes('sentry') || + !frame.filename.includes('helpers') || // Filename would look something like this: 'node_modules/@sentry/browser/build/npm/esm/helpers.js' + !frame.context_line.includes(SENTRY_INTERNAL_FN_APPLY) // Must have context_line with the exact fn.apply pattern + ) { + return false; + } + + // Check pre_context array for comment pattern + if (frame.pre_context) { + const len = frame.pre_context.length; + for (let i = 0; i < len; i++) { + if (frame.pre_context[i]?.includes(SENTRY_INTERNAL_COMMENT)) { + return true; + } + } + } + + return false; +} + +function getBundleKeysForAllFramesWithFilenames( + event: Event, + ignoreSentryInternalFrames?: boolean, +): string[][] | undefined { const frames = getFramesFromEvent(event); if (!frames) { return undefined; } - return ( - frames - // Exclude frames without a filename or without lineno and colno, - // since these are likely native code or built-ins - .filter(frame => !!frame.filename && (frame.lineno ?? frame.colno) != null) - .map(frame => { - if (frame.module_metadata) { - return Object.keys(frame.module_metadata) - .filter(key => key.startsWith(BUNDLER_PLUGIN_APP_KEY_PREFIX)) - .map(key => key.slice(BUNDLER_PLUGIN_APP_KEY_PREFIX.length)); - } + return frames + .filter((frame, index) => { + // Exclude frames without a filename + if (!frame.filename) { + return false; + } + // Exclude frames without location info, since these are likely native code or built-ins. + // JS frames have lineno/colno, WASM frames have instruction_addr instead. + if (frame.lineno == null && frame.colno == null && frame.instruction_addr == null) { + return false; + } + // Optionally ignore Sentry internal frames + return !ignoreSentryInternalFrames || !isSentryInternalFrame(frame, index); + }) + .map(frame => { + if (!frame.module_metadata) { return []; - }) - ); + } + return Object.keys(frame.module_metadata) + .filter(key => key.startsWith(BUNDLER_PLUGIN_APP_KEY_PREFIX)) + .map(key => key.slice(BUNDLER_PLUGIN_APP_KEY_PREFIX.length)); + }); } const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:'; +const SENTRY_INTERNAL_COMMENT = 'Attempt to invoke user-land function'; +const SENTRY_INTERNAL_FN_APPLY = 'fn.apply(this, wrappedArguments)'; diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index a39aa75d7074..3408b01a5f96 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -1,14 +1,13 @@ import { serializeAttributes } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; -import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; +import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import type { Scope, ScopeData } from '../scope'; import type { Integration } from '../types-hoist/integration'; import type { Log, SerializedLog } from '../types-hoist/log'; -import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { consoleSandbox, debug } from '../utils/debug-logger'; import { isParameterizedString } from '../utils/is'; +import { getCombinedScopeData } from '../utils/scopeData'; import { _getSpanForScope } from '../utils/spanOnScope'; import { timestampInSeconds } from '../utils/time'; import { _getTraceInfoFromScope } from '../utils/trace-info'; @@ -98,7 +97,7 @@ export function _INTERNAL_captureLog( const { user: { id, email, username }, attributes: scopeAttributes = {}, - } = getMergedScopeData(currentScope); + } = getCombinedScopeData(getIsolationScope(), currentScope); setLogAttribute(processedLogAttributes, 'user.id', id, false); setLogAttribute(processedLogAttributes, 'user.email', email, false); @@ -212,20 +211,6 @@ export function _INTERNAL_getLogBuffer(client: Client): Array | u return _getBufferMap().get(client); } -/** - * Get the scope data for the current scope after merging with the - * global scope and isolation scope. - * - * @param currentScope - The current scope. - * @returns The scope data. - */ -function getMergedScopeData(currentScope: Scope): ScopeData { - const scopeData = getGlobalScope().getScopeData(); - mergeScopeData(scopeData, getIsolationScope().getScopeData()); - mergeScopeData(scopeData, currentScope.getScopeData()); - return scopeData; -} - function _getBufferMap(): WeakMap> { // The reference to the Client <> LogBuffer map is stored on the carrier to ensure it's always the same return getGlobalSingleton('clientToLogBufferMap', () => new WeakMap>()); diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 1ee93e8dcd5a..54ee4a1e1eb4 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -8,6 +8,37 @@ const filenameMetadataMap = new Map(); /** Set of stack strings that have already been parsed. */ const parsedStacks = new Set(); +/** + * Builds a map of filenames to module metadata from the global _sentryModuleMetadata object. + * This is useful for forwarding metadata from web workers to the main thread. + * + * @param parser - Stack parser to use for extracting filenames from stack traces + * @returns A map of filename to metadata object + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getFilenameToMetadataMap(parser: StackParser): Record { + if (!GLOBAL_OBJ._sentryModuleMetadata) { + return {}; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const filenameMap: Record = {}; + + for (const stack of Object.keys(GLOBAL_OBJ._sentryModuleMetadata)) { + const metadata = GLOBAL_OBJ._sentryModuleMetadata[stack]; + const frames = parser(stack); + + for (const frame of frames.reverse()) { + if (frame.filename) { + filenameMap[frame.filename] = metadata; + break; + } + } + } + + return filenameMap; +} + function ensureMetadataStacksAreParsed(parser: StackParser): void { if (!GLOBAL_OBJ._sentryModuleMetadata) { return; diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 7ac1372d1285..bdd13d884967 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -1,12 +1,14 @@ +import { type RawAttributes, serializeAttributes } from '../attributes'; import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; -import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; +import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import type { Scope, ScopeData } from '../scope'; +import type { Scope } from '../scope'; import type { Integration } from '../types-hoist/integration'; -import type { Metric, SerializedMetric, SerializedMetricAttributeValue } from '../types-hoist/metric'; -import { mergeScopeData } from '../utils/applyScopeDataToEvent'; +import type { Metric, SerializedMetric } from '../types-hoist/metric'; +import type { User } from '../types-hoist/user'; import { debug } from '../utils/debug-logger'; +import { getCombinedScopeData } from '../utils/scopeData'; import { _getSpanForScope } from '../utils/spanOnScope'; import { timestampInSeconds } from '../utils/time'; import { _getTraceInfoFromScope } from '../utils/trace-info'; @@ -14,50 +16,6 @@ import { createMetricEnvelope } from './envelope'; const MAX_METRIC_BUFFER_SIZE = 1000; -/** - * Converts a metric attribute to a serialized metric attribute. - * - * @param value - The value of the metric attribute. - * @returns The serialized metric attribute. - */ -export function metricAttributeToSerializedMetricAttribute(value: unknown): SerializedMetricAttributeValue { - switch (typeof value) { - case 'number': - if (Number.isInteger(value)) { - return { - value, - type: 'integer', - }; - } - return { - value, - type: 'double', - }; - case 'boolean': - return { - value, - type: 'boolean', - }; - case 'string': - return { - value, - type: 'string', - }; - default: { - let stringValue = ''; - try { - stringValue = JSON.stringify(value) ?? ''; - } catch { - // Do nothing - } - return { - value: stringValue, - type: 'string', - }; - } - } -} - /** * Sets a metric attribute if the value exists and the attribute key is not already present. * @@ -120,7 +78,7 @@ export interface InternalCaptureMetricOptions { /** * Enriches metric with all contextual attributes (user, SDK metadata, replay, etc.) */ -function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentScope: Scope): Metric { +function _enrichMetricAttributes(beforeMetric: Metric, client: Client, user: User): Metric { const { release, environment } = client.getOptions(); const processedMetricAttributes = { @@ -128,12 +86,9 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentSc }; // Add user attributes - const { - user: { id, email, username }, - } = getMergedScopeData(currentScope); - setMetricAttribute(processedMetricAttributes, 'user.id', id, false); - setMetricAttribute(processedMetricAttributes, 'user.email', email, false); - setMetricAttribute(processedMetricAttributes, 'user.name', username, false); + setMetricAttribute(processedMetricAttributes, 'user.id', user.id, false); + setMetricAttribute(processedMetricAttributes, 'user.email', user.email, false); + setMetricAttribute(processedMetricAttributes, 'user.name', user.username, false); // Add Sentry metadata setMetricAttribute(processedMetricAttributes, 'sentry.release', release); @@ -168,15 +123,12 @@ function _enrichMetricAttributes(beforeMetric: Metric, client: Client, currentSc /** * Creates a serialized metric ready to be sent to Sentry. */ -function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Scope): SerializedMetric { - // Serialize attributes - const serializedAttributes: Record = {}; - for (const key in metric.attributes) { - if (metric.attributes[key] !== undefined) { - serializedAttributes[key] = metricAttributeToSerializedMetricAttribute(metric.attributes[key]); - } - } - +function _buildSerializedMetric( + metric: Metric, + client: Client, + currentScope: Scope, + scopeAttributes: RawAttributes> | undefined, +): SerializedMetric { // Get trace context const [, traceContext] = _getTraceInfoFromScope(client, currentScope); const span = _getSpanForScope(currentScope); @@ -191,7 +143,10 @@ function _buildSerializedMetric(metric: Metric, client: Client, currentScope: Sc type: metric.type, unit: metric.unit, value: metric.value, - attributes: serializedAttributes, + attributes: { + ...serializeAttributes(scopeAttributes), + ...serializeAttributes(metric.attributes, 'skip-undefined'), + }, }; } @@ -225,7 +180,8 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal } // Enrich metric with contextual attributes - const enrichedMetric = _enrichMetricAttributes(beforeMetric, client, currentScope); + const { user, attributes: scopeAttributes } = getCombinedScopeData(getIsolationScope(), currentScope); + const enrichedMetric = _enrichMetricAttributes(beforeMetric, client, user); client.emit('processMetric', enrichedMetric); @@ -239,7 +195,7 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal return; } - const serializedMetric = _buildSerializedMetric(processedMetric, client, currentScope); + const serializedMetric = _buildSerializedMetric(processedMetric, client, currentScope, scopeAttributes); DEBUG_BUILD && debug.log('[Metric]', serializedMetric); @@ -288,20 +244,6 @@ export function _INTERNAL_getMetricBuffer(client: Client): Array> { // The reference to the Client <> MetricBuffer map is stored on the carrier to ensure it's always the same return getGlobalSingleton('clientToMetricBufferMap', () => new WeakMap>()); diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 0639cdb845f1..b5a64bb8818a 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -22,6 +22,7 @@ import { isPlainObject } from './utils/is'; import { merge } from './utils/merge'; import { uuid4 } from './utils/misc'; import { generateTraceId } from './utils/propagationContext'; +import { safeMathRandom } from './utils/randomSafeContext'; import { _getSpanForScope, _setSpanForScope } from './utils/spanOnScope'; import { truncate } from './utils/string'; import { dateTimestampInSeconds } from './utils/time'; @@ -168,7 +169,7 @@ export class Scope { this._sdkProcessingMetadata = {}; this._propagationContext = { traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }; } @@ -306,8 +307,8 @@ export class Scope { /** * Sets attributes onto the scope. * - * These attributes are currently only applied to logs. - * In the future, they will also be applied to metrics and spans. + * These attributes are currently applied to logs and metrics. + * In the future, they will also be applied to spans. * * Important: For now, only strings, numbers and boolean attributes are supported, despite types allowing for * more complex attribute types. We'll add this support in the future but already specify the wider type to @@ -338,8 +339,8 @@ export class Scope { /** * Sets an attribute onto the scope. * - * These attributes are currently only applied to logs. - * In the future, they will also be applied to metrics and spans. + * These attributes are currently applied to logs and metrics. + * In the future, they will also be applied to spans. * * Important: For now, only strings, numbers and boolean attributes are supported, despite types allowing for * more complex attribute types. We'll add this support in the future but already specify the wider type to @@ -550,7 +551,10 @@ export class Scope { this._session = undefined; _setSpanForScope(this, undefined); this._attachments = []; - this.setPropagationContext({ traceId: generateTraceId(), sampleRand: Math.random() }); + this.setPropagationContext({ + traceId: generateTraceId(), + sampleRand: safeMathRandom(), + }); this._notifyScopeListeners(); return this; diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index e2808d5f2642..7959ee05bcdf 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -115,6 +115,11 @@ export const GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE = 'gen_ai.usage.total_tokens'; */ export const GEN_AI_OPERATION_NAME_ATTRIBUTE = 'gen_ai.operation.name'; +/** + * Original length of messages array, used to indicate truncations had occured + */ +export const GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE = 'gen_ai.request.messages.original_length'; + /** * The prompt messages * Only recorded when recordInputs is enabled @@ -154,6 +159,13 @@ export const GEN_AI_AGENT_NAME_ATTRIBUTE = 'gen_ai.agent.name'; */ export const GEN_AI_PIPELINE_NAME_ATTRIBUTE = 'gen_ai.pipeline.name'; +/** + * The conversation ID for linking messages across API calls + * For OpenAI Assistants API: thread_id + * For LangGraph: configurable.thread_id + */ +export const GEN_AI_CONVERSATION_ID_ATTRIBUTE = 'gen_ai.conversation.id'; + /** * The number of cache creation input tokens used */ @@ -179,6 +191,41 @@ export const GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE = 'gen_ai.usage.input_to */ export const GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE = 'gen_ai.invoke_agent'; +/** + * The span operation name for generating text + */ +export const GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE = 'gen_ai.generate_text'; + +/** + * The span operation name for streaming text + */ +export const GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE = 'gen_ai.stream_text'; + +/** + * The span operation name for generating object + */ +export const GEN_AI_GENERATE_OBJECT_DO_GENERATE_OPERATION_ATTRIBUTE = 'gen_ai.generate_object'; + +/** + * The span operation name for streaming object + */ +export const GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE = 'gen_ai.stream_object'; + +/** + * The span operation name for embedding + */ +export const GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE = 'gen_ai.embed'; + +/** + * The span operation name for embedding many + */ +export const GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE = 'gen_ai.embed_many'; + +/** + * The span operation name for executing a tool + */ +export const GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE = 'gen_ai.execute_tool'; + // ============================================================================= // OPENAI-SPECIFIC ATTRIBUTES // ============================================================================= @@ -219,6 +266,7 @@ export const OPENAI_OPERATIONS = { CHAT: 'chat', RESPONSES: 'responses', EMBEDDINGS: 'embeddings', + CONVERSATIONS: 'conversations', } as const; // ============================================================================= diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index d8d06efdc9e5..49ed1c3b3354 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -12,7 +12,6 @@ import { GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, - GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, @@ -24,13 +23,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { - buildMethodPath, - getFinalOperationName, - getSpanOperation, - getTruncatedJsonString, - setTokenUsageAttributes, -} from '../ai/utils'; +import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { AnthropicAiInstrumentedMethod, @@ -39,7 +32,7 @@ import type { AnthropicAiStreamingEvent, ContentBlock, } from './types'; -import { handleResponseError, messagesFromParams, shouldInstrument } from './utils'; +import { handleResponseError, messagesFromParams, setMessagesAttribute, shouldInstrument } from './utils'; /** * Extract request attributes from method arguments @@ -83,15 +76,7 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { const messages = messagesFromParams(params); - if (messages.length) { - const truncatedMessages = getTruncatedJsonString(messages); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); - } - - if ('input' in params) { - const truncatedInput = getTruncatedJsonString(params.input); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); - } + setMessagesAttribute(span, messages); if ('prompt' in params) { span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); diff --git a/packages/core/src/tracing/anthropic-ai/utils.ts b/packages/core/src/tracing/anthropic-ai/utils.ts index 01f86b41adfc..f10b3ebe6358 100644 --- a/packages/core/src/tracing/anthropic-ai/utils.ts +++ b/packages/core/src/tracing/anthropic-ai/utils.ts @@ -1,6 +1,11 @@ import { captureException } from '../../exports'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types-hoist/span'; +import { + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types'; @@ -11,6 +16,19 @@ export function shouldInstrument(methodPath: string): methodPath is AnthropicAiI return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); } +/** + * Set the messages and messages original length attributes. + */ +export function setMessagesAttribute(span: Span, messages: unknown): void { + const length = Array.isArray(messages) ? messages.length : undefined; + if (length !== 0) { + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(messages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: length, + }); + } +} + /** * Capture error information from the response * @see https://docs.anthropic.com/en/api/errors#error-shapes @@ -32,11 +50,15 @@ export function handleResponseError(span: Span, response: AnthropicAiResponse): * Include the system prompt in the messages list, if available */ export function messagesFromParams(params: Record): unknown[] { - const { system, messages } = params; + const { system, messages, input } = params; const systemMessages = typeof system === 'string' ? [{ role: 'system', content: params.system }] : []; - const userMessages = Array.isArray(messages) ? messages : messages != null ? [messages] : []; + const inputParamMessages = Array.isArray(input) ? input : input != null ? [input] : undefined; + + const messagesParamMessages = Array.isArray(messages) ? messages : messages != null ? [messages] : []; + + const userMessages = inputParamMessages ?? messagesParamMessages; return [...systemMessages, ...userMessages]; } diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index 9c53e09fd1ca..53af7a9632cb 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -11,6 +11,7 @@ import { GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, @@ -165,8 +166,9 @@ function addPrivateRequestAttributes(span: Span, params: Record messages.push(...contentUnionToMessages(params.message as PartListUnion, 'user')); } - if (messages.length) { + if (Array.isArray(messages) && messages.length) { span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: messages.length, [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncateGenAiMessages(messages)), }); } diff --git a/packages/core/src/tracing/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts index 9a8fa9aed26d..0a07ae8df370 100644 --- a/packages/core/src/tracing/langchain/utils.ts +++ b/packages/core/src/tracing/langchain/utils.ts @@ -5,6 +5,7 @@ import { GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, @@ -253,6 +254,7 @@ export function extractLLMRequestAttributes( const attrs = baseRequestAttributes(system, modelName, 'pipeline', llm, invocationParams, langSmithMetadata); if (recordInputs && Array.isArray(prompts) && prompts.length > 0) { + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, prompts.length); const messages = prompts.map(p => ({ role: 'user', content: p })); setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(messages)); } @@ -282,6 +284,7 @@ export function extractChatModelRequestAttributes( if (recordInputs && Array.isArray(langChainMessages) && langChainMessages.length > 0) { const normalized = normalizeLangChainMessages(langChainMessages.flat()); + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, normalized.length); const truncated = truncateGenAiMessages(normalized); setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(truncated)); } diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index 5601cddf458b..c0800e05e6da 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -3,11 +3,13 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from ' import { SPAN_STATUS_ERROR } from '../../tracing'; import { GEN_AI_AGENT_NAME_ATTRIBUTE, + GEN_AI_CONVERSATION_ID_ATTRIBUTE, GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_PIPELINE_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { truncateGenAiMessages } from '../ai/messageTruncation'; import type { LangChainMessage } from '../langchain/types'; @@ -113,6 +115,15 @@ function instrumentCompiledGraphInvoke( span.updateName(`invoke_agent ${graphName}`); } + // Extract thread_id from the config (second argument) + // LangGraph uses config.configurable.thread_id for conversation/session linking + const config = args.length > 1 ? (args[1] as Record | undefined) : undefined; + const configurable = config?.configurable as Record | undefined; + const threadId = configurable?.thread_id; + if (threadId && typeof threadId === 'string') { + span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, threadId); + } + // Extract available tools from the graph instance const tools = extractToolsFromCompiledGraph(graphInstance); if (tools) { @@ -128,7 +139,10 @@ function instrumentCompiledGraphInvoke( if (inputMessages && recordInputs) { const normalizedMessages = normalizeLangChainMessages(inputMessages); const truncatedMessages = truncateGenAiMessages(normalizedMessages); - span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, JSON.stringify(truncatedMessages)); + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(truncatedMessages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: normalizedMessages.length, + }); } // Call original invoke diff --git a/packages/core/src/tracing/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts index e8b5c6ddc87f..426cda443680 100644 --- a/packages/core/src/tracing/openai/constants.ts +++ b/packages/core/src/tracing/openai/constants.ts @@ -2,7 +2,15 @@ export const OPENAI_INTEGRATION_NAME = 'OpenAI'; // https://platform.openai.com/docs/quickstart?api-mode=responses // https://platform.openai.com/docs/quickstart?api-mode=chat -export const INSTRUMENTED_METHODS = ['responses.create', 'chat.completions.create', 'embeddings.create'] as const; +// https://platform.openai.com/docs/api-reference/conversations +export const INSTRUMENTED_METHODS = [ + 'responses.create', + 'chat.completions.create', + 'embeddings.create', + // Conversations API - for conversation state management + // https://platform.openai.com/docs/guides/conversation-state + 'conversations.create', +] as const; export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ 'response.output_item.added', 'response.function_call_arguments.delta', diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index c68e920daf2b..6789f5fca3ce 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -7,15 +7,9 @@ import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, - GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE, - GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, - GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, - GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, - GEN_AI_REQUEST_STREAM_ATTRIBUTE, - GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, - GEN_AI_REQUEST_TOP_P_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; @@ -31,17 +25,34 @@ import type { } from './types'; import { addChatCompletionAttributes, + addConversationAttributes, addEmbeddingsAttributes, addResponsesApiAttributes, buildMethodPath, + extractRequestParameters, getOperationName, getSpanOperation, isChatCompletionResponse, + isConversationResponse, isEmbeddingsResponse, isResponsesApiResponse, shouldInstrument, } from './utils'; +/** + * Extract available tools from request parameters + */ +function extractAvailableTools(params: Record): string | undefined { + const tools = Array.isArray(params.tools) ? params.tools : []; + const hasWebSearchOptions = params.web_search_options && typeof params.web_search_options === 'object'; + const webSearchOptions = hasWebSearchOptions + ? [{ type: 'web_search_options', ...(params.web_search_options as Record) }] + : []; + + const availableTools = [...tools, ...webSearchOptions]; + return availableTools.length > 0 ? JSON.stringify(availableTools) : undefined; +} + /** * Extract request attributes from method arguments */ @@ -52,36 +63,15 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record 0 && typeof args[0] === 'object' && args[0] !== null) { const params = args[0] as Record; - const tools = Array.isArray(params.tools) ? params.tools : []; - const hasWebSearchOptions = params.web_search_options && typeof params.web_search_options === 'object'; - const webSearchOptions = hasWebSearchOptions - ? [{ type: 'web_search_options', ...(params.web_search_options as Record) }] - : []; - - const availableTools = [...tools, ...webSearchOptions]; - - if (availableTools.length > 0) { - attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = JSON.stringify(availableTools); + const availableTools = extractAvailableTools(params); + if (availableTools) { + attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = availableTools; } - } - - if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { - const params = args[0] as Record; - attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = params.model ?? 'unknown'; - if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature; - if ('top_p' in params) attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = params.top_p; - if ('frequency_penalty' in params) - attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = params.frequency_penalty; - if ('presence_penalty' in params) attributes[GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE] = params.presence_penalty; - if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; - if ('encoding_format' in params) attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE] = params.encoding_format; - if ('dimensions' in params) attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] = params.dimensions; + Object.assign(attributes, extractRequestParameters(params)); } else { attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = 'unknown'; } @@ -91,7 +81,7 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { - if ('messages' in params) { - const truncatedMessages = getTruncatedJsonString(params.messages); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); - } - if ('input' in params) { - const truncatedInput = getTruncatedJsonString(params.input); - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); + const src = 'input' in params ? params.input : 'messages' in params ? params.messages : undefined; + // typically an array, but can be other types. skip if an empty array. + const length = Array.isArray(src) ? src.length : undefined; + if (src && length !== 0) { + const truncatedInput = getTruncatedJsonString(src); + span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, truncatedInput); + if (length) { + span.setAttribute(GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, length); + } } } diff --git a/packages/core/src/tracing/openai/types.ts b/packages/core/src/tracing/openai/types.ts index 6dcd644bfe17..94809041d94e 100644 --- a/packages/core/src/tracing/openai/types.ts +++ b/packages/core/src/tracing/openai/types.ts @@ -153,7 +153,22 @@ export interface OpenAICreateEmbeddingsObject { }; } -export type OpenAiResponse = OpenAiChatCompletionObject | OpenAIResponseObject | OpenAICreateEmbeddingsObject; +/** + * OpenAI Conversations API Conversation object + * @see https://platform.openai.com/docs/api-reference/conversations + */ +export interface OpenAIConversationObject { + id: string; + object: 'conversation'; + created_at: number; + metadata?: Record; +} + +export type OpenAiResponse = + | OpenAiChatCompletionObject + | OpenAIResponseObject + | OpenAICreateEmbeddingsObject + | OpenAIConversationObject; /** * Streaming event types for the Responses API diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index 4dff5b4fdbb8..007dd93a91b1 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -1,5 +1,14 @@ import type { Span } from '../../types-hoist/span'; import { + GEN_AI_CONVERSATION_ID_ATTRIBUTE, + GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE, + GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P_ATTRIBUTE, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, @@ -19,6 +28,7 @@ import type { ChatCompletionChunk, InstrumentedMethod, OpenAiChatCompletionObject, + OpenAIConversationObject, OpenAICreateEmbeddingsObject, OpenAIResponseObject, ResponseStreamingEvent, @@ -37,6 +47,9 @@ export function getOperationName(methodPath: string): string { if (methodPath.includes('embeddings')) { return OPENAI_OPERATIONS.EMBEDDINGS; } + if (methodPath.includes('conversations')) { + return OPENAI_OPERATIONS.CONVERSATIONS; + } return methodPath.split('.').pop() || 'unknown'; } @@ -101,6 +114,19 @@ export function isEmbeddingsResponse(response: unknown): response is OpenAICreat ); } +/** + * Check if response is a Conversations API object + * @see https://platform.openai.com/docs/api-reference/conversations + */ +export function isConversationResponse(response: unknown): response is OpenAIConversationObject { + return ( + response !== null && + typeof response === 'object' && + 'object' in response && + (response as Record).object === 'conversation' + ); +} + /** * Check if streaming event is from the Responses API */ @@ -221,6 +247,27 @@ export function addEmbeddingsAttributes(span: Span, response: OpenAICreateEmbedd } } +/** + * Add attributes for Conversations API responses + * @see https://platform.openai.com/docs/api-reference/conversations + */ +export function addConversationAttributes(span: Span, response: OpenAIConversationObject): void { + const { id, created_at } = response; + + span.setAttributes({ + [OPENAI_RESPONSE_ID_ATTRIBUTE]: id, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: id, + // The conversation id is used to link messages across API calls + [GEN_AI_CONVERSATION_ID_ATTRIBUTE]: id, + }); + + if (created_at) { + span.setAttributes({ + [OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(created_at * 1000).toISOString(), + }); + } +} + /** * Set token usage attributes * @param span - The span to add attributes to @@ -273,3 +320,45 @@ export function setCommonResponseAttributes(span: Span, id: string, model: strin [OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(timestamp * 1000).toISOString(), }); } + +/** + * Extract conversation ID from request parameters + * Supports both Conversations API and previous_response_id chaining + * @see https://platform.openai.com/docs/guides/conversation-state + */ +function extractConversationId(params: Record): string | undefined { + // Conversations API: conversation parameter (e.g., "conv_...") + if ('conversation' in params && typeof params.conversation === 'string') { + return params.conversation; + } + // Responses chaining: previous_response_id links to parent response + if ('previous_response_id' in params && typeof params.previous_response_id === 'string') { + return params.previous_response_id; + } + return undefined; +} + +/** + * Extract request parameters including model settings and conversation context + */ +export function extractRequestParameters(params: Record): Record { + const attributes: Record = { + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: params.model ?? 'unknown', + }; + + if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature; + if ('top_p' in params) attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = params.top_p; + if ('frequency_penalty' in params) attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = params.frequency_penalty; + if ('presence_penalty' in params) attributes[GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE] = params.presence_penalty; + if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; + if ('encoding_format' in params) attributes[GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE] = params.encoding_format; + if ('dimensions' in params) attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE] = params.dimensions; + + // Capture conversation ID for linking messages across API calls + const conversationId = extractConversationId(params); + if (conversationId) { + attributes[GEN_AI_CONVERSATION_ID_ATTRIBUTE] = conversationId; + } + + return attributes; +} diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index b147bb92fa63..28a5bccd4147 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -17,6 +17,7 @@ import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; +import { safeMathRandom } from '../utils/randomSafeContext'; import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope'; import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing'; @@ -293,7 +294,7 @@ export function startNewTrace(callback: () => T): T { return withScope(scope => { scope.setPropagationContext({ traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }); DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); return withActiveSpan(null, callback); diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index e64b4b1a9cbf..3415852ac4f3 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -19,9 +19,10 @@ import { accumulateTokensForParent, applyAccumulatedTokens, convertAvailableToolsToJsonString, + getSpanOpFromName, requestMessagesFromPrompt, } from './utils'; -import type { ProviderMetadata } from './vercel-ai-attributes'; +import type { OpenAiProviderMetadata, ProviderMetadata } from './vercel-ai-attributes'; import { AI_MODEL_ID_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE, @@ -64,10 +65,8 @@ function onVercelAiSpanStart(span: Span): void { return; } - // The AI model ID must be defined for generate, stream, and embed spans. - // The provider is optional and may not always be present. - const aiModelId = attributes[AI_MODEL_ID_ATTRIBUTE]; - if (typeof aiModelId !== 'string' || !aiModelId) { + // Check if this is a Vercel AI span by name pattern. + if (!name.startsWith('ai.')) { return; } @@ -225,76 +224,35 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute } span.setAttribute('ai.streaming', name.includes('stream')); - // Generate Spans - if (name === 'ai.generateText') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.generateText.doGenerate') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_text'); - span.updateName(`generate_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.streamText') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.streamText.doStream') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_text'); - span.updateName(`stream_text ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.generateObject') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.generateObject.doGenerate') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.generate_object'); - span.updateName(`generate_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.streamObject') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.streamObject.doStream') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.stream_object'); - span.updateName(`stream_object ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.embed') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; - } - - if (name === 'ai.embed.doEmbed') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed'); - span.updateName(`embed ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name === 'ai.embedMany') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.invoke_agent'); - return; + // Set the op based on the span name + const op = getSpanOpFromName(name); + if (op) { + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, op); } - if (name === 'ai.embedMany.doEmbed') { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.embed_many'); - span.updateName(`embed_many ${attributes[AI_MODEL_ID_ATTRIBUTE]}`); - return; - } - - if (name.startsWith('ai.stream')) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'ai.run'); - return; + // Update span names for .do* spans to include the model ID (only if model ID exists) + const modelId = attributes[AI_MODEL_ID_ATTRIBUTE]; + if (modelId) { + switch (name) { + case 'ai.generateText.doGenerate': + span.updateName(`generate_text ${modelId}`); + break; + case 'ai.streamText.doStream': + span.updateName(`stream_text ${modelId}`); + break; + case 'ai.generateObject.doGenerate': + span.updateName(`generate_object ${modelId}`); + break; + case 'ai.streamObject.doStream': + span.updateName(`stream_object ${modelId}`); + break; + case 'ai.embed.doEmbed': + span.updateName(`embed ${modelId}`); + break; + case 'ai.embedMany.doEmbed': + span.updateName(`embed_many ${modelId}`); + break; + } } } @@ -312,28 +270,28 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { if (providerMetadata) { try { const providerMetadataObject = JSON.parse(providerMetadata) as ProviderMetadata; - if (providerMetadataObject.openai) { + + // Handle OpenAI metadata (v5 uses 'openai', v6 Azure Responses API uses 'azure') + const openaiMetadata: OpenAiProviderMetadata | undefined = + providerMetadataObject.openai ?? providerMetadataObject.azure; + if (openaiMetadata) { setAttributeIfDefined( attributes, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, - providerMetadataObject.openai.cachedPromptTokens, - ); - setAttributeIfDefined( - attributes, - 'gen_ai.usage.output_tokens.reasoning', - providerMetadataObject.openai.reasoningTokens, + openaiMetadata.cachedPromptTokens, ); + setAttributeIfDefined(attributes, 'gen_ai.usage.output_tokens.reasoning', openaiMetadata.reasoningTokens); setAttributeIfDefined( attributes, 'gen_ai.usage.output_tokens.prediction_accepted', - providerMetadataObject.openai.acceptedPredictionTokens, + openaiMetadata.acceptedPredictionTokens, ); setAttributeIfDefined( attributes, 'gen_ai.usage.output_tokens.prediction_rejected', - providerMetadataObject.openai.rejectedPredictionTokens, + openaiMetadata.rejectedPredictionTokens, ); - setAttributeIfDefined(attributes, 'gen_ai.conversation.id', providerMetadataObject.openai.responseId); + setAttributeIfDefined(attributes, 'gen_ai.conversation.id', openaiMetadata.responseId); } if (providerMetadataObject.anthropic) { diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index bc390ccc1672..05dcc1f43817 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -1,7 +1,16 @@ import type { TraceContext } from '../../types-hoist/context'; import type { Span, SpanAttributes, SpanJSON } from '../../types-hoist/span'; import { + GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE, + GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE, + GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE, + GEN_AI_GENERATE_OBJECT_DO_GENERATE_OPERATION_ATTRIBUTE, + GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE, + GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, + GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE, + GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; @@ -134,6 +143,57 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes !attributes[AI_PROMPT_MESSAGES_ATTRIBUTE] ) { const messages = convertPromptToMessages(prompt); - if (messages.length) span.setAttribute(GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, getTruncatedJsonString(messages)); + if (messages.length) { + span.setAttributes({ + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(messages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: messages.length, + }); + } + } else if (typeof attributes[AI_PROMPT_MESSAGES_ATTRIBUTE] === 'string') { + try { + const messages = JSON.parse(attributes[AI_PROMPT_MESSAGES_ATTRIBUTE]); + if (Array.isArray(messages)) { + span.setAttributes({ + [AI_PROMPT_MESSAGES_ATTRIBUTE]: undefined, + [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: getTruncatedJsonString(messages), + [GEN_AI_REQUEST_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: messages.length, + }); + } + // eslint-disable-next-line no-empty + } catch {} + } +} + +/** + * Maps a Vercel AI span name to the corresponding Sentry op. + */ +export function getSpanOpFromName(name: string): string | undefined { + switch (name) { + case 'ai.generateText': + case 'ai.streamText': + case 'ai.generateObject': + case 'ai.streamObject': + case 'ai.embed': + case 'ai.embedMany': + return GEN_AI_INVOKE_AGENT_OPERATION_ATTRIBUTE; + case 'ai.generateText.doGenerate': + return GEN_AI_GENERATE_TEXT_DO_GENERATE_OPERATION_ATTRIBUTE; + case 'ai.streamText.doStream': + return GEN_AI_STREAM_TEXT_DO_STREAM_OPERATION_ATTRIBUTE; + case 'ai.generateObject.doGenerate': + return GEN_AI_GENERATE_OBJECT_DO_GENERATE_OPERATION_ATTRIBUTE; + case 'ai.streamObject.doStream': + return GEN_AI_STREAM_OBJECT_DO_STREAM_OPERATION_ATTRIBUTE; + case 'ai.embed.doEmbed': + return GEN_AI_EMBED_DO_EMBED_OPERATION_ATTRIBUTE; + case 'ai.embedMany.doEmbed': + return GEN_AI_EMBED_MANY_DO_EMBED_OPERATION_ATTRIBUTE; + case 'ai.toolCall': + return GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE; + default: + if (name.startsWith('ai.stream')) { + return 'ai.run'; + } + return undefined; } } diff --git a/packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts b/packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts index 95052fc1265a..3bb37e6a429a 100644 --- a/packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts +++ b/packages/core/src/tracing/vercel-ai/vercel-ai-attributes.ts @@ -821,7 +821,7 @@ export const AI_TOOL_CALL_SPAN_ATTRIBUTES = { * @see https://github.com/vercel/ai/blob/65e042afde6aad4da9d7a62526ece839eb34f9a5/packages/openai/src/openai-chat-language-model.ts#L397-L416 * @see https://github.com/vercel/ai/blob/65e042afde6aad4da9d7a62526ece839eb34f9a5/packages/openai/src/responses/openai-responses-language-model.ts#L377C7-L384 */ -interface OpenAiProviderMetadata { +export interface OpenAiProviderMetadata { /** * The number of predicted output tokens that were accepted. * @see https://ai-sdk.dev/providers/ai-sdk-providers/openai#predicted-outputs @@ -1041,9 +1041,11 @@ interface PerplexityProviderMetadata { export interface ProviderMetadata { openai?: OpenAiProviderMetadata; + azure?: OpenAiProviderMetadata; // v6: Azure Responses API uses 'azure' key instead of 'openai' anthropic?: AnthropicProviderMetadata; bedrock?: AmazonBedrockProviderMetadata; google?: GoogleGenerativeAIProviderMetadata; + vertex?: GoogleGenerativeAIProviderMetadata; // v6: Google Vertex uses 'vertex' key instead of 'google' deepseek?: DeepSeekProviderMetadata; perplexity?: PerplexityProviderMetadata; } diff --git a/packages/core/src/types-hoist/metric.ts b/packages/core/src/types-hoist/metric.ts index 6ac63da6032b..976fc9fe863f 100644 --- a/packages/core/src/types-hoist/metric.ts +++ b/packages/core/src/types-hoist/metric.ts @@ -1,3 +1,5 @@ +import type { Attributes, TypedAttributeValue } from '../attributes'; + export type MetricType = 'counter' | 'gauge' | 'distribution'; export interface Metric { @@ -27,11 +29,10 @@ export interface Metric { attributes?: Record; } -export type SerializedMetricAttributeValue = - | { value: string; type: 'string' } - | { value: number; type: 'integer' } - | { value: number; type: 'double' } - | { value: boolean; type: 'boolean' }; +/** + * @deprecated this was not intended for public consumption + */ +export type SerializedMetricAttributeValue = TypedAttributeValue; export interface SerializedMetric { /** @@ -72,7 +73,7 @@ export interface SerializedMetric { /** * Arbitrary structured data that stores information about the metric. */ - attributes?: Record; + attributes?: Attributes; } export type SerializedMetricContainer = { diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 3d4ad7b67ea5..ac4ce839ff85 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -78,7 +78,7 @@ export interface ClientOptions crypto.randomUUID!()).replace(/-/g, ''); } } catch { // some runtimes can crash invoking crypto diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index fd1cb62440f4..3a127d332686 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -1,16 +1,15 @@ import type { Client } from '../client'; import { DEFAULT_ENVIRONMENT } from '../constants'; -import { getGlobalScope } from '../currentScopes'; import { notifyEventProcessors } from '../eventProcessors'; import type { CaptureContext, ScopeContext } from '../scope'; import { Scope } from '../scope'; import type { Event, EventHint } from '../types-hoist/event'; import type { ClientOptions } from '../types-hoist/options'; import type { StackParser } from '../types-hoist/stacktrace'; -import { applyScopeDataToEvent, mergeScopeData } from './applyScopeDataToEvent'; import { getFilenameToDebugIdMap } from './debug-ids'; import { addExceptionMechanism, uuid4 } from './misc'; import { normalize } from './normalize'; +import { applyScopeDataToEvent, getCombinedScopeData } from './scopeData'; import { truncate } from './string'; import { dateTimestampInSeconds } from './time'; @@ -79,17 +78,7 @@ export function prepareEvent( // This should be the last thing called, since we want that // {@link Scope.addEventProcessor} gets the finished prepared event. // Merge scope data together - const data = getGlobalScope().getScopeData(); - - if (isolationScope) { - const isolationData = isolationScope.getScopeData(); - mergeScopeData(data, isolationData); - } - - if (finalScope) { - const finalScopeData = finalScope.getScopeData(); - mergeScopeData(data, finalScopeData); - } + const data = getCombinedScopeData(isolationScope, finalScope); const attachments = [...(hint.attachments || []), ...data.attachments]; if (attachments.length) { diff --git a/packages/core/src/utils/randomSafeContext.ts b/packages/core/src/utils/randomSafeContext.ts new file mode 100644 index 000000000000..ce4bf5a8f16d --- /dev/null +++ b/packages/core/src/utils/randomSafeContext.ts @@ -0,0 +1,43 @@ +import { GLOBAL_OBJ } from './worldwide'; + +export type RandomSafeContextRunner = (callback: () => T) => T; + +// undefined = not yet resolved, null = no runner found, function = runner found +let RESOLVED_RUNNER: RandomSafeContextRunner | null | undefined; + +/** + * Simple wrapper that allows SDKs to *secretly* set context wrapper to generate safe random IDs in cache components contexts + */ +export function withRandomSafeContext(cb: () => T): T { + // Skips future symbol lookups if we've already resolved (or attempted to resolve) the runner once + if (RESOLVED_RUNNER !== undefined) { + return RESOLVED_RUNNER ? RESOLVED_RUNNER(cb) : cb(); + } + + const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: RandomSafeContextRunner } = GLOBAL_OBJ; + + if (sym in globalWithSymbol && typeof globalWithSymbol[sym] === 'function') { + RESOLVED_RUNNER = globalWithSymbol[sym]; + return RESOLVED_RUNNER(cb); + } + + RESOLVED_RUNNER = null; + return cb(); +} + +/** + * Identical to Math.random() but wrapped in withRandomSafeContext + * to ensure safe random number generation in certain contexts (e.g., Next.js Cache Components). + */ +export function safeMathRandom(): number { + return withRandomSafeContext(() => Math.random()); +} + +/** + * Identical to Date.now() but wrapped in withRandomSafeContext + * to ensure safe time value generation in certain contexts (e.g., Next.js Cache Components). + */ +export function safeDateNow(): number { + return withRandomSafeContext(() => Date.now()); +} diff --git a/packages/core/src/utils/ratelimit.ts b/packages/core/src/utils/ratelimit.ts index 4cb8cb9d07a5..606969d88858 100644 --- a/packages/core/src/utils/ratelimit.ts +++ b/packages/core/src/utils/ratelimit.ts @@ -1,5 +1,6 @@ import type { DataCategory } from '../types-hoist/datacategory'; import type { TransportMakeRequestResponse } from '../types-hoist/transport'; +import { safeDateNow } from './randomSafeContext'; // Intentionally keeping the key broad, as we don't know for sure what rate limit headers get returned from backend export type RateLimits = Record; @@ -12,7 +13,7 @@ export const DEFAULT_RETRY_AFTER = 60 * 1000; // 60 seconds * @param now current unix timestamp * */ -export function parseRetryAfterHeader(header: string, now: number = Date.now()): number { +export function parseRetryAfterHeader(header: string, now: number = safeDateNow()): number { const headerDelay = parseInt(`${header}`, 10); if (!isNaN(headerDelay)) { return headerDelay * 1000; @@ -40,7 +41,7 @@ export function disabledUntil(limits: RateLimits, dataCategory: DataCategory): n /** * Checks if a category is rate limited */ -export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = Date.now()): boolean { +export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, now: number = safeDateNow()): boolean { return disabledUntil(limits, dataCategory) > now; } @@ -52,7 +53,7 @@ export function isRateLimited(limits: RateLimits, dataCategory: DataCategory, no export function updateRateLimits( limits: RateLimits, { statusCode, headers }: TransportMakeRequestResponse, - now: number = Date.now(), + now: number = safeDateNow(), ): RateLimits { const updatedRateLimits: RateLimits = { ...limits, diff --git a/packages/core/src/utils/applyScopeDataToEvent.ts b/packages/core/src/utils/scopeData.ts similarity index 90% rename from packages/core/src/utils/applyScopeDataToEvent.ts rename to packages/core/src/utils/scopeData.ts index 3770c41977dc..6d8f68c747b5 100644 --- a/packages/core/src/utils/applyScopeDataToEvent.ts +++ b/packages/core/src/utils/scopeData.ts @@ -1,4 +1,5 @@ -import type { ScopeData } from '../scope'; +import { getGlobalScope } from '../currentScopes'; +import type { Scope, ScopeData } from '../scope'; import { getDynamicSamplingContextFromSpan } from '../tracing/dynamicSamplingContext'; import type { Breadcrumb } from '../types-hoist/breadcrumb'; import type { Event } from '../types-hoist/event'; @@ -113,6 +114,20 @@ export function mergeArray( event[prop] = merged.length ? merged : undefined; } +/** + * Get the scope data for the current scope after merging with the + * global scope and isolation scope. + * + * @param currentScope - The current scope. + * @returns The scope data. + */ +export function getCombinedScopeData(isolationScope: Scope | undefined, currentScope: Scope | undefined): ScopeData { + const scopeData = getGlobalScope().getScopeData(); + isolationScope && mergeScopeData(scopeData, isolationScope.getScopeData()); + currentScope && mergeScopeData(scopeData, currentScope.getScopeData()); + return scopeData; +} + function applyDataToEvent(event: Event, data: ScopeData): void { const { extra, tags, user, contexts, level, transactionName } = data; diff --git a/packages/core/src/utils/time.ts b/packages/core/src/utils/time.ts index ff858a15b0ac..10a5103b2fc1 100644 --- a/packages/core/src/utils/time.ts +++ b/packages/core/src/utils/time.ts @@ -1,3 +1,4 @@ +import { safeDateNow, withRandomSafeContext } from './randomSafeContext'; import { GLOBAL_OBJ } from './worldwide'; const ONE_SECOND_IN_MS = 1000; @@ -21,7 +22,7 @@ interface Performance { * Returns a timestamp in seconds since the UNIX epoch using the Date API. */ export function dateTimestampInSeconds(): number { - return Date.now() / ONE_SECOND_IN_MS; + return safeDateNow() / ONE_SECOND_IN_MS; } /** @@ -50,7 +51,7 @@ function createUnixTimestampInSecondsFunc(): () => number { // See: https://github.com/mdn/content/issues/4713 // See: https://dev.to/noamr/when-a-millisecond-is-not-a-millisecond-3h6 return () => { - return (timeOrigin + performance.now()) / ONE_SECOND_IN_MS; + return (timeOrigin + withRandomSafeContext(() => performance.now())) / ONE_SECOND_IN_MS; }; } @@ -74,30 +75,38 @@ export function timestampInSeconds(): number { /** * Cached result of getBrowserTimeOrigin. */ -let cachedTimeOrigin: [number | undefined, string] | undefined; +let cachedTimeOrigin: number | null | undefined = null; /** * Gets the time origin and the mode used to determine it. + * + * Unfortunately browsers may report an inaccurate time origin data, through either performance.timeOrigin or + * performance.timing.navigationStart, which results in poor results in performance data. We only treat time origin + * data as reliable if they are within a reasonable threshold of the current time. + * + * TODO: move to `@sentry/browser-utils` package. */ -function getBrowserTimeOrigin(): [number | undefined, string] { - // Unfortunately browsers may report an inaccurate time origin data, through either performance.timeOrigin or - // performance.timing.navigationStart, which results in poor results in performance data. We only treat time origin - // data as reliable if they are within a reasonable threshold of the current time. - +function getBrowserTimeOrigin(): number | undefined { const { performance } = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; if (!performance?.now) { - return [undefined, 'none']; + return undefined; } - const threshold = 3600 * 1000; - const performanceNow = performance.now(); - const dateNow = Date.now(); + const threshold = 300_000; // 5 minutes in milliseconds + const performanceNow = withRandomSafeContext(() => performance.now()); + const dateNow = safeDateNow(); + + const timeOrigin = performance.timeOrigin; + if (typeof timeOrigin === 'number') { + const timeOriginDelta = Math.abs(timeOrigin + performanceNow - dateNow); + if (timeOriginDelta < threshold) { + return timeOrigin; + } + } - // if timeOrigin isn't available set delta to threshold so it isn't used - const timeOriginDelta = performance.timeOrigin - ? Math.abs(performance.timeOrigin + performanceNow - dateNow) - : threshold; - const timeOriginIsReliable = timeOriginDelta < threshold; + // TODO: Remove all code related to `performance.timing.navigationStart` once we drop support for Safari 14. + // `performance.timeSince` is available in Safari 15. + // see: https://caniuse.com/mdn-api_performance_timeorigin // While performance.timing.navigationStart is deprecated in favor of performance.timeOrigin, performance.timeOrigin // is not as widely supported. Namely, performance.timeOrigin is undefined in Safari as of writing. @@ -106,22 +115,16 @@ function getBrowserTimeOrigin(): [number | undefined, string] { // Date API. // eslint-disable-next-line deprecation/deprecation const navigationStart = performance.timing?.navigationStart; - const hasNavigationStart = typeof navigationStart === 'number'; - // if navigationStart isn't available set delta to threshold so it isn't used - const navigationStartDelta = hasNavigationStart ? Math.abs(navigationStart + performanceNow - dateNow) : threshold; - const navigationStartIsReliable = navigationStartDelta < threshold; - - if (timeOriginIsReliable || navigationStartIsReliable) { - // Use the more reliable time origin - if (timeOriginDelta <= navigationStartDelta) { - return [performance.timeOrigin, 'timeOrigin']; - } else { - return [navigationStart, 'navigationStart']; + if (typeof navigationStart === 'number') { + const navigationStartDelta = Math.abs(navigationStart + performanceNow - dateNow); + if (navigationStartDelta < threshold) { + return navigationStart; } } - // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to Date. - return [dateNow, 'dateNow']; + // Either both timeOrigin and navigationStart are skewed or neither is available, fallback to subtracting + // `performance.now()` from `Date.now()`. + return dateNow - performanceNow; } /** @@ -129,9 +132,9 @@ function getBrowserTimeOrigin(): [number | undefined, string] { * performance API is available. */ export function browserPerformanceTimeOrigin(): number | undefined { - if (!cachedTimeOrigin) { + if (cachedTimeOrigin === null) { cachedTimeOrigin = getBrowserTimeOrigin(); } - return cachedTimeOrigin[0]; + return cachedTimeOrigin; } diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index aa5a15153674..25e3295118f8 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -7,6 +7,7 @@ import { baggageHeaderToDynamicSamplingContext } from './baggage'; import { extractOrgIdFromClient } from './dsn'; import { parseSampleRate } from './parseSampleRate'; import { generateSpanId, generateTraceId } from './propagationContext'; +import { safeMathRandom } from './randomSafeContext'; // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- RegExp is used for readability here export const TRACEPARENT_REGEXP = new RegExp( @@ -65,7 +66,7 @@ export function propagationContextFromHeaders( if (!traceparentData?.traceId) { return { traceId: generateTraceId(), - sampleRand: Math.random(), + sampleRand: safeMathRandom(), }; } @@ -133,12 +134,12 @@ function getSampleRandFromTraceparentAndDsc( if (parsedSampleRate && traceparentData?.parentSampled !== undefined) { return traceparentData.parentSampled ? // Returns a sample rand with positive sampling decision [0, sampleRate) - Math.random() * parsedSampleRate + safeMathRandom() * parsedSampleRate : // Returns a sample rand with negative sampling decision [sampleRate, 1) - parsedSampleRate + Math.random() * (1 - parsedSampleRate); + parsedSampleRate + safeMathRandom() * (1 - parsedSampleRate); } else { // If nothing applies, return a random sample rand. - return Math.random(); + return safeMathRandom(); } } diff --git a/packages/core/src/utils/vercelWaitUntil.ts b/packages/core/src/utils/vercelWaitUntil.ts index bfcaa6b4b832..32d801a6723c 100644 --- a/packages/core/src/utils/vercelWaitUntil.ts +++ b/packages/core/src/utils/vercelWaitUntil.ts @@ -1,5 +1,7 @@ import { GLOBAL_OBJ } from './worldwide'; +declare const EdgeRuntime: string | undefined; + interface VercelRequestContextGlobal { get?(): | { @@ -14,6 +16,11 @@ interface VercelRequestContextGlobal { * Vendored from https://www.npmjs.com/package/@vercel/functions */ export function vercelWaitUntil(task: Promise): void { + // We only flush manually in Vercel Edge runtime + // In Node runtime, we use process.on('SIGTERM') instead + if (typeof EdgeRuntime !== 'string') { + return; + } const vercelRequestContextGlobal: VercelRequestContextGlobal | undefined = // @ts-expect-error This is not typed GLOBAL_OBJ[Symbol.for('@vercel/request-context')]; diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts index 9d9b2d5c1e9a..13b9e026e6e9 100644 --- a/packages/core/test/lib/attributes.test.ts +++ b/packages/core/test/lib/attributes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { attributeValueToTypedAttributeValue, isAttributeObject } from '../../src/attributes'; +import { attributeValueToTypedAttributeValue, isAttributeObject, serializeAttributes } from '../../src/attributes'; describe('attributeValueToTypedAttributeValue', () => { describe('without fallback (default behavior)', () => { @@ -267,8 +267,43 @@ describe('attributeValueToTypedAttributeValue', () => { type: 'string', }); }); + + it.each([null, { value: null }, { value: null, unit: 'byte' }])('stringifies %s values', value => { + const result = attributeValueToTypedAttributeValue(value, true); + expect(result).toMatchObject({ + value: 'null', + type: 'string', + }); + }); + + it.each([undefined, { value: undefined }])('stringifies %s values to ""', value => { + const result = attributeValueToTypedAttributeValue(value, true); + expect(result).toEqual({ + value: '', + type: 'string', + }); + }); + + it('stringifies undefined values with unit to ""', () => { + const result = attributeValueToTypedAttributeValue({ value: undefined, unit: 'byte' }, true); + expect(result).toEqual({ + value: '', + unit: 'byte', + type: 'string', + }); + }); }); }); + + describe('with fallback="skip-undefined"', () => { + it.each([undefined, { value: undefined }, { value: undefined, unit: 'byte' }])( + 'ignores undefined values (%s)', + value => { + const result = attributeValueToTypedAttributeValue(value, 'skip-undefined'); + expect(result).toBeUndefined(); + }, + ); + }); }); describe('isAttributeObject', () => { @@ -297,3 +332,118 @@ describe('isAttributeObject', () => { }, ); }); + +describe('serializeAttributes', () => { + it('returns an empty object for undefined attributes', () => { + const result = serializeAttributes(undefined); + expect(result).toStrictEqual({}); + }); + + it('returns an empty object for an empty object', () => { + const result = serializeAttributes({}); + expect(result).toStrictEqual({}); + }); + + it('serializes valid, non-primitive values', () => { + const result = serializeAttributes({ foo: 'bar', bar: { value: 123 }, baz: { value: 456, unit: 'byte' } }); + expect(result).toStrictEqual({ + bar: { + type: 'integer', + value: 123, + }, + baz: { + type: 'integer', + unit: 'byte', + value: 456, + }, + foo: { + type: 'string', + value: 'bar', + }, + }); + }); + + it('ignores undefined values if fallback is false', () => { + const result = serializeAttributes( + { foo: undefined, bar: { value: undefined }, baz: { value: undefined, unit: 'byte' } }, + false, + ); + expect(result).toStrictEqual({}); + }); + + it('ignores undefined values if fallback is "skip-undefined"', () => { + const result = serializeAttributes( + { foo: undefined, bar: { value: undefined }, baz: { value: undefined, unit: 'byte' } }, + 'skip-undefined', + ); + expect(result).toStrictEqual({}); + }); + + it('stringifies undefined values to "" if fallback is true', () => { + const result = serializeAttributes( + { foo: undefined, bar: { value: undefined }, baz: { value: undefined, unit: 'byte' } }, + true, + ); + expect(result).toStrictEqual({ + bar: { + type: 'string', + value: '', + }, + baz: { + type: 'string', + unit: 'byte', + value: '', + }, + foo: { type: 'string', value: '' }, + }); + }); + + it('ignores null values by default', () => { + const result = serializeAttributes({ foo: null, bar: { value: null }, baz: { value: null, unit: 'byte' } }); + expect(result).toStrictEqual({}); + }); + + it('stringifies to `"null"` if fallback is true', () => { + const result = serializeAttributes({ foo: null, bar: { value: null }, baz: { value: null, unit: 'byte' } }, true); + expect(result).toStrictEqual({ + foo: { + type: 'string', + value: 'null', + }, + bar: { + type: 'string', + value: 'null', + }, + baz: { + type: 'string', + unit: 'byte', + value: 'null', + }, + }); + }); + + describe('invalid (non-primitive) values', () => { + it("doesn't fall back to stringification by default", () => { + const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} }); + expect(result).toStrictEqual({}); + }); + + it('falls back to stringification of unsupported non-primitive values if fallback is true', () => { + const result = serializeAttributes({ foo: { some: 'object' }, bar: [1, 2, 3], baz: () => {} }, true); + expect(result).toStrictEqual({ + bar: { + type: 'string', + value: '[1,2,3]', + }, + baz: { + type: 'string', + value: '', + }, + foo: { + type: 'string', + value: '{"some":"object"}', + }, + }); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts b/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts index a86ccbd534d0..5cfcd5cb1bfe 100644 --- a/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts @@ -13,33 +13,31 @@ describe('MCP Server PII Filtering', () => { vi.clearAllMocks(); }); - describe('Integration Tests', () => { + describe('Integration Tests - Network PII', () => { let mockMcpServer: ReturnType; - let wrappedMcpServer: ReturnType; let mockTransport: ReturnType; beforeEach(() => { mockMcpServer = createMockMcpServer(); - wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); mockTransport = createMockTransport(); mockTransport.sessionId = 'test-session-123'; }); - it('should include PII data when sendDefaultPii is true', async () => { - // Mock client with sendDefaultPii: true + it('should include network PII when sendDefaultPii is true', async () => { getClientSpy.mockReturnValue({ getOptions: () => ({ sendDefaultPii: true }), getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), emit: vi.fn(), } as unknown as ReturnType); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); await wrappedMcpServer.connect(mockTransport); const jsonRpcRequest = { jsonrpc: '2.0', method: 'tools/call', id: 'req-pii-true', - params: { name: 'weather', arguments: { location: 'London', units: 'metric' } }, + params: { name: 'weather', arguments: { location: 'London' } }, }; const extraWithClientInfo = { @@ -51,35 +49,31 @@ describe('MCP Server PII Filtering', () => { mockTransport.onmessage?.(jsonRpcRequest, extraWithClientInfo); - expect(startInactiveSpanSpy).toHaveBeenCalledWith({ - name: 'tools/call weather', - op: 'mcp.server', - forceTransaction: true, - attributes: expect.objectContaining({ - 'client.address': '192.168.1.100', - 'client.port': 54321, - 'mcp.request.argument.location': '"London"', - 'mcp.request.argument.units': '"metric"', - 'mcp.tool.name': 'weather', + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'client.address': '192.168.1.100', + 'client.port': 54321, + }), }), - }); + ); }); - it('should exclude PII data when sendDefaultPii is false', async () => { - // Mock client with sendDefaultPii: false + it('should exclude network PII when sendDefaultPii is false', async () => { getClientSpy.mockReturnValue({ getOptions: () => ({ sendDefaultPii: false }), getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), emit: vi.fn(), } as unknown as ReturnType); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); await wrappedMcpServer.connect(mockTransport); const jsonRpcRequest = { jsonrpc: '2.0', method: 'tools/call', id: 'req-pii-false', - params: { name: 'weather', arguments: { location: 'London', units: 'metric' } }, + params: { name: 'weather', arguments: { location: 'London' } }, }; const extraWithClientInfo = { @@ -96,8 +90,6 @@ describe('MCP Server PII Filtering', () => { attributes: expect.not.objectContaining({ 'client.address': expect.anything(), 'client.port': expect.anything(), - 'mcp.request.argument.location': expect.anything(), - 'mcp.request.argument.units': expect.anything(), }), }), ); @@ -111,49 +103,6 @@ describe('MCP Server PII Filtering', () => { }), ); }); - - it('should filter tool result content when sendDefaultPii is false', async () => { - // Mock client with sendDefaultPii: false - getClientSpy.mockReturnValue({ - getOptions: () => ({ sendDefaultPii: false }), - } as ReturnType); - - await wrappedMcpServer.connect(mockTransport); - - const mockSpan = { - setAttributes: vi.fn(), - setStatus: vi.fn(), - end: vi.fn(), - } as unknown as ReturnType; - startInactiveSpanSpy.mockReturnValueOnce(mockSpan); - - const toolCallRequest = { - jsonrpc: '2.0', - method: 'tools/call', - id: 'req-tool-result-filtered', - params: { name: 'weather-lookup' }, - }; - - mockTransport.onmessage?.(toolCallRequest, {}); - - const toolResponse = { - jsonrpc: '2.0', - id: 'req-tool-result-filtered', - result: { - content: [{ type: 'text', text: 'Sensitive weather data for London' }], - isError: false, - }, - }; - - mockTransport.send?.(toolResponse); - - // Tool result content should be filtered out, but metadata should remain - const setAttributesCall = mockSpan.setAttributes.mock.calls[0]?.[0]; - expect(setAttributesCall).toBeDefined(); - expect(setAttributesCall).not.toHaveProperty('mcp.tool.result.content'); - expect(setAttributesCall).toHaveProperty('mcp.tool.result.is_error', false); - expect(setAttributesCall).toHaveProperty('mcp.tool.result.content_count', 1); - }); }); describe('filterMcpPiiFromSpanData Function', () => { @@ -161,80 +110,34 @@ describe('MCP Server PII Filtering', () => { const spanData = { 'client.address': '192.168.1.100', 'client.port': 54321, - 'mcp.request.argument.location': '"San Francisco"', - 'mcp.tool.result.content': 'Weather data: 18°C', - 'mcp.tool.result.content_count': 1, - 'mcp.prompt.result.description': 'Code review prompt for sensitive analysis', - 'mcp.prompt.result.message_content': 'Please review this confidential code.', - 'mcp.prompt.result.message_count': 1, - 'mcp.resource.result.content': 'Sensitive resource content', - 'mcp.logging.message': 'User requested weather', 'mcp.resource.uri': 'file:///private/docs/secret.txt', - 'mcp.method.name': 'tools/call', // Non-PII should remain + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'weather', }; const result = filterMcpPiiFromSpanData(spanData, true); - expect(result).toEqual(spanData); // All data preserved + expect(result).toEqual(spanData); }); - it('should remove PII data when sendDefaultPii is false', () => { + it('should only remove network PII when sendDefaultPii is false', () => { const spanData = { 'client.address': '192.168.1.100', 'client.port': 54321, - 'mcp.request.argument.location': '"San Francisco"', - 'mcp.request.argument.units': '"celsius"', - 'mcp.tool.result.content': 'Weather data: 18°C', - 'mcp.tool.result.content_count': 1, - 'mcp.prompt.result.description': 'Code review prompt for sensitive analysis', - 'mcp.prompt.result.message_count': 2, - 'mcp.prompt.result.0.role': 'user', - 'mcp.prompt.result.0.content': 'Sensitive prompt content', - 'mcp.prompt.result.1.role': 'assistant', - 'mcp.prompt.result.1.content': 'Another sensitive response', - 'mcp.resource.result.content_count': 1, - 'mcp.resource.result.uri': 'file:///private/file.txt', - 'mcp.resource.result.content': 'Sensitive resource content', - 'mcp.logging.message': 'User requested weather', 'mcp.resource.uri': 'file:///private/docs/secret.txt', - 'mcp.method.name': 'tools/call', // Non-PII should remain - 'mcp.session.id': 'test-session-123', // Non-PII should remain + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'weather', + 'mcp.session.id': 'test-session-123', }; const result = filterMcpPiiFromSpanData(spanData, false); - // Client info should be filtered expect(result).not.toHaveProperty('client.address'); expect(result).not.toHaveProperty('client.port'); - - // Request arguments should be filtered - expect(result).not.toHaveProperty('mcp.request.argument.location'); - expect(result).not.toHaveProperty('mcp.request.argument.units'); - - // Specific PII content attributes should be filtered - expect(result).not.toHaveProperty('mcp.tool.result.content'); - expect(result).not.toHaveProperty('mcp.prompt.result.description'); - - // Count attributes should remain as they don't contain sensitive content - expect(result).toHaveProperty('mcp.tool.result.content_count', 1); - expect(result).toHaveProperty('mcp.prompt.result.message_count', 2); - - // All tool and prompt result content should be filtered (including indexed attributes) - expect(result).not.toHaveProperty('mcp.prompt.result.0.role'); - expect(result).not.toHaveProperty('mcp.prompt.result.0.content'); - expect(result).not.toHaveProperty('mcp.prompt.result.1.role'); - expect(result).not.toHaveProperty('mcp.prompt.result.1.content'); - - expect(result).toHaveProperty('mcp.resource.result.content_count', 1); - expect(result).toHaveProperty('mcp.resource.result.uri', 'file:///private/file.txt'); - expect(result).toHaveProperty('mcp.resource.result.content', 'Sensitive resource content'); - - // Other PII attributes should be filtered - expect(result).not.toHaveProperty('mcp.logging.message'); expect(result).not.toHaveProperty('mcp.resource.uri'); - // Non-PII attributes should remain expect(result).toHaveProperty('mcp.method.name', 'tools/call'); + expect(result).toHaveProperty('mcp.tool.name', 'weather'); expect(result).toHaveProperty('mcp.session.id', 'test-session-123'); }); @@ -243,10 +146,11 @@ describe('MCP Server PII Filtering', () => { expect(result).toEqual({}); }); - it('should handle span data with no PII attributes', () => { + it('should handle span data with no network PII attributes', () => { const spanData = { 'mcp.method.name': 'tools/list', 'mcp.session.id': 'test-session', + 'mcp.tool.name': 'weather', }; const result = filterMcpPiiFromSpanData(spanData, false); diff --git a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts index 0ad969d5b46e..356cc4152123 100644 --- a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts @@ -26,7 +26,7 @@ describe('MCP Server Semantic Conventions', () => { beforeEach(() => { mockMcpServer = createMockMcpServer(); - wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer, { recordInputs: true, recordOutputs: true }); mockTransport = createMockTransport(); mockTransport.sessionId = 'test-session-123'; }); @@ -506,5 +506,87 @@ describe('MCP Server Semantic Conventions', () => { expect(setStatusSpy).not.toHaveBeenCalled(); expect(endSpy).toHaveBeenCalled(); }); + + it('should capture tool result metadata but not content when recordOutputs is false', async () => { + const server = wrapMcpServerWithSentry(createMockMcpServer(), { recordOutputs: false }); + const transport = createMockTransport(); + await server.connect(transport); + + const setAttributesSpy = vi.fn(); + const mockSpan = { setAttributes: setAttributesSpy, setStatus: vi.fn(), end: vi.fn() }; + startInactiveSpanSpy.mockReturnValueOnce( + mockSpan as unknown as ReturnType, + ); + + transport.onmessage?.({ jsonrpc: '2.0', method: 'tools/call', id: 'req-1', params: { name: 'tool' } }, {}); + transport.send?.({ + jsonrpc: '2.0', + id: 'req-1', + result: { + content: [{ type: 'text', text: 'sensitive', mimeType: 'text/plain', uri: 'file:///secret', name: 'file' }], + isError: false, + }, + }); + + const attrs = setAttributesSpy.mock.calls.find(c => c[0]?.['mcp.tool.result.content_count'])?.[0]; + expect(attrs).toMatchObject({ 'mcp.tool.result.is_error': false, 'mcp.tool.result.content_count': 1 }); + expect(attrs).not.toHaveProperty('mcp.tool.result.content'); + expect(attrs).not.toHaveProperty('mcp.tool.result.uri'); + }); + + it('should capture prompt result metadata but not content when recordOutputs is false', async () => { + const server = wrapMcpServerWithSentry(createMockMcpServer(), { recordOutputs: false }); + const transport = createMockTransport(); + await server.connect(transport); + + const setAttributesSpy = vi.fn(); + const mockSpan = { setAttributes: setAttributesSpy, setStatus: vi.fn(), end: vi.fn() }; + startInactiveSpanSpy.mockReturnValueOnce( + mockSpan as unknown as ReturnType, + ); + + transport.onmessage?.({ jsonrpc: '2.0', method: 'prompts/get', id: 'req-1', params: { name: 'prompt' } }, {}); + transport.send?.({ + jsonrpc: '2.0', + id: 'req-1', + result: { + description: 'sensitive description', + messages: [{ role: 'user', content: { type: 'text', text: 'sensitive' } }], + }, + }); + + const attrs = setAttributesSpy.mock.calls.find(c => c[0]?.['mcp.prompt.result.message_count'])?.[0]; + expect(attrs).toMatchObject({ 'mcp.prompt.result.message_count': 1 }); + expect(attrs).not.toHaveProperty('mcp.prompt.result.description'); + expect(attrs).not.toHaveProperty('mcp.prompt.result.message_role'); + }); + + it('should capture notification metadata but not logging message when recordInputs is false', async () => { + const server = wrapMcpServerWithSentry(createMockMcpServer(), { recordInputs: false }); + const transport = createMockTransport(); + await server.connect(transport); + + const loggingNotification = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', logger: 'test-logger', data: 'sensitive log message' }, + }; + + transport.onmessage?.(loggingNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'mcp.logging.level': 'info', + 'mcp.logging.logger': 'test-logger', + 'mcp.logging.data_type': 'string', + }), + }), + expect.any(Function), + ); + + const lastCall = startSpanSpy.mock.calls[startSpanSpy.mock.calls.length - 1]; + expect(lastCall?.[0]?.attributes).not.toHaveProperty('mcp.logging.message'); + }); }); }); diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts index d128e12d8635..e8ffb31477ad 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -190,7 +190,7 @@ describe('MCP Server Transport Instrumentation', () => { beforeEach(() => { mockMcpServer = createMockMcpServer(); - wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer, { recordInputs: true }); mockStdioTransport = createMockStdioTransport(); mockStdioTransport.sessionId = 'stdio-session-456'; }); @@ -308,7 +308,7 @@ describe('MCP Server Transport Instrumentation', () => { it('should test wrapTransportOnMessage directly', () => { const originalOnMessage = mockTransport.onmessage; - wrapTransportOnMessage(mockTransport); + wrapTransportOnMessage(mockTransport, { recordInputs: false, recordOutputs: false }); expect(mockTransport.onmessage).not.toBe(originalOnMessage); }); @@ -316,7 +316,7 @@ describe('MCP Server Transport Instrumentation', () => { it('should test wrapTransportSend directly', () => { const originalSend = mockTransport.send; - wrapTransportSend(mockTransport); + wrapTransportSend(mockTransport, { recordInputs: false, recordOutputs: false }); expect(mockTransport.send).not.toBe(originalSend); }); @@ -345,12 +345,17 @@ describe('MCP Server Transport Instrumentation', () => { params: { name: 'test-tool', arguments: { input: 'test' } }, }; - const config = buildMcpServerSpanConfig(jsonRpcRequest, mockTransport, { - requestInfo: { - remoteAddress: '127.0.0.1', - remotePort: 8080, + const config = buildMcpServerSpanConfig( + jsonRpcRequest, + mockTransport, + { + requestInfo: { + remoteAddress: '127.0.0.1', + remotePort: 8080, + }, }, - }); + { recordInputs: true, recordOutputs: true }, + ); expect(config).toEqual({ name: 'tools/call test-tool', @@ -655,4 +660,102 @@ describe('MCP Server Transport Instrumentation', () => { expect(mockSpan.end).toHaveBeenCalled(); }); }); + + describe('Wrapper Options', () => { + it('should NOT capture inputs/outputs when sendDefaultPii is false', async () => { + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: false }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'tool-1', + params: { name: 'weather', arguments: { location: 'London' } }, + }, + {}, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'mcp.request.argument.location': expect.anything(), + }), + }), + ); + }); + + it('should capture inputs/outputs when sendDefaultPii is true', async () => { + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'tool-1', + params: { name: 'weather', arguments: { location: 'London' } }, + }, + {}, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'mcp.request.argument.location': '"London"', + }), + }), + ); + }); + + it('should allow explicit override of defaults', async () => { + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer, { recordInputs: false }); + const transport = createMockTransport(); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'tool-1', + params: { name: 'weather', arguments: { location: 'London' } }, + }, + {}, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'mcp.request.argument.location': expect.anything(), + }), + }), + ); + }); + }); }); diff --git a/packages/core/test/lib/integrations/third-party-errors-filter.test.ts b/packages/core/test/lib/integrations/third-party-errors-filter.test.ts index 2b5445a4544e..e519cfd2564c 100644 --- a/packages/core/test/lib/integrations/third-party-errors-filter.test.ts +++ b/packages/core/test/lib/integrations/third-party-errors-filter.test.ts @@ -132,6 +132,78 @@ const eventWithOnlyThirdPartyFrames: Event = { }, }; +const eventWithThirdPartyAndSentryInternalFrames: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ' // means the sentry.javascript SDK caught an error invoking your application code. This', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Third party error', + }, + ], + }, +}; + +const eventWithThirdPartySentryInternalAndFirstPartyFrames: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 1, + filename: __filename, + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ' // means the sentry.javascript SDK caught an error invoking your application code. This', + ], + }, + { + colno: 3, + filename: 'other-file.js', + function: 'function', + lineno: 3, + }, + ], + }, + type: 'Error', + value: 'Mixed error', + }, + ], + }, +}; + // This only needs the stackParser const MOCK_CLIENT = {} as unknown as Client; @@ -146,6 +218,8 @@ describe('ThirdPartyErrorFilter', () => { addMetadataToStackFrames(stackParser, eventWithThirdAndFirstPartyFrames); addMetadataToStackFrames(stackParser, eventWithOnlyFirstPartyFrames); addMetadataToStackFrames(stackParser, eventWithOnlyThirdPartyFrames); + addMetadataToStackFrames(stackParser, eventWithThirdPartyAndSentryInternalFrames); + addMetadataToStackFrames(stackParser, eventWithThirdPartySentryInternalAndFirstPartyFrames); }); describe('drop-error-if-contains-third-party-frames', () => { @@ -287,4 +361,315 @@ describe('ThirdPartyErrorFilter', () => { expect(result?.tags).toMatchObject({ third_party_code: true }); }); }); + + describe('experimentalExcludeSentryInternalFrames', () => { + describe('drop-error-if-exclusively-contains-third-party-frames', () => { + it('drops event with only third-party + Sentry internal frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('keeps event with third-party + Sentry internal + first-party frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartySentryInternalAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('does not drop event with only third-party + Sentry internal frames when option is disabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: false, + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBeDefined(); + }); + + it('defaults to false', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + // experimentalExcludeSentryInternalFrames not set, should default to false + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because option defaults to false + expect(result).toBeDefined(); + }); + }); + + describe('drop-error-if-contains-third-party-frames', () => { + it('drops event with third-party + Sentry internal frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartyAndSentryInternalFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('keeps event with third-party + Sentry internal + first-party frames when option is enabled', async () => { + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithThirdPartySentryInternalAndFirstPartyFrames); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should drop because it contains third-party frames (even with first-party frames) + expect(result).toBe(null); + }); + }); + + describe('comment pattern detection', () => { + it('detects Sentry internal frame by context_line with both patterns', async () => { + const eventWithContextLine: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithContextLine); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('detects Sentry internal frame by pre_context with both patterns', async () => { + const eventWithPreContext: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithPreContext); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + expect(result).toBe(null); + }); + + it('does not detect Sentry internal frame when fn.apply pattern is missing', async () => { + const eventWithoutFnApply: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 115, + context_line: ' const wrappedArguments = args.map(arg => wrap(arg, options));', + post_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithoutFnApply); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because fn.apply pattern is missing + expect(result).toBeDefined(); + }); + + it('does not match when Sentry internal frame is not the last frame', async () => { + const eventWithSentryFrameNotLast: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + { + colno: 2, + filename: '@sentry/browser/build/npm/esm/helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 3, + filename: 'another-file.js', + function: 'function', + lineno: 3, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithSentryFrameNotLast); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because Sentry frame is not the last frame + expect(result).toBeDefined(); + }); + + it('does not match when filename does not contain both helpers and sentry', async () => { + const eventWithWrongFilename: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { + colno: 2, + filename: 'some-helpers.js', + function: 'sentryWrapped', + lineno: 117, + context_line: ' return fn.apply(this, wrappedArguments);', + pre_context: [ + ' // Attempt to invoke user-land function', + ' // NOTE: If you are a Sentry user, and you are seeing this stack frame, it', + ], + }, + { + colno: 1, + filename: 'other-file.js', + function: 'function', + lineno: 1, + }, + ], + }, + type: 'Error', + value: 'Test error', + }, + ], + }, + }; + + const integration = thirdPartyErrorFilterIntegration({ + behaviour: 'drop-error-if-exclusively-contains-third-party-frames', + filterKeys: ['some-key'], + ignoreSentryInternalFrames: true, + }); + + const event = clone(eventWithWrongFilename); + const result = await integration.processEvent?.(event, {}, MOCK_CLIENT); + // Should not drop because filename doesn't contain "sentry" + expect(result).toBeDefined(); + }); + }); + }); }); diff --git a/packages/core/test/lib/metadata.test.ts b/packages/core/test/lib/metadata.test.ts index bedf4cdcf7e9..e312698a6cf8 100644 --- a/packages/core/test/lib/metadata.test.ts +++ b/packages/core/test/lib/metadata.test.ts @@ -1,5 +1,10 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { addMetadataToStackFrames, getMetadataForUrl, stripMetadataFromStackFrames } from '../../src/metadata'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + addMetadataToStackFrames, + getFilenameToMetadataMap, + getMetadataForUrl, + stripMetadataFromStackFrames, +} from '../../src/metadata'; import type { Event } from '../../src/types-hoist/event'; import { nodeStackLineParser } from '../../src/utils/node-stack-trace'; import { createStackParser } from '../../src/utils/stacktrace'; @@ -44,6 +49,10 @@ describe('Metadata', () => { GLOBAL_OBJ._sentryModuleMetadata[stack] = { team: 'frontend' }; }); + afterEach(() => { + delete GLOBAL_OBJ._sentryModuleMetadata; + }); + it('is parsed', () => { const metadata = getMetadataForUrl(parser, __filename); @@ -97,3 +106,71 @@ describe('Metadata', () => { ]); }); }); + +describe('getFilenameToMetadataMap', () => { + afterEach(() => { + delete GLOBAL_OBJ._sentryModuleMetadata; + }); + + it('returns empty object when no metadata is available', () => { + delete GLOBAL_OBJ._sentryModuleMetadata; + + const result = getFilenameToMetadataMap(parser); + + expect(result).toEqual({}); + }); + + it('extracts filenames from stack traces and maps to metadata', () => { + const stack1 = `Error + at Object. (/path/to/file1.js:10:15) + at Module._compile (internal/modules/cjs/loader.js:1063:30)`; + + const stack2 = `Error + at processTicksAndRejections (/path/to/file2.js:20:25)`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stack1]: { '_sentryBundlerPluginAppKey:my-app': true, team: 'frontend' }, + [stack2]: { '_sentryBundlerPluginAppKey:my-app': true, team: 'backend' }, + }; + + const result = getFilenameToMetadataMap(parser); + + expect(result).toEqual({ + '/path/to/file1.js': { '_sentryBundlerPluginAppKey:my-app': true, team: 'frontend' }, + '/path/to/file2.js': { '_sentryBundlerPluginAppKey:my-app': true, team: 'backend' }, + }); + }); + + it('handles stack traces with native code frames', () => { + const stackNoFilename = `Error + at [native code]`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stackNoFilename]: { '_sentryBundlerPluginAppKey:my-app': true }, + }; + + const result = getFilenameToMetadataMap(parser); + + // Native code may be parsed as a filename by the parser + // This is acceptable behavior as long as we don't error + expect(result).toBeDefined(); + }); + + it('handles multiple stacks with the same filename', () => { + const stack1 = `Error + at functionA (/path/to/same-file.js:10:15)`; + + const stack2 = `Error + at functionB (/path/to/same-file.js:20:25)`; + + GLOBAL_OBJ._sentryModuleMetadata = { + [stack1]: { '_sentryBundlerPluginAppKey:app1': true }, + [stack2]: { '_sentryBundlerPluginAppKey:app2': true }, + }; + + const result = getFilenameToMetadataMap(parser); + + // Last one wins (based on iteration order) + expect(result['/path/to/same-file.js']).toBeDefined(); + }); +}); diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index 3e479e282a0c..434f4b6c8289 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -4,7 +4,6 @@ import { _INTERNAL_captureMetric, _INTERNAL_flushMetricsBuffer, _INTERNAL_getMetricBuffer, - metricAttributeToSerializedMetricAttribute, } from '../../../src/metrics/internal'; import type { Metric } from '../../../src/types-hoist/metric'; import * as loggerModule from '../../../src/utils/debug-logger'; @@ -12,74 +11,6 @@ import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; const PUBLIC_DSN = 'https://username@domain/123'; -describe('metricAttributeToSerializedMetricAttribute', () => { - it('serializes integer values', () => { - const result = metricAttributeToSerializedMetricAttribute(42); - expect(result).toEqual({ - value: 42, - type: 'integer', - }); - }); - - it('serializes double values', () => { - const result = metricAttributeToSerializedMetricAttribute(42.34); - expect(result).toEqual({ - value: 42.34, - type: 'double', - }); - }); - - it('serializes boolean values', () => { - const result = metricAttributeToSerializedMetricAttribute(true); - expect(result).toEqual({ - value: true, - type: 'boolean', - }); - }); - - it('serializes string values', () => { - const result = metricAttributeToSerializedMetricAttribute('endpoint'); - expect(result).toEqual({ - value: 'endpoint', - type: 'string', - }); - }); - - it('serializes object values as JSON strings', () => { - const obj = { name: 'John', age: 30 }; - const result = metricAttributeToSerializedMetricAttribute(obj); - expect(result).toEqual({ - value: JSON.stringify(obj), - type: 'string', - }); - }); - - it('serializes array values as JSON strings', () => { - const array = [1, 2, 3, 'test']; - const result = metricAttributeToSerializedMetricAttribute(array); - expect(result).toEqual({ - value: JSON.stringify(array), - type: 'string', - }); - }); - - it('serializes undefined values as empty strings', () => { - const result = metricAttributeToSerializedMetricAttribute(undefined); - expect(result).toEqual({ - value: '', - type: 'string', - }); - }); - - it('serializes null values as JSON strings', () => { - const result = metricAttributeToSerializedMetricAttribute(null); - expect(result).toEqual({ - value: 'null', - type: 'string', - }); - }); -}); - describe('_INTERNAL_captureMetric', () => { it('captures and sends metrics', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); @@ -240,6 +171,52 @@ describe('_INTERNAL_captureMetric', () => { }); }); + it('includes scope attributes in metric attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setAttribute('scope_attribute_1', 1); + scope.setAttributes({ scope_attribute_2: { value: 'test' }, scope_attribute_3: { value: 38, unit: 'gigabyte' } }); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + scope_attribute_1: { + value: 1, + type: 'integer', + }, + scope_attribute_2: { + value: 'test', + type: 'string', + }, + scope_attribute_3: { + value: 38, + unit: 'gigabyte', + type: 'integer', + }, + }); + }); + + it('prefers metric attributes over scope attributes', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + scope.setAttribute('my-attribute', 42); + + _INTERNAL_captureMetric( + { type: 'counter', name: 'test.metric', value: 1, attributes: { 'my-attribute': 43 } }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual({ + 'my-attribute': { value: 43, type: 'integer' }, + }); + }); + it('flushes metrics buffer when it reaches max size', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 339a57828e5b..f1e5c58550be 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -10,7 +10,7 @@ import { import { Scope } from '../../src/scope'; import type { Breadcrumb } from '../../src/types-hoist/breadcrumb'; import type { Event } from '../../src/types-hoist/event'; -import { applyScopeDataToEvent } from '../../src/utils/applyScopeDataToEvent'; +import { applyScopeDataToEvent } from '../../src/utils/scopeData'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { clearGlobalScope } from '../testutils'; diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts index 3b491753c8bb..d09401367e5d 100644 --- a/packages/core/test/lib/transports/offline.test.ts +++ b/packages/core/test/lib/transports/offline.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; import { createClientReportEnvelope, createEnvelope, @@ -9,7 +9,7 @@ import { parseEnvelope, } from '../../../src'; import type { CreateOfflineStore, OfflineTransportOptions } from '../../../src/transports/offline'; -import { makeOfflineTransport, START_DELAY } from '../../../src/transports/offline'; +import { makeOfflineTransport, MIN_DELAY, START_DELAY } from '../../../src/transports/offline'; import type { ClientReport } from '../../../src/types-hoist/clientreport'; import type { Envelope, EventEnvelope, EventItem, ReplayEnvelope } from '../../../src/types-hoist/envelope'; import type { ReplayEvent } from '../../../src/types-hoist/replay'; @@ -139,23 +139,13 @@ function createTestStore(...popResults: MockResult[]): { }; } -function waitUntil(fn: () => boolean, timeout: number): Promise { - return new Promise(resolve => { - let runtime = 0; - - const interval = setInterval(() => { - runtime += 100; - - if (fn() || runtime >= timeout) { - clearTimeout(interval); - resolve(); - } - }, 100); - }); -} - describe('makeOfflineTransport', () => { it('Sends envelope and checks the store for further envelopes', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }); let queuedCount = 0; @@ -173,13 +163,18 @@ describe('makeOfflineTransport', () => { expect(queuedCount).toEqual(0); expect(getSendCount()).toEqual(1); - await waitUntil(() => getCalls().length == 1, 1_000); + await vi.advanceTimersByTimeAsync(START_DELAY); // After a successful send, the store should be checked expect(getCalls()).toEqual(['shift']); }); it('Envelopes are added after existing envelopes in the queue', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 }); const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); @@ -187,7 +182,7 @@ describe('makeOfflineTransport', () => { expect(result).toEqual({ statusCode: 200 }); - await waitUntil(() => getCalls().length == 2, 1_000); + await vi.advanceTimersByTimeAsync(START_DELAY); expect(getSendCount()).toEqual(2); // After a successful send from the store, the store should be checked again to ensure it's empty @@ -195,6 +190,11 @@ describe('makeOfflineTransport', () => { }); it('Queues envelope if wrapped transport throws error', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport(new Error()); let queuedCount = 0; @@ -210,7 +210,7 @@ describe('makeOfflineTransport', () => { expect(result).toEqual({}); - await waitUntil(() => getCalls().length === 1, 1_000); + await vi.advanceTimersByTimeAsync(1_000); expect(getSendCount()).toEqual(0); expect(queuedCount).toEqual(1); @@ -218,6 +218,11 @@ describe('makeOfflineTransport', () => { }); it('Does not queue envelopes if status code >= 400', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(); const { getSendCount, baseTransport } = createTestTransport({ statusCode: 500 }); let queuedCount = 0; @@ -233,101 +238,106 @@ describe('makeOfflineTransport', () => { expect(result).toEqual({ statusCode: 500 }); - await waitUntil(() => getSendCount() === 1, 1_000); + await vi.advanceTimersByTimeAsync(1_000); expect(getSendCount()).toEqual(1); expect(queuedCount).toEqual(0); expect(getCalls()).toEqual([]); }); - it( - 'Retries sending envelope after failure', - async () => { - const { getCalls, store } = createTestStore(); - const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }); - const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); - const result = await transport.send(ERROR_ENVELOPE); - expect(result).toEqual({}); - expect(getCalls()).toEqual(['push']); + it('Retries sending envelope after failure', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); - await waitUntil(() => getCalls().length === 3 && getSendCount() === 1, START_DELAY * 2); + const { getCalls, store } = createTestStore(); + const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }); + const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); + const result = await transport.send(ERROR_ENVELOPE); + expect(result).toEqual({}); + expect(getCalls()).toEqual(['push']); - expect(getSendCount()).toEqual(1); - expect(getCalls()).toEqual(['push', 'shift', 'shift']); - }, - START_DELAY + 2_000, - ); + await vi.advanceTimersByTimeAsync(START_DELAY * 2); - it( - 'When flushAtStartup is enabled, sends envelopes found in store shortly after startup', - async () => { - const { getCalls, store } = createTestStore(ERROR_ENVELOPE, ERROR_ENVELOPE); - const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _transport = makeOfflineTransport(baseTransport)({ - ...transportOptions, - createStore: store, - flushAtStartup: true, - }); + expect(getSendCount()).toEqual(1); + expect(getCalls()).toEqual(['push', 'shift', 'shift']); + }); - await waitUntil(() => getCalls().length === 3 && getSendCount() === 2, START_DELAY * 2); + it('When flushAtStartup is enabled, sends envelopes found in store shortly after startup', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); - expect(getSendCount()).toEqual(2); - expect(getCalls()).toEqual(['shift', 'shift', 'shift']); - }, - START_DELAY + 2_000, - ); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE, ERROR_ENVELOPE); + const { getSendCount, baseTransport } = createTestTransport({ statusCode: 200 }, { statusCode: 200 }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + flushAtStartup: true, + }); - it( - 'Unshifts envelopes on retry failure', - async () => { - const { getCalls, store } = createTestStore(ERROR_ENVELOPE); - const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _transport = makeOfflineTransport(baseTransport)({ - ...transportOptions, - createStore: store, - flushAtStartup: true, - }); + await vi.advanceTimersByTimeAsync(START_DELAY * 2); + + expect(getSendCount()).toEqual(2); + expect(getCalls()).toEqual(['shift', 'shift', 'shift']); + }); - await waitUntil(() => getCalls().length === 2, START_DELAY * 2); + it('Unshifts envelopes on retry failure', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); - expect(getSendCount()).toEqual(0); - expect(getCalls()).toEqual(['shift', 'unshift']); - }, - START_DELAY + 2_000, - ); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); + const { getSendCount, baseTransport } = createTestTransport(new Error(), { statusCode: 200 }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + flushAtStartup: true, + }); - it( - 'Updates sent_at envelope header on retry', - async () => { - const testStartTime = new Date(); + await vi.advanceTimersByTimeAsync(START_DELAY * 2); + + expect(getSendCount()).toEqual(0); + expect(getCalls()).toEqual(['shift', 'unshift']); + }); - // Create an envelope with a sent_at header very far in the past - const env: EventEnvelope = [...ERROR_ENVELOPE]; - env[0]!.sent_at = new Date(2020, 1, 1).toISOString(); + it('Updates sent_at envelope header on retry', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); - const { getCalls, store } = createTestStore(ERROR_ENVELOPE); - const { getSentEnvelopes, baseTransport } = createTestTransport({ statusCode: 200 }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const _transport = makeOfflineTransport(baseTransport)({ - ...transportOptions, - createStore: store, - flushAtStartup: true, - }); + const testStartTime = new Date(); - await waitUntil(() => getCalls().length >= 1, START_DELAY * 2); - expect(getCalls()).toEqual(['shift']); + // Create an envelope with a sent_at header very far in the past + const env: EventEnvelope = [...ERROR_ENVELOPE]; + env[0]!.sent_at = new Date(2020, 1, 1).toISOString(); - // When it gets shifted out of the store, the sent_at header should be updated - const envelopes = getSentEnvelopes().map(parseEnvelope) as EventEnvelope[]; - expect(envelopes[0]?.[0]).toBeDefined(); - const sent_at = new Date(envelopes[0]![0].sent_at); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); + const { getSentEnvelopes, baseTransport } = createTestTransport({ statusCode: 200 }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _transport = makeOfflineTransport(baseTransport)({ + ...transportOptions, + createStore: store, + flushAtStartup: true, + }); - expect(sent_at.getTime()).toBeGreaterThan(testStartTime.getTime()); - }, - START_DELAY + 2_000, - ); + await vi.advanceTimersByTimeAsync(START_DELAY); + + expect(getCalls()).toEqual(['shift']); + + // When it gets shifted out of the store, the sent_at header should be updated + const envelopes = getSentEnvelopes().map(parseEnvelope) as EventEnvelope[]; + expect(envelopes[0]?.[0]).toBeDefined(); + const sent_at = new Date(envelopes[0]![0].sent_at); + + expect(sent_at.getTime()).toBeGreaterThan(testStartTime.getTime()); + }); it('shouldStore can stop envelopes from being stored on send failure', async () => { const { getCalls, store } = createTestStore(); @@ -384,51 +394,56 @@ describe('makeOfflineTransport', () => { expect(getCalls()).toEqual([]); }); - it( - 'Sends replay envelopes in order', - async () => { - const { getCalls, store } = createTestStore(REPLAY_ENVELOPE('1'), REPLAY_ENVELOPE('2')); - const { getSendCount, getSentEnvelopes, baseTransport } = createTestTransport( - new Error(), - { statusCode: 200 }, - { statusCode: 200 }, - { statusCode: 200 }, - ); - const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); - const result = await transport.send(REPLAY_ENVELOPE('3')); - - expect(result).toEqual({}); - expect(getCalls()).toEqual(['push']); - - await waitUntil(() => getCalls().length === 6 && getSendCount() === 3, START_DELAY * 5); - - expect(getSendCount()).toEqual(3); - expect(getCalls()).toEqual([ - // We're sending a replay envelope and they always get queued - 'push', - // The first envelope popped out fails to send so it gets added to the front of the queue - 'shift', - 'unshift', - // The rest of the attempts succeed - 'shift', - 'shift', - 'shift', - ]); - - const envelopes = getSentEnvelopes().map(parseEnvelope); - - // Ensure they're still in the correct order - expect((envelopes[0]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('1'); - expect((envelopes[1]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('2'); - expect((envelopes[2]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('3'); - }, - START_DELAY + 2_000, - ); + it('Sends replay envelopes in order', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + + const { getCalls, store } = createTestStore(REPLAY_ENVELOPE('1'), REPLAY_ENVELOPE('2')); + const { getSendCount, getSentEnvelopes, baseTransport } = createTestTransport( + new Error(), + { statusCode: 200 }, + { statusCode: 200 }, + { statusCode: 200 }, + ); + const transport = makeOfflineTransport(baseTransport)({ ...transportOptions, createStore: store }); + const result = await transport.send(REPLAY_ENVELOPE('3')); + + expect(result).toEqual({}); + expect(getCalls()).toEqual(['push']); + + await vi.advanceTimersByTimeAsync(START_DELAY + MIN_DELAY * 3); + + expect(getSendCount()).toEqual(3); + expect(getCalls()).toEqual([ + // We're sending a replay envelope and they always get queued + 'push', + // The first envelope popped out fails to send so it gets added to the front of the queue + 'shift', + 'unshift', + // The rest of the attempts succeed + 'shift', + 'shift', + 'shift', + ]); + + const envelopes = getSentEnvelopes().map(parseEnvelope); + + // Ensure they're still in the correct order + expect((envelopes[0]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('1'); + expect((envelopes[1]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('2'); + expect((envelopes[2]?.[1]?.[0]?.[1] as ErrorEvent).message).toEqual('3'); + }); - // eslint-disable-next-line @sentry-internal/sdk/no-skipped-tests - it.skip( + it( 'Follows the Retry-After header', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const { getCalls, store } = createTestStore(ERROR_ENVELOPE); const { getSendCount, baseTransport } = createTestTransport( { @@ -454,11 +469,11 @@ describe('makeOfflineTransport', () => { headers: { 'x-sentry-rate-limits': '', 'retry-after': '3' }, }); - await waitUntil(() => getSendCount() === 1, 500); + await vi.advanceTimersByTimeAsync(2_999); expect(getSendCount()).toEqual(1); - await waitUntil(() => getCalls().length === 2, START_DELAY * 2); + await vi.advanceTimersByTimeAsync(START_DELAY); expect(getSendCount()).toEqual(2); expect(queuedCount).toEqual(0); diff --git a/packages/core/test/lib/utils/anthropic-utils.test.ts b/packages/core/test/lib/utils/anthropic-utils.test.ts index 0be295b85813..74d4e6b85c17 100644 --- a/packages/core/test/lib/utils/anthropic-utils.test.ts +++ b/packages/core/test/lib/utils/anthropic-utils.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { messagesFromParams, shouldInstrument } from '../../../src/tracing/anthropic-ai/utils'; +import { messagesFromParams, setMessagesAttribute, shouldInstrument } from '../../../src/tracing/anthropic-ai/utils'; +import type { Span } from '../../../src/types-hoist/span'; describe('anthropic-ai-utils', () => { describe('shouldInstrument', () => { @@ -25,6 +26,19 @@ describe('anthropic-ai-utils', () => { ]); }); + it('looks to params.input ahead of params.messages', () => { + expect( + messagesFromParams({ + input: [{ role: 'user', content: 'input' }], + messages: [{ role: 'user', content: 'hello' }], + system: 'You are a friendly robot awaiting a greeting.', + }), + ).toStrictEqual([ + { role: 'system', content: 'You are a friendly robot awaiting a greeting.' }, + { role: 'user', content: 'input' }, + ]); + }); + it('includes system message along with non-array messages', () => { expect( messagesFromParams({ @@ -53,4 +67,42 @@ describe('anthropic-ai-utils', () => { ).toStrictEqual([{ role: 'user', content: 'hello' }]); }); }); + + describe('setMessagesAtribute', () => { + const mock = { + attributes: {} as Record, + setAttributes(kv: Record) { + for (const [key, val] of Object.entries(kv)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + if (val === undefined) delete this.attributes[key]; + else this.attributes[key] = val; + } + }, + }; + const span = mock as unknown as Span; + + it('sets length along with truncated value', () => { + const content = 'A'.repeat(200_000); + setMessagesAttribute(span, [{ role: 'user', content }]); + const result = [{ role: 'user', content: 'A'.repeat(19972) }]; + expect(mock.attributes).toStrictEqual({ + 'gen_ai.request.messages.original_length': 1, + 'gen_ai.request.messages': JSON.stringify(result), + }); + }); + + it('removes length when setting new value ', () => { + setMessagesAttribute(span, { content: 'hello, world' }); + expect(mock.attributes).toStrictEqual({ + 'gen_ai.request.messages': '{"content":"hello, world"}', + }); + }); + + it('ignores empty array', () => { + setMessagesAttribute(span, []); + expect(mock.attributes).toStrictEqual({ + 'gen_ai.request.messages': '{"content":"hello, world"}', + }); + }); + }); }); diff --git a/packages/core/test/lib/utils/dsn.test.ts b/packages/core/test/lib/utils/dsn.test.ts index 0555ae583c02..29c6dc12884c 100644 --- a/packages/core/test/lib/utils/dsn.test.ts +++ b/packages/core/test/lib/utils/dsn.test.ts @@ -1,12 +1,15 @@ import { beforeEach, describe, expect, it, test, vi } from 'vitest'; -import { DEBUG_BUILD } from '../../../src/debug-build'; import { debug } from '../../../src/utils/debug-logger'; import { dsnToString, extractOrgIdFromClient, extractOrgIdFromDsnHost, makeDsn } from '../../../src/utils/dsn'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; -function testIf(condition: boolean) { - return condition ? test : test.skip; -} +let mockDebugBuild = true; + +vi.mock('../../../src/debug-build', () => ({ + get DEBUG_BUILD() { + return mockDebugBuild; + }, +})); const loggerErrorSpy = vi.spyOn(debug, 'error').mockImplementation(() => {}); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -14,6 +17,7 @@ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); describe('Dsn', () => { beforeEach(() => { vi.clearAllMocks(); + mockDebugBuild = true; }); describe('fromComponents', () => { @@ -51,7 +55,7 @@ describe('Dsn', () => { expect(dsn?.projectId).toBe('123'); }); - testIf(DEBUG_BUILD)('returns `undefined` for missing components', () => { + it('returns `undefined` for missing components', () => { expect( makeDsn({ host: '', @@ -88,7 +92,7 @@ describe('Dsn', () => { expect(loggerErrorSpy).toHaveBeenCalledTimes(4); }); - testIf(DEBUG_BUILD)('returns `undefined` if components are invalid', () => { + it('returns `undefined` if components are invalid', () => { expect( makeDsn({ host: 'sentry.io', @@ -167,12 +171,53 @@ describe('Dsn', () => { expect(dsn?.projectId).toBe('321'); }); - testIf(DEBUG_BUILD)('returns undefined when provided invalid Dsn', () => { + test('with IPv4 hostname', () => { + const dsn = makeDsn('https://abc@192.168.1.1/123'); + expect(dsn?.protocol).toBe('https'); + expect(dsn?.publicKey).toBe('abc'); + expect(dsn?.pass).toBe(''); + expect(dsn?.host).toBe('192.168.1.1'); + expect(dsn?.port).toBe(''); + expect(dsn?.path).toBe(''); + expect(dsn?.projectId).toBe('123'); + }); + + test.each([ + '[2001:db8::1]', + '[::1]', // loopback + '[::ffff:192.0.2.1]', // IPv4-mapped IPv6 (contains dots) + '[fe80::1]', // link-local + '[2001:db8:85a3::8a2e:370:7334]', // compressed in middle + '[2001:db8::]', // trailing zeros compressed + '[2001:0db8:0000:0000:0000:0000:0000:0001]', // full form with leading zeros + '[fe80::1%eth0]', // zone identifier with interface name (contains percent sign) + '[fe80::1%25eth0]', // zone identifier URL-encoded (percent as %25) + '[fe80::a:b:c:d%en0]', // zone identifier with different interface + ])('with IPv6 hostname %s', hostname => { + const dsn = makeDsn(`https://abc@${hostname}/123`); + expect(dsn?.protocol).toBe('https'); + expect(dsn?.publicKey).toBe('abc'); + expect(dsn?.pass).toBe(''); + expect(dsn?.host).toBe(hostname); + expect(dsn?.port).toBe(''); + expect(dsn?.path).toBe(''); + expect(dsn?.projectId).toBe('123'); + }); + + test('skips validation for non-debug builds', () => { + mockDebugBuild = false; + const dsn = makeDsn('httx://abc@192.168.1.1/123'); + expect(dsn?.protocol).toBe('httx'); + expect(dsn?.publicKey).toBe('abc'); + expect(dsn?.pass).toBe(''); + }); + + it('returns undefined when provided invalid Dsn', () => { expect(makeDsn('some@random.dsn')).toBeUndefined(); expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); - testIf(DEBUG_BUILD)('returns undefined if mandatory fields are missing', () => { + it('returns undefined if mandatory fields are missing', () => { expect(makeDsn('://abc@sentry.io/123')).toBeUndefined(); expect(makeDsn('https://@sentry.io/123')).toBeUndefined(); expect(makeDsn('https://abc@123')).toBeUndefined(); @@ -180,7 +225,7 @@ describe('Dsn', () => { expect(consoleErrorSpy).toHaveBeenCalledTimes(4); }); - testIf(DEBUG_BUILD)('returns undefined if fields are invalid', () => { + it('returns undefined if fields are invalid', () => { expect(makeDsn('httpx://abc@sentry.io/123')).toBeUndefined(); expect(makeDsn('httpx://abc@sentry.io:xxx/123')).toBeUndefined(); expect(makeDsn('http://abc@sentry.io/abc')).toBeUndefined(); diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index c68a35e5becc..ff951e8be40b 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -5,6 +5,7 @@ import { getSpanOperation, isChatCompletionChunk, isChatCompletionResponse, + isConversationResponse, isResponsesApiResponse, isResponsesApiStreamEvent, shouldInstrument, @@ -22,6 +23,11 @@ describe('openai-utils', () => { expect(getOperationName('some.path.responses.method')).toBe('responses'); }); + it('should return conversations for conversations methods', () => { + expect(getOperationName('conversations.create')).toBe('conversations'); + expect(getOperationName('some.path.conversations.method')).toBe('conversations'); + }); + it('should return the last part of path for unknown methods', () => { expect(getOperationName('some.unknown.method')).toBe('method'); expect(getOperationName('create')).toBe('create'); @@ -44,6 +50,7 @@ describe('openai-utils', () => { it('should return true for instrumented methods', () => { expect(shouldInstrument('responses.create')).toBe(true); expect(shouldInstrument('chat.completions.create')).toBe(true); + expect(shouldInstrument('conversations.create')).toBe(true); }); it('should return false for non-instrumented methods', () => { @@ -146,4 +153,36 @@ describe('openai-utils', () => { expect(isChatCompletionChunk({ object: null })).toBe(false); }); }); + + describe('isConversationResponse', () => { + it('should return true for valid conversation responses', () => { + const validConversation = { + object: 'conversation', + id: 'conv_689667905b048191b4740501625afd940c7533ace33a2dab', + created_at: 1704067200, + }; + expect(isConversationResponse(validConversation)).toBe(true); + }); + + it('should return true for conversation with metadata', () => { + const conversationWithMetadata = { + object: 'conversation', + id: 'conv_123', + created_at: 1704067200, + metadata: { user_id: 'user_123' }, + }; + expect(isConversationResponse(conversationWithMetadata)).toBe(true); + }); + + it('should return false for invalid responses', () => { + expect(isConversationResponse(null)).toBe(false); + expect(isConversationResponse(undefined)).toBe(false); + expect(isConversationResponse('string')).toBe(false); + expect(isConversationResponse(123)).toBe(false); + expect(isConversationResponse({})).toBe(false); + expect(isConversationResponse({ object: 'thread' })).toBe(false); + expect(isConversationResponse({ object: 'response' })).toBe(false); + expect(isConversationResponse({ object: null })).toBe(false); + }); + }); }); diff --git a/packages/core/test/lib/utils/promisebuffer.test.ts b/packages/core/test/lib/utils/promisebuffer.test.ts index 9c944ffd0c39..d1b4b9abc48d 100644 --- a/packages/core/test/lib/utils/promisebuffer.test.ts +++ b/packages/core/test/lib/utils/promisebuffer.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { makePromiseBuffer } from '../../../src/utils/promisebuffer'; import { rejectedSyncPromise, resolvedSyncPromise } from '../../../src/utils/syncpromise'; describe('PromiseBuffer', () => { + afterEach(() => { + vi.useRealTimers(); + }); describe('add()', () => { test('enforces limit of promises', async () => { const buffer = makePromiseBuffer(5); @@ -105,20 +108,28 @@ describe('PromiseBuffer', () => { describe('drain()', () => { test('drains all promises without timeout', async () => { + vi.useFakeTimers(); + const buffer = makePromiseBuffer(); - const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); - const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); - const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); - const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); - const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1))); + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); [p1, p2, p3, p4, p5].forEach(p => { void buffer.add(p); }); expect(buffer.$.length).toEqual(5); - const result = await buffer.drain(); + + const drainPromise = buffer.drain(); + + // Advance time to resolve all promises + await vi.advanceTimersByTimeAsync(10); + + const result = await drainPromise; expect(result).toEqual(true); expect(buffer.$.length).toEqual(0); @@ -130,13 +141,15 @@ describe('PromiseBuffer', () => { }); test('drains all promises with timeout', async () => { + vi.useFakeTimers(); + const buffer = makePromiseBuffer(); - const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 2))); - const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 4))); - const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 6))); - const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 8))); - const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 10))); + const p1 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 20))); + const p2 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 40))); + const p3 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 60))); + const p4 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 80))); + const p5 = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100))); [p1, p2, p3, p4, p5].forEach(p => { void buffer.add(p); @@ -149,15 +162,29 @@ describe('PromiseBuffer', () => { expect(p5).toHaveBeenCalled(); expect(buffer.$.length).toEqual(5); - const result = await buffer.drain(6); + + // Start draining with a 50ms timeout + const drainPromise = buffer.drain(50); + + // Advance time by 50ms - this will: + // - Resolve p1 (20ms) and p2 (40ms) + // - Trigger the drain timeout (50ms) + // - p3, p4, p5 are still pending + await vi.advanceTimersByTimeAsync(50); + + const result = await drainPromise; expect(result).toEqual(false); - // p5 & p4 are still in the buffer - // Leaving some wiggle room, possibly one or two items are still in the buffer - // to avoid flakiness - expect(buffer.$.length).toBeGreaterThanOrEqual(1); - // Now drain final item - const result2 = await buffer.drain(); + // p3, p4 & p5 are still in the buffer + expect(buffer.$.length).toEqual(3); + + // Now drain remaining items without timeout + const drainPromise2 = buffer.drain(); + + // Advance time to resolve remaining promises + await vi.advanceTimersByTimeAsync(100); + + const result2 = await drainPromise2; expect(result2).toEqual(true); expect(buffer.$.length).toEqual(0); }); diff --git a/packages/core/test/lib/utils/applyScopeDataToEvent.test.ts b/packages/core/test/lib/utils/scopeData.test.ts similarity index 87% rename from packages/core/test/lib/utils/applyScopeDataToEvent.test.ts rename to packages/core/test/lib/utils/scopeData.test.ts index a23404eaf70f..50af1179a4c4 100644 --- a/packages/core/test/lib/utils/applyScopeDataToEvent.test.ts +++ b/packages/core/test/lib/utils/scopeData.test.ts @@ -1,16 +1,18 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ScopeData } from '../../../src'; -import { startInactiveSpan } from '../../../src'; +import { Scope, startInactiveSpan } from '../../../src'; +import * as currentScopes from '../../../src/currentScopes'; import type { Attachment } from '../../../src/types-hoist/attachment'; import type { Breadcrumb } from '../../../src/types-hoist/breadcrumb'; import type { Event, EventType } from '../../../src/types-hoist/event'; import type { EventProcessor } from '../../../src/types-hoist/eventprocessor'; import { applyScopeDataToEvent, + getCombinedScopeData, mergeAndOverwriteScopeData, mergeArray, mergeScopeData, -} from '../../../src/utils/applyScopeDataToEvent'; +} from '../../../src/utils/scopeData'; describe('mergeArray', () => { it.each([ @@ -353,3 +355,50 @@ describe('applyScopeDataToEvent', () => { }, ); }); + +describe('getCombinedScopeData', () => { + const globalScope = new Scope(); + const isolationScope = new Scope(); + const currentScope = new Scope(); + + it('returns the combined scope data with correct precedence', () => { + globalScope.setTag('foo', 'bar'); + globalScope.setTag('dogs', 'boring'); + globalScope.setTag('global', 'global'); + + isolationScope.setTag('dogs', 'great'); + isolationScope.setTag('foo', 'nope'); + isolationScope.setTag('isolation', 'isolation'); + + currentScope.setTag('foo', 'baz'); + currentScope.setTag('current', 'current'); + + vi.spyOn(currentScopes, 'getGlobalScope').mockReturnValue(globalScope); + + expect(getCombinedScopeData(isolationScope, currentScope)).toEqual({ + attachments: [], + attributes: {}, + breadcrumbs: [], + contexts: {}, + eventProcessors: [], + extra: {}, + fingerprint: [], + level: undefined, + propagationContext: { + sampleRand: expect.any(Number), + traceId: expect.any(String), + }, + sdkProcessingMetadata: {}, + span: undefined, + tags: { + current: 'current', + global: 'global', + isolation: 'isolation', + foo: 'baz', + dogs: 'great', + }, + transactionName: undefined, + user: {}, + }); + }); +}); diff --git a/packages/core/test/lib/utils/time.test.ts b/packages/core/test/lib/utils/time.test.ts new file mode 100644 index 000000000000..a1d537df5862 --- /dev/null +++ b/packages/core/test/lib/utils/time.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest'; + +async function getFreshPerformanceTimeOrigin() { + // Adding the query param with the date, forces a fresh import each time this is called + // otherwise, the dynamic import would be cached and thus fall back to the cached value. + const timeModule = await import(`../../../src/utils/time?update=${Date.now()}`); + return timeModule.browserPerformanceTimeOrigin(); +} + +const RELIABLE_THRESHOLD_MS = 300_000; + +describe('browserPerformanceTimeOrigin', () => { + it('returns `performance.timeOrigin` if it is available and reliable', async () => { + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBeDefined(); + expect(timeOrigin).toBeGreaterThan(0); + expect(timeOrigin).toBeLessThan(Date.now()); + expect(timeOrigin).toBe(performance.timeOrigin); + }); + + it('returns `undefined` if `performance.now` is not available', async () => { + vi.stubGlobal('performance', undefined); + + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBeUndefined(); + + vi.unstubAllGlobals(); + }); + + it('returns `Date.now() - performance.now()` if `performance.timeOrigin` is not reliable', async () => { + const currentTimeMs = 1767778040866; + + const unreliableTime = currentTimeMs - RELIABLE_THRESHOLD_MS - 2_000; + + const timeSincePageloadMs = 1_234.56789; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + + vi.stubGlobal('performance', { + timeOrigin: unreliableTime, + timing: { + navigationStart: unreliableTime, + }, + now: () => timeSincePageloadMs, + }); + + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBe(currentTimeMs - timeSincePageloadMs); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('returns `Date.now() - performance.now()` if neither `performance.timeOrigin` nor `performance.timing.navigationStart` are available', async () => { + const currentTimeMs = 1767778040866; + + const timeSincePageloadMs = 1_234.56789; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + vi.stubGlobal('performance', { + timeOrigin: undefined, + timing: { + navigationStart: undefined, + }, + now: () => timeSincePageloadMs, + }); + + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBe(currentTimeMs - timeSincePageloadMs); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('returns `performance.timing.navigationStart` if `performance.timeOrigin` is not available', async () => { + const currentTimeMs = 1767778040870; + + const navigationStartMs = currentTimeMs - 2_000; + + const timeSincePageloadMs = 1_234.789; + + vi.useFakeTimers(); + vi.setSystemTime(new Date(currentTimeMs)); + + vi.stubGlobal('performance', { + timeOrigin: undefined, + timing: { + navigationStart: navigationStartMs, + }, + now: () => timeSincePageloadMs, + }); + + const timeOrigin = await getFreshPerformanceTimeOrigin(); + expect(timeOrigin).toBe(navigationStartMs); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + describe('caching', () => { + it('caches `undefined` result', async () => { + vi.stubGlobal('performance', undefined); + + const timeModule = await import(`../../../src/utils/time?update=${Date.now()}`); + + const result1 = timeModule.browserPerformanceTimeOrigin(); + + expect(result1).toBeUndefined(); + + vi.stubGlobal('performance', { + timeOrigin: 1000, + now: () => 100, + }); + + const result2 = timeModule.browserPerformanceTimeOrigin(); + expect(result2).toBeUndefined(); // Should still be undefined due to caching + + vi.unstubAllGlobals(); + }); + + it('caches `number` result', async () => { + const timeModule = await import(`../../../src/utils/time?update=${Date.now()}`); + const result = timeModule.browserPerformanceTimeOrigin(); + const timeOrigin = performance.timeOrigin; + expect(result).toBe(timeOrigin); + + vi.stubGlobal('performance', { + now: undefined, + }); + + const result2 = timeModule.browserPerformanceTimeOrigin(); + expect(result2).toBe(timeOrigin); + + vi.unstubAllGlobals(); + }); + }); +}); diff --git a/packages/core/test/lib/utils/vercelWaitUntil.test.ts b/packages/core/test/lib/utils/vercelWaitUntil.test.ts index 78637cb3ef18..1f6be3b7924f 100644 --- a/packages/core/test/lib/utils/vercelWaitUntil.test.ts +++ b/packages/core/test/lib/utils/vercelWaitUntil.test.ts @@ -1,8 +1,28 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { vercelWaitUntil } from '../../../src/utils/vercelWaitUntil'; import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; describe('vercelWaitUntil', () => { + const VERCEL_REQUEST_CONTEXT_SYMBOL = Symbol.for('@vercel/request-context'); + const globalWithEdgeRuntime = globalThis as typeof globalThis & { EdgeRuntime?: string }; + const globalWithVercelRequestContext = GLOBAL_OBJ as unknown as Record; + + // `vercelWaitUntil` only runs in Vercel Edge runtime, which is detected via the global `EdgeRuntime` variable. + // In tests we set it explicitly so the logic is actually exercised. + const originalEdgeRuntime = globalWithEdgeRuntime.EdgeRuntime; + + beforeEach(() => { + globalWithEdgeRuntime.EdgeRuntime = 'edge-runtime'; + }); + + afterEach(() => { + if (originalEdgeRuntime === undefined) { + delete globalWithEdgeRuntime.EdgeRuntime; + } else { + globalWithEdgeRuntime.EdgeRuntime = originalEdgeRuntime; + } + }); + it('should do nothing if GLOBAL_OBJ does not have the @vercel/request-context symbol', () => { const task = Promise.resolve(); vercelWaitUntil(task); @@ -10,31 +30,34 @@ describe('vercelWaitUntil', () => { }); it('should do nothing if get method is not defined', () => { - // @ts-expect-error - Not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = {}; + const originalRequestContext = globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL]; + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = {}; const task = Promise.resolve(); vercelWaitUntil(task); // No assertions needed, just ensuring no errors are thrown + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = originalRequestContext; }); it('should do nothing if waitUntil method is not defined', () => { - // @ts-expect-error - Not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { + const originalRequestContext = globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL]; + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = { get: () => ({}), }; const task = Promise.resolve(); vercelWaitUntil(task); // No assertions needed, just ensuring no errors are thrown + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = originalRequestContext; }); it('should call waitUntil method if it is defined', () => { + const originalRequestContext = globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL]; const waitUntilMock = vi.fn(); - // @ts-expect-error - Not typed - GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = { get: () => ({ waitUntil: waitUntilMock }), }; const task = Promise.resolve(); vercelWaitUntil(task); expect(waitUntilMock).toHaveBeenCalledWith(task); + globalWithVercelRequestContext[VERCEL_REQUEST_CONTEXT_SYMBOL] = originalRequestContext; }); }); diff --git a/packages/eslint-plugin-sdk/src/index.js b/packages/eslint-plugin-sdk/src/index.js index 24cc9c4cc00c..c23a1afcd373 100644 --- a/packages/eslint-plugin-sdk/src/index.js +++ b/packages/eslint-plugin-sdk/src/index.js @@ -15,5 +15,6 @@ module.exports = { 'no-regexp-constructor': require('./rules/no-regexp-constructor'), 'no-focused-tests': require('./rules/no-focused-tests'), 'no-skipped-tests': require('./rules/no-skipped-tests'), + 'no-unsafe-random-apis': require('./rules/no-unsafe-random-apis'), }, }; diff --git a/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js new file mode 100644 index 000000000000..8a9a27795481 --- /dev/null +++ b/packages/eslint-plugin-sdk/src/rules/no-unsafe-random-apis.js @@ -0,0 +1,147 @@ +'use strict'; + +/** + * @fileoverview Rule to enforce wrapping random/time APIs with withRandomSafeContext + * + * This rule detects uses of APIs that generate random values or time-based values + * and ensures they are wrapped with `withRandomSafeContext()` to ensure safe + * random number generation in certain contexts (e.g., React Server Components with caching). + */ + +// APIs that should be wrapped with withRandomSafeContext, with their specific messages +const UNSAFE_MEMBER_CALLS = [ + { + object: 'Date', + property: 'now', + messageId: 'unsafeDateNow', + }, + { + object: 'Math', + property: 'random', + messageId: 'unsafeMathRandom', + }, + { + object: 'performance', + property: 'now', + messageId: 'unsafePerformanceNow', + }, + { + object: 'crypto', + property: 'randomUUID', + messageId: 'unsafeCryptoRandomUUID', + }, + { + object: 'crypto', + property: 'getRandomValues', + messageId: 'unsafeCryptoGetRandomValues', + }, +]; + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Enforce wrapping random/time APIs (Date.now, Math.random, performance.now, crypto.randomUUID) with withRandomSafeContext', + category: 'Best Practices', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + unsafeDateNow: + '`Date.now()` should be replaced with `safeDateNow()` from `@sentry/core` to ensure safe time value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeMathRandom: + '`Math.random()` should be replaced with `safeMathRandom()` from `@sentry/core` to ensure safe random value generation. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafePerformanceNow: + '`performance.now()` should be wrapped with `withRandomSafeContext()` to ensure safe time value generation. Use: `withRandomSafeContext(() => performance.now())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoRandomUUID: + '`crypto.randomUUID()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.randomUUID())`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + unsafeCryptoGetRandomValues: + '`crypto.getRandomValues()` should be wrapped with `withRandomSafeContext()` to ensure safe random value generation. Use: `withRandomSafeContext(() => crypto.getRandomValues(...))`. You can disable this rule with an eslint-disable comment if this usage is intentional.', + }, + }, + create: function (context) { + /** + * Check if a node is inside a withRandomSafeContext call + */ + function isInsidewithRandomSafeContext(node) { + let current = node.parent; + + while (current) { + // Check if we're inside a callback passed to withRandomSafeContext + if ( + current.type === 'CallExpression' && + current.callee.type === 'Identifier' && + current.callee.name === 'withRandomSafeContext' + ) { + return true; + } + + // Also check for arrow functions or regular functions passed to withRandomSafeContext + if ( + (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') && + current.parent?.type === 'CallExpression' && + current.parent.callee.type === 'Identifier' && + current.parent.callee.name === 'withRandomSafeContext' + ) { + return true; + } + + current = current.parent; + } + + return false; + } + + /** + * Check if a node is inside the safeRandomGeneratorRunner.ts file (the definition file) + */ + function isInSafeRandomGeneratorRunner(_node) { + const filename = context.getFilename(); + return filename.includes('safeRandomGeneratorRunner'); + } + + return { + CallExpression(node) { + // Skip if we're in the safeRandomGeneratorRunner.ts file itself + if (isInSafeRandomGeneratorRunner(node)) { + return; + } + + // Check for member expression calls like Date.now(), Math.random(), etc. + if (node.callee.type === 'MemberExpression') { + const callee = node.callee; + + // Get the object name (e.g., 'Date', 'Math', 'performance', 'crypto') + let objectName = null; + if (callee.object.type === 'Identifier') { + objectName = callee.object.name; + } + + // Get the property name (e.g., 'now', 'random', 'randomUUID') + let propertyName = null; + if (callee.property.type === 'Identifier') { + propertyName = callee.property.name; + } else if (callee.computed && callee.property.type === 'Literal') { + propertyName = callee.property.value; + } + + if (!objectName || !propertyName) { + return; + } + + // Check if this is one of the unsafe APIs + const unsafeApi = UNSAFE_MEMBER_CALLS.find(api => api.object === objectName && api.property === propertyName); + + if (unsafeApi && !isInsidewithRandomSafeContext(node)) { + context.report({ + node, + messageId: unsafeApi.messageId, + }); + } + } + }, + }; + }, +}; diff --git a/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts new file mode 100644 index 000000000000..e145336d6c3e --- /dev/null +++ b/packages/eslint-plugin-sdk/test/lib/rules/no-unsafe-random-apis.test.ts @@ -0,0 +1,146 @@ +import { RuleTester } from 'eslint'; +import { describe, test } from 'vitest'; +// @ts-expect-error untyped module +import rule from '../../../src/rules/no-unsafe-random-apis'; + +describe('no-unsafe-random-apis', () => { + test('ruleTester', () => { + const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + }, + }); + + ruleTester.run('no-unsafe-random-apis', rule, { + valid: [ + // Wrapped with withRandomSafeContext - arrow function + { + code: 'withRandomSafeContext(() => Date.now())', + }, + { + code: 'withRandomSafeContext(() => Math.random())', + }, + { + code: 'withRandomSafeContext(() => performance.now())', + }, + { + code: 'withRandomSafeContext(() => crypto.randomUUID())', + }, + { + code: 'withRandomSafeContext(() => crypto.getRandomValues(new Uint8Array(16)))', + }, + // Wrapped with withRandomSafeContext - regular function + { + code: 'withRandomSafeContext(function() { return Date.now(); })', + }, + // Nested inside withRandomSafeContext + { + code: 'withRandomSafeContext(() => { const x = Date.now(); return x + Math.random(); })', + }, + // Expression inside withRandomSafeContext + { + code: 'withRandomSafeContext(() => Date.now() / 1000)', + }, + // Other unrelated calls should be fine + { + code: 'const x = someObject.now()', + }, + { + code: 'const x = Date.parse("2021-01-01")', + }, + { + code: 'const x = Math.floor(5.5)', + }, + { + code: 'const x = performance.mark("test")', + }, + ], + invalid: [ + // Direct Date.now() calls + { + code: 'const time = Date.now()', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Direct Math.random() calls + { + code: 'const random = Math.random()', + errors: [ + { + messageId: 'unsafeMathRandom', + }, + ], + }, + // Direct performance.now() calls + { + code: 'const perf = performance.now()', + errors: [ + { + messageId: 'unsafePerformanceNow', + }, + ], + }, + // Direct crypto.randomUUID() calls + { + code: 'const uuid = crypto.randomUUID()', + errors: [ + { + messageId: 'unsafeCryptoRandomUUID', + }, + ], + }, + // Direct crypto.getRandomValues() calls + { + code: 'const bytes = crypto.getRandomValues(new Uint8Array(16))', + errors: [ + { + messageId: 'unsafeCryptoGetRandomValues', + }, + ], + }, + // Inside a function but not wrapped + { + code: 'function getTime() { return Date.now(); }', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Inside an arrow function but not wrapped with withRandomSafeContext + { + code: 'const getTime = () => Date.now()', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Inside someOtherWrapper + { + code: 'someOtherWrapper(() => Date.now())', + errors: [ + { + messageId: 'unsafeDateNow', + }, + ], + }, + // Multiple violations + { + code: 'const a = Date.now(); const b = Math.random();', + errors: [ + { + messageId: 'unsafeDateNow', + }, + { + messageId: 'unsafeMathRandom', + }, + ], + }, + ], + }); + }); +}); diff --git a/packages/feedback/src/core/createMainStyles.ts b/packages/feedback/src/core/createMainStyles.ts index 6ed6e2e357d8..36d38e62740a 100644 --- a/packages/feedback/src/core/createMainStyles.ts +++ b/packages/feedback/src/core/createMainStyles.ts @@ -71,7 +71,7 @@ export function createMainStyles({ font-family: var(--font-family); font-size: var(--font-size); - ${colorScheme !== 'system' ? 'color-scheme: only light;' : ''} + ${colorScheme !== 'system' ? `color-scheme: only ${colorScheme};` : ''} ${getThemedCssVariables( colorScheme === 'dark' ? { ...DEFAULT_DARK, ...themeDark } : { ...DEFAULT_LIGHT, ...themeLight }, @@ -83,12 +83,13 @@ ${ ? ` @media (prefers-color-scheme: dark) { :host { + color-scheme: only dark; + ${getThemedCssVariables({ ...DEFAULT_DARK, ...themeDark })} } }` : '' } -} `; if (styleNonce) { diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index 5b2caff2b00b..5db18b752c8b 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -49,7 +49,8 @@ }, "dependencies": { "@sentry/core": "10.32.1", - "@sentry/node": "10.32.1" + "@sentry/node": "10.32.1", + "@sentry/node-core": "10.32.1" }, "devDependencies": { "@google-cloud/bigquery": "^5.3.0", diff --git a/packages/google-cloud-serverless/src/sdk.ts b/packages/google-cloud-serverless/src/sdk.ts index 2699eb4f9e2f..1161ab60300e 100644 --- a/packages/google-cloud-serverless/src/sdk.ts +++ b/packages/google-cloud-serverless/src/sdk.ts @@ -2,13 +2,10 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, init as initNode } from '@sentry/node'; +import { isCjs } from '@sentry/node-core'; import { googleCloudGrpcIntegration } from './integrations/google-cloud-grpc'; import { googleCloudHttpIntegration } from './integrations/google-cloud-http'; -function isCjs(): boolean { - return typeof require !== 'undefined'; -} - function getCjsOnlyIntegrations(): Integration[] { return isCjs() ? [ diff --git a/packages/nextjs/.eslintrc.js b/packages/nextjs/.eslintrc.js index 1f0ae547d4e0..4a5bdd17795e 100644 --- a/packages/nextjs/.eslintrc.js +++ b/packages/nextjs/.eslintrc.js @@ -7,6 +7,9 @@ module.exports = { jsx: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, overrides: [ { files: ['scripts/**/*.ts'], @@ -27,5 +30,11 @@ module.exports = { globalThis: 'readonly', }, }, + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, ], }; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index a113781f7140..d07549214eec 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -87,14 +87,12 @@ "@sentry/react": "10.32.1", "@sentry/vercel-edge": "10.32.1", "@sentry/webpack-plugin": "^4.6.1", - "resolve": "1.22.8", "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" }, "devDependencies": { - "@types/resolve": "1.20.3", "eslint-plugin-react": "^7.31.11", - "next": "13.5.9", + "next": "14.2.35", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 07d1ee5c4e84..d7a2478987ae 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -5,6 +5,7 @@ import type { Client, EventProcessor, Integration } from '@sentry/core'; import { addEventProcessor, applySdkMetadata, consoleSandbox, getGlobalScope, GLOBAL_OBJ } from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; +import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; @@ -48,6 +49,15 @@ export function init(options: BrowserOptions): Client | undefined { } clientIsInitialized = true; + if (!DEBUG_BUILD && options.debug) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You have enabled `debug: true`, but Sentry debug logging was removed from your bundle (likely via `withSentryConfig({ disableLogger: true })` / `webpack.treeshake.removeDebugLogging: true`). Set that option to `false` to see Sentry debug output.', + ); + }); + } + // Remove cached trace meta tags for ISR/SSG pages before initializing // This prevents the browser tracing integration from using stale trace IDs if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts index c85bdc4f2ad3..8cd0c016d0fb 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentryVercelCrons.ts @@ -1,4 +1,4 @@ -import { captureCheckIn } from '@sentry/core'; +import { _INTERNAL_safeDateNow, captureCheckIn } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { VercelCronsConfig } from '../types'; @@ -57,14 +57,14 @@ export function wrapApiHandlerWithSentryVercelCrons { captureCheckIn({ checkInId, monitorSlug, status: 'error', - duration: Date.now() / 1000 - startTime, + duration: _INTERNAL_safeDateNow() / 1000 - startTime, }); }; @@ -82,7 +82,7 @@ export function wrapApiHandlerWithSentryVercelCrons { @@ -98,7 +98,7 @@ export function wrapApiHandlerWithSentryVercelCrons | undefined { + // OTEL spans expose attributes in different shapes depending on implementation. + // We only need best-effort read access. + type MaybeSpanAttributes = { + attributes?: Record; + _attributes?: Record; + }; + + const maybeSpan = span as unknown as MaybeSpanAttributes; + const attrs = maybeSpan.attributes || maybeSpan._attributes; + return attrs; +} + /** * Checks if a span's HTTP target matches the tunnel route. */ diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 3125102e9656..4bdf841cef2c 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -43,6 +43,7 @@ export type WrappingLoaderOptions = { wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'route-handler'; vercelCronsConfig?: VercelCronsConfig; nextjsRequestAsyncStorageModulePath?: string; + isDev?: boolean; }; /** @@ -66,6 +67,7 @@ export default function wrappingLoader( wrappingTargetKind, vercelCronsConfig, nextjsRequestAsyncStorageModulePath, + isDev, } = 'getOptions' in this ? this.getOptions() : this.query; this.async(); @@ -220,7 +222,7 @@ export default function wrappingLoader( // Run the proxy module code through Rollup, in order to split the `export * from ''` out into // individual exports (which nextjs seems to require). - wrapUserCode(templateCode, userCode, userModuleSourceMap) + wrapUserCode(templateCode, userCode, userModuleSourceMap, isDev, this.resourcePath) .then(({ code: wrappedCode, map: wrappedCodeSourceMap }) => { this.callback(null, wrappedCode, wrappedCodeSourceMap); }) @@ -245,6 +247,9 @@ export default function wrappingLoader( * * @param wrapperCode The wrapper module code * @param userModuleCode The user module code + * @param userModuleSourceMap The source map for the user module + * @param isDev Whether we're in development mode (affects sourcemap generation) + * @param userModulePath The absolute path to the user's original module (for sourcemap accuracy) * @returns The wrapped user code and a source map that describes the transformations done by this function */ async function wrapUserCode( @@ -252,6 +257,8 @@ async function wrapUserCode( userModuleCode: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any userModuleSourceMap: any, + isDev?: boolean, + userModulePath?: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise<{ code: string; map?: any }> { const wrap = (withDefaultExport: boolean): Promise => @@ -267,21 +274,48 @@ async function wrapUserCode( resolveId: id => { if (id === SENTRY_WRAPPER_MODULE_NAME || id === WRAPPING_TARGET_MODULE_NAME) { return id; - } else { - return null; } + + return null; }, load(id) { if (id === SENTRY_WRAPPER_MODULE_NAME) { return withDefaultExport ? wrapperCode : wrapperCode.replace('export { default } from', 'export {} from'); - } else if (id === WRAPPING_TARGET_MODULE_NAME) { + } + + if (id !== WRAPPING_TARGET_MODULE_NAME) { + return null; + } + + // In prod/build, we should not interfere with sourcemaps + if (!isDev || !userModulePath) { + return { code: userModuleCode, map: userModuleSourceMap }; + } + + // In dev mode, we need to adjust the sourcemap to use absolute paths for the user's file. + // This ensures debugger breakpoints correctly map back to the original file. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const userSources: string[] = userModuleSourceMap?.sources; + if (Array.isArray(userSources)) { return { code: userModuleCode, - map: userModuleSourceMap, // give rollup access to original user module source map + map: { + ...userModuleSourceMap, + sources: userSources.map((source: string, index: number) => (index === 0 ? userModulePath : source)), + }, }; - } else { - return null; } + + // If no sourcemap exists, create a simple identity mapping with the absolute path + return { + code: userModuleCode, + map: { + version: 3, + sources: [userModulePath], + sourcesContent: [userModuleCode], + mappings: '', + }, + }; }, }, @@ -352,7 +386,22 @@ async function wrapUserCode( const finalBundle = await rollupBuild.generate({ format: 'esm', - sourcemap: 'hidden', // put source map data in the bundle but don't generate a source map comment in the output + // In dev mode, use inline sourcemaps so debuggers can map breakpoints back to original source. + // In production, use hidden sourcemaps (no sourceMappingURL comment) to avoid exposing internals. + sourcemap: isDev ? 'inline' : 'hidden', + // In dev mode, preserve absolute paths in sourcemaps so debuggers can correctly resolve breakpoints. + // By default, Rollup converts absolute paths to relative paths, which breaks debugging. + // We only do this in dev mode to avoid interfering with Sentry's sourcemap upload in production. + sourcemapPathTransform: isDev + ? relativeSourcePath => { + // If we have userModulePath and this relative path matches the end of it, use the absolute path + if (userModulePath?.endsWith(relativeSourcePath)) { + return userModulePath; + } + // Keep other paths (like sentry-wrapper-module) as-is + return relativeSourcePath; + } + : undefined, }); // The module at index 0 is always the entrypoint, which in this case is the proxy module. diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index d37285983d31..487ab05c55d8 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -28,9 +28,10 @@ function isRouteGroup(name: string): boolean { return name.startsWith('(') && name.endsWith(')'); } -function normalizeRoutePath(routePath: string): string { +function normalizeRouteGroupPath(routePath: string): string { // Remove route group segments from the path - return routePath.replace(/\/\([^)]+\)/g, ''); + // Using positive lookahead with (?=[^)\/]*\)) to avoid polynomial matching + return routePath.replace(/\/\((?=[^)/]*\))[^)/]+\)/g, ''); } function getDynamicRouteSegment(name: string): string { @@ -140,7 +141,7 @@ function scanAppDirectory(dir: string, basePath: string = '', includeRouteGroups if (pageFile) { // Conditionally normalize the path based on includeRouteGroups option - const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/'); + const routePath = includeRouteGroups ? basePath || '/' : normalizeRouteGroupPath(basePath || '/'); const isDynamic = routePath.includes(':'); // Check if this page has generateStaticParams (ISR/SSG indicator) diff --git a/packages/nextjs/src/config/polyfills/perf_hooks.js b/packages/nextjs/src/config/polyfills/perf_hooks.js index 1a0ce4a2af76..2c04ae100099 100644 --- a/packages/nextjs/src/config/polyfills/perf_hooks.js +++ b/packages/nextjs/src/config/polyfills/perf_hooks.js @@ -1,3 +1,4 @@ +/* eslint-disable @sentry-internal/sdk/no-unsafe-random-apis */ // Polyfill for Node.js perf_hooks module in edge runtime // This mirrors the polyfill from packages/vercel-edge/rollup.npm.config.mjs const __sentry__timeOrigin = Date.now(); diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 0d4a55687d2f..d4ff32be0c0a 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -1,6 +1,6 @@ import { parseSemver } from '@sentry/core'; import * as fs from 'fs'; -import { sync as resolveSync } from 'resolve'; +import { createRequire } from 'module'; /** * Returns the version of Next.js installed in the project, or undefined if it cannot be determined. @@ -23,7 +23,7 @@ export function getNextjsVersion(): string | undefined { function resolveNextjsPackageJson(): string | undefined { try { - return resolveSync('next/package.json', { basedir: process.cwd() }); + return createRequire(`${process.cwd()}/`).resolve('next/package.json'); } catch { return undefined; } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 60f227b3c42c..497f170725c8 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -3,8 +3,8 @@ import { debug, escapeStringForRegex, loadModule, parseSemver } from '@sentry/core'; import * as fs from 'fs'; +import { createRequire } from 'module'; import * as path from 'path'; -import { sync as resolveSync } from 'resolve'; import type { VercelCronsConfig } from '../common/types'; import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions'; import type { RouteManifest } from './manifest/types'; @@ -150,6 +150,7 @@ export function constructWebpackConfigFunction({ projectDir, rawNewConfig.resolve?.modules, ), + isDev, }; const normalizeLoaderResourcePath = (resourcePath: string): string => { @@ -790,7 +791,7 @@ function addValueInjectionLoader({ function resolveNextPackageDirFromDirectory(basedir: string): string | undefined { try { - return path.dirname(resolveSync('next/package.json', { basedir })); + return path.dirname(createRequire(`${basedir}/`).resolve('next/package.json')); } catch { // Should not happen in theory return undefined; diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 835ef6dc68a4..df203edad29e 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -94,6 +94,7 @@ export function withSentryConfig(nextConfig?: C, sentryBuildOptions: SentryBu */ function generateRandomTunnelRoute(): string { // Generate a random 8-character alphanumeric string + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const randomString = Math.random().toString(36).substring(2, 10); return `/${randomString}`; } @@ -121,8 +122,16 @@ function migrateDeprecatedWebpackOptions(userSentryOptions: SentryBuildOptions): return newValue ?? deprecatedValue; }; - const deprecatedMessage = (deprecatedPath: string, newPath: string): string => - `[@sentry/nextjs] DEPRECATION WARNING: ${deprecatedPath} is deprecated and will be removed in a future version. Use ${newPath} instead.`; + const deprecatedMessage = (deprecatedPath: string, newPath: string): string => { + const message = `[@sentry/nextjs] DEPRECATION WARNING: ${deprecatedPath} is deprecated and will be removed in a future version. Use ${newPath} instead.`; + + // In Turbopack builds, webpack configuration is not applied, so webpack-scoped options won't have any effect. + if (detectActiveBundler() === 'turbopack' && newPath.startsWith('webpack.')) { + return `${message} (Not supported with Turbopack.)`; + } + + return message; + }; /* eslint-disable deprecation/deprecation */ // Migrate each deprecated option to the new path, but only if the new path isn't already set diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index fcaad178b9fa..94c71a52c483 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,7 +1,7 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ -import { context } from '@opentelemetry/api'; +import { context, createContextKey } from '@opentelemetry/api'; import { applySdkMetadata, type EventProcessor, @@ -12,6 +12,7 @@ import { getRootSpan, GLOBAL_OBJ, registerSpanErrorInstrumentation, + type Scope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -19,9 +20,9 @@ import { spanToJSON, stripUrlQueryAndFragment, } from '@sentry/core'; -import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { DEBUG_BUILD } from '../common/debug-build'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; @@ -41,6 +42,32 @@ export { wrapApiHandlerWithSentry } from './wrapApiHandlerWithSentry'; export type EdgeOptions = VercelEdgeOptions; +type CurrentScopes = { + scope: Scope; + isolationScope: Scope; +}; + +// This key must match `@sentry/opentelemetry`'s `SENTRY_SCOPES_CONTEXT_KEY`. +// We duplicate it here so the Edge bundle does not need to import the full `@sentry/opentelemetry` package. +const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); + +type ContextWithGetValue = { + getValue(key: unknown): unknown; +}; + +function getScopesFromContext(otelContext: unknown): CurrentScopes | undefined { + if (!otelContext || typeof otelContext !== 'object') { + return undefined; + } + + const maybeContext = otelContext as Partial; + if (typeof maybeContext.getValue !== 'function') { + return undefined; + } + + return maybeContext.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined; +} + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; @@ -55,6 +82,13 @@ export function init(options: VercelEdgeOptions = {}): void { return; } + if (!DEBUG_BUILD && options.debug) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You have enabled `debug: true`, but Sentry debug logging was removed from your bundle (likely via `withSentryConfig({ disableLogger: true })` / `webpack.treeshake.removeDebugLogging: true`). Set that option to `false` to see Sentry debug output.', + ); + } + const customDefaultIntegrations = getDefaultIntegrations(options); // This value is injected at build time, based on the output directory specified in the build config. Though a default diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 7dc533e171b1..91d1dd65ca06 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -34,6 +34,7 @@ import { isBuild } from '../common/utils/isBuild'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { handleOnSpanStart } from './handleOnSpanStart'; +import { prepareSafeIdGeneratorContext } from './prepareSafeIdGeneratorContext'; export * from '@sentry/node'; @@ -92,10 +93,18 @@ export function showReportDialog(): void { /** Inits the Sentry NextJS SDK on node. */ export function init(options: NodeOptions): NodeClient | undefined { + prepareSafeIdGeneratorContext(); if (isBuild()) { return; } + if (!DEBUG_BUILD && options.debug) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You have enabled `debug: true`, but Sentry debug logging was removed from your bundle (likely via `withSentryConfig({ disableLogger: true })` / `webpack.treeshake.removeDebugLogging: true`). Set that option to `false` to see Sentry debug output.', + ); + } + const customDefaultIntegrations = getDefaultIntegrations(options) .filter(integration => integration.name !== 'Http') .concat( diff --git a/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts new file mode 100644 index 000000000000..bd262eb736e1 --- /dev/null +++ b/packages/nextjs/src/server/prepareSafeIdGeneratorContext.ts @@ -0,0 +1,49 @@ +import { + type _INTERNAL_RandomSafeContextRunner as _INTERNAL_RandomSafeContextRunner, + debug, + GLOBAL_OBJ, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../common/debug-build'; + +// Inline AsyncLocalStorage interface from current types +// Avoids conflict with resolving it from getBuiltinModule +type OriginalAsyncLocalStorage = typeof AsyncLocalStorage; + +/** + * Prepares the global object to generate safe random IDs in cache components contexts + * See: https://github.com/getsentry/sentry-javascript/blob/ceb003c15973c2d8f437dfb7025eedffbc8bc8b0/packages/core/src/utils/propagationContext.ts#L1 + */ +export function prepareSafeIdGeneratorContext(): void { + const sym = Symbol.for('__SENTRY_SAFE_RANDOM_ID_WRAPPER__'); + const globalWithSymbol: typeof GLOBAL_OBJ & { [sym]?: _INTERNAL_RandomSafeContextRunner } = GLOBAL_OBJ; + const als = getAsyncLocalStorage(); + if (!als || typeof als.snapshot !== 'function') { + DEBUG_BUILD && + debug.warn( + '[@sentry/nextjs] No AsyncLocalStorage found in the runtime or AsyncLocalStorage.snapshot() is not available, skipping safe random ID generator context preparation, you may see some errors with cache components.', + ); + return; + } + + globalWithSymbol[sym] = als.snapshot(); + DEBUG_BUILD && debug.log('[@sentry/nextjs] Prepared safe random ID generator context'); +} + +function getAsyncLocalStorage(): OriginalAsyncLocalStorage | undefined { + // May exist in the Next.js runtime globals + // Doesn't exist in some of our tests + if (typeof AsyncLocalStorage !== 'undefined') { + return AsyncLocalStorage; + } + + // Try to resolve it dynamically without synchronously importing the module + // This is done to avoid importing the module synchronously at the top + // which means this is safe across runtimes + if ('getBuiltinModule' in process && typeof process.getBuiltinModule === 'function') { + const { AsyncLocalStorage } = process.getBuiltinModule('async_hooks') ?? {}; + + return AsyncLocalStorage as OriginalAsyncLocalStorage; + } + + return undefined; +} diff --git a/packages/nextjs/test/config/conflictingDebugOptions.test.ts b/packages/nextjs/test/config/conflictingDebugOptions.test.ts new file mode 100644 index 000000000000..8c0920382c4a --- /dev/null +++ b/packages/nextjs/test/config/conflictingDebugOptions.test.ts @@ -0,0 +1,82 @@ +import { JSDOM } from 'jsdom'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +function didWarnAboutDebugRemoved(warnSpy: ReturnType): boolean { + return warnSpy.mock.calls.some(call => + call.some( + arg => + typeof arg === 'string' && + arg.includes('You have enabled `debug: true`') && + arg.includes('debug logging was removed from your bundle'), + ), + ); +} + +describe('debug: true + removeDebugLogging warning', () => { + let dom: JSDOM; + let originalDocument: unknown; + let originalLocation: unknown; + let originalAddEventListener: unknown; + + beforeAll(() => { + dom = new JSDOM('', { url: 'https://example.com/' }); + + originalDocument = (globalThis as any).document; + originalLocation = (globalThis as any).location; + originalAddEventListener = (globalThis as any).addEventListener; + + Object.defineProperty(globalThis, 'document', { value: dom.window.document, writable: true }); + Object.defineProperty(globalThis, 'location', { value: dom.window.location, writable: true }); + Object.defineProperty(globalThis, 'addEventListener', { value: () => undefined, writable: true }); + }); + + afterAll(() => { + Object.defineProperty(globalThis, 'document', { value: originalDocument, writable: true }); + Object.defineProperty(globalThis, 'location', { value: originalLocation, writable: true }); + Object.defineProperty(globalThis, 'addEventListener', { value: originalAddEventListener, writable: true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unmock('../../src/common/debug-build.js'); + delete process.env.NEXT_OTEL_FETCH_DISABLED; + delete process.env.NEXT_PHASE; + }); + + it('warns on client/server/edge when debug is true but DEBUG_BUILD is false', async () => { + vi.doMock('../../src/common/debug-build.js', () => ({ DEBUG_BUILD: false })); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = await import('../../src/client/index.js'); + client.init({ dsn: TEST_DSN, debug: true } as any); + + const server = await import('../../src/server/index.js'); + server.init({ dsn: TEST_DSN, debug: true } as any); + + const edge = await import('../../src/edge/index.js'); + edge.init({ dsn: TEST_DSN, debug: true } as any); + + expect(didWarnAboutDebugRemoved(warnSpy)).toBe(true); + }); + + it('does not emit that warning when DEBUG_BUILD is true', async () => { + vi.doMock('../../src/common/debug-build.js', () => ({ DEBUG_BUILD: true })); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const client = await import('../../src/client/index.js'); + client.init({ dsn: TEST_DSN, debug: true } as any); + + const server = await import('../../src/server/index.js'); + server.init({ dsn: TEST_DSN, debug: true } as any); + + const edge = await import('../../src/edge/index.js'); + edge.init({ dsn: TEST_DSN, debug: true } as any); + + expect(didWarnAboutDebugRemoved(warnSpy)).toBe(false); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx new file mode 100644 index 000000000000..39c826b4bf16 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx @@ -0,0 +1 @@ +// API Internal Page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx new file mode 100644 index 000000000000..3776b7545439 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx @@ -0,0 +1 @@ +// Login V2 Page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx new file mode 100644 index 000000000000..66c18edfd787 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx @@ -0,0 +1 @@ +// Features Beta Page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index c2d455361c4c..32ac315b3571 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -12,11 +12,14 @@ describe('route-groups', () => { expect(manifest).toEqual({ staticRoutes: [ { path: '/' }, + { path: '/api' }, { path: '/login' }, { path: '/signup' }, + { path: '/login' }, // from (auth-v2) { path: '/dashboard' }, { path: '/settings/profile' }, { path: '/public/about' }, + { path: '/features' }, ], dynamicRoutes: [ { @@ -28,6 +31,8 @@ describe('route-groups', () => { ], isrRoutes: [], }); + // Verify we have 9 static routes total (including duplicates from special chars) + expect(manifest.staticRoutes).toHaveLength(9); }); test('should handle dynamic routes within route groups', () => { @@ -37,6 +42,17 @@ describe('route-groups', () => { expect(regex.test('/dashboard/abc')).toBe(true); expect(regex.test('/dashboard/123/456')).toBe(false); }); + + test.each([ + { routeGroup: '(auth-v2)', strippedPath: '/login', description: 'hyphens' }, + { routeGroup: '(api_internal)', strippedPath: '/api', description: 'underscores' }, + { routeGroup: '(v2.0.beta)', strippedPath: '/features', description: 'dots' }, + ])('should strip route groups with $description', ({ routeGroup, strippedPath }) => { + // Verify the stripped path exists + expect(manifest.staticRoutes.find(route => route.path === strippedPath)).toBeDefined(); + // Verify the route group was stripped, not included + expect(manifest.staticRoutes.find(route => route.path.includes(routeGroup))).toBeUndefined(); + }); }); describe('includeRouteGroups: true', () => { @@ -46,11 +62,14 @@ describe('route-groups', () => { expect(manifest).toEqual({ staticRoutes: [ { path: '/' }, + { path: '/(api_internal)/api' }, { path: '/(auth)/login' }, { path: '/(auth)/signup' }, + { path: '/(auth-v2)/login' }, { path: '/(dashboard)/dashboard' }, { path: '/(dashboard)/settings/profile' }, { path: '/(marketing)/public/about' }, + { path: '/(v2.0.beta)/features' }, ], dynamicRoutes: [ { @@ -62,6 +81,7 @@ describe('route-groups', () => { ], isrRoutes: [], }); + expect(manifest.staticRoutes).toHaveLength(9); }); test('should handle dynamic routes within route groups with proper regex escaping', () => { @@ -92,5 +112,13 @@ describe('route-groups', () => { expect(authSignup).toBeDefined(); expect(marketingPublic).toBeDefined(); }); + + test.each([ + { fullPath: '/(auth-v2)/login', description: 'hyphens' }, + { fullPath: '/(api_internal)/api', description: 'underscores' }, + { fullPath: '/(v2.0.beta)/features', description: 'dots' }, + ])('should preserve route groups with $description when includeRouteGroups is true', ({ fullPath }) => { + expect(manifest.staticRoutes.find(route => route.path === fullPath)).toBeDefined(); + }); }); }); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index ed4b96a78125..7dfa68ccbcde 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -390,6 +390,21 @@ describe('withSentryConfig', () => { ); }); + it('adds a turbopack note when the deprecated option only applies to webpack', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); + + const sentryOptions = { + disableLogger: true, + }; + + materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Use webpack.treeshake.removeDebugLogging instead. (Not supported with Turbopack.)'), + ); + }); + it('does not warn when using new webpack path', () => { delete process.env.TURBOPACK; diff --git a/packages/nextjs/test/config/wrappingLoader.test.ts b/packages/nextjs/test/config/wrappingLoader.test.ts index ab33450790bb..97e2b016301e 100644 --- a/packages/nextjs/test/config/wrappingLoader.test.ts +++ b/packages/nextjs/test/config/wrappingLoader.test.ts @@ -249,4 +249,90 @@ describe('wrappingLoader', () => { expect(wrappedCode).toMatch(/const proxy = userProvidedProxy \? wrappedHandler : undefined/); }); }); + + describe('sourcemap handling', () => { + it('should include inline sourcemap in dev mode', async () => { + const callback = vi.fn(); + + const userCode = ` + export function middleware(request) { + return new Response('ok'); + } + `; + const userCodeSourceMap = undefined; + + const loaderPromise = new Promise(resolve => { + const loaderThis = { + ...defaultLoaderThis, + resourcePath: '/my/src/middleware.ts', + callback: callback.mockImplementation(() => { + resolve(); + }), + getOptions() { + return { + pagesDir: '/my/pages', + appDir: '/my/app', + pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX, + excludeServerRoutes: [], + wrappingTargetKind: 'middleware', + vercelCronsConfig: undefined, + nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js', + isDev: true, + }; + }, + } satisfies LoaderThis; + + wrappingLoader.call(loaderThis, userCode, userCodeSourceMap); + }); + + await loaderPromise; + + const wrappedCode = callback.mock.calls[0][1] as string; + + // In dev mode, should have inline sourcemap for debugger support + expect(wrappedCode).toContain('//# sourceMappingURL=data:application/json;charset=utf-8;base64,'); + }); + + it('should not include inline sourcemap in production mode', async () => { + const callback = vi.fn(); + + const userCode = ` + export function middleware(request) { + return new Response('ok'); + } + `; + const userCodeSourceMap = undefined; + + const loaderPromise = new Promise(resolve => { + const loaderThis = { + ...defaultLoaderThis, + resourcePath: '/my/src/middleware.ts', + callback: callback.mockImplementation(() => { + resolve(); + }), + getOptions() { + return { + pagesDir: '/my/pages', + appDir: '/my/app', + pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX, + excludeServerRoutes: [], + wrappingTargetKind: 'middleware', + vercelCronsConfig: undefined, + nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js', + isDev: false, + }; + }, + } satisfies LoaderThis; + + wrappingLoader.call(loaderThis, userCode, userCodeSourceMap); + }); + + await loaderPromise; + + const wrappedCode = callback.mock.calls[0][1] as string; + + // In production mode, should NOT have inline sourcemap (hidden sourcemap instead) + expect(wrappedCode).not.toContain('//# sourceMappingURL=data:application/json;charset=utf-8;base64,'); + }); + }); }); diff --git a/packages/node-core/.eslintrc.js b/packages/node-core/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node-core/.eslintrc.js +++ b/packages/node-core/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 5308dd52ae48..ad99efc5fcba 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -69,7 +69,7 @@ "@apm-js-collab/tracing-hooks": "^0.3.1", "@sentry/core": "10.32.1", "@sentry/opentelemetry": "10.32.1", - "import-in-the-middle": "^2" + "import-in-the-middle": "^2.0.1" }, "devDependencies": { "@apm-js-collab/code-transformer": "^0.8.2", diff --git a/packages/node-core/src/cron/node-cron.ts b/packages/node-core/src/cron/node-cron.ts index 0c6d2a8e5ca1..763b260353cf 100644 --- a/packages/node-core/src/cron/node-cron.ts +++ b/packages/node-core/src/cron/node-cron.ts @@ -1,4 +1,4 @@ -import { captureException, withMonitor } from '@sentry/core'; +import { captureException, type MonitorConfig, withMonitor } from '@sentry/core'; import { replaceCronNames } from './common'; export interface NodeCronOptions { @@ -32,7 +32,10 @@ export interface NodeCron { * ); * ``` */ -export function instrumentNodeCron(lib: Partial & T): T { +export function instrumentNodeCron( + lib: Partial & T, + monitorConfig: Pick = {}, +): T { return new Proxy(lib, { get(target, prop) { if (prop === 'schedule' && target.schedule) { @@ -69,6 +72,7 @@ export function instrumentNodeCron(lib: Partial & T): T { { schedule: { type: 'crontab', value: replaceCronNames(expression) }, timezone, + ...monitorConfig, }, ); }; diff --git a/packages/node-core/src/integrations/anr/index.ts b/packages/node-core/src/integrations/anr/index.ts index e33c92a1eb3b..e2207f9379c7 100644 --- a/packages/node-core/src/integrations/anr/index.ts +++ b/packages/node-core/src/integrations/anr/index.ts @@ -5,12 +5,11 @@ import { debug, defineIntegration, getClient, + getCombinedScopeData, getCurrentScope, getFilenameToDebugIdMap, - getGlobalScope, getIsolationScope, GLOBAL_OBJ, - mergeScopeData, } from '@sentry/core'; import { NODE_VERSION } from '../../nodeVersion'; import type { NodeClient } from '../../sdk/client'; @@ -35,9 +34,7 @@ function globalWithScopeFetchFn(): typeof GLOBAL_OBJ & { __SENTRY_GET_SCOPES__?: /** Fetches merged scope data */ function getScopeData(): ScopeData { - const scope = getGlobalScope().getScopeData(); - mergeScopeData(scope, getIsolationScope().getScopeData()); - mergeScopeData(scope, getCurrentScope().getScopeData()); + const scope = getCombinedScopeData(getIsolationScope(), getCurrentScope()); // We remove attachments because they likely won't serialize well as json scope.attachments = []; diff --git a/packages/node-core/src/integrations/context.ts b/packages/node-core/src/integrations/context.ts index cad8a1c4a443..6584640935ee 100644 --- a/packages/node-core/src/integrations/context.ts +++ b/packages/node-core/src/integrations/context.ts @@ -204,6 +204,7 @@ function getCultureContext(): CultureContext | undefined { */ export function getAppContext(): AppContext { const app_memory = process.memoryUsage().rss; + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis const app_start_time = new Date(Date.now() - process.uptime() * 1000).toISOString(); // https://nodejs.org/api/process.html#processavailablememory const appContext: AppContext = { app_start_time, app_memory }; @@ -236,6 +237,7 @@ export function getDeviceContext(deviceOpt: DeviceContextOptions | true): Device // Hence, we only set boot time, if we get a valid uptime value. // @see https://github.com/getsentry/sentry-javascript/issues/5856 if (typeof uptime === 'number') { + // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis device.boot_time = new Date(Date.now() - uptime * 1000).toISOString(); } diff --git a/packages/node-core/src/integrations/onuncaughtexception.ts b/packages/node-core/src/integrations/onuncaughtexception.ts index 41c4bf96917d..8afa70787a5c 100644 --- a/packages/node-core/src/integrations/onuncaughtexception.ts +++ b/packages/node-core/src/integrations/onuncaughtexception.ts @@ -1,4 +1,5 @@ import { captureException, debug, defineIntegration, getClient } from '@sentry/core'; +import { isMainThread } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; import type { NodeClient } from '../sdk/client'; import { logAndExitProcess } from '../utils/errorhandling'; @@ -44,6 +45,12 @@ export const onUncaughtExceptionIntegration = defineIntegration((options: Partia return { name: INTEGRATION_NAME, setup(client: NodeClient) { + // errors in worker threads are already handled by the childProcessIntegration + // also we don't want to exit the Node process on worker thread errors + if (!isMainThread) { + return; + } + global.process.on('uncaughtException', makeErrorHandler(client, optionsWithDefaults)); }, }; diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index f20d4bae4098..bd8c8ba40b4f 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -19,10 +19,27 @@ type LevelMapping = { }; type Pino = { + [key: symbol]: unknown; levels: LevelMapping; [SENTRY_TRACK_SYMBOL]?: 'track' | 'ignore'; }; +/** + * Gets a custom Pino key from a logger instance by searching for the symbol. + * Pino uses non-global symbols like Symbol('pino.messageKey'): https://github.com/pinojs/pino/blob/8a816c0b1f72de5ae9181f3bb402109b66f7d812/lib/symbols.js + */ +function getPinoKey(logger: Pino, symbolName: string, defaultKey: string): string { + const symbols = Object.getOwnPropertySymbols(logger); + const symbolString = `Symbol(${symbolName})`; + for (const sym of symbols) { + if (sym.toString() === symbolString) { + const value = logger[sym]; + return typeof value === 'string' ? value : defaultKey; + } + } + return defaultKey; +} + type MergeObject = { [key: string]: unknown; err?: Error; @@ -134,7 +151,8 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial = { @@ -163,8 +181,9 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial { + // We have 500ms for processing here, so we try to make sure to have enough time to send the events + await client.flush(200); + }); + } + return client; } diff --git a/packages/node-core/src/transports/http.ts b/packages/node-core/src/transports/http.ts index 3319353aff14..7b2cea994a17 100644 --- a/packages/node-core/src/transports/http.ts +++ b/packages/node-core/src/transports/http.ts @@ -125,12 +125,15 @@ function createRequestExecutor( body = body.pipe(createGzip()); } + const hostnameIsIPv6 = hostname.startsWith('['); + const req = httpModule.request( { method: 'POST', agent, headers, - hostname, + // Remove "[" and "]" from IPv6 hostnames + hostname: hostnameIsIPv6 ? hostname.slice(1, -1) : hostname, path: `${pathname}${search}`, port, protocol, diff --git a/packages/node-core/test/sdk/init.test.ts b/packages/node-core/test/sdk/init.test.ts index d5f150f03a59..144ff3e2dc37 100644 --- a/packages/node-core/test/sdk/init.test.ts +++ b/packages/node-core/test/sdk/init.test.ts @@ -111,6 +111,64 @@ describe('init()', () => { expect(client).toBeInstanceOf(NodeClient); }); + it('registers a SIGTERM handler on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + process.env.VERCEL = '1'; + + const baselineListeners = process.listeners('SIGTERM'); + + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + + expect(addedListeners).toHaveLength(1); + + // Cleanup: remove the handler we added in this test. + process.off('SIGTERM', addedListeners[0] as any); + process.env.VERCEL = originalVercelEnv; + }); + + it('flushes when SIGTERM is received on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + process.env.VERCEL = '1'; + + const baselineListeners = process.listeners('SIGTERM'); + + const client = init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + expect(client).toBeInstanceOf(NodeClient); + + const flushSpy = vi.spyOn(client as NodeClient, 'flush').mockResolvedValue(true); + + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + expect(addedListeners).toHaveLength(1); + + process.emit('SIGTERM'); + + expect(flushSpy).toHaveBeenCalledWith(200); + + // Cleanup: remove the handler we added in this test. + process.off('SIGTERM', addedListeners[0] as any); + process.env.VERCEL = originalVercelEnv; + }); + + it('does not register a SIGTERM handler when not running on Vercel', () => { + const originalVercelEnv = process.env.VERCEL; + delete process.env.VERCEL; + + const baselineListeners = process.listeners('SIGTERM'); + + init({ dsn: PUBLIC_DSN, skipOpenTelemetrySetup: true }); + + const postInitListeners = process.listeners('SIGTERM'); + const addedListeners = postInitListeners.filter(l => !baselineListeners.includes(l)); + + expect(addedListeners).toHaveLength(0); + + process.env.VERCEL = originalVercelEnv; + }); + describe('environment variable options', () => { const originalProcessEnv = { ...process.env }; diff --git a/packages/node/.eslintrc.js b/packages/node/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/node/.eslintrc.js +++ b/packages/node/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/node/package.json b/packages/node/package.json index 7a4926837e52..bd312657e92f 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -98,7 +98,7 @@ "@sentry/core": "10.32.1", "@sentry/node-core": "10.32.1", "@sentry/opentelemetry": "10.32.1", - "import-in-the-middle": "^2", + "import-in-the-middle": "^2.0.1", "minimatch": "^9.0.0" }, "devDependencies": { diff --git a/packages/node/src/integrations/tracing/fastify/index.ts b/packages/node/src/integrations/tracing/fastify/index.ts index c777fa20136d..02f9a2adde4b 100644 --- a/packages/node/src/integrations/tracing/fastify/index.ts +++ b/packages/node/src/integrations/tracing/fastify/index.ts @@ -14,7 +14,7 @@ import { import { generateInstrumentOnce } from '@sentry/node-core'; import { DEBUG_BUILD } from '../../../debug-build'; import { FastifyOtelInstrumentation } from './fastify-otel/index'; -import type { FastifyInstance, FastifyReply, FastifyRequest } from './types'; +import type { FastifyInstance, FastifyMinimal, FastifyReply, FastifyRequest } from './types'; import { FastifyInstrumentationV3 } from './v3/instrumentation'; /** @@ -244,7 +244,7 @@ function defaultShouldHandleError(_error: Error, _request: FastifyRequest, reply * app.listen({ port: 3000 }); * ``` */ -export function setupFastifyErrorHandler(fastify: FastifyInstance, options?: Partial): void { +export function setupFastifyErrorHandler(fastify: FastifyMinimal, options?: Partial): void { if (options?.shouldHandleError) { getFastifyIntegration()?.setShouldHandleError(options.shouldHandleError); } diff --git a/packages/node/src/integrations/tracing/fastify/types.ts b/packages/node/src/integrations/tracing/fastify/types.ts index 1bc426e58aad..7068afabadb0 100644 --- a/packages/node/src/integrations/tracing/fastify/types.ts +++ b/packages/node/src/integrations/tracing/fastify/types.ts @@ -27,6 +27,15 @@ export interface FastifyInstance { addHook(hook: 'onRequest', handler: (request: FastifyRequest, reply: FastifyReply) => void): FastifyInstance; } +/** + * Minimal type for `setupFastifyErrorHandler` parameter. + * Uses structural typing without overloads to avoid exactOptionalPropertyTypes issues. + * https://github.com/getsentry/sentry-javascript/issues/18619 + */ +export type FastifyMinimal = { + register: (plugin: (instance: any, opts: any, done: () => void) => void) => unknown; +}; + export interface FastifyReply { send: () => FastifyReply; statusCode: number; diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 872e0153edba..792032a7314e 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -15,6 +15,8 @@ import { import { INTEGRATION_NAME } from './constants'; import type { TelemetrySettings, VercelAiIntegration } from './types'; +const SUPPORTED_VERSIONS = ['>=3.0.0 <7']; + // List of patched methods // From: https://sdk.vercel.ai/docs/ai-sdk-core/telemetry#collected-data const INSTRUMENTED_METHODS = [ @@ -186,7 +188,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { * Initializes the instrumentation by defining the modules to be patched. */ public init(): InstrumentationModuleDefinition { - const module = new InstrumentationNodeModuleDefinition('ai', ['>=3.0.0 <6'], this._patch.bind(this)); + const module = new InstrumentationNodeModuleDefinition('ai', SUPPORTED_VERSIONS, this._patch.bind(this)); return module; } diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index bbb4086d3dc2..37e6e52c1477 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -53,6 +53,7 @@ "@sentry/cloudflare": "10.32.1", "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", + "@sentry/node-core": "10.32.1", "@sentry/rollup-plugin": "^4.6.1", "@sentry/vite-plugin": "^4.6.1", "@sentry/vue": "10.32.1" diff --git a/packages/nuxt/src/client/sdk.ts b/packages/nuxt/src/client/sdk.ts index 5db856dae689..f0654a97c201 100644 --- a/packages/nuxt/src/client/sdk.ts +++ b/packages/nuxt/src/client/sdk.ts @@ -1,6 +1,6 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowser } from '@sentry/browser'; import type { Client } from '@sentry/core'; -import { applySdkMetadata } from '@sentry/core'; +import { applySdkMetadata, DEFAULT_ENVIRONMENT, DEV_ENVIRONMENT } from '@sentry/core'; import type { SentryNuxtClientOptions } from '../common/types'; /** @@ -12,6 +12,7 @@ export function init(options: SentryNuxtClientOptions): Client | undefined { const sentryOptions = { /* BrowserTracing is added later with the Nuxt client plugin */ defaultIntegrations: [...getBrowserDefaultIntegrations(options)], + environment: import.meta.dev ? DEV_ENVIRONMENT : DEFAULT_ENVIRONMENT, ...options, }; diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index 2b492b1249ac..3be024815b4c 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -1,12 +1,21 @@ import * as path from 'node:path'; import type { Client, Event, EventProcessor, Integration } from '@sentry/core'; -import { applySdkMetadata, debug, flush, getGlobalScope, vercelWaitUntil } from '@sentry/core'; +import { + applySdkMetadata, + debug, + DEFAULT_ENVIRONMENT, + DEV_ENVIRONMENT, + flush, + getGlobalScope, + vercelWaitUntil, +} from '@sentry/core'; import { getDefaultIntegrations as getDefaultNodeIntegrations, httpIntegration, init as initNode, type NodeOptions, } from '@sentry/node'; +import { isCjs } from '@sentry/node-core'; import { DEBUG_BUILD } from '../common/debug-build'; import type { SentryNuxtServerOptions } from '../common/types'; @@ -17,8 +26,9 @@ import type { SentryNuxtServerOptions } from '../common/types'; */ export function init(options: SentryNuxtServerOptions): Client | undefined { const sentryOptions = { - ...options, + environment: !isCjs() && import.meta.dev ? DEV_ENVIRONMENT : DEFAULT_ENVIRONMENT, defaultIntegrations: getNuxtDefaultIntegrations(options), + ...options, }; applySdkMetadata(sentryOptions, 'nuxt', ['nuxt', 'node']); diff --git a/packages/nuxt/test/client/sdk.test.ts b/packages/nuxt/test/client/sdk.test.ts index 29448c720ea4..833872069e4e 100644 --- a/packages/nuxt/test/client/sdk.test.ts +++ b/packages/nuxt/test/client/sdk.test.ts @@ -41,5 +41,41 @@ describe('Nuxt Client SDK', () => { it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); + + it('uses default integrations when not provided in options', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(browserInit).toHaveBeenCalledTimes(1); + const callArgs = browserInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBeDefined(); + expect(Array.isArray(callArgs?.defaultIntegrations)).toBe(true); + }); + + it('allows options.defaultIntegrations to override default integrations', () => { + const customIntegrations = [{ name: 'CustomIntegration' }]; + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: customIntegrations as any, + }); + + expect(browserInit).toHaveBeenCalledTimes(1); + const callArgs = browserInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBe(customIntegrations); + }); + + it('allows options.defaultIntegrations to be set to false', () => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: false, + }); + + expect(browserInit).toHaveBeenCalledTimes(1); + const callArgs = browserInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBe(false); + }); }); }); diff --git a/packages/nuxt/test/server/sdk.test.ts b/packages/nuxt/test/server/sdk.test.ts index 626b574612b0..c2565217d138 100644 --- a/packages/nuxt/test/server/sdk.test.ts +++ b/packages/nuxt/test/server/sdk.test.ts @@ -41,6 +41,42 @@ describe('Nuxt Server SDK', () => { expect(init({})).not.toBeUndefined(); }); + it('uses default integrations when not provided in options', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const callArgs = nodeInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBeDefined(); + expect(Array.isArray(callArgs?.defaultIntegrations)).toBe(true); + }); + + it('allows options.defaultIntegrations to override default integrations', () => { + const customIntegrations = [{ name: 'CustomIntegration' }]; + + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: customIntegrations as any, + }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const callArgs = nodeInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBe(customIntegrations); + }); + + it('allows options.defaultIntegrations to be set to false', () => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + defaultIntegrations: false, + }); + + expect(nodeInit).toHaveBeenCalledTimes(1); + const callArgs = nodeInit.mock.calls[0]?.[0]; + expect(callArgs).toBeDefined(); + expect(callArgs?.defaultIntegrations).toBe(false); + }); + describe('lowQualityTransactionsFilter', () => { const options = { debug: false }; const filter = lowQualityTransactionsFilter(options); diff --git a/packages/opentelemetry/.eslintrc.js b/packages/opentelemetry/.eslintrc.js index fdb9952bae52..4b5e6310c8ee 100644 --- a/packages/opentelemetry/.eslintrc.js +++ b/packages/opentelemetry/.eslintrc.js @@ -3,4 +3,15 @@ module.exports = { node: true, }, extends: ['../../.eslintrc.js'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', + }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/opentelemetry/src/constants.ts b/packages/opentelemetry/src/constants.ts index 375e42dfdd00..3500ad6c4782 100644 --- a/packages/opentelemetry/src/constants.ts +++ b/packages/opentelemetry/src/constants.ts @@ -9,6 +9,10 @@ export const SENTRY_TRACE_STATE_URL = 'sentry.url'; export const SENTRY_TRACE_STATE_SAMPLE_RAND = 'sentry.sample_rand'; export const SENTRY_TRACE_STATE_SAMPLE_RATE = 'sentry.sample_rate'; +// NOTE: `@sentry/nextjs` has a local copy of this context key for Edge bundles: +// - `packages/nextjs/src/edge/index.ts` (`SENTRY_SCOPES_CONTEXT_KEY`) +// +// If you change the key name passed to `createContextKey(...)`, update that file too. export const SENTRY_SCOPES_CONTEXT_KEY = createContextKey('sentry_scopes'); export const SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY = createContextKey('sentry_fork_isolation_scope'); diff --git a/packages/opentelemetry/src/sampler.ts b/packages/opentelemetry/src/sampler.ts index e06fe51bfd2a..7f7edd441612 100644 --- a/packages/opentelemetry/src/sampler.ts +++ b/packages/opentelemetry/src/sampler.ts @@ -12,6 +12,7 @@ import { } from '@opentelemetry/semantic-conventions'; import type { Client, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, baggageHeaderToDynamicSamplingContext, debug, hasSpansEnabled, @@ -121,7 +122,7 @@ export class SentrySampler implements Sampler { const dscString = parentContext?.traceState ? parentContext.traceState.get(SENTRY_TRACE_STATE_DSC) : undefined; const dsc = dscString ? baggageHeaderToDynamicSamplingContext(dscString) : undefined; - const sampleRand = parseSampleRate(dsc?.sample_rand) ?? Math.random(); + const sampleRand = parseSampleRate(dsc?.sample_rand) ?? _INTERNAL_safeMathRandom(); const [sampled, sampleRate, localSampleRateWasApplied] = sampleSpan( options, diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index ea85641387a5..f02df1d9d56c 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -12,6 +12,7 @@ import type { TransactionSource, } from '@sentry/core'; import { + _INTERNAL_safeDateNow, captureEvent, convertSpanLinksForEnvelope, debounce, @@ -82,7 +83,7 @@ export class SentrySpanExporter { }) { this._finishedSpanBucketSize = options?.timeout || DEFAULT_TIMEOUT; this._finishedSpanBuckets = new Array(this._finishedSpanBucketSize).fill(undefined); - this._lastCleanupTimestampInS = Math.floor(Date.now() / 1000); + this._lastCleanupTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); this._spansToBucketEntry = new WeakMap(); this._sentSpans = new Map(); this._debouncedFlush = debounce(this.flush.bind(this), 1, { maxWait: 100 }); @@ -93,7 +94,7 @@ export class SentrySpanExporter { * This is called by the span processor whenever a span is ended. */ public export(span: ReadableSpan): void { - const currentTimestampInS = Math.floor(Date.now() / 1000); + const currentTimestampInS = Math.floor(_INTERNAL_safeDateNow() / 1000); if (this._lastCleanupTimestampInS !== currentTimestampInS) { let droppedSpanCount = 0; @@ -146,7 +147,7 @@ export class SentrySpanExporter { `SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`, ); - const expirationDate = Date.now() + DEFAULT_TIMEOUT * 1000; + const expirationDate = _INTERNAL_safeDateNow() + DEFAULT_TIMEOUT * 1000; for (const span of sentSpans) { this._sentSpans.set(span.spanContext().spanId, expirationDate); @@ -226,7 +227,7 @@ export class SentrySpanExporter { /** Remove "expired" span id entries from the _sentSpans cache. */ private _flushSentSpanCache(): void { - const currentTimestamp = Date.now(); + const currentTimestamp = _INTERNAL_safeDateNow(); // Note, it is safe to delete items from the map as we go: https://stackoverflow.com/a/35943995/90297 for (const [spanId, expirationTime] of this._sentSpans.entries()) { if (expirationTime <= currentTimestamp) { diff --git a/packages/opentelemetry/src/utils/contextData.ts b/packages/opentelemetry/src/utils/contextData.ts index 468b377f9ccd..78577131d0c7 100644 --- a/packages/opentelemetry/src/utils/contextData.ts +++ b/packages/opentelemetry/src/utils/contextData.ts @@ -11,6 +11,10 @@ const SCOPE_CONTEXT_FIELD = '_scopeContext'; * This requires a Context Manager that was wrapped with getWrappedContextManager. */ export function getScopesFromContext(context: Context): CurrentScopes | undefined { + // NOTE: `@sentry/nextjs` has a local copy of this helper for Edge bundles: + // - `packages/nextjs/src/edge/index.ts` (`getScopesFromContext`) + // + // If you change how scopes are stored/read (key or retrieval), update that file too. return context.getValue(SENTRY_SCOPES_CONTEXT_KEY) as CurrentScopes | undefined; } diff --git a/packages/opentelemetry/src/utils/isSentryRequest.ts b/packages/opentelemetry/src/utils/isSentryRequest.ts index d6b59880137b..6e06bcf5ab2e 100644 --- a/packages/opentelemetry/src/utils/isSentryRequest.ts +++ b/packages/opentelemetry/src/utils/isSentryRequest.ts @@ -9,6 +9,10 @@ import { spanHasAttributes } from './spanTypes'; * @returns boolean */ export function isSentryRequestSpan(span: AbstractSpan): boolean { + // NOTE: `@sentry/nextjs` has a local copy of this helper for Edge bundles: + // - `packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts` (`isSentryRequestSpan`) + // + // If you change supported OTEL attribute keys or request detection logic, update that file too. if (!spanHasAttributes(span)) { return false; } diff --git a/packages/react-router/src/server/instrumentation/util.ts b/packages/react-router/src/server/instrumentation/util.ts index 19aec91999fc..3cad321dcfcc 100644 --- a/packages/react-router/src/server/instrumentation/util.ts +++ b/packages/react-router/src/server/instrumentation/util.ts @@ -5,10 +5,10 @@ */ export function getOpName(pathName: string, requestMethod: string): string { return isLoaderRequest(pathName, requestMethod) - ? 'function.react-router.loader' + ? 'function.react_router.loader' : isActionRequest(pathName, requestMethod) - ? 'function.react-router.action' - : 'function.react-router'; + ? 'function.react_router.action' + : 'function.react_router'; } /** diff --git a/packages/react-router/src/server/wrapServerAction.ts b/packages/react-router/src/server/wrapServerAction.ts index e816c3c63886..991327a60d10 100644 --- a/packages/react-router/src/server/wrapServerAction.ts +++ b/packages/react-router/src/server/wrapServerAction.ts @@ -67,7 +67,7 @@ export function wrapServerAction(options: SpanOptions = {}, actionFn: (args: ...options, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.action', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.action', ...options.attributes, }, }, diff --git a/packages/react-router/src/server/wrapServerLoader.ts b/packages/react-router/src/server/wrapServerLoader.ts index 7e5083d4d5c8..fc28d504637f 100644 --- a/packages/react-router/src/server/wrapServerLoader.ts +++ b/packages/react-router/src/server/wrapServerLoader.ts @@ -67,7 +67,7 @@ export function wrapServerLoader(options: SpanOptions = {}, loaderFn: (args: ...options, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.loader', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.loader', ...options.attributes, }, }, diff --git a/packages/react-router/test/server/wrapServerAction.test.ts b/packages/react-router/test/server/wrapServerAction.test.ts index 5eb92ef53b3b..c0cde751e472 100644 --- a/packages/react-router/test/server/wrapServerAction.test.ts +++ b/packages/react-router/test/server/wrapServerAction.test.ts @@ -31,7 +31,7 @@ describe('wrapServerAction', () => { name: 'Executing Server Action', attributes: { [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.action', - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.action', }, }, expect.any(Function), @@ -61,7 +61,7 @@ describe('wrapServerAction', () => { name: 'Custom Action', attributes: { [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.action', - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.action', 'sentry.custom': 'value', }, }, diff --git a/packages/react-router/test/server/wrapServerLoader.test.ts b/packages/react-router/test/server/wrapServerLoader.test.ts index b375d9b4da51..032107c1075e 100644 --- a/packages/react-router/test/server/wrapServerLoader.test.ts +++ b/packages/react-router/test/server/wrapServerLoader.test.ts @@ -31,7 +31,7 @@ describe('wrapServerLoader', () => { name: 'Executing Server Loader', attributes: { [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.loader', - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.loader', }, }, expect.any(Function), @@ -61,7 +61,7 @@ describe('wrapServerLoader', () => { name: 'Custom Loader', attributes: { [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.loader', - [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react_router.loader', 'sentry.custom': 'value', }, }, diff --git a/packages/react/package.json b/packages/react/package.json index 52fcaabcafe7..aaa63b032ca6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -40,8 +40,7 @@ }, "dependencies": { "@sentry/browser": "10.32.1", - "@sentry/core": "10.32.1", - "hoist-non-react-statics": "^3.3.2" + "@sentry/core": "10.32.1" }, "peerDependencies": { "react": "^16.14.0 || 17.x || 18.x || 19.x" @@ -51,7 +50,7 @@ "@testing-library/react-hooks": "^7.0.2", "@types/history-4": "npm:@types/history@4.7.8", "@types/history-5": "npm:@types/history@4.7.8", - "@types/hoist-non-react-statics": "^3.3.5", + "@types/node-fetch": "^2.6.11", "@types/react": "17.0.3", "@types/react-router-4": "npm:@types/react-router@4.0.25", "@types/react-router-5": "npm:@types/react-router@5.1.20", diff --git a/packages/react/src/hoist-non-react-statics.ts b/packages/react/src/hoist-non-react-statics.ts index 970928c80d23..3ab5cb69d257 100644 --- a/packages/react/src/hoist-non-react-statics.ts +++ b/packages/react/src/hoist-non-react-statics.ts @@ -1,5 +1,169 @@ -import * as hoistNonReactStaticsImport from 'hoist-non-react-statics'; +/** + * Inlined implementation of hoist-non-react-statics + * Original library: https://github.com/mridgway/hoist-non-react-statics + * License: BSD-3-Clause + * Copyright 2015, Yahoo! Inc. + * + * This is an inlined version to avoid ESM compatibility issues with the original package. + */ -// Ensure we use the default export from hoist-non-react-statics if available, -// falling back to the module itself. This handles both ESM and CJS usage. -export const hoistNonReactStatics = hoistNonReactStaticsImport.default || hoistNonReactStaticsImport; +import type * as React from 'react'; + +/** + * React statics that should not be hoisted + */ +const REACT_STATICS = { + childContextTypes: true, + contextType: true, + contextTypes: true, + defaultProps: true, + displayName: true, + getDefaultProps: true, + getDerivedStateFromError: true, + getDerivedStateFromProps: true, + mixins: true, + propTypes: true, + type: true, +} as const; + +/** + * Known JavaScript function statics that should not be hoisted + */ +const KNOWN_STATICS = { + name: true, + length: true, + prototype: true, + caller: true, + callee: true, + arguments: true, + arity: true, +} as const; + +/** + * Statics specific to ForwardRef components + */ +const FORWARD_REF_STATICS = { + $$typeof: true, + render: true, + defaultProps: true, + displayName: true, + propTypes: true, +} as const; + +/** + * Statics specific to Memo components + */ +const MEMO_STATICS = { + $$typeof: true, + compare: true, + defaultProps: true, + displayName: true, + propTypes: true, + type: true, +} as const; + +/** + * Inlined react-is utilities + * We only need to detect ForwardRef and Memo types + */ +const ForwardRefType = Symbol.for('react.forward_ref'); +const MemoType = Symbol.for('react.memo'); + +/** + * Check if a component is a Memo component + */ +function isMemo(component: unknown): boolean { + return ( + typeof component === 'object' && component !== null && (component as { $$typeof?: symbol }).$$typeof === MemoType + ); +} + +/** + * Map of React component types to their specific statics + */ +const TYPE_STATICS: Record> = {}; +TYPE_STATICS[ForwardRefType] = FORWARD_REF_STATICS; +TYPE_STATICS[MemoType] = MEMO_STATICS; + +/** + * Get the appropriate statics object for a given component + */ +function getStatics(component: React.ComponentType): Record { + // React v16.11 and below + if (isMemo(component)) { + return MEMO_STATICS; + } + + // React v16.12 and above + const componentType = (component as { $$typeof?: symbol }).$$typeof; + return (componentType && TYPE_STATICS[componentType]) || REACT_STATICS; +} + +const defineProperty = Object.defineProperty.bind(Object); +const getOwnPropertyNames = Object.getOwnPropertyNames.bind(Object); +const getOwnPropertySymbols = Object.getOwnPropertySymbols?.bind(Object); +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor.bind(Object); +const getPrototypeOf = Object.getPrototypeOf.bind(Object); +const objectPrototype = Object.prototype; + +/** + * Copies non-react specific statics from a child component to a parent component. + * Similar to Object.assign, but copies all static properties from source to target, + * excluding React-specific statics and known JavaScript statics. + * + * @param targetComponent - The component to copy statics to + * @param sourceComponent - The component to copy statics from + * @param excludelist - An optional object of keys to exclude from hoisting + * @returns The target component with hoisted statics + */ +export function hoistNonReactStatics< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends React.ComponentType, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + S extends React.ComponentType, + C extends Record = Record, +>(targetComponent: T, sourceComponent: S, excludelist?: C): T { + if (typeof sourceComponent !== 'string') { + // Don't hoist over string (html) components + if (objectPrototype) { + const inheritedComponent = getPrototypeOf(sourceComponent); + + if (inheritedComponent && inheritedComponent !== objectPrototype) { + hoistNonReactStatics(targetComponent, inheritedComponent, excludelist); + } + } + + let keys: (string | symbol)[] = getOwnPropertyNames(sourceComponent); + + if (getOwnPropertySymbols) { + keys = keys.concat(getOwnPropertySymbols(sourceComponent)); + } + + const targetStatics = getStatics(targetComponent); + const sourceStatics = getStatics(sourceComponent); + + for (const key of keys) { + const keyStr = String(key); + if ( + !KNOWN_STATICS[keyStr as keyof typeof KNOWN_STATICS] && + !excludelist?.[keyStr] && + !sourceStatics?.[keyStr] && + !targetStatics?.[keyStr] && + !getOwnPropertyDescriptor(targetComponent, key) // Don't overwrite existing properties + ) { + const descriptor = getOwnPropertyDescriptor(sourceComponent, key); + + if (descriptor) { + try { + // Avoid failures from read-only properties + defineProperty(targetComponent, key, descriptor); + } catch (e) { + // Silently ignore errors + } + } + } + } + } + + return targetComponent; +} diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index 0eba31722819..d2424697d9d5 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -49,14 +49,17 @@ export function tanstackRouterBrowserTracingIntegration( ); const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + // If we only match __root__, we ended up not matching any route at all, so + // we fall back to the pathname. + const routeMatch = lastMatch?.routeId !== '__root__' ? lastMatch : undefined; startBrowserTracingPageLoadSpan(client, { - name: lastMatch ? lastMatch.routeId : initialWindowLocation.pathname, + name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: lastMatch ? 'route' : 'url', - ...routeMatchToParamSpanAttributes(lastMatch), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(routeMatch), }, }); } @@ -74,21 +77,23 @@ export function tanstackRouterBrowserTracingIntegration( return; } - const onResolvedMatchedRoutes = castRouterInstance.matchRoutes( + const matchedRoutesOnBeforeNavigate = castRouterInstance.matchRoutes( onBeforeNavigateArgs.toLocation.pathname, onBeforeNavigateArgs.toLocation.search, { preload: false, throwOnError: false }, ); - const onBeforeNavigateLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onBeforeNavigateLastMatch = matchedRoutesOnBeforeNavigate[matchedRoutesOnBeforeNavigate.length - 1]; + const onBeforeNavigateRouteMatch = + onBeforeNavigateLastMatch?.routeId !== '__root__' ? onBeforeNavigateLastMatch : undefined; const navigationLocation = WINDOW.location; const navigationSpan = startBrowserTracingNavigationSpan(client, { - name: onBeforeNavigateLastMatch ? onBeforeNavigateLastMatch.routeId : navigationLocation.pathname, + name: onBeforeNavigateRouteMatch ? onBeforeNavigateRouteMatch.routeId : navigationLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateLastMatch ? 'route' : 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateRouteMatch ? 'route' : 'url', }, }); @@ -96,18 +101,20 @@ export function tanstackRouterBrowserTracingIntegration( const unsubscribeOnResolved = castRouterInstance.subscribe('onResolved', onResolvedArgs => { unsubscribeOnResolved(); if (navigationSpan) { - const onResolvedMatchedRoutes = castRouterInstance.matchRoutes( + const matchedRoutesOnResolved = castRouterInstance.matchRoutes( onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search, { preload: false, throwOnError: false }, ); - const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onResolvedLastMatch = matchedRoutesOnResolved[matchedRoutesOnResolved.length - 1]; + const onResolvedRouteMatch = + onResolvedLastMatch?.routeId !== '__root__' ? onResolvedLastMatch : undefined; - if (onResolvedLastMatch) { - navigationSpan.updateName(onResolvedLastMatch.routeId); + if (onResolvedRouteMatch) { + navigationSpan.updateName(onResolvedRouteMatch.routeId); navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedLastMatch)); + navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedRouteMatch)); } } }); diff --git a/packages/react/test/hoist-non-react-statics.test.tsx b/packages/react/test/hoist-non-react-statics.test.tsx new file mode 100644 index 000000000000..5c4ecb82126b --- /dev/null +++ b/packages/react/test/hoist-non-react-statics.test.tsx @@ -0,0 +1,293 @@ +import * as React from 'react'; +import { describe, expect, it } from 'vitest'; +import { hoistNonReactStatics } from '../src/hoist-non-react-statics'; + +describe('hoistNonReactStatics', () => { + it('hoists custom static properties', () => { + class Source extends React.Component { + static customStatic = 'customValue'; + static anotherStatic = 42; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('customValue'); + expect((Target as any).anotherStatic).toBe(42); + }); + + it('does not overwrite existing properties on target', () => { + class Source extends React.Component { + static customStatic = 'sourceValue'; + } + class Target extends React.Component { + static customStatic = 'targetValue'; + } + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('targetValue'); + }); + + it('returns the target component', () => { + class Source extends React.Component {} + class Target extends React.Component {} + + const result = hoistNonReactStatics(Target, Source); + + expect(result).toBe(Target); + }); + + it('handles function components', () => { + const Source = () =>
Source
; + (Source as any).customStatic = 'value'; + const Target = () =>
Target
; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('value'); + }); + + it('does not hoist known JavaScript statics', () => { + class Source extends React.Component { + static customStatic = 'customValue'; + } + class Target extends React.Component {} + const originalName = Target.name; + const originalLength = Target.length; + + hoistNonReactStatics(Target, Source); + + expect(Target.name).toBe(originalName); + expect(Target.length).toBe(originalLength); + expect((Target as any).customStatic).toBe('customValue'); + }); + + it('does not hoist React-specific statics', () => { + class Source extends React.Component { + static defaultProps = { foo: 'bar' }; + static customStatic = 'customValue'; + } + class Target extends React.Component { + static defaultProps = { baz: 'qux' }; + } + const originalDefaultProps = Target.defaultProps; + + hoistNonReactStatics(Target, Source); + + expect(Target.defaultProps).toBe(originalDefaultProps); + expect((Target as any).customStatic).toBe('customValue'); + }); + + it('does not hoist displayName', () => { + const Source = () =>
; + (Source as any).displayName = 'SourceComponent'; + (Source as any).customStatic = 'value'; + const Target = () =>
; + (Target as any).displayName = 'TargetComponent'; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).displayName).toBe('TargetComponent'); + expect((Target as any).customStatic).toBe('value'); + }); + + it('respects custom excludelist', () => { + class Source extends React.Component { + static customStatic1 = 'value1'; + static customStatic2 = 'value2'; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source, { customStatic1: true }); + + expect((Target as any).customStatic1).toBeUndefined(); + expect((Target as any).customStatic2).toBe('value2'); + }); + + it('handles ForwardRef components', () => { + const SourceInner = (_props: any, _ref: any) =>
; + const Source = React.forwardRef(SourceInner); + (Source as any).customStatic = 'value'; + const TargetInner = (_props: any, _ref: any) =>
; + const Target = React.forwardRef(TargetInner); + const originalRender = (Target as any).render; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).render).toBe(originalRender); + expect((Target as any).customStatic).toBe('value'); + }); + + it('handles Memo components', () => { + const SourceComponent = () =>
; + const Source = React.memo(SourceComponent); + (Source as any).customStatic = 'value'; + const TargetComponent = () =>
; + const Target = React.memo(TargetComponent); + const originalType = (Target as any).type; + + hoistNonReactStatics(Target, Source); + + expect((Target as any).type).toBe(originalType); + expect((Target as any).customStatic).toBe('value'); + }); + + it('hoists symbol properties', () => { + const customSymbol = Symbol('custom'); + class Source extends React.Component {} + (Source as any)[customSymbol] = 'symbolValue'; + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any)[customSymbol]).toBe('symbolValue'); + }); + + it('preserves property descriptors', () => { + class Source extends React.Component {} + Object.defineProperty(Source, 'customStatic', { + value: 'value', + writable: false, + enumerable: true, + configurable: false, + }); + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + const descriptor = Object.getOwnPropertyDescriptor(Target, 'customStatic'); + expect(descriptor?.value).toBe('value'); + expect(descriptor?.writable).toBe(false); + expect(descriptor?.enumerable).toBe(true); + expect(descriptor?.configurable).toBe(false); + }); + + it('handles getters and setters', () => { + let backingValue = 'initial'; + class Source extends React.Component {} + Object.defineProperty(Source, 'customStatic', { + get: () => backingValue, + set: (value: string) => { + backingValue = value; + }, + }); + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('initial'); + (Target as any).customStatic = 'modified'; + expect((Target as any).customStatic).toBe('modified'); + }); + + it('silently handles read-only property errors', () => { + class Source extends React.Component {} + Object.defineProperty(Source, 'customStatic', { value: 'sourceValue', writable: true }); + class Target extends React.Component {} + Object.defineProperty(Target, 'customStatic', { value: 'targetValue', writable: false }); + + expect(() => hoistNonReactStatics(Target, Source)).not.toThrow(); + expect((Target as any).customStatic).toBe('targetValue'); + }); + + it('hoists statics from the prototype chain', () => { + class GrandParent extends React.Component { + static grandParentStatic = 'grandParent'; + } + class Parent extends GrandParent { + static parentStatic = 'parent'; + } + class Source extends Parent { + static sourceStatic = 'source'; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).sourceStatic).toBe('source'); + expect((Target as any).parentStatic).toBe('parent'); + expect((Target as any).grandParentStatic).toBe('grandParent'); + }); + + it('does not hoist from Object.prototype', () => { + class Source extends React.Component { + static customStatic = 'value'; + } + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).customStatic).toBe('value'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect((Target as any).hasOwnProperty).toBe(Object.prototype.hasOwnProperty); + }); + + it('handles string components', () => { + const Target = () =>
; + (Target as any).existingStatic = 'value'; + + hoistNonReactStatics(Target, 'div' as any); + + expect((Target as any).existingStatic).toBe('value'); + }); + + it('handles falsy static values', () => { + class Source extends React.Component {} + (Source as any).nullStatic = null; + (Source as any).undefinedStatic = undefined; + (Source as any).zeroStatic = 0; + (Source as any).falseStatic = false; + class Target extends React.Component {} + + hoistNonReactStatics(Target, Source); + + expect((Target as any).nullStatic).toBeNull(); + expect((Target as any).undefinedStatic).toBeUndefined(); + expect((Target as any).zeroStatic).toBe(0); + expect((Target as any).falseStatic).toBe(false); + }); + + it('works with HOC pattern', () => { + class OriginalComponent extends React.Component { + static customMethod() { + return 'custom'; + } + render() { + return
Original
; + } + } + const WrappedComponent: React.FC = () => ; + + hoistNonReactStatics(WrappedComponent, OriginalComponent); + + expect((WrappedComponent as any).customMethod()).toBe('custom'); + }); + + it('preserves target displayName in HOC pattern', () => { + const OriginalComponent = () =>
Original
; + (OriginalComponent as any).displayName = 'Original'; + (OriginalComponent as any).someStaticProp = 'value'; + const WrappedComponent: React.FC = () => ; + (WrappedComponent as any).displayName = 'ErrorBoundary(Original)'; + + hoistNonReactStatics(WrappedComponent, OriginalComponent); + + expect((WrappedComponent as any).displayName).toBe('ErrorBoundary(Original)'); + expect((WrappedComponent as any).someStaticProp).toBe('value'); + }); + + it('works with multiple HOC composition', () => { + class Original extends React.Component { + static originalStatic = 'original'; + } + const Hoc1 = () => ; + (Hoc1 as any).hoc1Static = 'hoc1'; + hoistNonReactStatics(Hoc1, Original); + const Hoc2 = () => ; + hoistNonReactStatics(Hoc2, Hoc1); + + expect((Hoc2 as any).originalStatic).toBe('original'); + expect((Hoc2 as any).hoc1Static).toBe('hoc1'); + }); +}); diff --git a/packages/replay-canvas/src/canvas.ts b/packages/replay-canvas/src/canvas.ts index 7861572b190f..0ed2b49d237e 100644 --- a/packages/replay-canvas/src/canvas.ts +++ b/packages/replay-canvas/src/canvas.ts @@ -77,6 +77,7 @@ export const _replayCanvasIntegration = ((options: Partial ] as [number, number], }; + let currentCanvasManager: CanvasManager | undefined; let canvasManagerResolve: (value: CanvasManager) => void; const _canvasManager: Promise = new Promise(resolve => (canvasManagerResolve = resolve)); @@ -104,14 +105,19 @@ export const _replayCanvasIntegration = ((options: Partial } }, }); + + currentCanvasManager = manager; + + // Resolve promise on first call for backward compatibility canvasManagerResolve(manager); + return manager; }, ...(CANVAS_QUALITY[quality || 'medium'] || CANVAS_QUALITY.medium), }; }, async snapshot(canvasElement?: HTMLCanvasElement, options?: SnapshotOptions) { - const canvasManager = await _canvasManager; + const canvasManager = currentCanvasManager || (await _canvasManager); canvasManager.snapshot(canvasElement, options); }, diff --git a/packages/replay-canvas/test/canvas.test.ts b/packages/replay-canvas/test/canvas.test.ts index 1acfeab69d21..ee51c91ce47a 100644 --- a/packages/replay-canvas/test/canvas.test.ts +++ b/packages/replay-canvas/test/canvas.test.ts @@ -103,3 +103,31 @@ it('has correct types', () => { const res2 = rc.snapshot(document.createElement('canvas')); expect(res2).toBeInstanceOf(Promise); }); + +it('tracks current canvas manager across multiple getCanvasManager calls', async () => { + const rc = _replayCanvasIntegration({ enableManualSnapshot: true }); + const options = rc.getOptions(); + + // First call - simulates initial recording session + // @ts-expect-error don't care about the normal options we need to call this with + options.getCanvasManager({}); + expect(CanvasManager).toHaveBeenCalledTimes(1); + + const mockManager1 = vi.mocked(CanvasManager).mock.results[0].value; + mockManager1.snapshot = vi.fn(); + + // Second call - simulates session refresh after inactivity or max age + // @ts-expect-error don't care about the normal options we need to call this with + options.getCanvasManager({}); + expect(CanvasManager).toHaveBeenCalledTimes(2); + + const mockManager2 = vi.mocked(CanvasManager).mock.results[1].value; + mockManager2.snapshot = vi.fn(); + + void rc.snapshot(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockManager1.snapshot).toHaveBeenCalledTimes(0); + expect(mockManager2.snapshot).toHaveBeenCalledTimes(1); +}); diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts index 389ce9bb6e10..c589430eb615 100644 --- a/packages/solid/src/tanstackrouter.ts +++ b/packages/solid/src/tanstackrouter.ts @@ -48,14 +48,17 @@ export function tanstackRouterBrowserTracingIntegration( ); const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + // If we only match __root__, we ended up not matching any route at all, so + // we fall back to the pathname. + const routeMatch = lastMatch?.routeId !== '__root__' ? lastMatch : undefined; startBrowserTracingPageLoadSpan(client, { - name: lastMatch ? lastMatch.routeId : initialWindowLocation.pathname, + name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.solid.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: lastMatch ? 'route' : 'url', - ...routeMatchToParamSpanAttributes(lastMatch), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(routeMatch), }, }); } @@ -73,21 +76,23 @@ export function tanstackRouterBrowserTracingIntegration( return; } - const onResolvedMatchedRoutes = router.matchRoutes( + const matchedRoutesOnBeforeNavigate = router.matchRoutes( onBeforeNavigateArgs.toLocation.pathname, onBeforeNavigateArgs.toLocation.search, { preload: false, throwOnError: false }, ); - const onBeforeNavigateLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onBeforeNavigateLastMatch = matchedRoutesOnBeforeNavigate[matchedRoutesOnBeforeNavigate.length - 1]; + const onBeforeNavigateRouteMatch = + onBeforeNavigateLastMatch?.routeId !== '__root__' ? onBeforeNavigateLastMatch : undefined; const navigationLocation = WINDOW.location; const navigationSpan = startBrowserTracingNavigationSpan(client, { - name: onBeforeNavigateLastMatch ? onBeforeNavigateLastMatch.routeId : navigationLocation.pathname, + name: onBeforeNavigateRouteMatch ? onBeforeNavigateRouteMatch.routeId : navigationLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateLastMatch ? 'route' : 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateRouteMatch ? 'route' : 'url', }, }); @@ -95,18 +100,20 @@ export function tanstackRouterBrowserTracingIntegration( const unsubscribeOnResolved = router.subscribe('onResolved', onResolvedArgs => { unsubscribeOnResolved(); if (navigationSpan) { - const onResolvedMatchedRoutes = router.matchRoutes( + const matchedRoutesOnResolved = router.matchRoutes( onResolvedArgs.toLocation.pathname, onResolvedArgs.toLocation.search, { preload: false, throwOnError: false }, ); - const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onResolvedLastMatch = matchedRoutesOnResolved[matchedRoutesOnResolved.length - 1]; + const onResolvedRouteMatch = + onResolvedLastMatch?.routeId !== '__root__' ? onResolvedLastMatch : undefined; - if (onResolvedLastMatch) { - navigationSpan.updateName(onResolvedLastMatch.routeId); + if (onResolvedRouteMatch) { + navigationSpan.updateName(onResolvedRouteMatch.routeId); navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedLastMatch)); + navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedRouteMatch)); } } }); diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index ba4f4e379818..b0a8f5dcaa87 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -69,7 +69,7 @@ "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", "@sentry/solid": "10.32.1", - "@sentry/vite-plugin": "^4.1.0" + "@sentry/vite-plugin": "^4.6.1" }, "devDependencies": { "@solidjs/router": "^0.15.0", diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index ff5d8ff134bb..a9eccf52c55f 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -52,7 +52,7 @@ "@sentry/core": "10.32.1", "@sentry/node": "10.32.1", "@sentry/svelte": "10.32.1", - "@sentry/vite-plugin": "^4.1.0", + "@sentry/vite-plugin": "^4.6.1", "magic-string": "0.30.7", "recast": "0.23.11", "sorcery": "1.0.0" diff --git a/packages/vercel-edge/.eslintrc.js b/packages/vercel-edge/.eslintrc.js index 6da218bd8641..073e587833b6 100644 --- a/packages/vercel-edge/.eslintrc.js +++ b/packages/vercel-edge/.eslintrc.js @@ -5,5 +5,14 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-class-field-initializers': 'off', + '@sentry-internal/sdk/no-unsafe-random-apis': 'error', }, + overrides: [ + { + files: ['test/**/*.ts', 'test/**/*.tsx'], + rules: { + '@sentry-internal/sdk/no-unsafe-random-apis': 'off', + }, + }, + ], }; diff --git a/packages/vercel-edge/rollup.npm.config.mjs b/packages/vercel-edge/rollup.npm.config.mjs index ae01f43703d0..d8f1704e2f8a 100644 --- a/packages/vercel-edge/rollup.npm.config.mjs +++ b/packages/vercel-edge/rollup.npm.config.mjs @@ -1,79 +1,138 @@ import replace from '@rollup/plugin-replace'; import { makeBaseNPMConfig, makeNPMConfigVariants, plugins } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants( - makeBaseNPMConfig({ - entrypoints: ['src/index.ts'], - bundledBuiltins: ['perf_hooks', 'util'], - packageSpecificConfig: { - context: 'globalThis', - output: { - preserveModules: false, - }, - plugins: [ - plugins.makeCommonJSPlugin({ transformMixedEsModules: true }), // Needed because various modules in the OTEL toolchain use CJS (require-in-the-middle, shimmer, etc..) - plugins.makeJsonPlugin(), // Needed because `require-in-the-middle` imports json via require - replace({ - preventAssignment: true, - values: { - 'process.argv0': JSON.stringify(''), // needed because otel relies on process.argv0 for the default service name, but that api is not available in the edge runtime. - }, - }), - { - // This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global. - // It also imports `inspect` and `promisify` from node's `util` which are not available in the edge runtime so we need to define a polyfill. - // Both of these APIs are not available in the edge runtime so we need to define a polyfill. - // Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62 - name: 'edge-runtime-polyfills', - banner: ` - { - if (globalThis.performance === undefined) { - globalThis.performance = { - timeOrigin: 0, - now: () => Date.now() - }; - } - } - `, - resolveId: source => { - if (source === 'perf_hooks') { - return '\0perf_hooks_sentry_shim'; - } else if (source === 'util') { - return '\0util_sentry_shim'; - } else { - return null; +const downlevelLogicalAssignmentsPlugin = { + name: 'downlevel-logical-assignments', + renderChunk(code) { + // ES2021 logical assignment operators (`||=`, `&&=`, `??=`) are not allowed by our ES2020 compatibility check. + // OTEL currently ships some of these, so we downlevel them in the final output. + // + // Note: This is intentionally conservative (only matches property access-like LHS) to avoid duplicating side effects. + // IMPORTANT: Use regex literals (not `String.raw` + `RegExp(...)`) to avoid accidental double-escaping. + let out = code; + + // ??= + out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*\?\?=\s*([^;]+);/g, (_m, left, right) => { + return `${left} = ${left} ?? ${right};`; + }); + + // ||= + out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*\|\|=\s*([^;]+);/g, (_m, left, right) => { + return `${left} = ${left} || ${right};`; + }); + + // &&= + out = out.replace(/([A-Za-z_$][\w$]*(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)+)\s*&&=\s*([^;]+);/g, (_m, left, right) => { + return `${left} = ${left} && ${right};`; + }); + + return { code: out, map: null }; + }, +}; + +const baseConfig = makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + bundledBuiltins: ['perf_hooks', 'util'], + packageSpecificConfig: { + context: 'globalThis', + output: { + preserveModules: false, + }, + plugins: [ + plugins.makeCommonJSPlugin({ transformMixedEsModules: true }), // Needed because various modules in the OTEL toolchain use CJS (require-in-the-middle, shimmer, etc..) + plugins.makeJsonPlugin(), // Needed because `require-in-the-middle` imports json via require + replace({ + preventAssignment: true, + values: { + 'process.argv0': JSON.stringify(''), // needed because otel relies on process.argv0 for the default service name, but that api is not available in the edge runtime. + }, + }), + { + // This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global. + // It also imports `inspect` and `promisify` from node's `util` which are not available in the edge runtime so we need to define a polyfill. + // Both of these APIs are not available in the edge runtime so we need to define a polyfill. + // Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62 + name: 'edge-runtime-polyfills', + banner: ` + { + if (globalThis.performance === undefined) { + globalThis.performance = { + timeOrigin: 0, + now: () => Date.now() + }; } - }, - load: id => { - if (id === '\0perf_hooks_sentry_shim') { - return ` - export const performance = { - timeOrigin: 0, - now: () => Date.now() - } - `; - } else if (id === '\0util_sentry_shim') { - return ` - export const inspect = (object) => - JSON.stringify(object, null, 2); + } + `, + resolveId: source => { + if (source === 'perf_hooks') { + return '\0perf_hooks_sentry_shim'; + } else if (source === 'util') { + return '\0util_sentry_shim'; + } else { + return null; + } + }, + load: id => { + if (id === '\0perf_hooks_sentry_shim') { + return ` + export const performance = { + timeOrigin: 0, + now: () => Date.now() + } + `; + } else if (id === '\0util_sentry_shim') { + return ` + export const inspect = (object) => + JSON.stringify(object, null, 2); - export const promisify = (fn) => { - return (...args) => { - return new Promise((resolve, reject) => { - fn(...args, (err, result) => { - if (err) reject(err); - else resolve(result); - }); + export const promisify = (fn) => { + return (...args) => { + return new Promise((resolve, reject) => { + fn(...args, (err, result) => { + if (err) reject(err); + else resolve(result); }); - }; + }); }; - `; - } else { - return null; - } - }, + }; + `; + } else { + return null; + } }, - ], - }, - }), -); + }, + downlevelLogicalAssignmentsPlugin, + ], + }, +}); + +// `makeBaseNPMConfig` marks dependencies/peers as external by default. +// For Edge, we must ensure the OTEL SDK bits which reference `process.argv0` are bundled so our replace() plugin applies. +const baseExternal = baseConfig.external; +baseConfig.external = (source, importer, isResolved) => { + // Never treat these as external - they need to be inlined so `process.argv0` can be replaced. + if ( + source === '@opentelemetry/resources' || + source.startsWith('@opentelemetry/resources/') || + source === '@opentelemetry/sdk-trace-base' || + source.startsWith('@opentelemetry/sdk-trace-base/') + ) { + return false; + } + + if (typeof baseExternal === 'function') { + return baseExternal(source, importer, isResolved); + } + + if (Array.isArray(baseExternal)) { + return baseExternal.includes(source); + } + + if (baseExternal instanceof RegExp) { + return baseExternal.test(source); + } + + return false; +}; + +export default makeNPMConfigVariants(baseConfig); diff --git a/packages/vercel-edge/test/build-artifacts.test.ts b/packages/vercel-edge/test/build-artifacts.test.ts new file mode 100644 index 000000000000..c4994f4f8b29 --- /dev/null +++ b/packages/vercel-edge/test/build-artifacts.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +function readBuildFile(relativePathFromPackageRoot: string): string { + const filePath = join(process.cwd(), relativePathFromPackageRoot); + return readFileSync(filePath, 'utf8'); +} + +describe('build artifacts', () => { + it('does not contain Node-only `process.argv0` usage (Edge compatibility)', () => { + const cjs = readBuildFile('build/cjs/index.js'); + const esm = readBuildFile('build/esm/index.js'); + + expect(cjs).not.toContain('process.argv0'); + expect(esm).not.toContain('process.argv0'); + }); + + it('does not contain ES2021 logical assignment operators (ES2020 compatibility)', () => { + const cjs = readBuildFile('build/cjs/index.js'); + const esm = readBuildFile('build/esm/index.js'); + + // ES2021 operators which `es-check es2020` rejects + expect(cjs).not.toContain('??='); + expect(cjs).not.toContain('||='); + expect(cjs).not.toContain('&&='); + + expect(esm).not.toContain('??='); + expect(esm).not.toContain('||='); + expect(esm).not.toContain('&&='); + }); +}); diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index a0ae76814c0c..d46f2a8c40c6 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -43,20 +43,22 @@ export function tanstackRouterBrowserTracingIntegration( if (instrumentPageLoad && initialWindowLocation) { const matchedRoutes = router.matchRoutes( initialWindowLocation.pathname, - router.options.parseSearch(initialWindowLocation.search), { preload: false, throwOnError: false }, ); const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + // If we only match __root__, we ended up not matching any route at all, so + // we fall back to the pathname. + const routeMatch = lastMatch?.routeId !== '__root__' ? lastMatch : undefined; startBrowserTracingPageLoadSpan(client, { - name: lastMatch ? lastMatch.routeId : initialWindowLocation.pathname, + name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: lastMatch ? 'route' : 'url', - ...routeMatchToParamSpanAttributes(lastMatch), + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(routeMatch), }, }); } @@ -87,18 +89,20 @@ export function tanstackRouterBrowserTracingIntegration( ); const onBeforeNavigateLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onBeforeNavigateRouteMatch = + onBeforeNavigateLastMatch?.routeId !== '__root__' ? onBeforeNavigateLastMatch : undefined; const navigationLocation = WINDOW.location; const navigationSpan = startBrowserTracingNavigationSpan(client, { - name: onBeforeNavigateLastMatch - ? onBeforeNavigateLastMatch.routeId + name: onBeforeNavigateRouteMatch + ? onBeforeNavigateRouteMatch.routeId : // In SSR/non-browser contexts, WINDOW.location may be undefined, so fall back to the router's location // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access navigationLocation?.pathname || onBeforeNavigateArgs.toLocation.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue.tanstack_router', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateLastMatch ? 'route' : 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateRouteMatch ? 'route' : 'url', }, }); @@ -116,11 +120,13 @@ export function tanstackRouterBrowserTracingIntegration( ); const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + const onResolvedRouteMatch = + onResolvedLastMatch?.routeId !== '__root__' ? onResolvedLastMatch : undefined; - if (onResolvedLastMatch) { - navigationSpan.updateName(onResolvedLastMatch.routeId); + if (onResolvedRouteMatch) { + navigationSpan.updateName(onResolvedRouteMatch.routeId); navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedLastMatch)); + navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedRouteMatch)); } } }); diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 5aa3888f1e4c..84076285fcdd 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -5,7 +5,16 @@ import { getImage, getImages } from './registry'; const INTEGRATION_NAME = 'Wasm'; -const _wasmIntegration = (() => { +interface WasmIntegrationOptions { + /** + * Key to identify this application for third-party error filtering. + * This key should match one of the keys provided to the `filterKeys` option + * of the `thirdPartyErrorFilterIntegration`. + */ + applicationKey?: string; +} + +const _wasmIntegration = ((options: WasmIntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { @@ -18,7 +27,7 @@ const _wasmIntegration = (() => { event.exception.values.forEach(exception => { if (exception.stacktrace?.frames) { hasAtLeastOneWasmFrameWithImage = - hasAtLeastOneWasmFrameWithImage || patchFrames(exception.stacktrace.frames); + hasAtLeastOneWasmFrameWithImage || patchFrames(exception.stacktrace.frames, options.applicationKey); } }); } @@ -37,13 +46,17 @@ export const wasmIntegration = defineIntegration(_wasmIntegration); const PARSER_REGEX = /^(.*?):wasm-function\[\d+\]:(0x[a-fA-F0-9]+)$/; +// We use the same prefix as bundler plugins so that thirdPartyErrorFilterIntegration +// recognizes WASM frames as first-party code without needing modifications. +const BUNDLER_PLUGIN_APP_KEY_PREFIX = '_sentryBundlerPluginAppKey:'; + /** * Patches a list of stackframes with wasm data needed for server-side symbolication * if applicable. Returns true if the provided list of stack frames had at least one * matching registered image. */ // Only exported for tests -export function patchFrames(frames: Array): boolean { +export function patchFrames(frames: Array, applicationKey?: string): boolean { let hasAtLeastOneWasmFrameWithImage = false; frames.forEach(frame => { if (!frame.filename) { @@ -71,6 +84,13 @@ export function patchFrames(frames: Array): boolean { frame.filename = match[1]; frame.platform = 'native'; + if (applicationKey) { + frame.module_metadata = { + ...frame.module_metadata, + [`${BUNDLER_PLUGIN_APP_KEY_PREFIX}${applicationKey}`]: true, + }; + } + if (index >= 0) { frame.addr_mode = `rel:${index}`; hasAtLeastOneWasmFrameWithImage = true; diff --git a/packages/wasm/test/stacktrace-parsing.test.ts b/packages/wasm/test/stacktrace-parsing.test.ts index f1f03c247fa8..658d02847305 100644 --- a/packages/wasm/test/stacktrace-parsing.test.ts +++ b/packages/wasm/test/stacktrace-parsing.test.ts @@ -1,7 +1,67 @@ +import type { StackFrame } from '@sentry/core'; import { describe, expect, it } from 'vitest'; import { patchFrames } from '../src/index'; describe('patchFrames()', () => { + it('should add module_metadata with applicationKey when provided', () => { + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/main.js', + function: 'run', + in_app: true, + }, + { + filename: 'http://localhost:8001/main.wasm:wasm-function[190]:0x5aeb', + function: 'MyClass::bar', + in_app: true, + }, + ]; + + patchFrames(frames, 'my-app'); + + // Non-WASM frame should not have module_metadata + expect(frames[0]?.module_metadata).toBeUndefined(); + + // WASM frame should have module_metadata with the application key + expect(frames[1]?.module_metadata).toEqual({ + '_sentryBundlerPluginAppKey:my-app': true, + }); + }); + + it('should preserve existing module_metadata when adding applicationKey', () => { + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/main.wasm:wasm-function[190]:0x5aeb', + function: 'MyClass::bar', + in_app: true, + module_metadata: { + existingKey: 'existingValue', + }, + }, + ]; + + patchFrames(frames, 'my-app'); + + expect(frames[0]?.module_metadata).toEqual({ + existingKey: 'existingValue', + '_sentryBundlerPluginAppKey:my-app': true, + }); + }); + + it('should not add module_metadata when applicationKey is not provided', () => { + const frames: StackFrame[] = [ + { + filename: 'http://localhost:8001/main.wasm:wasm-function[190]:0x5aeb', + function: 'MyClass::bar', + in_app: true, + }, + ]; + + patchFrames(frames); + + expect(frames[0]?.module_metadata).toBeUndefined(); + }); + it('should correctly extract instruction addresses', () => { const frames = [ { diff --git a/yarn.lock b/yarn.lock index e62326e9210f..35c1b6941b0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4922,10 +4922,10 @@ "@anthropic-ai/sdk" "^0.65.0" fast-xml-parser "^4.4.1" -"@langchain/core@^0.3.28": - version "0.3.78" - resolved "https://registry.npmjs.org/@langchain/core/-/core-0.3.78.tgz#40e69fba6688858edbcab4473358ec7affc685fd" - integrity sha512-Nn0x9erQlK3zgtRU1Z8NUjLuyW0gzdclMsvLQ6wwLeDqV91pE+YKl6uQb+L2NUDs4F0N7c2Zncgz46HxrvPzuA== +"@langchain/core@^0.3.80": + version "0.3.80" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-0.3.80.tgz#c494a6944e53ab28bf32dc531e257b17cfc8f797" + integrity sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA== dependencies: "@cfworker/json-schema" "^4.0.2" ansi-styles "^5.0.0" @@ -5237,55 +5237,55 @@ yargs "^17.0.0" zod "^3.23.8" -"@next/env@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/env/-/env-13.5.9.tgz#3298c57c9ad9f333774484e03cf20fba90cd79c4" - integrity sha512-h9+DconfsLkhHIw950Som5t5DC0kZReRRVhT4XO2DLo5vBK3PQK6CbFr8unxjHwvIcRdDvb8rosKleLdirfShQ== - -"@next/swc-darwin-arm64@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.9.tgz#46c3a525039171ff1a83c813d7db86fb7808a9b2" - integrity sha512-pVyd8/1y1l5atQRvOaLOvfbmRwefxLhqQOzYo/M7FQ5eaRwA1+wuCn7t39VwEgDd7Aw1+AIWwd+MURXUeXhwDw== - -"@next/swc-darwin-x64@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.9.tgz#b690452e9a6ce839f8738e27e9fd1a8567dd7554" - integrity sha512-DwdeJqP7v8wmoyTWPbPVodTwCybBZa02xjSJ6YQFIFZFZ7dFgrieKW4Eo0GoIcOJq5+JxkQyejmI+8zwDp3pwA== - -"@next/swc-linux-arm64-gnu@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.9.tgz#c3e335e2da3ba932c0b2f571f0672d1aa7af33df" - integrity sha512-wdQsKsIsGSNdFojvjW3Ozrh8Q00+GqL3wTaMjDkQxVtRbAqfFBtrLPO0IuWChVUP2UeuQcHpVeUvu0YgOP00+g== - -"@next/swc-linux-arm64-musl@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.9.tgz#54600d4917bace2508725cc963eeeb3b6432889e" - integrity sha512-6VpS+bodQqzOeCwGxoimlRoosiWlSc0C224I7SQWJZoyJuT1ChNCo+45QQH+/GtbR/s7nhaUqmiHdzZC9TXnXA== - -"@next/swc-linux-x64-gnu@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.9.tgz#f869c2066f13ff2818140e0a145dfea1ea7c0333" - integrity sha512-XxG3yj61WDd28NA8gFASIR+2viQaYZEFQagEodhI/R49gXWnYhiflTeeEmCn7Vgnxa/OfK81h1gvhUZ66lozpw== - -"@next/swc-linux-x64-musl@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.9.tgz#09295ea60a42a1b22d927802d6e543d8a8bbb186" - integrity sha512-/dnscWqfO3+U8asd+Fc6dwL2l9AZDl7eKtPNKW8mKLh4Y4wOpjJiamhe8Dx+D+Oq0GYVjuW0WwjIxYWVozt2bA== - -"@next/swc-win32-arm64-msvc@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.9.tgz#f39e3513058d7af6e9f6b1f296bf071301217159" - integrity sha512-T/iPnyurOK5a4HRUcxAlss8uzoEf5h9tkd+W2dSWAfzxv8WLKlUgbfk+DH43JY3Gc2xK5URLuXrxDZ2mGfk/jw== - -"@next/swc-win32-ia32-msvc@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.9.tgz#d567f471e182efa4ea29f47f3030613dd3fc68b5" - integrity sha512-BLiPKJomaPrTAb7ykjA0LPcuuNMLDVK177Z1xe0nAem33+9FIayU4k/OWrtSn9SAJW/U60+1hoey5z+KCHdRLQ== - -"@next/swc-win32-x64-msvc@13.5.9": - version "13.5.9" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.9.tgz#35c53bd6d33040ec0ce1dd613c59112aac06b235" - integrity sha512-/72/dZfjXXNY/u+n8gqZDjI6rxKMpYsgBBYNZKWOQw0BpBF7WCnPflRy3ZtvQ2+IYI3ZH2bPyj7K+6a6wNk90Q== +"@next/env@14.2.35": + version "14.2.35" + resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.35.tgz#e979016d0ca8500a47d41ffd02625fe29b8df35a" + integrity sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ== + +"@next/swc-darwin-arm64@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz#9e74a4223f1e5e39ca4f9f85709e0d95b869b298" + integrity sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA== + +"@next/swc-darwin-x64@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz#fcf0c45938da9b0cc2ec86357d6aefca90bd17f3" + integrity sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA== + +"@next/swc-linux-arm64-gnu@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz#837f91a740eb4420c06f34c4677645315479d9be" + integrity sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw== + +"@next/swc-linux-arm64-musl@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz#dc8903469e5c887b25e3c2217a048bd30c58d3d4" + integrity sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg== + +"@next/swc-linux-x64-gnu@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz#344438be592b6b28cc540194274561e41f9933e5" + integrity sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg== + +"@next/swc-linux-x64-musl@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz#3379fad5e0181000b2a4fac0b80f7ca4ffe795c8" + integrity sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA== + +"@next/swc-win32-arm64-msvc@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz#bca8f4dde34656aef8e99f1e5696de255c2f00e5" + integrity sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ== + +"@next/swc-win32-ia32-msvc@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz#a69c581483ea51dd3b8907ce33bb101fe07ec1df" + integrity sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q== + +"@next/swc-win32-x64-msvc@14.2.33": + version "14.2.33" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz#f1a40062530c17c35a86d8c430b3ae465eb7cea1" + integrity sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg== "@ngtools/webpack@14.2.13": version "14.2.13" @@ -7156,7 +7156,7 @@ "@sentry/bundler-plugin-core" "4.6.1" unplugin "1.0.1" -"@sentry/vite-plugin@^4.1.0", "@sentry/vite-plugin@^4.6.1": +"@sentry/vite-plugin@^4.6.1": version "4.6.1" resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-4.6.1.tgz#883d8448c033b309528985e12e0d5d1af99ee1c6" integrity sha512-Qvys1y3o8/bfL3ikrHnJS9zxdjt0z3POshdBl3967UcflrTqBmnGNkcVk53SlmtJWIfh85fgmrLvGYwZ2YiqNg== @@ -7996,11 +7996,17 @@ svelte-hmr "^0.16.0" vitefu "^0.2.5" -"@swc/helpers@0.5.2": - version "0.5.2" - resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d" - integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw== +"@swc/counter@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + +"@swc/helpers@0.5.5": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.5.tgz#12689df71bfc9b21c4f4ca00ae55f2f16c8b77c0" + integrity sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A== dependencies: + "@swc/counter" "^0.1.3" tslib "^2.4.0" "@tanstack/history@1.132.21": @@ -8758,14 +8764,6 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA== -"@types/hoist-non-react-statics@^3.3.5": - version "3.3.5" - resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" - integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg== - dependencies: - "@types/react" "*" - hoist-non-react-statics "^3.3.0" - "@types/html-minifier-terser@^6.0.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" @@ -8916,6 +8914,14 @@ resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.11.tgz#70b7131f65038ae63cfe841354c8aba363632344" integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg== +"@types/node-fetch@^2.6.11": + version "2.6.13" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.13.tgz#e0c9b7b5edbdb1b50ce32c127e85e880872d56ee" + integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw== + dependencies: + "@types/node" "*" + form-data "^4.0.4" + "@types/node-forge@^1.3.0": version "1.3.11" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" @@ -9069,11 +9075,6 @@ resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== -"@types/resolve@1.20.3": - version "1.20.3" - resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.3.tgz#066742d69a0bbba8c5d7d517f82e1140ddeb3c3c" - integrity sha512-NH5oErHOtHZYcjCtg69t26aXEk4BN2zLWqf7wnDZ+dpe0iR7Rds1SPGEItl3fca21oOe0n3OCnZ4W7jBxu7FOw== - "@types/retry@0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -12705,10 +12706,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669: - version "1.0.30001674" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001674.tgz#eb200a716c3e796d33d30b9c8890517a72f862c8" - integrity sha512-jOsKlZVRnzfhLojb+Ykb+gyUSp9Xb57So+fAiFlLzzTKpqg8xxSav0e40c8/4F/v9N8QSvrRRaLeVzQbLqomYw== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669: + version "1.0.30001762" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz#e4dbfeda63d33258cdde93e53af2023a13ba27d4" + integrity sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw== capture-exit@^2.0.0: version "2.0.0" @@ -18834,7 +18835,7 @@ hoist-non-react-statics@^1.2.0: resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" integrity sha1-qkSM8JhtVcxAdzsXF0t90GbLfPs= -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -19238,10 +19239,10 @@ import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-in-the-middle@^2, import-in-the-middle@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-2.0.0.tgz#295948cee94d0565314824c6bd75379d13e5b1a5" - integrity sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A== +import-in-the-middle@2.0.1, import-in-the-middle@^2.0.0, import-in-the-middle@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-2.0.1.tgz#8d1aa2db18374f2c811de2aa4756ebd6e9859243" + integrity sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA== dependencies: acorn "^8.14.0" acorn-import-attributes "^1.9.5" @@ -23090,28 +23091,28 @@ new-find-package-json@^2.0.0: dependencies: debug "^4.3.4" -next@13.5.9: - version "13.5.9" - resolved "https://registry.yarnpkg.com/next/-/next-13.5.9.tgz#a8c38254279eb30a264c1c640bf77340289ba6e3" - integrity sha512-h4ciD/Uxf1PwsiX0DQePCS5rMoyU5a7rQ3/Pg6HBLwpa/SefgNj1QqKSZsWluBrYyqdtEyqKrjeOszgqZlyzFQ== +next@14.2.35: + version "14.2.35" + resolved "https://registry.yarnpkg.com/next/-/next-14.2.35.tgz#7c68873a15fe5a19401f2f993fea535be3366ee9" + integrity sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig== dependencies: - "@next/env" "13.5.9" - "@swc/helpers" "0.5.2" + "@next/env" "14.2.35" + "@swc/helpers" "0.5.5" busboy "1.6.0" - caniuse-lite "^1.0.30001406" + caniuse-lite "^1.0.30001579" + graceful-fs "^4.2.11" postcss "8.4.31" styled-jsx "5.1.1" - watchpack "2.4.0" optionalDependencies: - "@next/swc-darwin-arm64" "13.5.9" - "@next/swc-darwin-x64" "13.5.9" - "@next/swc-linux-arm64-gnu" "13.5.9" - "@next/swc-linux-arm64-musl" "13.5.9" - "@next/swc-linux-x64-gnu" "13.5.9" - "@next/swc-linux-x64-musl" "13.5.9" - "@next/swc-win32-arm64-msvc" "13.5.9" - "@next/swc-win32-ia32-msvc" "13.5.9" - "@next/swc-win32-x64-msvc" "13.5.9" + "@next/swc-darwin-arm64" "14.2.33" + "@next/swc-darwin-x64" "14.2.33" + "@next/swc-linux-arm64-gnu" "14.2.33" + "@next/swc-linux-arm64-musl" "14.2.33" + "@next/swc-linux-x64-gnu" "14.2.33" + "@next/swc-linux-x64-musl" "14.2.33" + "@next/swc-win32-arm64-msvc" "14.2.33" + "@next/swc-win32-ia32-msvc" "14.2.33" + "@next/swc-win32-x64-msvc" "14.2.33" ng-packagr@^14.2.2: version "14.3.0" @@ -27064,15 +27065,6 @@ resolve@1.22.1: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@1.22.8: - version "1.22.8" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" - integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== - dependencies: - is-core-module "^2.13.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.22.10, resolve@^1.22.4, resolve@^1.22.6, resolve@^1.22.8, resolve@^1.4.0, resolve@^1.5.0: version "1.22.10" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" @@ -31413,14 +31405,6 @@ watch-detector@^1.0.0, watch-detector@^1.0.2: silent-error "^1.1.1" tmp "^0.1.0" -watchpack@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" - integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== - dependencies: - glob-to-regexp "^0.4.1" - graceful-fs "^4.1.2" - watchpack@^2.4.0, watchpack@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.1.tgz#29308f2cac150fa8e4c92f90e0ec954a9fed7fff"