diff --git a/.agents/skills/add-ai-integration/SKILL.md b/.agents/skills/add-ai-integration/SKILL.md index a32d2ef766c9..c3b823fdfa17 100644 --- a/.agents/skills/add-ai-integration/SKILL.md +++ b/.agents/skills/add-ai-integration/SKILL.md @@ -58,6 +58,7 @@ What the reference files won't tell you: - [ ] Instrumentation in `src/ai/`, integration in `src/integrations/`, registered in `getTracingIntegrations()` (LangChain first) - [ ] Exported from `packages/server-utils/src/index.ts`, re-exported from the supported runtime packages - [ ] E2E tests in `dev-packages/node-integration-tests/suites/tracing/{provider}/` (and `cloudflare-integration-tests/` if supported) +- [ ] The new suite passes on the other runtimes that run the Node suites, or is excluded there (see "Other Runtimes" in `dev-packages/node-integration-tests/README.md`) - [ ] Ops and attributes from `@sentry/conventions`, op derived via `getGenAiSpanOp()` - [ ] Recording gated on `resolveAIRecordingOptions()`; no truncation, no token rollup - [ ] JSDoc names the channels subscribed to, the supported SDK versions, and — for Pattern 2 — that it requires the Sentry runtime hook or bundler plugin diff --git a/.agents/skills/vendor-otel/SKILL.md b/.agents/skills/vendor-otel/SKILL.md index 309b7701d757..8ef36fa5769d 100644 --- a/.agents/skills/vendor-otel/SKILL.md +++ b/.agents/skills/vendor-otel/SKILL.md @@ -43,7 +43,8 @@ grep "import.*from '" | grep -v "@opentelemetry\|'\./\|@sentry\|'util'\|' Check test coverage and report gaps: -- Integration tests: `dev-packages/node-integration-tests/suites/tracing//` +- Integration tests: `dev-packages/node-integration-tests/suites/tracing//`. Other runtimes run these + suites too; see "Other Runtimes" in `dev-packages/node-integration-tests/README.md`. - E2E tests: `dev-packages/e2e-tests/test-applications/node-/` - Unit tests: `packages/node/test/integrations/tracing/.test.ts` diff --git a/.agents/skills/write-tests/SKILL.md b/.agents/skills/write-tests/SKILL.md index b9a898f3e347..af8f0408e8b9 100644 --- a/.agents/skills/write-tests/SKILL.md +++ b/.agents/skills/write-tests/SKILL.md @@ -337,6 +337,9 @@ describe('patchRoute', () => { Node integration tests (`dev-packages/node-integration-tests/`) use `createEsmAndCjsTests` to run a real Node scenario file and assert on captured Sentry envelopes. +Other runtimes run these suites too. A new suite must pass there or be excluded; see "Other +Runtimes" in `dev-packages/node-integration-tests/README.md`. + ### Minimize `test()` calls — each one spawns a separate Node process **This is the opposite of the Playwright rule.** In Playwright, each `test()` is cheap — use diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4eb40336293b..a6dc12f405db 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,10 +8,9 @@ /dev-packages/browser-integration-tests/ @getsentry/team-javascript-sdks-browser # Node/server runtimes and related packages -# TEMP: whole JS SDK team reviews orchestrion work; revert to team-javascript-sdks-server after -/packages/node/ @getsentry/team-javascript-sdks -/packages/server-runtime-injection/ @getsentry/team-javascript-sdks -/packages/server-utils/ @getsentry/team-javascript-sdks +/packages/node/ @getsentry/team-javascript-sdks-server +/packages/server-runtime-injection/ @getsentry/team-javascript-sdks-server +/packages/server-utils/ @getsentry/team-javascript-sdks-server /packages/node-native/ @getsentry/team-javascript-sdks-server /packages/profiling-node/ @getsentry/team-javascript-sdks-server /packages/opentelemetry/ @getsentry/team-javascript-sdks-server diff --git a/.github/FLAKY_CI_FAILURE_TEMPLATE.md b/.github/FLAKY_CI_FAILURE_TEMPLATE.md deleted file mode 100644 index 23843c2c507f..000000000000 --- a/.github/FLAKY_CI_FAILURE_TEMPLATE.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: '[Flaky CI]: {{ env.JOB_NAME }} - {{ env.TEST_NAME }}' -labels: Tests, "Flaky Test" ---- - -### Flakiness Type - -Other / Unknown - -### Name of Job - -{{ env.JOB_NAME }} - -### Name of Test - -{{ env.TEST_NAME }} - -### Link to Test Run - -{{ env.RUN_LINK }} - ---- - -_This issue was automatically created._ diff --git a/.github/ISSUE_TEMPLATE/flaky.yml b/.github/ISSUE_TEMPLATE/flaky.yml index 22b035ec1145..91b467a8b4dd 100644 --- a/.github/ISSUE_TEMPLATE/flaky.yml +++ b/.github/ISSUE_TEMPLATE/flaky.yml @@ -12,6 +12,7 @@ body: - Timeout - Assertion failure - Other / Unknown + default: 1 validations: required: true - type: input diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 45e298794f10..908dfc211966 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -783,7 +783,7 @@ jobs: job_node_integration_tests: name: Node (${{ matrix.node }})${{ (matrix.typescript && format(' (TS {0})', matrix.typescript)) || '' }} Integration - Tests + Tests (${{ matrix.shard }}/3) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_node_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 @@ -792,12 +792,20 @@ jobs: fail-fast: false matrix: node: [20.19, 22, 24, 26] + shard: [1, 2, 3] typescript: - false include: # Only check typescript for latest version (to streamline CI) - node: 24 typescript: '5.0' + shard: 1 + - node: 24 + typescript: '5.0' + shard: 2 + - node: 24 + typescript: '5.0' + shard: 3 steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -832,14 +840,18 @@ jobs: - name: Run integration tests working-directory: dev-packages/node-integration-tests - run: yarn test + run: yarn test --shard=${{ matrix.shard }}/3 job_cloudflare_integration_tests: - name: Cloudflare Integration Tests + name: Cloudflare Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_cloudflare_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -856,7 +868,7 @@ jobs: - name: Run integration tests working-directory: dev-packages/cloudflare-integration-tests - run: yarn test + run: yarn test --shard=${{ matrix.shard }}/2 job_bundler_plugin_integration_tests: name: Bundler Plugin Integration Tests (Node ${{ matrix.node }}) @@ -1261,28 +1273,8 @@ jobs: # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-24.04 - permissions: - issues: write - checks: read + permissions: {} steps: - - name: Check out current commit - if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') - uses: actions/checkout@v7 - with: - sparse-checkout: | - .github - scripts - - - name: Create issues for failed jobs - if: github.ref == 'refs/heads/develop' && contains(needs.*.result, 'failure') - uses: actions/github-script@v9 - with: - script: | - const { default: run } = await import( - `${process.env.GITHUB_WORKSPACE}/scripts/report-ci-failures.mjs` - ); - await run({ github, context, core }); - - name: Check for failures if: cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: | diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml index 02fb540f71a3..ebb003de3c26 100644 --- a/.github/workflows/cleanup-e2e-workers.yml +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -31,6 +31,6 @@ jobs: if ! output=$(npx --yes wrangler@4 delete --name "$WORKER" --force 2>&1); then echo "$output" - # 10007 means the worker does not exist, i.e. the PR never ran the optional E2E job. - echo "$output" | grep -q 'code: 10007' || exit 1 + # 10007 and 10090 mean the worker does not exist, e.g. the PR never ran the optional E2E job. + echo "$output" | grep -Eq 'code: (10007|10090)\]' || exit 1 fi diff --git a/.github/workflows/weekly-flaky-tests.yml b/.github/workflows/weekly-flaky-tests.yml new file mode 100644 index 000000000000..5c99a4b5f93f --- /dev/null +++ b/.github/workflows/weekly-flaky-tests.yml @@ -0,0 +1,35 @@ +name: 'CI: Weekly flaky tests' + +on: + workflow_dispatch: + schedule: + - cron: '0 7 * * 1' + timezone: 'Europe/Vienna' + +permissions: + contents: read + actions: read + checks: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + report: + name: Weekly flaky test report + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Analyze the last seven days of develop CI + uses: actions/github-script@v9 + with: + retries: 3 + script: | + const { default: run } = await import( + `${process.env.GITHUB_WORKSPACE}/scripts/weekly-flaky-tests.mjs` + ); + await run({ github, context, core }); diff --git a/.size-limit.js b/.size-limit.js index 3b96129de159..3991fe66e743 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,7 +8,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '34 KB', + limit: '35 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -65,7 +65,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '56 KB', + limit: '57 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -73,7 +73,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'spanStreamingIntegration'), gzip: true, - limit: '56 KB', + limit: '57 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -81,7 +81,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), gzip: true, - limit: '59 KB', + limit: '60 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -89,7 +89,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '95 KB', + limit: '96 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -121,7 +121,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '100 KB', + limit: '101 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -129,7 +129,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '113 KB', + limit: '114 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -145,7 +145,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'sendFeedback'), gzip: true, - limit: '39 KB', + limit: '40 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -161,7 +161,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics'), gzip: true, - limit: '35 KB', + limit: '36 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -177,7 +177,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'metrics', 'logger'), gzip: true, - limit: '36 KB', + limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, // React SDK (ESM) @@ -196,7 +196,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '58 KB', + limit: '59 KB', disablePlugins: ['@size-limit/esbuild'], }, // Vue SDK (ESM) @@ -213,7 +213,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '58 KB', + limit: '59 KB', disablePlugins: ['@size-limit/esbuild'], }, // Svelte SDK (ESM) @@ -222,7 +222,7 @@ module.exports = [ path: 'packages/svelte/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '34 KB', + limit: '35 KB', disablePlugins: ['@size-limit/esbuild'], }, // Browser CDN bundles @@ -237,21 +237,21 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '56 KB', + limit: '57 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Logs, Metrics)', path: createCDNPath('bundle.logs.metrics.min.js'), gzip: true, - limit: '38 KB', + limit: '39 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: true, - limit: '58 KB', + limit: '59 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -265,28 +265,28 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '94 KB', + limit: '95 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '96 KB', + limit: '97 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: true, - limit: '100 KB', + limit: '101 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: true, - limit: '102 KB', + limit: '103 KB', disablePlugins: ['@size-limit/esbuild'], }, // browser CDN bundles (non-gzipped) @@ -295,7 +295,7 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '96 KB', + limit: '97 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -303,7 +303,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '158 KB', + limit: '159 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -319,7 +319,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '164 KB', + limit: '165 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -327,7 +327,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '232 KB', + limit: '233 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -335,7 +335,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '277 KB', + limit: '279 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -343,7 +343,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '283 KB', + limit: '285 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -351,7 +351,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '291 KB', + limit: '293 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -359,7 +359,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '297 KB', + limit: '299 KB', disablePlugins: ['@size-limit/esbuild'], }, // Next.js SDK (ESM) @@ -379,7 +379,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '56 KB', + limit: '57 KB', disablePlugins: ['@size-limit/esbuild'], }, // Core SDK subpath entry points (ESM) @@ -388,7 +388,7 @@ module.exports = [ path: 'packages/core/build/esm/server.js', import: '*', gzip: true, - limit: '43 KB', + limit: '45 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '135 KB', + limit: '142 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -414,7 +414,7 @@ module.exports = [ path: ['packages/server-runtime-injection/build/esm/hook.js', 'packages/node/build/import-hook.mjs'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '87 KB', + limit: '88 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { // Both packages declare `sideEffects: false`, which lets webpack @@ -430,7 +430,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '95 KB', + limit: '96 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -452,7 +452,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '114 KB', + limit: '121 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -473,7 +473,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '103 KB', + limit: '104 KB', disablePlugins: ['@size-limit/esbuild'], }, // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output @@ -484,7 +484,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '205 KiB', + limit: '208 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { @@ -504,7 +504,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '502 KiB', + limit: '507 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1f1fea7198..6b52a3163c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,116 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 11.1.0 + +### Important Changes + +- **feat(server-utils): Auto-instrument MCP servers via orchestrion ([#24529](https://github.com/getsentry/sentry-javascript/pull/24529))** + + A new default `mcpServerIntegration` wraps every `McpServer` instance (from `@modelcontextprotocol/server` and `@modelcontextprotocol/sdk`) when it is created. You no longer need to call `wrapMcpServerWithSentry` manually. You can still call `wrapMcpServerWithSentry` to override options, for example `recordInputs: false`. + +- **feat(node): Support Prisma 8 in `prismaIntegration` ([#24682](https://github.com/getsentry/sentry-javascript/pull/24682))** + + `prismaIntegration` now creates a `prisma:client:operation` span for each Prisma 8 ORM call, with the database query spans nested below it. No code changes are necessary. + +- **feat(server-utils): Add TypeSafe integration ([#24703](https://github.com/getsentry/sentry-javascript/pull/24703))** + + A new default `typesafeIntegration` creates a `gen_ai.evaluate` span for each TypeSafe Jev call through `@typesafe-ai/sdk` (`TypeSafeClient.systemOne`). For runtimes without auto-instrumentation, use `instrumentTypeSafeClient()`. + +- **feat(server-utils): Instrument Vercel AI `experimental_evaluate` ([#24694](https://github.com/getsentry/sentry-javascript/pull/24694))** + + Vercel AI `experimental_evaluate` calls now create a `gen_ai.evaluate` span, with the state and questions as input messages and the answers as output messages. + +### Other Changes + +- feat(browser): Add `onError` callback to `showReportDialog` ([#24780](https://github.com/getsentry/sentry-javascript/pull/24780)) +- feat(bun): Add client address, port and protocol to Bun.serve spans ([#24523](https://github.com/getsentry/sentry-javascript/pull/24523)) +- feat(cloudflare): Re-export httpServerIntegration from /request ([#24614](https://github.com/getsentry/sentry-javascript/pull/24614)) +- feat(remix): Add Remix 3 export subpaths and optional peer deps ([#24697](https://github.com/getsentry/sentry-javascript/pull/24697)) +- feat(server-utils): Support amqplib v2 ([#24652](https://github.com/getsentry/sentry-javascript/pull/24652)) +- feat(server-utils): Support tedious v20 ([#24644](https://github.com/getsentry/sentry-javascript/pull/24644)) +- fix: Injection silently no-ops on Windows (`registerHooks` path) ([#24705](https://github.com/getsentry/sentry-javascript/pull/24705)) +- fix(bun): Keep diagnostics channel subscriptions alive ([#24632](https://github.com/getsentry/sentry-javascript/pull/24632)) +- fix(cloudflare): Call connect() on the binding for service bindings and DO stubs ([#24593](https://github.com/getsentry/sentry-javascript/pull/24593)) +- fix(cloudflare): Keep the user's class name when auto-wrapping an export ([#24700](https://github.com/getsentry/sentry-javascript/pull/24700)) +- fix(core): Support joined set-cookie headers (split them) ([#24659](https://github.com/getsentry/sentry-javascript/pull/24659)) +- fix(nextjs): Don't prepend basePath to absolute router navigation URLs ([#24680](https://github.com/getsentry/sentry-javascript/pull/24680)) +- fix(nextjs): Limit Turbopack orchestrion loader to instrumented packages ([#24792](https://github.com/getsentry/sentry-javascript/pull/24792)) +- fix(nextjs): Stop Turbopack loaders from loading the Sentry CLI ([#24651](https://github.com/getsentry/sentry-javascript/pull/24651)) +- fix(server-runtime-injection): Keep ES modules working on Deno ([#24669](https://github.com/getsentry/sentry-javascript/pull/24669)) +- fix(server-utils): Deduplicate Google GenAI streaming tool calls ([#23432](https://github.com/getsentry/sentry-javascript/pull/23432)) +- fix(sveltekit,remix): Export Mistral manual wrapper ([#24566](https://github.com/getsentry/sentry-javascript/pull/24566)) +- fix(sveltekit): Resolve `@opentelemetry/api` via the SDK on SvelteKit 3 ([#24736](https://github.com/getsentry/sentry-javascript/pull/24736)) +- fix(wasm): map wasm:// stack frames to registered debug images ([#23999](https://github.com/getsentry/sentry-javascript/pull/23999)) +- perf(bundler-plugins): Use fast component annotation for HTML injection mode ([#24440](https://github.com/getsentry/sentry-javascript/pull/24440)) +- perf(bundler-plugins): Use fast component annotation for Webpack and Turbopack ([#24448](https://github.com/getsentry/sentry-javascript/pull/24448)) +- ref(bundler-plugins): Remove Babel fallback from component annotation ([#24449](https://github.com/getsentry/sentry-javascript/pull/24449)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#24674](https://github.com/getsentry/sentry-javascript/pull/24674)) +- chore: Add external contributor to CHANGELOG.md ([#24675](https://github.com/getsentry/sentry-javascript/pull/24675)) +- chore: Add external contributor to CHANGELOG.md ([#24745](https://github.com/getsentry/sentry-javascript/pull/24745)) +- chore: Add external contributor to CHANGELOG.md ([#24766](https://github.com/getsentry/sentry-javascript/pull/24766)) +- chore: Add external contributor to CHANGELOG.md ([#24782](https://github.com/getsentry/sentry-javascript/pull/24782)) +- chore: Update base node version to 24.21.0 ([#24343](https://github.com/getsentry/sentry-javascript/pull/24343)) +- chore: upgrade oxfmt to 0.70.0 ([#24637](https://github.com/getsentry/sentry-javascript/pull/24637)) +- chore(ci): Assign server package reviews back to server team ([#24743](https://github.com/getsentry/sentry-javascript/pull/24743)) +- chore(deps-dev): bump elysia from 1.4.27 to 1.4.29 ([#24673](https://github.com/getsentry/sentry-javascript/pull/24673)) +- chore(deps): Bump `@sentry/conventions` to 0.24.0 ([#24645](https://github.com/getsentry/sentry-javascript/pull/24645)) +- chore(size-limit): weekly auto-bump ([#24729](https://github.com/getsentry/sentry-javascript/pull/24729)) +- ci: Replace per-run flaky issues with a weekly report ([#24707](https://github.com/getsentry/sentry-javascript/pull/24707)) +- ci: shard Cloudflare integration tests ([#24726](https://github.com/getsentry/sentry-javascript/pull/24726)) +- ci: shard Node integration tests and increase browser Playwright workers ([#24676](https://github.com/getsentry/sentry-javascript/pull/24676)) +- docs: Point browser SDK READMEs to documentation ([#24634](https://github.com/getsentry/sentry-javascript/pull/24634)) +- docs: Point Effect, Elysia, and Wasm READMEs to documentation ([#24636](https://github.com/getsentry/sentry-javascript/pull/24636)) +- docs: Point framework SDK READMEs to documentation ([#24631](https://github.com/getsentry/sentry-javascript/pull/24631)) +- docs: Point profiling and native READMEs to documentation ([#24640](https://github.com/getsentry/sentry-javascript/pull/24640)) +- docs: Point Replay and Feedback READMEs to documentation ([#24639](https://github.com/getsentry/sentry-javascript/pull/24639)) +- docs: Point server SDK READMEs to documentation ([#24635](https://github.com/getsentry/sentry-javascript/pull/24635)) +- docs: Point tooling READMEs to documentation ([#24641](https://github.com/getsentry/sentry-javascript/pull/24641)) +- docs: Standardize internal package READMEs ([#24638](https://github.com/getsentry/sentry-javascript/pull/24638)) +- docs(angular): Point README to SDK documentation ([#24623](https://github.com/getsentry/sentry-javascript/pull/24623)) +- docs(astro): Point README to SDK documentation ([#24626](https://github.com/getsentry/sentry-javascript/pull/24626)) +- docs(aws-serverless): Point README to SDK documentation ([#24627](https://github.com/getsentry/sentry-javascript/pull/24627)) +- docs(cloudflare): Point README to SDK documentation ([#24624](https://github.com/getsentry/sentry-javascript/pull/24624)) +- docs(ember): Point README to SDK documentation ([#24628](https://github.com/getsentry/sentry-javascript/pull/24628)) +- docs(hono): Point README to SDK documentation ([#24621](https://github.com/getsentry/sentry-javascript/pull/24621)) +- docs(nestjs): Point README to SDK documentation ([#24618](https://github.com/getsentry/sentry-javascript/pull/24618)) +- docs(nitro): Point README to SDK documentation ([#24629](https://github.com/getsentry/sentry-javascript/pull/24629)) +- docs(opentelemetry): Point README to documentation ([#24642](https://github.com/getsentry/sentry-javascript/pull/24642)) +- docs(react-router): Point README to SDK documentation ([#24625](https://github.com/getsentry/sentry-javascript/pull/24625)) +- docs(remix): Point README to SDK documentation ([#24630](https://github.com/getsentry/sentry-javascript/pull/24630)) +- docs(solidstart): Point README to SDK documentation ([#24622](https://github.com/getsentry/sentry-javascript/pull/24622)) +- fix(ci): Handle missing workers in E2E cleanup ([#24779](https://github.com/getsentry/sentry-javascript/pull/24779)) +- fix(server-utils): Use Bun-safe diagnostics channel wrappers in MCP integration ([#24797](https://github.com/getsentry/sentry-javascript/pull/24797)) +- test(bun, deno): Run shared Node integration suites on Bun and Deno ([#24609](https://github.com/getsentry/sentry-javascript/pull/24609)) +- test(bun): Exclude Typesafe tests in bun ([#24785](https://github.com/getsentry/sentry-javascript/pull/24785)) +- test(bun): Run all Node integration suites on Bun ([#24610](https://github.com/getsentry/sentry-javascript/pull/24610)) +- test(bun): Run the Bun-only suites with the shared Node runner ([#24668](https://github.com/getsentry/sentry-javascript/pull/24668)) +- test(cloudflare): Add e2e test app for `@cloudflare/think` ([#24660](https://github.com/getsentry/sentry-javascript/pull/24660)) +- test(cloudflare): Resend requests until the deployed Cloudflare Worker answers ([#24560](https://github.com/getsentry/sentry-javascript/pull/24560)) +- test(deno): Run all Node integration suites on Deno ([#24611](https://github.com/getsentry/sentry-javascript/pull/24611)) +- test(e2e): Add cache origin link tests for cached components in pages ([#24739](https://github.com/getsentry/sentry-javascript/pull/24739)) +- test(e2e): Add cache origin link tests for use cache route handlers ([#24734](https://github.com/getsentry/sentry-javascript/pull/24734)) +- test(e2e): Add Next.js cache component nesting scenarios ([#24704](https://github.com/getsentry/sentry-javascript/pull/24704)) +- test(e2e): Add node-prisma-8 test app ([#24683](https://github.com/getsentry/sentry-javascript/pull/24683)) +- test(e2e): Add pageload connection test for cache component apps ([#24741](https://github.com/getsentry/sentry-javascript/pull/24741)) +- test(e2e): fix flaky hydrogen send-to-sentry tests ([#24708](https://github.com/getsentry/sentry-javascript/pull/24708)) +- test(effect): Make effect/http stable ([#24781](https://github.com/getsentry/sentry-javascript/pull/24781)) +- test(node): Add Prisma 8 integration test suite ([#24681](https://github.com/getsentry/sentry-javascript/pull/24681)) +- test(node): Assert absence of PII attributes in OpenAI integration tests ([#24031](https://github.com/getsentry/sentry-javascript/pull/24031)) +- test(node): Port envelope-header tests to span streaming ([#24528](https://github.com/getsentry/sentry-javascript/pull/24528)) +- test(node): Use a separate instrument file for the Vercel AI evaluate privacy test ([#24742](https://github.com/getsentry/sentry-javascript/pull/24742)) +- test(nuxt): Fix Nuxt 5 E2E by pinning @nuxt/cli ([#24788](https://github.com/getsentry/sentry-javascript/pull/24788)) +- test(remix): Add Remix 3 e2e test application ([#24698](https://github.com/getsentry/sentry-javascript/pull/24698)) +- test(vue): verify vue-router 5 support ([#24595](https://github.com/getsentry/sentry-javascript/pull/24595)) + +
+ +Work in this release was contributed by @camc314, @ihsraham, @zkasuran, @Shubham-Padkonde, and @itz-puneet. Thank you for your contributions! + ## 11.0.0 Version `11.0.0` marks a major release of the Sentry JavaScript SDKs containing breaking changes. diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index e88d92787a7e..cfc10ae5460e 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -63,7 +63,7 @@ "@sentry/browser": "11.0.0", "@sentry/replay": "11.0.0", "@sentry/opentelemetry": "11.0.0", - "@sentry/conventions": "0.23.0", + "@sentry/conventions": "0.24.0", "@supabase/supabase-js": "2.109.0", "axios": "1.18.0", "babel-loader": "^10.1.1", diff --git a/dev-packages/browser-integration-tests/playwright.browser.config.ts b/dev-packages/browser-integration-tests/playwright.browser.config.ts index dd48c8f54746..9fbee13a25dd 100644 --- a/dev-packages/browser-integration-tests/playwright.browser.config.ts +++ b/dev-packages/browser-integration-tests/playwright.browser.config.ts @@ -3,6 +3,7 @@ import CorePlaywrightConfig from './playwright.config'; const config: PlaywrightTestConfig = { ...CorePlaywrightConfig, + workers: process.env.CI ? 6 : undefined, testDir: './suites', }; diff --git a/dev-packages/browser-integration-tests/suites/public-api/showReportDialog/on-error/subject.js b/dev-packages/browser-integration-tests/suites/public-api/showReportDialog/on-error/subject.js new file mode 100644 index 000000000000..d547113b8f5e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/showReportDialog/on-error/subject.js @@ -0,0 +1,6 @@ +Sentry.showReportDialog({ + eventId: 'test_id', + onError: error => { + window._reportDialogError = error.message; + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/public-api/showReportDialog/on-error/test.ts b/dev-packages/browser-integration-tests/suites/public-api/showReportDialog/on-error/test.ts new file mode 100644 index 000000000000..d0d94225c375 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/showReportDialog/on-error/test.ts @@ -0,0 +1,15 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; + +sentryTest('calls `onError` when the dialog script is blocked', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Registered after `getLocalTestUrl` so it takes precedence over the fixture's ingest route + await page.route(/\/api\/embed\/error-page\//, route => route.abort('blockedbyclient')); + + await page.goto(url); + + const errorMessage = await page.waitForFunction(() => (window as any)._reportDialogError); + + expect(await errorMessage.jsonValue()).toBe('Failed to load the report dialog script'); +}); diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/init.js b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/init.js index d5c0d011b788..602bdd6b9a16 100644 --- a/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/init.js +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/init.js @@ -1,20 +1,15 @@ import * as Sentry from '@sentry/browser'; -import { registerWebWorkerWasm } from '@sentry/wasm'; +import { wasmIntegration } from '@sentry/wasm'; window.Sentry = Sentry; Sentry.init({ traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', -}); - -// `registerWebWorkerWasm` installs the same patches a worker would, and reports -// every registered module to the scope it is given. Collecting them here is the -// only way to observe registration from the page, since main-thread images stay -// module-internal until a frame matches one. -window.registeredImages = []; -registerWebWorkerWasm({ - self: { - postMessage: message => window.registeredImages.push(...(message._sentryWasmImages || [])), + integrations: [wasmIntegration()], + beforeSend: event => { + window.events.push(event); + return null; }, }); +window.events = []; diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/subject.js b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/subject.js index d58714c72fca..e182f6c3295f 100644 --- a/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/subject.js +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/subject.js @@ -1,12 +1,20 @@ -window.loadWasmFromBuffer = async () => { - const response = await fetch('https://localhost:5887/simple.wasm'); - const buffer = await response.arrayBuffer(); +window.getEvent = async () => { + function crash() { + throw new Error('whoops'); + } - await WebAssembly.instantiate(new Uint8Array(buffer), { + const response = await fetch('https://localhost:5887/named.wasm'); + const buffer = await response.arrayBuffer(); + const { instance } = await WebAssembly.instantiate(new Uint8Array(buffer), { env: { - external_func: () => {}, + external_func: crash, }, }); - return window.registeredImages; + try { + instance.exports.internal_func(); + } catch (err) { + Sentry.captureException(err); + return window.events.pop(); + } }; diff --git a/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/test.ts b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/test.ts index 151285f9ee51..22a5d623b872 100644 --- a/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/test.ts +++ b/dev-packages/browser-integration-tests/suites/wasm/instantiateBufferRegistration/test.ts @@ -1,49 +1,68 @@ -import type { Page, Route } from '@playwright/test'; import { expect } from '@playwright/test'; import fs from 'fs'; import path from 'path'; import { sentryTest } from '../../../utils/fixtures'; import { shouldSkipWASMTests } from '../../../utils/wasmHelpers'; -async function serveWasmFixture(page: Page): Promise { - // `page.route` resolves with a `Disposable` as of Playwright 1.63, so it can't be returned directly. - await page.route('**/simple.wasm', (route: Route) => { - const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'simple.wasm')); - - return route.fulfill({ - status: 200, - body: wasmModule, - headers: { - 'Content-Type': 'application/wasm', - }, - }); - }); -} - +// `named.wasm` is `../simple.wasm` plus a module-name subsection (`namedmodule`) +// in its `name` section. Chrome labels bytes-compiled modules with that name +// as `wasm://wasm/namedmodule-`, not with the fetch URL. sentryTest( - 'registers a module loaded via fetch, arrayBuffer and instantiate under its response url', + 'maps frames of a module compiled from fetched bytes to its debug image', async ({ getLocalTestUrl, page, browserName }) => { if (shouldSkipWASMTests(browserName)) { sentryTest.skip(); } const url = await getLocalTestUrl({ testDir: __dirname }); - await serveWasmFixture(page); + + await page.route('**/named.wasm', route => { + const wasmModule = fs.readFileSync(path.resolve(__dirname, '..', 'named.wasm')); + + return route.fulfill({ + status: 200, + body: wasmModule, + headers: { + 'Content-Type': 'application/wasm', + }, + }); + }); + await page.goto(url); - const images = await page.evaluate(async () => { + const event = await page.evaluate(async () => { // @ts-expect-error this function exists - return window.loadWasmFromBuffer(); + return window.getEvent(); }); - expect(images).toEqual([ - { - type: 'wasm', - code_file: 'https://localhost:5887/simple.wasm', - code_id: '0ba020cdd2444f7eafdd25999a8e9010', - debug_file: null, - debug_id: '0ba020cdd2444f7eafdd25999a8e90100', - }, - ]); + expect(event.exception.values[0].stacktrace.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filename: 'https://localhost:5887/named.wasm', + function: 'namedmodule.internal_func', + in_app: true, + instruction_addr: '0x8c', + addr_mode: 'rel:0', + platform: 'native', + }), + expect.objectContaining({ + filename: expect.stringMatching(/subject\.bundle\.js$/), + function: 'crash', + in_app: true, + }), + ]), + ); + + expect(event.debug_meta).toMatchObject({ + images: [ + { + code_file: 'https://localhost:5887/named.wasm', + code_id: '0ba020cdd2444f7eafdd25999a8e9010', + debug_file: null, + debug_id: '0ba020cdd2444f7eafdd25999a8e90100', + type: 'wasm', + }, + ], + }); }, ); diff --git a/dev-packages/browser-integration-tests/suites/wasm/named.wasm b/dev-packages/browser-integration-tests/suites/wasm/named.wasm new file mode 100644 index 000000000000..15f49406f293 Binary files /dev/null and b/dev-packages/browser-integration-tests/suites/wasm/named.wasm differ diff --git a/dev-packages/browser-integration-tests/suites/wasm/webWorker/assets/worker.js b/dev-packages/browser-integration-tests/suites/wasm/webWorker/assets/worker.js index 81f9462f1aab..ba3a85d04715 100644 --- a/dev-packages/browser-integration-tests/suites/wasm/webWorker/assets/worker.js +++ b/dev-packages/browser-integration-tests/suites/wasm/webWorker/assets/worker.js @@ -3,6 +3,30 @@ // between worker and main thread independent of SDK implementation details // in production code you would do: registerWebWorkerWasm({ self }); +// Bytes-compiled modules carry no URL, so remember the fetch URL per buffer +// like the SDK's Response patch does. +const bufferUrls = new WeakMap(); +const origArrayBuffer = Response.prototype.arrayBuffer; +Response.prototype.arrayBuffer = function arrayBuffer() { + return origArrayBuffer.call(this).then(buffer => { + if (this.url) { + bufferUrls.set(buffer, this.url); + } + return buffer; + }); +}; + +const origInstantiate = WebAssembly.instantiate; +WebAssembly.instantiate = function instantiate(source, importObject) { + return origInstantiate(source, importObject).then(result => { + const url = bufferUrls.get(source instanceof ArrayBuffer ? source : source.buffer); + if (url && result.module) { + registerModuleAndForward(result.module, url); + } + return result; + }); +}; + const origInstantiateStreaming = WebAssembly.instantiateStreaming; WebAssembly.instantiateStreaming = function instantiateStreaming(response, importObject) { return Promise.resolve(response).then(res => { @@ -26,6 +50,10 @@ function registerModuleAndForward(module, url) { debug_file: null, debug_id: `${`${buildId}00000000000000000000000000000000`.slice(0, 32)}0`, }; + const moduleName = getModuleName(module); + if (moduleName) { + image.moduleName = moduleName; + } self.postMessage({ _sentryMessage: true, @@ -46,6 +74,24 @@ function getBuildId(module) { return null; } +// Read the module name from the `name` custom section. Chrome labels +// bytes-compiled modules `wasm://wasm/-`, and the main thread +// links such frames to this image by that name. +function getModuleName(module) { + const sections = WebAssembly.Module.customSections(module, 'name'); + if (sections.length === 0) { + return null; + } + const bytes = new Uint8Array(sections[0]); + // Subsection id 0 is the module name: id, payload size, name length, name. + // Sizes are LEB128, but the fixture's fit in one byte each. + if (bytes[0] !== 0) { + return null; + } + const length = bytes[2]; + return new TextDecoder().decode(bytes.subarray(3, 3 + length)); +} + // Handle messages from the main thread self.addEventListener('message', async event => { if (event.origin !== '' && event.origin !== self.location.origin) { @@ -56,15 +102,23 @@ self.addEventListener('message', async event => { throw new Error('WASM error from worker'); } - if (event.data.type === 'load-wasm-and-crash') { + if (event.data.type === 'load-wasm-and-crash' || event.data.type === 'load-wasm-bytes-and-crash') { const wasmUrl = event.data.wasmUrl; + const imports = { + env: { + external_func: crash, + }, + }; try { - const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), { - env: { - external_func: crash, - }, - }); + let instance; + if (event.data.type === 'load-wasm-bytes-and-crash') { + const response = await fetch(wasmUrl); + const buffer = await response.arrayBuffer(); + ({ instance } = await WebAssembly.instantiate(new Uint8Array(buffer), imports)); + } else { + ({ instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), imports)); + } instance.exports.internal_func(); } catch (err) { diff --git a/dev-packages/browser-integration-tests/suites/wasm/webWorker/subject.js b/dev-packages/browser-integration-tests/suites/wasm/webWorker/subject.js index 22d1f7c57f03..d944ae1fd100 100644 --- a/dev-packages/browser-integration-tests/suites/wasm/webWorker/subject.js +++ b/dev-packages/browser-integration-tests/suites/wasm/webWorker/subject.js @@ -6,3 +6,10 @@ window.triggerWasmError = () => { wasmUrl: 'https://localhost:5887/simple.wasm', }); }; + +window.triggerWasmBufferError = () => { + window.wasmWorker.postMessage({ + type: 'load-wasm-bytes-and-crash', + wasmUrl: 'https://localhost:5887/named.wasm', + }); +}; diff --git a/dev-packages/browser-integration-tests/suites/wasm/webWorker/test.ts b/dev-packages/browser-integration-tests/suites/wasm/webWorker/test.ts index 5faec2160229..45dbae8f5329 100644 --- a/dev-packages/browser-integration-tests/suites/wasm/webWorker/test.ts +++ b/dev-packages/browser-integration-tests/suites/wasm/webWorker/test.ts @@ -9,6 +9,7 @@ declare global { interface Window { wasmWorker: Worker; triggerWasmError: () => void; + triggerWasmBufferError: () => void; } } @@ -137,3 +138,70 @@ sentryTest( ); }, ); + +// `named.wasm` is `simple.wasm` plus a module-name subsection (`namedmodule`). +// Chrome labels bytes-compiled modules with a name as `wasm://wasm/namedmodule-`, +// not with the fetch URL, so the frame has to be linked to the image by that name. +sentryTest( + 'WASM frames from a module compiled from fetched bytes in a worker should map to its debug image', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipWASMTests(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/named.wasm', route => { + const wasmModule = fs.readFileSync(path.resolve(__dirname, '../named.wasm')); + return route.fulfill({ + status: 200, + body: wasmModule, + headers: { + 'Content-Type': 'application/wasm', + }, + }); + }); + + await page.route('**/worker.js', route => { + return route.fulfill({ + path: `${__dirname}/assets/worker.js`, + }); + }); + + const errorEventPromise = waitForErrorRequest(page, e => { + return e.exception?.values?.[0]?.value === 'WASM error from worker'; + }); + + await page.goto(url); + + await page.waitForFunction(() => window.wasmWorker !== undefined); + + await page.evaluate(() => { + window.triggerWasmBufferError(); + }); + + const errorEvent = envelopeRequestParser(await errorEventPromise); + + expect(errorEvent.debug_meta?.images).toEqual([ + { + type: 'wasm', + code_file: 'https://localhost:5887/named.wasm', + code_id: '0ba020cdd2444f7eafdd25999a8e9010', + debug_file: null, + debug_id: '0ba020cdd2444f7eafdd25999a8e90100', + }, + ]); + + expect(errorEvent.exception?.values?.[0]?.stacktrace?.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filename: 'https://localhost:5887/named.wasm', + function: 'namedmodule.internal_func', + platform: 'native', + instruction_addr: '0x8c', + addr_mode: 'rel:0', + }), + ]), + ); + }, +); diff --git a/dev-packages/bun-integration-tests/expect.ts b/dev-packages/bun-integration-tests/expect.ts deleted file mode 100644 index 6f1add5ffeae..000000000000 --- a/dev-packages/bun-integration-tests/expect.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { Contexts, Envelope, Event, SdkInfo } from '@sentry/core'; -import { SDK_VERSION } from '@sentry/core'; -import { expect } from 'vitest'; - -export const UUID_MATCHER = expect.stringMatching(/^[\da-f]{32}$/); -export const UUID_V4_MATCHER = expect.stringMatching( - /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, -); -export const SHORT_UUID_MATCHER = expect.stringMatching(/^[\da-f]{16}$/); -export const ISO_DATE_MATCHER = expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - -function dropUndefinedKeys>(obj: T): T { - for (const [key, value] of Object.entries(obj)) { - if (value === undefined) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete obj[key]; - } - } - return obj; -} - -function getSdk(sdk: 'bun' | 'hono'): SdkInfo { - return { - integrations: expect.any(Array), - name: `sentry.javascript.${sdk}`, - packages: [ - { - name: `npm:@sentry/${sdk}`, - version: SDK_VERSION, - }, - ...(sdk === 'hono' ? [{ name: 'npm:@sentry/bun', version: SDK_VERSION }] : []), - ], - version: SDK_VERSION, - }; -} - -function defaultContexts(eventContexts: Contexts = {}): Contexts { - return dropUndefinedKeys({ - app: { app_memory: expect.any(Number), app_start_time: expect.any(String), free_memory: expect.any(Number) }, - cloud_resource: expect.any(Object), - trace: { - trace_id: UUID_MATCHER, - span_id: SHORT_UUID_MATCHER, - }, - culture: { locale: expect.any(String), timezone: expect.any(String) }, - device: expect.any(Object), - os: expect.any(Object), - runtime: { name: 'bun', version: expect.any(String) }, - ...eventContexts, - }); -} - -export function expectedEvent(event: Event, { sdk }: { sdk: 'bun' | 'hono' }): Event { - return dropUndefinedKeys({ - event_id: UUID_MATCHER, - timestamp: expect.any(Number), - environment: 'production', - platform: 'node', - modules: expect.any(Object), - sdk: getSdk(sdk), - server_name: expect.any(String), - // release is auto-detected from GitHub CI env vars, so only expect it if we know it will be there - ...(process.env.GITHUB_SHA ? { release: expect.any(String) } : {}), - ...event, - contexts: defaultContexts(event.contexts), - }); -} - -export function eventEnvelope( - event: Event, - { - includeSampleRand = false, - includeTransaction = true, - sdk = 'bun', - }: { includeSampleRand?: boolean; includeTransaction?: boolean; sdk?: 'bun' | 'hono' } = {}, -): Envelope { - return [ - { - event_id: UUID_MATCHER, - sent_at: ISO_DATE_MATCHER, - sdk: { name: `sentry.javascript.${sdk}`, version: SDK_VERSION }, - trace: { - environment: event.environment || 'production', - public_key: 'public', - trace_id: UUID_MATCHER, - - sample_rate: expect.any(String), - sampled: expect.any(String), - // release is auto-detected from GitHub CI env vars, so only expect it if we know it will be there - ...(process.env.GITHUB_SHA ? { release: expect.any(String) } : {}), - ...(includeSampleRand && { sample_rand: expect.stringMatching(/^[01](\.\d+)?$/) }), - ...(includeTransaction && { transaction: expect.any(String) }), - }, - }, - [[{ type: 'event' }, expectedEvent(event, { sdk })]], - ]; -} diff --git a/dev-packages/bun-integration-tests/node-suites/alias-sentry-bun.ts b/dev-packages/bun-integration-tests/node-suites/alias-sentry-bun.ts new file mode 100644 index 000000000000..a0a452b295cf --- /dev/null +++ b/dev-packages/bun-integration-tests/node-suites/alias-sentry-bun.ts @@ -0,0 +1,35 @@ +import { plugin } from 'bun'; +import Module from 'node:module'; + +// Maps `@sentry/node` to `@sentry/bun` in the Node suite files. `@sentry/bun` imports +// `@sentry/node` itself, so imports from outside the Node suite files are not touched. +const NODE_SUITE_FILE = /[\\/]node-integration-tests[\\/](suites|utils)[\\/]/; + +// Bun's runtime `onResolve` does not see bare package specifiers, so ES modules are rewritten on +// load. `onLoad` output for a CommonJS file does not run, so those files keep their source. +// Dependencies in `node_modules` are skipped: Bun treats a module returned from `onLoad` as async, +// and a CommonJS `require()` of it then fails. +const NODE_SUITE_ESM_FILE = + /[\\/]node-integration-tests[\\/](suites|utils)[\\/](?!(?:.*[\\/])?node_modules[\\/]).*\.(mjs|ts)$/; +const SENTRY_NODE_SPECIFIER = /(['"])@sentry\/node\1/g; + +plugin({ + name: 'alias-sentry-node-to-sentry-bun', + setup(build) { + build.onLoad({ filter: NODE_SUITE_ESM_FILE }, async args => { + const source = await Bun.file(args.path).text(); + return { + contents: source.replace(SENTRY_NODE_SPECIFIER, '$1@sentry/bun$1'), + loader: args.path.endsWith('.ts') ? 'ts' : 'js', + }; + }); + }, +}); + +type ResolveFilename = (request: string, parent: { filename?: string } | undefined, ...rest: unknown[]) => string; +const moduleWithResolve = Module as unknown as { _resolveFilename: ResolveFilename }; +const originalResolveFilename = moduleWithResolve._resolveFilename; +moduleWithResolve._resolveFilename = function (request, parent, ...rest) { + const aliased = request === '@sentry/node' && NODE_SUITE_FILE.test(parent?.filename ?? '') ? '@sentry/bun' : request; + return originalResolveFilename.call(this, aliased, parent, ...rest); +}; diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts new file mode 100644 index 000000000000..c06bd54b0c4c --- /dev/null +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -0,0 +1,218 @@ +// Node suites that do not run on Bun, relative to `node-integration-tests`. A single test that +// fails on Bun is skipped with `test.skipIf` on `RUNTIME` in the Node suite, not listed here. + +// Node-only features: ANR and native thread watchdogs, child processes, the AWS Lambda Node runtime, +// and `node:sqlite`, which `flue` needs. +const NODE_ONLY = [ + 'suites/anr/test.ts', + 'suites/aws-serverless/**', + 'suites/breadcrumbs/**', + 'suites/child-process/test.ts', + 'suites/thread-blocked-native/test.ts', + 'suites/tracing/flue/test.ts', +]; + +// Bun does not publish `http.server.request.start`, so `@sentry/node` creates no `http.server` +// span and does not isolate incoming requests. `@sentry/bun` has `bunHttpServerIntegration` for this, +// so the `@sentry/bun` project runs these suites, except the ones in `SENTRY_BUN_EXCLUDE`. +const NO_HTTP_SERVER_SPANS = [ + 'suites/sessions/**', + 'suites/tracing/envelope-header/sampleRate-propagation/test.ts', + 'suites/tracing/httpIntegration-streamed/test.ts', + 'suites/tracing/httpIntegration/test.ts', + 'suites/tracing/httpServerSpans-streamed-unrouted/test.ts', + 'suites/tracing/ignoreSpans-streamed/**', + 'suites/tracing/meta-tags-twp-errors/test.ts', + 'suites/tracing/meta-tags/test.ts', + 'suites/tracing/requestData-streamed/test.ts', + 'suites/tracing/sample-rand-propagation/test.ts', + 'suites/tracing/sample-rate-propagation/**', + 'suites/tracing/sampling-static/test.ts', + 'suites/tracing/sampling-streamed/test.ts', + 'suites/tracing/traceid-recycling-with-spans/test.ts', + 'suites/tracing/traceid-recycling/test.ts', +]; + +// `@sentry/node` instruments `fetch` through undici's diagnostics channels, which Bun's `fetch` +// does not publish. `@sentry/bun` has its own `fetchIntegration` for this, so the `@sentry/bun` +// project runs these suites, except the ones in `SENTRY_BUN_EXCLUDE`. +const NO_FETCH_INSTRUMENTATION = [ + 'suites/tracing/double-baggage/**', + 'suites/tracing/http-client-span-streamed/test.ts', + 'suites/tracing/http-client-spans/fetch-basic-streamed/test.ts', + 'suites/tracing/http-client-spans/fetch-basic/test.ts', + 'suites/tracing/http-client-spans/fetch-error/test.ts', + 'suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts', + 'suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts', + 'suites/tracing/http-client-spans/fetch-strip-query/test.ts', + 'suites/tracing/no-parent-span-client-report/test.ts', + 'suites/tracing/requests/fetch-breadcrumbs/test.ts', + 'suites/tracing/requests/fetch-no-trace-propagation/test.ts', + 'suites/tracing/requests/fetch-no-tracing-no-spans/test.ts', + 'suites/tracing/requests/fetch-no-tracing/test.ts', + 'suites/tracing/requests/fetch-sampled-no-active-span/test.ts', + 'suites/tracing/requests/fetch-unsampled/test.ts', + 'suites/tracing/requests/traceparent/test.ts', +]; + +// Bun 1.3.14 (the CI version) does not instrument outgoing `node:http` requests. These suites pass +// on Bun 1.4.2. See https://github.com/getsentry/sentry-javascript/issues/23881 +const NO_OUTGOING_HTTP_INSTRUMENTATION = [ + 'suites/tracing/dsc-txn-name-update/test.ts', + 'suites/tracing/http-client-spans/http-basic/test.ts', + 'suites/tracing/http-client-spans/http-strip-query/test.ts', + 'suites/tracing/requests/http-breadcrumbs/test.ts', + 'suites/tracing/requests/http-maxed-out-sockets/test.ts', + 'suites/tracing/requests/http-no-trace-propagation/test.ts', + 'suites/tracing/requests/http-no-tracing-no-spans/test.ts', + 'suites/tracing/requests/http-no-tracing/test.ts', + 'suites/tracing/requests/http-sampled-no-active-span/test.ts', + 'suites/tracing/requests/http-sampled/test.ts', + 'suites/tracing/requests/http-unsampled/test.ts', + 'suites/tracing/tracePropagationTargets/**', +]; + +// `bun run` cannot inject the diagnostics channels into libraries, so framework, database and AI +// instrumentation creates no spans. Apps must be built with `@sentry/bun/plugin`. +// See https://github.com/getsentry/sentry-javascript/issues/23882 +const NO_AUTO_INSTRUMENTATION = [ + 'suites/express/**', + 'suites/fs-instrumentation/test.ts', + 'suites/hono-sdk/test.ts', + 'suites/pino/test.ts', + 'suites/tracing/amqplib/test.ts', + 'suites/tracing/anthropic/test.ts', + 'suites/tracing/apollo-graphql/**', + 'suites/tracing/dataloader/test.ts', + 'suites/tracing/fastify/test.ts', + 'suites/tracing/genericPool-v2/test.ts', + 'suites/tracing/genericPool/test.ts', + 'suites/tracing/is-localhost/test.ts', + 'suites/tracing/google-genai-v2/test.ts', + 'suites/tracing/google-genai/test.ts', + 'suites/tracing/groq/test.ts', + 'suites/tracing/hapi/test.ts', + 'suites/tracing/ioredis-dc/test.ts', + 'suites/tracing/kafkajs/test.ts', + 'suites/tracing/knex/**', + 'suites/tracing/koa/test.ts', + 'suites/tracing/langchain/**', + 'suites/tracing/langgraph/test.ts', + 'suites/tracing/lru-memoizer/test.ts', + 'suites/tracing/mastra/test.ts', + 'suites/tracing/mcp-handler-exact-once/test.ts', + 'suites/tracing/mcp-server/**', + 'suites/tracing/mistral/test.ts', + 'suites/tracing/mongodb-v4/test.ts', + 'suites/tracing/mongodb-v5/test.ts', + 'suites/tracing/mongodb-v6/test.ts', + 'suites/tracing/mongodb-v7/test.ts', + 'suites/tracing/mongodb/test.ts', + 'suites/tracing/mongoose-tracing-channel/test.ts', + 'suites/tracing/mongoose-v5/test.ts', + 'suites/tracing/mongoose-v7/test.ts', + 'suites/tracing/mongoose-v8/test.ts', + 'suites/tracing/mongoose-v9/test.ts', + 'suites/tracing/mongoose/test.ts', + 'suites/tracing/mysql/test.ts', + 'suites/tracing/mysql2-tracing-channel/test.ts', + 'suites/tracing/mysql2/test.ts', + 'suites/tracing/openai/test.ts', + 'suites/tracing/openai/v6/test.ts', + 'suites/tracing/openai/v7/test.ts', + 'suites/tracing/orchestrion-lazy-registration/test.ts', + 'suites/tracing/postgres-streamed/test.ts', + 'suites/tracing/postgres/test.ts', + 'suites/tracing/postgresjs-streamed/test.ts', + 'suites/tracing/postgresjs/test.ts', + 'suites/tracing/prisma-orm-v5/test.ts', + 'suites/tracing/prisma-orm-v6/test.ts', + 'suites/tracing/prisma-orm-v7/test.ts', + 'suites/tracing/prisma-orm-v8/test.ts', + 'suites/tracing/redis-cache/test.ts', + 'suites/tracing/redis-dc/test.ts', + 'suites/tracing/redis/test.ts', + 'suites/tracing/tedious/test.ts', + 'suites/tracing/together-ai/test.ts', + 'suites/tracing/typesafe/test.ts', + 'suites/tracing/vercelai/**', +]; + +// Fail on Bun, cause not investigated yet. `system-error` and `tracer-start-active-span-error` +// fail on Bun 1.3.14 and pass on Bun 1.4.2. With the `@sentry/bun` alias, `system-error` also +// fails because `@sentry/bun` does not include `nodeSystemErrorIntegration`. +const NOT_TRIAGED = [ + 'suites/contextLines/filename-with-spaces/test.ts', + 'suites/modules/test.ts', + 'suites/proxy/test.ts', + 'suites/system-error/test.ts', + 'suites/tracing/tracer-start-active-span-error/test.ts', +]; + +// The scenario configures `nativeNodeFetchIntegration`, which `@sentry/bun` does not export. +const NO_NATIVE_NODE_FETCH_INTEGRATION = [ + 'suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts', + 'suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts', + 'suites/tracing/requests/fetch-no-trace-propagation/test.ts', + 'suites/tracing/requests/fetch-no-tracing-no-spans/test.ts', +]; + +// The `fetchIntegration` of `@sentry/bun` comes from `@sentry/core`. Its spans, breadcrumbs and +// `sentry-trace` headers differ from the ones `@sentry/node` creates, for example the span origin +// is `auto.http.fetch`, not `auto.http.node_fetch`, and the spans have no `url.path`, so +// `ignoreSpans` cannot match them on it. +const FETCH_INTEGRATION_DIFFERS = [ + 'suites/tracing/double-baggage/spans-parent/test.ts', + 'suites/tracing/http-client-spans/fetch-basic/test.ts', + 'suites/tracing/http-client-spans/fetch-error/test.ts', + 'suites/tracing/http-client-spans/fetch-strip-query/test.ts', + 'suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts', + 'suites/tracing/requests/fetch-breadcrumbs/test.ts', + 'suites/tracing/requests/fetch-sampled-no-active-span/test.ts', +]; + +// `@sentry/bun` creates `http.server` spans with `bunHttpServerIntegration`, not `httpIntegration`, +// so the incoming-request options that the scenario passes to `httpIntegration` (for example +// `sessionFlushingDelayMS`, `onSpanCreated`, `ignoreIncomingRequests`, `ignoreStaticAssets`) have +// no effect. Its spans also differ: the origin is `auto.http.server`, not `auto.http.http_server`, +// response headers become attributes, and there is no `http.response.status_text`. And a server +// that sets `emit` back to the `emit` it had before its first request gets no spans after that. +const HTTP_SERVER_OPTIONS_IGNORED = [ + 'suites/sessions/exited-session-aggregate/test.ts', + 'suites/tracing/httpIntegration/test.ts', +]; + +// With `@sentry/bun`, these suites fail only because they need Express spans or route names, +// which `bun run` does not create. The crashed and errored session suites also need +// `sessionFlushingDelayMS` (see above). +// See https://github.com/getsentry/sentry-javascript/issues/23882 +const NO_EXPRESS_INSTRUMENTATION = [ + 'suites/sessions/crashed-session-aggregate/test.ts', + 'suites/sessions/errored-session-aggregate/test.ts', + 'suites/tracing/httpIntegration-streamed/test.ts', + 'suites/tracing/ignoreSpans-streamed/attributes/test.ts', + 'suites/tracing/ignoreSpans-streamed/children/test.ts', + 'suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts', + 'suites/tracing/ignoreSpans-streamed/segments/test.ts', + 'suites/tracing/sampling-streamed/test.ts', + 'suites/tracing/traceid-recycling-with-spans/test.ts', +]; + +export const NODE_SUITES_EXCLUDE = [ + '**/node_modules/**', + ...NODE_ONLY, + ...NO_OUTGOING_HTTP_INSTRUMENTATION, + ...NO_AUTO_INSTRUMENTATION, + ...NOT_TRIAGED, +]; + +// Excluded only in the `node-suites` project, which runs the suites with `@sentry/node`. +export const SENTRY_NODE_EXCLUDE = [...NO_HTTP_SERVER_SPANS, ...NO_FETCH_INSTRUMENTATION]; + +// Excluded only in the `node-suites-sentry-bun` project, which maps `@sentry/node` to `@sentry/bun`. +export const SENTRY_BUN_EXCLUDE = [ + ...HTTP_SERVER_OPTIONS_IGNORED, + ...NO_EXPRESS_INSTRUMENTATION, + ...NO_NATIVE_NODE_FETCH_INTEGRATION, + ...FETCH_INTEGRATION_DIFFERS, +]; diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 1844f06177cb..6c6895e774bd 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -21,6 +21,7 @@ "pg": "8.16.0" }, "devDependencies": { + "@sentry-internal/node-integration-tests": "11.0.0", "@sentry-internal/test-utils": "11.0.0", "bun-types": "^1.2.9", "vitest": "^3.2.7" diff --git a/dev-packages/bun-integration-tests/runner.ts b/dev-packages/bun-integration-tests/runner.ts deleted file mode 100644 index 51f9e4e7fa60..000000000000 --- a/dev-packages/bun-integration-tests/runner.ts +++ /dev/null @@ -1,282 +0,0 @@ -import type { Envelope, EnvelopeItemType } from '@sentry/core'; -import { normalize } from '@sentry/core'; -import { createBasicSentryServer } from '@sentry-internal/test-utils'; -import { spawn } from 'child_process'; -import { existsSync, statSync } from 'fs'; -import { join } from 'path'; -import { inspect } from 'util'; -import { expect } from 'vitest'; - -const CLEANUP_STEPS = new Set<() => void>(); - -export function cleanupChildProcesses(): void { - for (const step of CLEANUP_STEPS) { - step(); - } - CLEANUP_STEPS.clear(); -} - -process.on('exit', cleanupChildProcesses); - -function deferredPromise( - done?: () => void, -): { resolve: (val: T) => void; reject: (reason?: unknown) => void; promise: Promise } { - let resolve; - let reject; - const promise = new Promise((res, rej) => { - resolve = (val: T) => { - res(val); - }; - reject = (reason: Error) => { - rej(reason); - }; - }); - if (!resolve || !reject) { - throw new Error('Failed to create deferred promise'); - } - return { - resolve, - reject, - promise: promise.finally(() => done?.()), - }; -} - -type Expected = Envelope | ((envelope: Envelope) => void); - -type StartResult = { - completed(): Promise; - makeRequest( - method: 'get' | 'post', - path: string, - options?: { headers?: Record; data?: BodyInit; expectError?: boolean }, - ): Promise; -}; - -/** Creates a test runner that spawns a Bun child process */ -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function createRunner(...paths: string[]) { - const testPath = join(...paths); - - let unordered = false; - - if (!existsSync(testPath)) { - throw new Error(`Test scenario not found: ${testPath}`); - } - - const entryFile = statSync(testPath).isDirectory() ? join(testPath, 'index.ts') : testPath; - - const expectedEnvelopes: Expected[] = []; - const ignored: Set = new Set(['session', 'sessions', 'client_report']); - const envVars: Record = {}; - - return { - withEnv: function (env: Record) { - Object.assign(envVars, env); - return this; - }, - expect: function (expected: Expected) { - expectedEnvelopes.push(expected); - return this; - }, - expectN: function (n: number, expected: Expected) { - for (let i = 0; i < n; i++) { - expectedEnvelopes.push(expected); - } - return this; - }, - unordered: function () { - unordered = true; - return this; - }, - ignore: function (...types: EnvelopeItemType[]) { - types.forEach(t => ignored.add(t)); - return this; - }, - unignore: function (...types: EnvelopeItemType[]) { - for (const t of types) { - ignored.delete(t); - } - return this; - }, - start: function (signal?: AbortSignal): StartResult { - const { resolve, reject, promise: isComplete } = deferredPromise(cleanupChildProcesses); - const expectedEnvelopeCount = expectedEnvelopes.length; - - let envelopeCount = 0; - const { resolve: setServerPort, promise: serverPortPromise } = deferredPromise(); - let child: ReturnType | undefined; - - function expectCallbackCalled(): void { - envelopeCount++; - if (envelopeCount === expectedEnvelopeCount) { - resolve(); - } - } - - function assertEnvelopeMatches(expected: Expected, envelope: Envelope): void { - if (typeof expected === 'function') { - expected(envelope); - } else { - expect(envelope).toEqual(expected); - } - } - - function newEnvelope(envelope: Envelope): void { - if (process.env.DEBUG) log('newEnvelope', inspect(envelope, false, null, true)); - - const envelopeItemType = envelope[1][0][0].type; - - if (ignored.has(envelopeItemType)) { - return; - } - - try { - if (unordered) { - const matchIndex = expectedEnvelopes.findIndex(candidate => { - try { - assertEnvelopeMatches(candidate, envelope); - return true; - } catch { - return false; - } - }); - - if (matchIndex < 0) { - return; - } - - expectedEnvelopes.splice(matchIndex, 1); - } else { - const expected = expectedEnvelopes.shift(); - - if (!expected) { - return; - } - - assertEnvelopeMatches(expected, envelope); - } - - expectCallbackCalled(); - } catch (e) { - reject(e); - } - } - - createBasicSentryServer(newEnvelope) - .then(([mockServerPort, mockServerClose]) => { - if (mockServerClose) { - CLEANUP_STEPS.add(() => { - mockServerClose(); - }); - } - - if (process.env.DEBUG) log('Starting scenario', testPath); - - if (!existsSync(entryFile)) { - reject(new Error(`Entry file not found: ${entryFile}`)); - return; - } - - const stdio: ('inherit' | 'ipc' | 'ignore' | 'pipe')[] = process.env.DEBUG - ? ['inherit', 'inherit', 'inherit', 'ipc'] - : ['ignore', 'pipe', 'pipe', 'ipc']; - - child = spawn('bun', ['run', entryFile], { - stdio, - signal, - env: { - ...process.env, - SENTRY_DSN: `http://public@localhost:${mockServerPort}/1337`, - ...envVars, - }, - }); - - CLEANUP_STEPS.add(() => { - child?.kill(); - }); - - child.on('error', e => { - // eslint-disable-next-line no-console - console.error('Error starting Bun child process:', e); - reject(e); - }); - - if (!process.env.DEBUG && child.stderr) { - let stderrData = ''; - child.stderr.on('data', (chunk: Buffer) => { - stderrData += chunk.toString(); - }); - child.on('exit', code => { - if (code !== 0 && code !== null && stderrData) { - // eslint-disable-next-line no-console - console.error('Bun process stderr:', stderrData); - } - }); - } - - child.on('message', (message: string) => { - const msg = JSON.parse(message) as { event: string; port?: number }; - if (msg.event === 'READY' && typeof msg.port === 'number') { - if (process.env.DEBUG) log('Bun server ready on port', msg.port); - setServerPort(msg.port); - } - }); - }) - .catch(e => reject(e)); - - return { - completed: async function (): Promise { - return isComplete; - }, - makeRequest: async function ( - method: 'get' | 'post', - path: string, - options: { headers?: Record; data?: BodyInit; expectError?: boolean } = {}, - ): Promise { - const url = `http://localhost:${await serverPortPromise}${path}`; - const body = options.data; - const headers = options.headers || {}; - const expectError = options.expectError || false; - - if (process.env.DEBUG) log('making request', method, url, headers, body); - - try { - const init: RequestInit = { headers, method, body }; - const res = await fetch(url, init); - - if (!res.ok) { - if (!expectError) { - reject(new Error(`Expected request to "${path}" to succeed, but got a ${res.status} response`)); - } - - return; - } - - if (expectError) { - reject(new Error(`Expected request to "${path}" to fail, but got a ${res.status} response`)); - return; - } - - if (res.headers.get('content-type')?.includes('application/json')) { - return await res.json(); - } - - return (await res.text()) as T; - } catch (e) { - if (expectError) { - return; - } - - reject(e); - return; - } - }, - }; - }, - }; -} - -function log(...args: unknown[]): void { - // eslint-disable-next-line no-console - console.log(...args.map(arg => normalize(arg))); -} diff --git a/dev-packages/bun-integration-tests/suites/basic/index.ts b/dev-packages/bun-integration-tests/suites/basic/index.ts index c760e61d6ba5..4d0e7a6cb9b3 100644 --- a/dev-packages/bun-integration-tests/suites/basic/index.ts +++ b/dev-packages/bun-integration-tests/suites/basic/index.ts @@ -1,3 +1,4 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/bun'; Sentry.init({ @@ -14,11 +15,6 @@ const server = Bun.serve({ throw new Error('This is a test error from the Bun integration tests'); } - if (url.pathname === '/message') { - Sentry.captureMessage('Hello from Bun'); - return new Response('OK'); - } - return new Response('Hello from Bun!'); }, error(_err) { @@ -26,4 +22,4 @@ const server = Bun.serve({ }, }); -process.send?.(JSON.stringify({ event: 'READY', port: server.port })); +sendPortToRunner(server.port!); diff --git a/dev-packages/bun-integration-tests/suites/basic/test.ts b/dev-packages/bun-integration-tests/suites/basic/test.ts index 9a9da240e373..68a30794ce70 100644 --- a/dev-packages/bun-integration-tests/suites/basic/test.ts +++ b/dev-packages/bun-integration-tests/suites/basic/test.ts @@ -1,54 +1,69 @@ -import { expect, it } from 'vitest'; -import { eventEnvelope } from '../../expect'; -import { createRunner } from '../../runner'; +import { SDK_VERSION } from '@sentry/core'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../node-integration-tests/utils/runner'; -it('captures an error thrown in Bun.serve fetch handler', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect( - eventEnvelope( - { - level: 'error', - exception: { - values: [ - { - type: 'Error', - value: 'This is a test error from the Bun integration tests', - stacktrace: { - frames: expect.any(Array), - }, - mechanism: { type: 'auto.http.bun.serve', handled: false }, +afterAll(() => { + cleanupChildProcesses(); +}); + +test('captures an error thrown in Bun.serve fetch handler', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() + .ignore('span') + .expect({ + event: { + level: 'error', + platform: 'node', + exception: { + values: [ + { + type: 'Error', + value: 'This is a test error from the Bun integration tests', + stacktrace: { + frames: expect.any(Array), }, - ], - }, - request: expect.objectContaining({ - method: 'GET', - url: expect.stringContaining('/error'), - }), + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }, + ], }, - { includeSampleRand: true, includeTransaction: false }, - ), - ) - .ignore('span') - .start(signal); + request: expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/error'), + }), + sdk: expect.objectContaining({ + name: 'sentry.javascript.bun', + packages: [{ name: 'npm:@sentry/bun', version: SDK_VERSION }], + }), + contexts: expect.objectContaining({ + runtime: { name: 'bun', version: expect.any(String) }, + }), + }, + }) + .start(); + await runner.makeRequest('get', '/error', { expectError: true }); await runner.completed(); }); -it('captures a manually sent message', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const [, envelopeItems] = envelope; - const [itemHeader, itemPayload] = envelopeItems[0]; - - expect(itemHeader.type).toBe('event'); - - expect(itemPayload).toMatchObject({ - level: 'info', - message: 'Hello from Bun', - }); - }) +test('sends the error with a sampled trace envelope header', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() .ignore('span') - .start(signal); - await runner.makeRequest('get', '/message'); + .expectHeader({ + event: { + sdk: { name: 'sentry.javascript.bun', version: SDK_VERSION }, + trace: expect.objectContaining({ + environment: 'production', + public_key: 'public', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + sample_rate: '1', + sampled: 'true', + sample_rand: expect.stringMatching(/^[01](\.\d+)?$/), + }), + }, + }) + .start(); + + await runner.makeRequest('get', '/error', { expectError: true }); await runner.completed(); }); diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts b/dev-packages/bun-integration-tests/suites/bun-runtime-metrics/scenario-all.ts similarity index 81% rename from dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts rename to dev-packages/bun-integration-tests/suites/bun-runtime-metrics/scenario-all.ts index b7883f63e146..6fcc7d11fac5 100644 --- a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts +++ b/dev-packages/bun-integration-tests/suites/bun-runtime-metrics/scenario-all.ts @@ -1,5 +1,4 @@ -import * as Sentry from '@sentry/node'; -import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +import * as Sentry from '@sentry/bun'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ @@ -9,7 +8,7 @@ Sentry.init({ environment: 'test', transport: loggingTransport, integrations: [ - bunRuntimeMetricsIntegration({ + Sentry.bunRuntimeMetricsIntegration({ collectionIntervalMs: 1000, collect: { cpuTime: true, diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts b/dev-packages/bun-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts similarity index 83% rename from dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts rename to dev-packages/bun-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts index 8e862bfd5db3..47b9e22fafcd 100644 --- a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts +++ b/dev-packages/bun-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts @@ -1,5 +1,4 @@ -import * as Sentry from '@sentry/node'; -import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +import * as Sentry from '@sentry/bun'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ @@ -9,7 +8,7 @@ Sentry.init({ environment: 'test', transport: loggingTransport, integrations: [ - bunRuntimeMetricsIntegration({ + Sentry.bunRuntimeMetricsIntegration({ collectionIntervalMs: 1000, collect: { cpuUtilization: false, diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts b/dev-packages/bun-integration-tests/suites/bun-runtime-metrics/scenario.ts similarity index 79% rename from dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts rename to dev-packages/bun-integration-tests/suites/bun-runtime-metrics/scenario.ts index d8dd283bde49..7f4ee4d4dcf1 100644 --- a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts +++ b/dev-packages/bun-integration-tests/suites/bun-runtime-metrics/scenario.ts @@ -1,5 +1,4 @@ -import * as Sentry from '@sentry/node'; -import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +import * as Sentry from '@sentry/bun'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ @@ -9,7 +8,7 @@ Sentry.init({ environment: 'test', transport: loggingTransport, integrations: [ - bunRuntimeMetricsIntegration({ + Sentry.bunRuntimeMetricsIntegration({ collectionIntervalMs: 1000, }), ], diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts b/dev-packages/bun-integration-tests/suites/bun-runtime-metrics/test.ts similarity index 95% rename from dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts rename to dev-packages/bun-integration-tests/suites/bun-runtime-metrics/test.ts index 78638b8b02cb..8ff4c916244c 100644 --- a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts +++ b/dev-packages/bun-integration-tests/suites/bun-runtime-metrics/test.ts @@ -1,10 +1,10 @@ import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../utils/runner'; +import { cleanupChildProcesses, createRunner } from '../../../node-integration-tests/utils/runner'; const SENTRY_ATTRIBUTES = { 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.bun', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.origin': { value: 'auto.bun.runtime_metrics', type: 'string' }, }; diff --git a/dev-packages/bun-integration-tests/suites/cjs/index.cjs b/dev-packages/bun-integration-tests/suites/cjs/index.cjs index d21203678e20..6efe24b8b7ad 100644 --- a/dev-packages/bun-integration-tests/suites/cjs/index.cjs +++ b/dev-packages/bun-integration-tests/suites/cjs/index.cjs @@ -1,4 +1,5 @@ const Sentry = require('@sentry/bun'); +const { sendPortToRunner } = require('@sentry-internal/node-integration-tests'); Sentry.init({ dsn: process.env.SENTRY_DSN, @@ -15,4 +16,4 @@ const server = Bun.serve({ }, }); -process.send?.(JSON.stringify({ event: 'READY', port: server.port })); +sendPortToRunner(server.port); diff --git a/dev-packages/bun-integration-tests/suites/cjs/test.ts b/dev-packages/bun-integration-tests/suites/cjs/test.ts index e70f0ddb8971..1523c4fd104c 100644 --- a/dev-packages/bun-integration-tests/suites/cjs/test.ts +++ b/dev-packages/bun-integration-tests/suites/cjs/test.ts @@ -1,35 +1,38 @@ -import { expect, it } from 'vitest'; -import { eventEnvelope } from '../../expect'; -import { createRunner } from '../../runner'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../node-integration-tests/utils/runner'; -it('initializes when @sentry/bun is loaded with require()', async ({ signal }) => { +afterAll(() => { + cleanupChildProcesses(); +}); + +test('initializes when @sentry/bun is loaded with require()', async () => { const runner = createRunner(__dirname, 'index.cjs') - .expect( - eventEnvelope( - { - level: 'error', - exception: { - values: [ - { - type: 'Error', - value: 'This is a test error from a CommonJS Bun app', - stacktrace: { - frames: expect.any(Array), - }, - mechanism: { type: 'auto.http.bun.serve', handled: false }, + .withMockSentryServer() + .ignore('span') + .expect({ + event: { + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'This is a test error from a CommonJS Bun app', + stacktrace: { + frames: expect.any(Array), }, - ], - }, - request: expect.objectContaining({ - method: 'GET', - url: expect.stringContaining('/error'), - }), + mechanism: { type: 'auto.http.bun.serve', handled: false }, + }, + ], }, - { includeSampleRand: true, includeTransaction: false }, - ), - ) - .ignore('span') - .start(signal); + request: expect.objectContaining({ + method: 'GET', + url: expect.stringContaining('/error'), + }), + sdk: expect.objectContaining({ name: 'sentry.javascript.bun' }), + }, + }) + .start(); + await runner.makeRequest('get', '/error', { expectError: true }); await runner.completed(); }); diff --git a/dev-packages/bun-integration-tests/suites/fetch/index.ts b/dev-packages/bun-integration-tests/suites/fetch/index.ts index f378ee6c786d..ede289da0818 100644 --- a/dev-packages/bun-integration-tests/suites/fetch/index.ts +++ b/dev-packages/bun-integration-tests/suites/fetch/index.ts @@ -1,3 +1,4 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/bun'; // The target server the instrumented app makes outgoing fetch requests to. It @@ -48,4 +49,4 @@ const server = Bun.serve({ }, }); -process.send?.(JSON.stringify({ event: 'READY', port: server.port })); +sendPortToRunner(server.port!); diff --git a/dev-packages/bun-integration-tests/suites/fetch/test.ts b/dev-packages/bun-integration-tests/suites/fetch/test.ts index e503f78519a5..53bc4ac9e841 100644 --- a/dev-packages/bun-integration-tests/suites/fetch/test.ts +++ b/dev-packages/bun-integration-tests/suites/fetch/test.ts @@ -1,49 +1,48 @@ -import type { Envelope, Event, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; -import { expect, it } from 'vitest'; -import { createRunner } from '../../runner'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../node-integration-tests/utils/runner'; -function getSpans(envelope: Envelope): SerializedStreamedSpan[] { - return (envelope[1][0][1] as SerializedStreamedSpanContainer).items; -} - -it('creates an http.client span for outgoing fetch requests', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const spans = getSpans(envelope); - - const segmentSpan = spans.find(span => span.is_segment); - expect(segmentSpan).toMatchObject({ - // `Bun.serve` without `routes` has no parameterized route, so the streamed segment is - // named after the method only; the path lives in `url.path`. - name: 'GET', - attributes: expect.objectContaining({ - 'sentry.op': { value: 'http.server', type: 'string' }, - 'url.path': { value: '/outgoing-fetch', type: 'string' }, - }), - }); - - const httpClientSpan = spans.find(span => span.attributes['sentry.op']?.value === 'http.client'); +afterAll(() => { + cleanupChildProcesses(); +}); - expect(httpClientSpan).toBeDefined(); - expect(httpClientSpan).toMatchObject({ - name: 'GET localhost', - parent_span_id: segmentSpan!.span_id, - attributes: expect.objectContaining({ - 'sentry.op': { value: 'http.client', type: 'string' }, - 'sentry.origin': { value: 'auto.http.fetch', type: 'string' }, - 'http.request.method': { value: 'GET', type: 'string' }, - type: { value: 'fetch', type: 'string' }, - }), - }); +test('creates an http.client span for outgoing fetch requests', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() + .expect({ + span: container => { + const segmentSpan = container.items.find(span => span.is_segment); + expect(segmentSpan).toMatchObject({ + // `Bun.serve` without `routes` has no parameterized route, so the streamed segment is + // named after the method only; the path lives in `url.path`. + name: 'GET', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'url.path': { value: '/outgoing-fetch', type: 'string' }, + }), + }); + + const httpClientSpan = container.items.find(span => span.attributes['sentry.op']?.value === 'http.client'); + + expect(httpClientSpan).toMatchObject({ + name: 'GET localhost', + parent_span_id: segmentSpan!.span_id, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.client', type: 'string' }, + 'sentry.origin': { value: 'auto.http.fetch', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + type: { value: 'fetch', type: 'string' }, + }), + }); + }, }) - .start(signal); + .start(); await runner.makeRequest('get', '/outgoing-fetch'); await runner.completed(); }); -it('propagates sentry-trace and baggage headers to allowed outgoing fetch requests', async ({ signal }) => { - const runner = createRunner(__dirname).start(signal); +test('propagates sentry-trace and baggage headers to allowed outgoing fetch requests', async () => { + const runner = createRunner(__dirname, 'index.ts').withMockSentryServer().start(); const response = await runner.makeRequest<{ headers: Record }>('get', '/outgoing-fetch'); @@ -54,40 +53,40 @@ it('propagates sentry-trace and baggage headers to allowed outgoing fetch reques expect(response?.headers.baggage).toContain(`sentry-trace_id=${traceId}`); }); -it('does not propagate headers to outgoing fetch requests outside tracePropagationTargets', async ({ signal }) => { - const runner = createRunner(__dirname).start(signal); +test('does not propagate headers to outgoing fetch requests outside tracePropagationTargets', async () => { + const runner = createRunner(__dirname, 'index.ts').withMockSentryServer().start(); const response = await runner.makeRequest<{ headers: Record }>('get', '/outgoing-fetch-disallowed'); + expect(response?.headers).toBeDefined(); expect(response?.headers['sentry-trace']).toBeUndefined(); expect(response?.headers.baggage).toBeUndefined(); }); -it('records a breadcrumb for outgoing fetch requests', async ({ signal }) => { +test('records a breadcrumb for outgoing fetch requests', async () => { // Streamed spans carry no breadcrumbs, so the breadcrumb is asserted on an error // captured right after the fetch instead. - const runner = createRunner(__dirname) - .expect(envelope => { - const [, envelopeItems] = envelope; - const [itemHeader, event] = envelopeItems[0] as [{ type: string }, Event]; - - expect(itemHeader.type).toBe('event'); - expect(event.exception?.values?.[0]?.value).toBe('fetch done'); - - expect(event.breadcrumbs).toContainEqual( - expect.objectContaining({ - category: 'fetch', - type: 'http', - data: expect.objectContaining({ - method: 'GET', - status_code: 200, - url: expect.stringMatching(/\/allowed$/), + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() + .ignore('span') + .expect({ + event: event => { + expect(event.exception?.values?.[0]?.value).toBe('fetch done'); + + expect(event.breadcrumbs).toContainEqual( + expect.objectContaining({ + category: 'fetch', + type: 'http', + data: expect.objectContaining({ + method: 'GET', + status_code: 200, + url: expect.stringMatching(/\/allowed$/), + }), }), - }), - ); + ); + }, }) - .ignore('span') - .start(signal); + .start(); await runner.makeRequest('get', '/outgoing-fetch-error'); await runner.completed(); diff --git a/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts b/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts index 075fc896618b..d45505e479cf 100644 --- a/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts +++ b/dev-packages/bun-integration-tests/suites/hono-sdk/index.ts @@ -1,5 +1,6 @@ import { sentry } from '@sentry/hono/bun'; import { Hono } from 'hono'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; const app = new Hono(); @@ -28,4 +29,4 @@ const server = Bun.serve({ fetch: app.fetch, }); -process.send?.(JSON.stringify({ event: 'READY', port: server.port })); +sendPortToRunner(server.port!); diff --git a/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts b/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts index 785cffb85589..7b9dbb0a1d6a 100644 --- a/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/bun-integration-tests/suites/hono-sdk/test.ts @@ -1,110 +1,139 @@ -import type { SerializedStreamedSpanContainer } from '@sentry/core'; -import { expect, it } from 'vitest'; -import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect'; -import { createRunner } from '../../runner'; +import { SDK_VERSION } from '@sentry/core'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../node-integration-tests/utils/runner'; -it('Hono app captures parametrized errors (Hono SDK on Bun)', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const [, envelopeItems] = envelope; - const [itemHeader, itemPayload] = envelopeItems[0]; - - expect(itemHeader.type).toBe('span'); +afterAll(() => { + cleanupChildProcesses(); +}); - const segmentSpan = (itemPayload as SerializedStreamedSpanContainer).items.find(span => span.is_segment); +test('Hono app captures parametrized errors (Hono SDK on Bun)', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() + .unordered() + .expect({ + span: container => { + const segmentSpan = container.items.find(span => span.is_segment); - expect(segmentSpan).toMatchObject({ - name: 'GET /error/:param', - is_segment: true, - span_id: expect.any(String), - trace_id: expect.any(String), - status: 'error', - attributes: expect.objectContaining({ - 'sentry.op': { value: 'http.server', type: 'string' }, - 'sentry.origin': { value: 'auto.http.bun.serve', type: 'string' }, - 'sentry.segment.name.source': { value: 'route', type: 'string' }, - 'http.route': { value: '/error/:param', type: 'string' }, - 'http.request.method': { value: 'GET', type: 'string' }, - 'http.response.status_code': { value: 500, type: 'integer' }, - 'url.path': { value: '/error/param-123', type: 'string' }, - }), - }); + expect(segmentSpan).toMatchObject({ + name: 'GET /error/:param', + is_segment: true, + span_id: expect.any(String), + trace_id: expect.any(String), + status: 'error', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.bun.serve', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.route': { value: '/error/:param', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'http.response.status_code': { value: 500, type: 'integer' }, + 'url.path': { value: '/error/param-123', type: 'string' }, + }), + }); + }, }) - - .expect( - eventEnvelope( - { - level: 'error', - transaction: 'GET /error/:param', - exception: { - values: [ - { - type: 'Error', - value: 'Test error from Hono app', - stacktrace: { - frames: expect.any(Array), - }, - mechanism: { type: 'auto.http.hono.context_error', handled: false }, - }, - ], - }, - request: { - cookies: {}, - headers: expect.any(Object), - method: 'GET', - url: expect.stringContaining('/error/param-123'), - }, - user: { ip_address: expect.any(String) }, - breadcrumbs: [ + .expect({ + event: { + level: 'error', + transaction: 'GET /error/:param', + exception: { + values: [ { - timestamp: expect.any(Number), - category: 'console', - level: 'error', - message: 'Error: Test error from Hono app', - data: expect.objectContaining({ - logger: 'console', - arguments: [{ message: 'Test error from Hono app', name: 'Error', stack: expect.any(String) }], - }), + type: 'Error', + value: 'Test error from Hono app', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.hono.context_error', handled: false }, }, ], }, - { sdk: 'hono', includeSampleRand: true, includeTransaction: true }, - ), - ) - .unordered() - .start(signal); + request: { + cookies: {}, + headers: expect.any(Object), + method: 'GET', + url: expect.stringContaining('/error/param-123'), + }, + user: { ip_address: expect.any(String) }, + // The runner reads the server port from a `console.log`, which adds a console breadcrumb too. + breadcrumbs: expect.arrayContaining([ + { + timestamp: expect.any(Number), + category: 'console', + level: 'error', + message: 'Error: Test error from Hono app', + data: expect.objectContaining({ + logger: 'console', + arguments: [{ message: 'Test error from Hono app', name: 'Error', stack: expect.any(String) }], + }), + }, + ]), + sdk: expect.objectContaining({ + name: 'sentry.javascript.hono', + packages: [ + { name: 'npm:@sentry/hono', version: SDK_VERSION }, + { name: 'npm:@sentry/bun', version: SDK_VERSION }, + ], + }), + contexts: expect.objectContaining({ + runtime: { name: 'bun', version: expect.any(String) }, + }), + }, + }) + .start(); await runner.makeRequest('get', '/error/param-123', { expectError: true }); await runner.completed(); }); -it('Hono app captures parametrized route names on Bun', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const [, envelopeItems] = envelope; - const [itemHeader, itemPayload] = envelopeItems[0]; +test('sends the Hono error with the transaction name in the trace envelope header', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() + .ignore('span') + .expectHeader({ + event: { + sdk: { name: 'sentry.javascript.hono', version: SDK_VERSION }, + trace: expect.objectContaining({ + environment: 'production', + public_key: 'public', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + sampled: 'true', + sample_rand: expect.stringMatching(/^[01](\.\d+)?$/), + transaction: 'GET /error/:param', + }), + }, + }) + .start(); - expect(itemHeader.type).toBe('span'); + await runner.makeRequest('get', '/error/param-123', { expectError: true }); + await runner.completed(); +}); - const segmentSpan = (itemPayload as SerializedStreamedSpanContainer).items.find(span => span.is_segment); +test('Hono app captures parametrized route names on Bun', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() + .expect({ + span: container => { + const segmentSpan = container.items.find(span => span.is_segment); - expect(segmentSpan).toMatchObject({ - name: 'GET /hello/:name', - is_segment: true, - span_id: SHORT_UUID_MATCHER, - trace_id: UUID_MATCHER, - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { value: 'http.server', type: 'string' }, - 'sentry.origin': { value: 'auto.http.bun.serve', type: 'string' }, - 'sentry.segment.name.source': { value: 'route', type: 'string' }, - 'http.route': { value: '/hello/:name', type: 'string' }, - 'http.request.method': { value: 'GET', type: 'string' }, - 'url.path': { value: '/hello/world', type: 'string' }, - }), - }); + expect(segmentSpan).toMatchObject({ + name: 'GET /hello/:name', + is_segment: true, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.bun.serve', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.route': { value: '/hello/:name', type: 'string' }, + 'http.request.method': { value: 'GET', type: 'string' }, + 'url.path': { value: '/hello/world', type: 'string' }, + }), + }); + }, }) - .start(signal); + .start(); await runner.makeRequest('get', '/hello/world'); await runner.completed(); diff --git a/dev-packages/bun-integration-tests/suites/is-localhost/index.ts b/dev-packages/bun-integration-tests/suites/is-localhost/index.ts index 0f02bee605ee..5c323f972ffb 100644 --- a/dev-packages/bun-integration-tests/suites/is-localhost/index.ts +++ b/dev-packages/bun-integration-tests/suites/is-localhost/index.ts @@ -1,3 +1,4 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/bun'; Sentry.init({ @@ -15,4 +16,4 @@ const server = Bun.serve({ }, }); -process.send?.(JSON.stringify({ event: 'READY', port: server.port })); +sendPortToRunner(server.port!); diff --git a/dev-packages/bun-integration-tests/suites/is-localhost/test.ts b/dev-packages/bun-integration-tests/suites/is-localhost/test.ts index 8250220b6891..2dfa918630ea 100644 --- a/dev-packages/bun-integration-tests/suites/is-localhost/test.ts +++ b/dev-packages/bun-integration-tests/suites/is-localhost/test.ts @@ -1,26 +1,26 @@ -import type { Envelope, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; -import { expect, it } from 'vitest'; -import { createRunner } from '../../runner'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../node-integration-tests/utils/runner'; -function getSpans(envelope: Envelope): SerializedStreamedSpan[] { - return (envelope[1][0][1] as SerializedStreamedSpanContainer).items; -} +afterAll(() => { + cleanupChildProcesses(); +}); // The runner always requests `http://localhost:`, so only the `true` case is reachable here. // The `false` case is covered by the unit tests for `isLocalhostRequest`. -it('sets sentry.is_localhost on every streamed span', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const spans = getSpans(envelope); - - expect(spans.some(span => span.is_segment)).toBe(true); - expect(spans.some(span => span.name === 'child-span')).toBe(true); +test('sets sentry.is_localhost on every streamed span', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() + .expect({ + span: container => { + expect(container.items.some(span => span.is_segment)).toBe(true); + expect(container.items.some(span => span.name === 'child-span')).toBe(true); - for (const span of spans) { - expect(span.attributes['sentry.is_localhost']).toEqual({ value: true, type: 'boolean' }); - } + for (const span of container.items) { + expect(span.attributes['sentry.is_localhost']).toEqual({ value: true, type: 'boolean' }); + } + }, }) - .start(signal); + .start(); await runner.makeRequest('get', '/'); await runner.completed(); diff --git a/dev-packages/bun-integration-tests/suites/request-bodies/index.ts b/dev-packages/bun-integration-tests/suites/request-bodies/index.ts index d3bf4d5417a5..dbd9ecfd5dd2 100644 --- a/dev-packages/bun-integration-tests/suites/request-bodies/index.ts +++ b/dev-packages/bun-integration-tests/suites/request-bodies/index.ts @@ -1,3 +1,4 @@ +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/bun'; // One scenario per process; the test picks the SDK setup through this variable. @@ -32,4 +33,4 @@ const server = Bun.serve({ }, }); -process.send?.(JSON.stringify({ event: 'READY', port: server.port })); +sendPortToRunner(server.port!); diff --git a/dev-packages/bun-integration-tests/suites/request-bodies/test.ts b/dev-packages/bun-integration-tests/suites/request-bodies/test.ts index da08cbc248c7..0134022d2b29 100644 --- a/dev-packages/bun-integration-tests/suites/request-bodies/test.ts +++ b/dev-packages/bun-integration-tests/suites/request-bodies/test.ts @@ -1,26 +1,25 @@ -import type { Envelope, TransactionEvent } from '@sentry/core'; -import { expect, it } from 'vitest'; -import { createRunner } from '../../runner'; +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../node-integration-tests/utils/runner'; -function getTransaction(envelope: Envelope): TransactionEvent { - const [itemHeader, itemPayload] = envelope[1][0]; - expect(itemHeader.type).toBe('transaction'); - return itemPayload as TransactionEvent; -} +afterAll(() => { + cleanupChildProcesses(); +}); -it('captures incoming request bodies by default', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const transaction = getTransaction(envelope); - expect(transaction.request).toMatchObject({ - method: 'POST', - url: expect.stringContaining('/default'), - query_string: 'source=test', - headers: expect.objectContaining({ 'content-type': 'text/plain' }), - data: 'captured-by-default', - }); +test('captures incoming request bodies by default', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() + .expect({ + transaction: transaction => { + expect(transaction.request).toMatchObject({ + method: 'POST', + url: expect.stringContaining('/default'), + query_string: 'source=test', + headers: expect.objectContaining({ 'content-type': 'text/plain' }), + data: 'captured-by-default', + }); + }, }) - .start(signal); + .start(); const response = await runner.makeRequest('post', '/default?source=test', { headers: { 'content-type': 'text/plain' }, @@ -30,14 +29,16 @@ it('captures incoming request bodies by default', async ({ signal }) => { await runner.completed(); }); -it('an explicit small size overrides disabled body collection', async ({ signal }) => { - const runner = createRunner(__dirname) +test('an explicit small size overrides disabled body collection', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() .withEnv({ BODY_MODE: 'explicit-small' }) - .expect(envelope => { - const transaction = getTransaction(envelope); - expect(transaction.request?.data).toBe(`${'a'.repeat(997)}...`); + .expect({ + transaction: transaction => { + expect(transaction.request?.data).toBe(`${'a'.repeat(997)}...`); + }, }) - .start(signal); + .start(); const body = 'a'.repeat(1_001); const response = await runner.makeRequest('post', '/explicit-small', { @@ -48,15 +49,17 @@ it('an explicit small size overrides disabled body collection', async ({ signal await runner.completed(); }); -it('an explicit none overrides enabled body collection', async ({ signal }) => { - const runner = createRunner(__dirname) +test('an explicit none overrides enabled body collection', async () => { + const runner = createRunner(__dirname, 'index.ts') + .withMockSentryServer() .withEnv({ BODY_MODE: 'explicit-none' }) - .expect(envelope => { - const transaction = getTransaction(envelope); - expect(transaction.request?.method).toBe('POST'); - expect(transaction.request?.data).toBeUndefined(); + .expect({ + transaction: transaction => { + expect(transaction.request?.method).toBe('POST'); + expect(transaction.request?.data).toBeUndefined(); + }, }) - .start(signal); + .start(); const response = await runner.makeRequest('post', '/explicit-none', { headers: { 'content-type': 'text/plain' }, diff --git a/dev-packages/bun-integration-tests/tsconfig.json b/dev-packages/bun-integration-tests/tsconfig.json index 8fe804d8c50d..ba0be38e72df 100644 --- a/dev-packages/bun-integration-tests/tsconfig.json +++ b/dev-packages/bun-integration-tests/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["suites/**/*.ts", "*.ts"], + "include": ["suites/**/*.ts", "node-suites/**/*.ts", "*.ts"], "compilerOptions": { "lib": ["ES2020"], diff --git a/dev-packages/bun-integration-tests/vite.config.mts b/dev-packages/bun-integration-tests/vite.config.mts index 23ea00e8c30a..e2ed86d0affb 100644 --- a/dev-packages/bun-integration-tests/vite.config.mts +++ b/dev-packages/bun-integration-tests/vite.config.mts @@ -1,5 +1,20 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; import baseConfig from '../../vite/vite.config'; +import { NODE_SUITES_EXCLUDE, SENTRY_BUN_EXCLUDE, SENTRY_NODE_EXCLUDE } from './node-suites/excludes'; + +const NODE_SUITES_ROOT = fileURLToPath(new URL('../node-integration-tests', import.meta.url)); + +// All Node suites also run on Bun. The scenarios stay in `node-integration-tests`. +const NODE_SUITES = ['suites/**/test.ts']; + +const nodeSuitesTest = { + root: NODE_SUITES_ROOT, + include: NODE_SUITES, + exclude: NODE_SUITES_EXCLUDE, + // Above the 30 second port timeout of the runner on Bun, so a slow start can still pass. + testTimeout: 45_000, +}; export default defineConfig({ ...baseConfig, @@ -9,7 +24,6 @@ export default defineConfig({ enabled: false, }, isolate: false, - include: ['./suites/**/test.ts'], testTimeout: 20_000, ...(process.env.DEBUG ? { @@ -18,15 +32,56 @@ export default defineConfig({ } : {}), pool: 'threads', - poolOptions: { - threads: { - singleThread: true, - }, - }, reporters: process.env.DEBUG ? ['default', { summary: false }] : process.env.GITHUB_ACTIONS ? ['dot', 'github-actions'] : ['verbose'], + projects: [ + { + extends: true, + test: { + name: 'bun', + include: ['./suites/**/test.ts'], + env: { RUNTIME: 'bun' }, + // Above the 30 second port timeout of the runner on Bun, so a slow start can still pass. + testTimeout: 45_000, + poolOptions: { + threads: { + singleThread: true, + }, + }, + }, + }, + { + extends: true, + test: { + ...nodeSuitesTest, + name: 'node-suites', + exclude: [...NODE_SUITES_EXCLUDE, ...SENTRY_NODE_EXCLUDE], + env: { RUNTIME: 'bun' }, + }, + }, + { + extends: true, + test: { + ...nodeSuitesTest, + name: 'node-suites-sentry-bun', + exclude: [ + ...NODE_SUITES_EXCLUDE, + ...SENTRY_BUN_EXCLUDE, + // The scenario creates a `NodeClient` itself, which sends `sentry.javascript.node`. + 'suites/public-api/logs/test.ts', + // `@sentry/bun` has `bunRuntimeMetricsIntegration` instead of `nodeRuntimeMetricsIntegration`. + 'suites/node-runtime-metrics/test.ts', + ], + env: { + RUNTIME: 'bun', + RUNTIME_PRELOAD: fileURLToPath(new URL('./node-suites/alias-sentry-bun.ts', import.meta.url)), + EXPECTED_SDK_NAME: 'sentry.javascript.bun', + }, + }, + }, + ], }, }); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation-next.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation-next.test.ts index ad52e996a9f5..41b172e14dd2 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation-next.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/component-annotation-next.test.ts @@ -34,13 +34,13 @@ test(import.meta.url, ({ runBundler, readOutputFiles }) => { "data-sentry-component": "App", children: [/* @__PURE__ */ jsxDEV(ComponentA, {}, void 0, false, { fileName: _jsxFileName, - lineNumber: 4, + lineNumber: 6, columnNumber: 7 }, this), ";"] }, void 0, true, { fileName: _jsxFileName, - lineNumber: 3, - columnNumber: 10 + lineNumber: 5, + columnNumber: 5 }, this); } console.log(App()); diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 7a2ff9c21551..7dd5ab3cf5ac 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -33,7 +33,7 @@ "@cloudflare/workers-types": "^4.20260426.0", "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1", "@sentry-internal/test-utils": "11.0.0", - "@sentry/conventions": "0.23.0", + "@sentry/conventions": "0.24.0", "eslint-plugin-regexp": "^3.1.0", "prisma": "6.15.0", "vite": "7.3.5", diff --git a/dev-packages/cloudflare-integration-tests/suites/binding-connect/index.ts b/dev-packages/cloudflare-integration-tests/suites/binding-connect/index.ts new file mode 100644 index 000000000000..cf28459187c5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/binding-connect/index.ts @@ -0,0 +1,44 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + SELF: Fetcher; + CONNECT_DO: DurableObjectNamespace; +} + +export class ConnectDurableObject extends DurableObject {} + +function tryConnect(connect: () => Socket): string { + try { + const socket = connect(); + socket.opened.catch(() => {}); + socket.closed.catch(() => {}); + return 'ok'; + } catch (error) { + return (error as Error).message; + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/connect') { + const stub = env.CONNECT_DO.get(env.CONNECT_DO.idFromName('connect')); + + return Response.json({ + durableObject: tryConnect(() => stub.connect('127.0.0.1:9')), + service: tryConnect(() => env.SELF.connect('127.0.0.1:9')), + }); + } + + return new Response('not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/binding-connect/test.ts b/dev-packages/cloudflare-integration-tests/suites/binding-connect/test.ts new file mode 100644 index 000000000000..defed12ded34 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/binding-connect/test.ts @@ -0,0 +1,25 @@ +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +it('calls connect() on Durable Object stubs and service bindings with the binding as `this`', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect((envelope: Envelope) => { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + expect(spanItem).toBeDefined(); + const segmentSpan = (spanItem![1] as SerializedStreamedSpanContainer).items.find(span => !!span.is_segment); + expect(segmentSpan).toMatchObject({ + name: 'GET', + status: 'ok', + attributes: expect.objectContaining({ 'url.path': { type: 'string', value: '/connect' } }), + }); + }) + .start(signal); + + const result = await runner.makeRequest<{ durableObject: string; service: string }>('get', '/connect'); + + expect(result?.durableObject).toBe('ok'); + // Local workerd rejects CONNECT on a Worker after the `this` check, so only that check is asserted here. + expect(result?.service).not.toMatch(/Illegal invocation/); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/binding-connect/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/binding-connect/wrangler.jsonc new file mode 100644 index 000000000000..0ad9e3a17f67 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/binding-connect/wrangler.jsonc @@ -0,0 +1,26 @@ +{ + "name": "binding-connect-worker", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "migrations": [ + { + "new_sqlite_classes": ["ConnectDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "ConnectDurableObject", + "name": "CONNECT_DO", + }, + ], + }, + "services": [ + { + "binding": "SELF", + "service": "binding-connect-worker", + }, + ], +} diff --git a/dev-packages/deno-integration-tests/node-suites/excludes.ts b/dev-packages/deno-integration-tests/node-suites/excludes.ts new file mode 100644 index 000000000000..e2b5e0cd5b01 --- /dev/null +++ b/dev-packages/deno-integration-tests/node-suites/excludes.ts @@ -0,0 +1,85 @@ +// Node suites that do not run on Deno, relative to `node-integration-tests`. A single test that +// fails on Deno is skipped with `test.skipIf` on `RUNTIME` in the Node suite, not listed here. + +// Node-only features: ANR and native thread watchdogs, child process and worker thread breadcrumbs. +const NODE_ONLY = ['suites/anr/test.ts', 'suites/breadcrumbs/**', 'suites/thread-blocked-native/test.ts']; + +// `@sentry/node` instruments `fetch` through undici's diagnostics channels, which Deno's `fetch` +// does not publish. `@sentry/deno` has its own `fetchIntegration` for this. These suites check +// spans, breadcrumbs or headers of outgoing `fetch` requests. +const NO_FETCH_INSTRUMENTATION = [ + 'suites/tracing/double-baggage/**', + 'suites/tracing/http-client-span-streamed/test.ts', + 'suites/tracing/http-client-spans/fetch-basic-streamed/test.ts', + 'suites/tracing/http-client-spans/fetch-basic/test.ts', + 'suites/tracing/http-client-spans/fetch-error/test.ts', + 'suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts', + 'suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts', + 'suites/tracing/http-client-spans/fetch-strip-query/test.ts', + 'suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts', + 'suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts', + 'suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts', + 'suites/tracing/no-parent-span-client-report/test.ts', + 'suites/tracing/requests/fetch-breadcrumbs/test.ts', + 'suites/tracing/requests/fetch-no-trace-propagation/test.ts', + 'suites/tracing/requests/fetch-no-tracing-no-spans/test.ts', + 'suites/tracing/requests/fetch-no-tracing/test.ts', + 'suites/tracing/requests/fetch-sampled-no-active-span/test.ts', + 'suites/tracing/requests/fetch-unsampled/test.ts', + 'suites/tracing/requests/traceparent/test.ts', + 'suites/tracing/sample-rand-propagation/test.ts', + 'suites/tracing/sample-rate-propagation/**', +]; + +// In the ESM tests Deno cannot find `PrismaClient`, a CommonJS export of `@prisma/client`. +const PRISMA_ESM_INTEROP = ['suites/tracing/prisma-orm-v5/test.ts', 'suites/tracing/prisma-orm-v6/test.ts']; + +// In the CJS tests Deno cannot `require()` a dependency that ships only ES modules: `graphql` 17, +// `escape-string-regexp` under `mastra`, and `@prisma/orm-postgres`. The ESM tests of `mastra` also +// check `fetch` spans. The ESM test of `prisma-orm-v8` imports JSON with `with { type: 'json' }`, +// which any module load hook breaks on Deno 2.8.3 (the CI version); it passes on Deno 2.9.0. +const REQUIRE_OF_ESM_ONLY_DEPENDENCY = [ + 'suites/tracing/graphql-tracing-channel/**', + 'suites/tracing/mastra/test.ts', + 'suites/tracing/prisma-orm-v8/test.ts', +]; + +// Some or all tests fail on Deno, cause not investigated yet. In most AI suites the span +// streaming test fails. `apollo-graphql` (CJS tests only) and `mongodb` fail on Deno 2.8.3 (the CI +// version) and pass on Deno 2.9.0. +const NOT_TRIAGED = [ + 'suites/tracing/anthropic/test.ts', + 'suites/tracing/apollo-graphql/**', + 'suites/tracing/fastify/test.ts', + 'suites/tracing/flue/test.ts', + 'suites/tracing/google-genai/test.ts', + 'suites/tracing/groq/test.ts', + 'suites/tracing/http-client-spans/http-strip-query/test.ts', + 'suites/tracing/ioredis-dc/test.ts', + 'suites/tracing/koa/test.ts', + 'suites/tracing/langchain/test.ts', + 'suites/tracing/langgraph/test.ts', + 'suites/tracing/mistral/test.ts', + 'suites/tracing/mongodb/test.ts', + 'suites/tracing/mongoose-v5/test.ts', + 'suites/tracing/mysql/test.ts', + 'suites/tracing/openai/test.ts', + 'suites/tracing/openai/v7/test.ts', + 'suites/tracing/orchestrion-lazy-registration/test.ts', + 'suites/tracing/prisma-orm-v7/test.ts', + 'suites/tracing/together-ai/test.ts', + 'suites/tracing/vercelai/v6_v7/test.ts', +]; + +// Passes on Deno when run alone, but failed in about 1 of 3 full runs of this package. +const FLAKY = ['suites/tracing/tracePropagationTargets/**']; + +export const NODE_SUITES_EXCLUDE = [ + '**/node_modules/**', + ...NODE_ONLY, + ...NO_FETCH_INSTRUMENTATION, + ...PRISMA_ESM_INTEROP, + ...REQUIRE_OF_ESM_ONLY_DEPENDENCY, + ...NOT_TRIAGED, + ...FLAKY, +]; diff --git a/dev-packages/deno-integration-tests/node-suites/import-map.json b/dev-packages/deno-integration-tests/node-suites/import-map.json new file mode 100644 index 000000000000..a0776ec28c65 --- /dev/null +++ b/dev-packages/deno-integration-tests/node-suites/import-map.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@sentry-internal/node-integration-tests": "../../node-integration-tests/build/esm/index.js" + } +} diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index 3fdf392ec21b..f2b8cc9335e9 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -11,8 +11,9 @@ "install:deno": "node ./scripts/install-deno.mjs", "lint": "oxlint . --type-aware", "lint:fix": "oxlint . --fix --type-aware", - "test": "run-s install:deno deno-types test:unit", - "test:unit": "deno test --allow-net --allow-read --allow-run --allow-env --no-check" + "test": "node ./scripts/run-tests.mjs", + "test:unit": "deno test --allow-net --allow-read --allow-run --allow-env --no-check suites", + "test:node-suites": "vitest run" }, "dependencies": { "@sentry/core": "11.0.0", @@ -20,6 +21,10 @@ "mysql": "^2.18.1", "pg": "^8.22.0" }, + "devDependencies": { + "@sentry-internal/node-integration-tests": "11.0.0", + "vitest": "^3.2.7" + }, "volta": { "extends": "../../package.json" } diff --git a/dev-packages/deno-integration-tests/scripts/run-tests.mjs b/dev-packages/deno-integration-tests/scripts/run-tests.mjs new file mode 100644 index 000000000000..dd6114395553 --- /dev/null +++ b/dev-packages/deno-integration-tests/scripts/run-tests.mjs @@ -0,0 +1,27 @@ +// `yarn test` runs the Deno-only suites (`deno test`) and then the shared Node suites (vitest), +// like the other integration test packages. +// +// `yarn test ` runs only the shared Node suites with that filter, e.g. `yarn test express`, +// because `deno test` does not take vitest filters. To filter the Deno-only suites by test name, +// run `yarn test:unit --filter `. +import { spawnSync } from 'node:child_process'; + +const filters = process.argv.slice(2); + +function run(script, args = []) { + const result = spawnSync('yarn', ['--silent', script, ...args], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +if (filters.length === 0) { + run('install:deno'); + run('deno-types'); + run('test:unit'); +} + +run('test:node-suites', filters); diff --git a/dev-packages/deno-integration-tests/vite.config.mts b/dev-packages/deno-integration-tests/vite.config.mts new file mode 100644 index 000000000000..6dccfdc74a5e --- /dev/null +++ b/dev-packages/deno-integration-tests/vite.config.mts @@ -0,0 +1,38 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vite/vite.config'; +import { NODE_SUITES_EXCLUDE } from './node-suites/excludes'; + +// Runs all Node suites on Deno. The scenarios stay in `node-integration-tests`, and the +// Deno-only suites in `suites/` run with `deno test`. +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + root: fileURLToPath(new URL('../node-integration-tests', import.meta.url)), + coverage: { + enabled: false, + }, + isolate: false, + include: ['suites/**/test.ts'], + exclude: NODE_SUITES_EXCLUDE, + env: { + RUNTIME: 'deno', + DENO_IMPORT_MAP: fileURLToPath(new URL('./node-suites/import-map.json', import.meta.url)), + }, + // Above the 30 second port timeout of the runner on Deno, so a slow start can still pass. + testTimeout: 45_000, + ...(process.env.DEBUG + ? { + disableConsoleIntercept: true, + silent: false, + } + : {}), + pool: 'threads', + reporters: process.env.DEBUG + ? ['default', { summary: false }] + : process.env.GITHUB_ACTIONS + ? ['dot', 'github-actions'] + : ['verbose'], + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json index 1a8b6a37a695..b792cbf5dcad 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json @@ -21,7 +21,6 @@ "wrangler": "^4.63.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json index af7235ed84a8..3c5502c6f6c6 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/package.json @@ -23,7 +23,6 @@ "wrangler": "^4.72.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json index 372b2c2055c6..2b33e38f5580 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json @@ -32,7 +32,6 @@ "ws": "^8.21.1" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/autoinstrument.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/autoinstrument.test.ts index 4139071b884f..792e61834ce1 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/autoinstrument.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/autoinstrument.test.ts @@ -97,10 +97,8 @@ for (const { title, binding, agentClass } of [ expect(rpcSpan.attributes['sentry.op']?.value).toBe('rpc'); expect(rpcSpan.attributes['sentry.origin']?.value).toBe('auto.faas.cloudflare.agents'); // Read back off the instance at runtime (`_ParentClass.name`), so it - // confirms the wrapper landed on the user's real class. Matched loosely - // because the transform renames the class it wraps to - // `__SENTRY_ORIGINAL___` and the bundler infers that name. - expect(rpcSpan.attributes['gen_ai.agent.name']?.value).toContain(agentClass); + // confirms the wrapper landed on the user's real class, with its name intact. + expect(rpcSpan.attributes['gen_ai.agent.name']?.value).toBe(agentClass); }); } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json index bca3f6ac9d3e..c2aa8fbb7bdc 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-local-workers/package.json @@ -25,7 +25,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json index c6ea2d8e823c..03853b91aaa1 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json @@ -27,7 +27,6 @@ "wrangler": "^4.86.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } 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 c31d593c1f70..bef9e2e7ffd4 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -28,7 +28,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json index 8c9f23e5b282..d9d14c23914e 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json @@ -28,7 +28,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json new file mode 100644 index 000000000000..0bb10a6be700 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json @@ -0,0 +1,50 @@ +{ + "name": "cloudflare-think", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview --port 38788", + "typecheck": "tsc --noEmit", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build-ai-v6": "pnpm install && pnpm add ai@^6.0.0 @openrouter/ai-sdk-provider@^2.9.1 && pnpm build", + "test:assert-ai-v6": "AI_SDK_MAJOR=6 pnpm test:prod" + }, + "dependencies": { + "@cloudflare/think": "^0.19.0", + "@openrouter/ai-sdk-provider": "^3.1.0", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "agents": "^0.24.0", + "ai": "^7.0.112", + "dataloader": "^2.2.3", + "zod": "^4.0.0" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "1.57.2", + "@cloudflare/workers-types": "^4.20260426.0", + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "^5.5.2", + "vite": "7.3.5", + "wrangler": "^4.136.2", + "ws": "^8.18.3" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-ai-v6", + "assert-command": "pnpm test:assert-ai-v6", + "label": "cloudflare-think (ai v6)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts new file mode 100644 index 000000000000..d5838d66e04a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/playwright.config.ts @@ -0,0 +1,23 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38788; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm preview`, + port: APP_PORT, + }, + // Each test drives a real OpenRouter tool-calling turn (up to two model calls) and then waits for + // the spans to flush, which does not fit the default 30s timeout when the provider is slow. The + // serial default from `getPlaywrightConfig` is kept: these turns share one worker and one event + // proxy, so running them in parallel only makes traces harder to tell apart. + { timeout: 90_000 }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/env.d.ts new file mode 100644 index 000000000000..c18846580a4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/env.d.ts @@ -0,0 +1,9 @@ +declare namespace Cloudflare { + interface Env { + E2E_TEST_DSN: string; + E2E_OPENROUTER_API_KEY: string; + ThinkAgent: DurableObjectNamespace; + } +} + +interface Env extends Cloudflare.Env {} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts new file mode 100644 index 000000000000..2e2784df6e67 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/index.ts @@ -0,0 +1,11 @@ +import { routeAgentRequest } from 'agents'; + +// Re-exported rather than declared here so the build-time wrapper has to cross a module boundary +// to find and wrap the class. See the note on the class itself. +export { ThinkAgent } from './think-agent'; + +export default { + async fetch(request, env): Promise { + return (await routeAgentRequest(request, env)) ?? new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/instrument.server.ts new file mode 100644 index 000000000000..4bd8e8b9fdc0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/instrument.server.ts @@ -0,0 +1,10 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +// The Sentry Vite plugin picks this file up by convention, next to the worker entry named in +// wrangler's `main`, and hands its default export to `withSentry`. +export default defineCloudflareOptions((env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/src/think-agent.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/think-agent.ts new file mode 100644 index 000000000000..93e28fe08307 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/src/think-agent.ts @@ -0,0 +1,76 @@ +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { Think } from '@cloudflare/think'; +import * as Sentry from '@sentry/cloudflare'; +import type { LanguageModel, ToolSet } from 'ai'; +import { tool } from 'ai'; +import DataLoader from 'dataloader'; +import { z } from 'zod'; + +/** + * Not wrapped by hand: `@sentry/cloudflare/vite` detects `extends Think` and rewrites the worker + * entry's export of this class into `instrumentAgentWithSentry(...)` at build time. Wrapping it + * here would prove nothing about the zero-config path. + * + * The class deliberately lives outside the worker entry, which only re-exports it. That is the + * harder case for the plugin: the entry never binds the class, so detection has to follow the + * re-export into this module and the wrapper has to import the class under a private name. + */ +export class ThinkAgent extends Think { + public getModel(): LanguageModel { + // Call OpenRouter directly (rather than the default Vercel AI Gateway) so the e2e test needs + // only a single OpenRouter key, reusing `E2E_OPENROUTER_API_KEY` like the other AI apps. A real + // provider is also what makes the outgoing model request observable as an `http.client` span. + const openrouter = createOpenRouter({ apiKey: this.env.E2E_OPENROUTER_API_KEY ?? '' }); + + return openrouter('openai/gpt-4o-mini'); + } + + public getSystemPrompt(): string { + return [ + 'You are a concise assistant used by an automated end-to-end test.', + 'When the user asks about the weather in a place, call the `get_weather` tool for that place and answer in one short sentence using its result.', + 'When the user asks you to trigger a failure, call the `fail_now` tool.', + 'Do not ask follow-up questions.', + ].join('\n'); + } + + public getTools(): ToolSet { + return { + get_weather: tool({ + description: 'Get the current weather for a location', + inputSchema: z.object({ location: z.string() }), + execute: async ({ location }: { location: string }) => { + // A manual span raised inside a tool must nest under that tool's `gen_ai.execute_tool` + // span, which only holds if Think runs the tool inside the SDK's async context. + return Sentry.startSpan({ name: 'lookup-forecast', op: 'gen_ai.tool.manual' }, async () => { + // `dataloader` is instrumented through the orchestrion module transform rather than by + // patching a global, so its spans are the probe for whether channel injection reached + // this bundled worker at all. + const loader = new DataLoader(async keys => keys.map(key => `forecast:${key}`)); + await Promise.all([loader.load(location), loader.load(location)]); + + return { city: location, condition: 'Sunny', temperatureC: 22 }; + }); + }, + }), + fail_now: tool({ + description: 'Always throws an error. Call this when the user asks to trigger a failure.', + // A nominal argument rather than `z.object({})`, and an explicit return type: the AI SDK + // infers `never` for an empty input schema, and a body that only throws infers + // `Promise`. Either one alone makes the `tool()` overload unresolvable. + inputSchema: z.object({ reason: z.string() }), + execute: async (_input: { reason: string }): Promise => { + throw new Error('Think tool failed on purpose'); + }, + }), + }; + } + + public async onRequest(request: Request): Promise { + const message = new URL(request.url).searchParams.get('message') ?? 'What is the weather in Paris?'; + + const result = await this.runTurn({ input: message }); + + return Response.json({ continuation: result.continuation }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-think/start-event-proxy.mjs new file mode 100644 index 000000000000..784fc99f86ba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/start-event-proxy.mjs @@ -0,0 +1,7 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-think', + envelopeDumpPath: process.env.SENTRY_ENVELOPE_DUMP_PATH, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/dataloader.test.ts new file mode 100644 index 000000000000..a2336e3ac52c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/dataloader.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './utils'; + +const APP = 'cloudflare-think'; + +const isDataloaderSpan = (span: { attributes?: Record }): boolean => + span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader'; + +/** + * `dataloader` is instrumented by the orchestrion module transform rather than by patching anything + * at runtime, so its spans are the probe for whether channel injection reached the worker at all. + * Libraries that do not need the transform (`node:http`, and the AI spans themselves) would pass + * even if injection were broken. + * + * On Cloudflare the injection is done at build time by `sentryCloudflareVitePlugin()`, so unlike the + * Node apps this needs no `--import` bootstrap. A Think worker is heavily bundled, which is exactly + * the situation where a transform can silently stop applying, so this is the test that catches it. + * + * The loader runs inside a tool so its spans land in the turn's trace rather than one of their own. + */ +test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => { + const agentId = newAgentId('dataloader'); + const ofThisTurn = isTurnOf(agentId); + + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + ofThisTurn(spansOfTrace as StreamedSpan[]) && + spansOfTrace.some(span => attr(span as StreamedSpan, 'gen_ai.tool.name') === 'get_weather') && + spansOfTrace.some(isDataloaderSpan), + ); + + await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?'); + + const spans = await spansPromise; + const dataloaderSpan = spans.find(isDataloaderSpan); + const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'get_weather'); + + // Sharing the trace is the point. Not asserting the exact parent: the model may call the tool more + // than once, so the tool span found here is not reliably the one that ran this loader. + expect(getSpanOp(dataloaderSpan!)).toBe('cache.get'); + expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts new file mode 100644 index 000000000000..76f65c74e01b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/errors.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp, waitForError } from '@sentry-internal/test-utils'; +import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './utils'; + +const APP = 'cloudflare-think'; + +/** + * A tool throw is not an agent failure: the AI SDK catches it, hands the error back to the model as + * a tool result, and the turn carries on and usually answers. So the error has to reach Sentry from + * the tool span itself, and the spans above it stay `ok` — the model calls did succeed. + */ +test('captures an error thrown inside a Think tool and marks its span errored', async ({ baseURL }) => { + const agentId = newAgentId('tool-error'); + const ofThisTurn = isTurnOf(agentId); + + const errorPromise = waitForError( + APP, + event => event.exception?.values?.[0]?.value === 'Think tool failed on purpose', + ); + + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + ofThisTurn(spansOfTrace as StreamedSpan[]) && + spansOfTrace.some(span => attr(span as StreamedSpan, 'gen_ai.tool.name') === 'fail_now'), + ); + + await runAgentTurn(baseURL!, agentId, 'Please trigger a failure now.'); + + const error = await errorPromise; + const spans = await spansPromise; + + const exception = error.exception?.values?.[0]; + expect(exception?.type).toBe('Error'); + expect(exception?.value).toBe('Think tool failed on purpose'); + expect(exception?.mechanism?.type).toBe('auto.vercelai.channel'); + + const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'); + expect(getSpanOp(toolSpan!)).toBe('gen_ai.execute_tool'); + expect(toolSpan?.status).toBe('error'); + + // The issue belongs to the same trace as the turn that produced it. + expect(error.contexts?.trace?.trace_id).toBe(toolSpan?.trace_id); + + // The model calls around the failing tool succeeded, so only the tool span is errored. + const modelCalls = spans.filter(span => getSpanOp(span) === 'gen_ai.generate_content'); + expect(modelCalls.length).toBeGreaterThan(0); + for (const modelCall of modelCalls) { + expect(modelCall.status).toBe('ok'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts new file mode 100644 index 000000000000..1368b59298a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/think.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { attr, isTurnOf, newAgentId, runAgentTurn, type StreamedSpan } from './utils'; + +const APP = 'cloudflare-think'; + +/** + * The whole point of the app: nothing wires Sentry into Think by hand. `sentryCloudflareVitePlugin()` + * detects `class ThinkAgent extends Think` and wraps the export at build time, and the `ai` SDK publishes + * the telemetry `vercelAIIntegration` consumes. The worker's only Sentry import is the `startSpan` the + * manual-span test below needs. Nothing here is Think-specific on the SDK side, so this test is what + * would catch either half of that chain breaking. + * + * `collectStreamedSpans` rather than `waitForStreamedSpans`: the streamed `invoke_agent` parent stays + * open until its children settle and flushes in a separate envelope from them, so a single envelope + * never holds the whole turn. + */ +test('captures the invoke_agent / generate_content / execute_tool hierarchy for a Think turn', async ({ baseURL }) => { + const agentId = newAgentId('hierarchy'); + const ofThisTurn = isTurnOf(agentId); + + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + ofThisTurn(spansOfTrace as StreamedSpan[]) && + ['gen_ai.invoke_agent', 'gen_ai.generate_content', 'gen_ai.execute_tool'].every(op => + spansOfTrace.some(span => getSpanOp(span) === op), + ), + ); + + await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?'); + + const spans = (await spansPromise) as StreamedSpan[]; + + const invokeAgent = spans.find(span => getSpanOp(span) === 'gen_ai.invoke_agent'); + const generateContent = spans.find(span => getSpanOp(span) === 'gen_ai.generate_content'); + const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); + + expect(attr(invokeAgent, 'sentry.origin')).toBe('auto.vercelai.channel'); + expect(attr(invokeAgent, 'gen_ai.operation.name')).toBe('invoke_agent'); + // Think passes `this.constructor.name` to the AI SDK as the `functionId`, so this only holds while + // the build-time wrapper leaves the user's class name alone (#24700). + expect(invokeAgent?.name).toBe('invoke_agent ThinkAgent'); + expect(attr(invokeAgent, 'gen_ai.function_id')).toBe('ThinkAgent'); + expect(attr(invokeAgent, 'gen_ai.provider.name')).toBe('openrouter'); + expect(typeof attr(invokeAgent, 'gen_ai.usage.input_tokens')).toBe('number'); + expect(typeof attr(invokeAgent, 'gen_ai.usage.output_tokens')).toBe('number'); + expect(typeof attr(invokeAgent, 'gen_ai.usage.total_tokens')).toBe('number'); + + expect(attr(generateContent, 'gen_ai.operation.name')).toBe('generate_content'); + expect(attr(generateContent, 'gen_ai.request.model')).toBe('openai/gpt-4o-mini'); + + expect(attr(executeTool, 'gen_ai.operation.name')).toBe('execute_tool'); + expect(attr(executeTool, 'gen_ai.tool.name')).toBe('get_weather'); + + // Inputs and outputs are recorded under the SDK's default data collection (the app sets no + // `dataCollection` override). + expect(String(attr(invokeAgent, 'gen_ai.input.messages') ?? '')).toContain('Paris'); + expect(String(attr(executeTool, 'gen_ai.tool.call.arguments') ?? '')).toContain('Paris'); + expect(String(attr(executeTool, 'gen_ai.tool.call.result') ?? '')).toContain('Sunny'); + + // Think drives one `streamText` per turn with the step loop inside it, so the model calls are + // children of the single agent span rather than siblings of it. + expect(generateContent?.parent_span_id).toBe(invokeAgent?.span_id); + expect(executeTool?.parent_span_id).toBe(invokeAgent?.span_id); +}); + +/** + * A span the user starts inside a tool has to nest under that tool's span, which only holds if Think + * runs tool execution inside the async context the SDK opened. Nothing in the SDK arranges this for + * Think specifically, so it is worth pinning. + */ +test('nests a manual span raised inside a tool under that tool span', async ({ baseURL }) => { + const agentId = newAgentId('manual-span'); + const ofThisTurn = isTurnOf(agentId); + + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + ofThisTurn(spansOfTrace as StreamedSpan[]) && + spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.execute_tool') && + spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.tool.manual'), + ); + + await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?'); + + const spans = (await spansPromise) as StreamedSpan[]; + const manualSpan = spans.find(span => getSpanOp(span) === 'gen_ai.tool.manual'); + const toolSpan = spans.find(span => span.span_id === manualSpan?.parent_span_id); + + expect(manualSpan?.name).toBe('lookup-forecast'); + expect(getSpanOp(toolSpan!)).toBe('gen_ai.execute_tool'); + expect(attr(toolSpan, 'gen_ai.tool.name')).toBe('get_weather'); +}); + +/** + * The request to the model provider must join the turn's trace rather than run beside it. A mocked + * model cannot show this, which is why the app calls OpenRouter for real. + * + * Where it lands differs by `ai` major, and the difference is ours rather than the framework's. On + * v7 the native `ai:telemetry` channel binds the model-call span into async context, so the fetch it + * wraps nests under `gen_ai.generate_content`. On v4-v6 the model span comes from the orchestrion + * `resolveLanguageModel` patch, which never makes that span active, so the fetch attaches to the + * agent span instead and lands as the model call's sibling. Both keep the request in the turn, which + * is what this asserts for every lane; the tighter parent is asserted only where it holds, so the + * v6 shape is recorded rather than papered over. + */ +test('keeps the provider HTTP call inside the turn, under the model call on ai >= 7', async ({ baseURL }) => { + const agentId = newAgentId('provider-http'); + const ofThisTurn = isTurnOf(agentId); + + const isProviderRequest = (span: StreamedSpan): boolean => + getSpanOp(span) === 'http.client' && String(attr(span, 'server.address') ?? span.name ?? '').includes('openrouter'); + + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + ofThisTurn(spansOfTrace as StreamedSpan[]) && + spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.generate_content') && + spansOfTrace.some(span => isProviderRequest(span as StreamedSpan)), + ); + + await runAgentTurn(baseURL!, agentId, 'What is the weather in Paris?'); + + const spans = (await spansPromise) as StreamedSpan[]; + const providerRequest = spans.find(isProviderRequest); + const invokeAgent = spans.find(span => getSpanOp(span) === 'gen_ai.invoke_agent'); + const parent = spans.find(span => span.span_id === providerRequest?.parent_span_id); + + expect(providerRequest?.trace_id).toBe(invokeAgent?.trace_id); + expect(['gen_ai.generate_content', 'gen_ai.invoke_agent']).toContain(getSpanOp(parent!)); + + if ((process.env.AI_SDK_MAJOR ?? '7') !== '6') { + expect(getSpanOp(parent!)).toBe('gen_ai.generate_content'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/utils.ts b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/utils.ts new file mode 100644 index 000000000000..dc30ad54f95d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tests/utils.ts @@ -0,0 +1,54 @@ +import { expect } from '@playwright/test'; + +export type StreamedSpan = { + span_id?: string; + parent_span_id?: string; + trace_id?: string; + name?: string; + status?: string; + attributes?: Record; +}; + +export const attr = (span: StreamedSpan | undefined, key: string): unknown => span?.attributes?.[key]?.value; + +/** + * An agent instance id nothing has used yet. + * + * The id is the `:name` path segment `routeAgentRequest` maps to a Durable Object, so reusing one + * across tests would reuse its stored transcript and alarm state. A fresh id per turn keeps each + * test to its own agent, and keeps `test:dev` from inheriting what `test:prod` left behind. + */ +export function newAgentId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Whether a trace belongs to the turn `agentId` drove. + * + * Every test in this app drives the same agent class through the same two tools, so a predicate + * written only in terms of span ops matches any test's trace. Playwright runs the files in parallel + * against one worker and one event proxy, so that is not hypothetical: a test asserting on + * `get_weather` will happily latch onto the trace of the one asserting on `fail_now`. The agent id + * is in the request path, which is unique per turn, so scoping on it is what keeps each test to its + * own trace. + */ +export function isTurnOf(agentId: string) { + return (spansOfTrace: StreamedSpan[]): boolean => + spansOfTrace.some(span => String(attr(span, 'url.path') ?? '').endsWith(`/${agentId}`)); +} + +/** + * Run one Think turn and wait for it to finish. + * + * The agent's `onRequest` drives `runTurn()` in its default `wait` mode, so unlike frameworks that + * admit the work and return `202`, the response only arrives once the turn has settled. That makes + * this a plain request: no polling needed. + */ +export async function runAgentTurn(baseURL: string, agentId: string, message: string): Promise { + const res = await fetch(`${baseURL}/agents/think-agent/${agentId}?message=${encodeURIComponent(message)}`, { + method: 'POST', + }); + + expect(res.status).toBe(200); + await res.text(); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-think/tsconfig.json new file mode 100644 index 000000000000..40863136da68 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022"], + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "exclude": ["tests"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/vite.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-think/vite.config.mts new file mode 100644 index 000000000000..46781d245acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/vite.config.mts @@ -0,0 +1,17 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + resolve: { + alias: { + // `@cloudflare/think` pulls in `just-bash` -> `turndown`, whose Node build calls a bare + // `require('@mixmark-io/domino')` at module scope when `DOMParser` is missing. workerd has no + // `DOMParser` and no `require`, so the worker dies on startup before any handler runs. This is + // upstream of Sentry — it reproduces with `cloudflare()` alone. `turndown` already ships a + // browser build with that branch compiled out; point at it directly. + turndown: 'turndown/lib/turndown.browser.es.js', + }, + }, + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/wrangler.toml b/dev-packages/e2e-tests/test-applications/cloudflare-think/wrangler.toml new file mode 100644 index 000000000000..6be3e1f730ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/wrangler.toml @@ -0,0 +1,18 @@ +#:schema node_modules/wrangler/config-schema.json +name = "cloudflare-think" +main = "src/index.ts" +compatibility_date = "2026-09-15" +compatibility_flags = ["nodejs_compat"] + +# `vite preview` takes no `--var` flag, so the DSN is declared here and read from +# the process environment the Playwright web server inherits. +[secrets] +required = ["E2E_TEST_DSN", "E2E_OPENROUTER_API_KEY"] + +[[durable_objects.bindings]] +name = "ThinkAgent" +class_name = "ThinkAgent" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["ThinkAgent"] diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json index bddb63d19e40..955e1435d558 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-vercelai-v7/package.json @@ -27,7 +27,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts similarity index 53% rename from dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs rename to dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts index 9dacbe052b8b..514927322439 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); -function wrangler(args, env = {}) { +function wrangler(args: string[], env: Record = {}): void { execFileSync('pnpm', ['exec', 'wrangler', ...args], { cwd: __dirname, env: { ...process.env, ...env }, @@ -18,10 +18,12 @@ function wrangler(args, env = {}) { * Workflow names are unique per Cloudflare account, so every worker gets its own. The Vite build writes the * config wrangler deploys from, and `.wrangler/deploy/config.json` points to it. */ -function nameWorkflowsAfterWorker(name) { - const redirect = JSON.parse(readFileSync(join(__dirname, '.wrangler/deploy/config.json'), 'utf8')); +function nameWorkflowsAfterWorker(name: string): void { + const redirect: { configPath: string } = JSON.parse( + readFileSync(join(__dirname, '.wrangler/deploy/config.json'), 'utf8'), + ); const configPath = join(__dirname, '.wrangler/deploy', redirect.configPath); - const config = JSON.parse(readFileSync(configPath, 'utf8')); + const config: { workflows?: { name: string }[] } = JSON.parse(readFileSync(configPath, 'utf8')); for (const workflow of config.workflows ?? []) { workflow.name = name; @@ -30,7 +32,7 @@ function nameWorkflowsAfterWorker(name) { } /** Deploys the worker under `name` and returns its workers.dev URL. */ -export function deployWorker(name, dsn) { +export function deployWorker(name: string, dsn: string): string { nameWorkflowsAfterWorker(name); const outputDir = mkdtempSync(join(tmpdir(), 'wrangler-output-')); const outputFile = join(outputDir, 'output.ndjson'); @@ -41,7 +43,7 @@ export function deployWorker(name, dsn) { const url = readFileSync(outputFile, 'utf8') .split('\n') .filter(Boolean) - .map(line => JSON.parse(line)) + .map(line => JSON.parse(line) as { type?: string; targets?: string[] }) .find(entry => entry.type === 'deploy') ?.targets?.find(target => target.endsWith('.workers.dev')); @@ -55,7 +57,7 @@ export function deployWorker(name, dsn) { } } -export function deleteWorker(name) { +export function deleteWorker(name: string): void { wrangler(['delete', '--name', name, '--force']); } @@ -63,12 +65,12 @@ export function deleteWorker(name) { * CI keeps its Workers: one per ref, overwritten by the next run of the same ref and deleted by the * cleanup workflow once a PR closes. Local runs delete theirs unless `E2E_KEEP_WORKER` is set. */ -export function keepsWorker() { +export function keepsWorker(): boolean { return Boolean(process.env.GITHUB_ACTIONS || process.env.E2E_KEEP_WORKER); } /** A freshly created workers.dev route can take a moment to become reachable. */ -export async function waitForWorker(url) { +export async function waitForWorker(url: string): Promise { const deadline = Date.now() + 60_000; while (Date.now() < deadline) { @@ -88,3 +90,39 @@ export async function waitForWorker(url) { throw new Error(`Worker at ${url} did not become reachable within 60s.`); } + +/** + * Sends a request until the Worker itself answers it, and returns the body of that answer. + * + * On the first deployment of a Worker name, Cloudflare has answered a request with a 500 while + * Workers Logs had no invocation for it. `status` is the status the Worker answers with. A Worker + * that threw answers with status 500 and Cloudflare error code 1101, which sets it apart from a 500 + * that did not come from the Worker. + */ +export async function fetchFromWorker(url: string, status: number, init?: RequestInit): Promise { + const deadline = Date.now() + 60_000; + let lastAnswer = 'no answer'; + + while (Date.now() < deadline) { + try { + const response = await fetch(url, init); + const body = await response.text(); + // Cloudflare sends its error page as HTML to some clients (Node's fetch among them) and as + // `error code: ` plain text to others, so the code is read from either format. + const errorCode = /cf-error-code">(\d+)<|^error code: (\d+)/.exec(body)?.slice(1).find(Boolean); + + if (response.status === status && (status !== 500 || errorCode === '1101')) { + return body; + } + + lastAnswer = `${response.status}, cf-ray ${response.headers.get('cf-ray')}, error code ${errorCode ?? 'none'}, body: ${body.slice(0, 200)}`; + } catch (error) { + lastAnswer = String(error); + } + + console.log(`The Worker did not answer ${url}: ${lastAnswer}`); + await new Promise(resolve => setTimeout(resolve, 2_000)); + } + + throw new Error(`The Worker did not answer ${url} with status ${status} within 60s. Last answer: ${lastAnswer}`); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts similarity index 94% rename from dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs rename to dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts index 08e9311cb7ca..b33f187e1d94 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker.mjs'; +import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker'; const WORKER_PREFIX = 'e2e-send-to-sentry'; @@ -9,7 +9,7 @@ const WORKER_PREFIX = 'e2e-send-to-sentry'; * next run of the same ref overwrites. Pull request refs look like `123/merge` and merge queue refs * like `gh-readonly-queue//pr-123-`; both map to the PR's Worker. */ -export function getWorkerName() { +export function getWorkerName(): string { if (!process.env.GITHUB_ACTIONS) { return `${WORKER_PREFIX}-local-${randomBytes(3).toString('hex')}`; } @@ -24,7 +24,7 @@ export function getWorkerName() { return `${WORKER_PREFIX}-${slug}`.slice(0, 63).replace(/-+$/, ''); } -export default async function globalSetup() { +export default async function globalSetup(): Promise { if (!existsSync(new URL('.wrangler/deploy/config.json', import.meta.url))) { throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.'); } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts similarity index 82% rename from dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs rename to dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts index 0fee903dc5c7..9f012cd51101 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts @@ -1,6 +1,6 @@ -import { deleteWorker, keepsWorker } from './deployed-worker.mjs'; +import { deleteWorker, keepsWorker } from './deployed-worker'; -export default function globalTeardown() { +export default function globalTeardown(): void { const workerName = process.env.E2E_TEST_WORKER_NAME; if (!workerName) { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json index 75bbb77d0b7c..3bebd9caebf1 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "vite build", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml dist .wrangler", "test:build": "pnpm install && pnpm build", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts index 0b79fb88052f..91e214f15493 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts @@ -3,8 +3,8 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', // The worker is deployed once for the whole run and deleted again afterwards. - globalSetup: './global-setup.mjs', - globalTeardown: './global-teardown.mjs', + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', /* Spans take ~2min to become queryable via the trace endpoint. */ timeout: 210_000, fullyParallel: true, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts index ab642bf55b1a..8578ecb7bee7 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -8,14 +8,15 @@ import { flattenTrace, traceTarget, } from '@sentry-internal/test-utils/cli'; +import { fetchFromWorker } from '../deployed-worker'; -// Set by global-setup.mjs once the worker for this run is deployed. +// Set by global-setup.ts once the worker for this run is deployed. const workerUrl = process.env.E2E_TEST_WORKER_URL; test('Sends a captured exception to Sentry', async () => { - const response = await fetch(`${workerUrl}/test-error`); - expect(response.status).toBe(200); - const { eventId, traceId } = await response.json(); + const { eventId, traceId }: { eventId: string; traceId: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-error`, 200), + ); console.log(`Polling for error eventId ${eventId}: sentry trace view ${traceTarget(traceId)}`); @@ -25,13 +26,12 @@ test('Sends a captured exception to Sentry', async () => { test('Sends an unhandled exception and its request span to Sentry', async () => { const traceId = randomBytes(16).toString('hex'); const publicKey = new URL(process.env.E2E_TEST_DSN!).username; - const response = await fetch(`${workerUrl}/test-unhandled-error`, { + await fetchFromWorker(`${workerUrl}/test-unhandled-error`, 500, { headers: { 'sentry-trace': `${traceId}-${randomBytes(8).toString('hex')}-1`, baggage: `sentry-trace_id=${traceId},sentry-public_key=${publicKey},sentry-sampled=true,sentry-sample_rate=1`, }, }); - expect(response.status).toBe(500); console.log(`Polling for unhandled error: sentry trace view ${traceTarget(traceId)}`); @@ -40,9 +40,9 @@ test('Sends an unhandled exception and its request span to Sentry', async () => }); test('Sends a request span to Sentry', async () => { - const response = await fetch(`${workerUrl}/test-span`); - expect(response.status).toBe(200); - const { spanId, traceId } = await response.json(); + const { spanId, traceId }: { spanId: string; traceId: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-span`, 200), + ); console.log(`Polling for request spanId ${spanId}: sentry trace view ${traceTarget(traceId)}`); @@ -52,9 +52,9 @@ test('Sends a request span to Sentry', async () => { }); test('Sends the spans of Workflow steps before the Workflow goes to sleep', async () => { - const response = await fetch(`${workerUrl}/test-workflow-sleep`); - expect(response.status).toBe(200); - const { instanceId, traceId } = await response.json(); + const { instanceId, traceId }: { instanceId: string; traceId: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-workflow-sleep`, 200), + ); console.log(`Polling for the Workflow step spans: sentry trace view ${traceTarget(traceId)}`); @@ -68,6 +68,8 @@ test('Sends the spans of Workflow steps before the Workflow goes to sleep', asyn ) .toBe(3); - const { status } = await fetch(`${workerUrl}/test-workflow-status?id=${instanceId}`).then(res => res.json()); + const { status }: { status: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-workflow-status?id=${instanceId}`, 200), + ); expect(['running', 'waiting']).toContain(status); }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json new file mode 100644 index 000000000000..657ecc4ef24a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["tests/**/*", "deployed-worker.ts", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc index 351a68bd023f..d6b15b1dbf5e 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc @@ -1,6 +1,6 @@ { "$schema": "node_modules/wrangler/config-schema.json", - // Placeholder only: every test run deploys under a unique name, see global-setup.mjs. + // Placeholder only: every test run deploys under a unique name, see global-setup.ts. "name": "cloudflare-workers-send-to-sentry", "main": "src/index.ts", "compatibility_date": "2026-05-20", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/package.json index 48a8d42b5a35..8a844eb74fc8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/package.json @@ -25,7 +25,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-workflow-legacy/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-workflow-legacy/package.json index d13053c80a6e..5f0e7973aaec 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-workflow-legacy/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-workflow-legacy/package.json @@ -21,7 +21,6 @@ "wrangler": "4.70.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json index 41caa28d08a2..e1889148b604 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/package.json @@ -25,7 +25,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json index f81bb3c8467a..56a59febe7f3 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/package.json @@ -25,7 +25,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "pnpm": { diff --git a/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json b/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json index bccf0b94e6b3..f3f429c25b9f 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json @@ -37,7 +37,6 @@ ] }, "volta": { - "node": "22.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/package.json b/dev-packages/e2e-tests/test-applications/effect-4-node/package.json index b07fe4ad4756..460bfb57ec45 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/package.json @@ -12,10 +12,10 @@ "test:assert": "pnpm test" }, "dependencies": { - "@effect/platform-node": "^4.0.0-beta.50", + "@effect/platform-node": "^4.0.0-rc.118", "@sentry/effect": "file:../../packed/sentry-effect-packed.tgz", "@types/node": "^18.19.1", - "effect": "^4.0.0-beta.50", + "effect": "^4.0.0-rc.118", "typescript": "~5.0.0" }, "devDependencies": { @@ -23,7 +23,6 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "node": "22.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts index 4f3c7f2f3128..db5b1fae44eb 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts @@ -8,7 +8,7 @@ import * as Layer from 'effect/Layer'; import * as Logger from 'effect/Logger'; import * as Tracer from 'effect/Tracer'; import * as References from 'effect/References'; -import { HttpRouter, HttpServerResponse } from 'effect/unstable/http'; +import { HttpRouter, HttpServerResponse } from 'effect/http'; import { createServer } from 'http'; const SentryLive = Layer.mergeAll( diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/fragments.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/fragments.ts index ccf430475620..e8639a6bfba7 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/fragments.ts +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/lib/fragments.ts @@ -102,73 +102,3 @@ export const CART_QUERY_FRAGMENT = `#graphql } } ` as const; - -const MENU_FRAGMENT = `#graphql - fragment MenuItem on MenuItem { - id - resourceId - tags - title - type - url - } - fragment ChildMenuItem on MenuItem { - ...MenuItem - } - fragment ParentMenuItem on MenuItem { - ...MenuItem - items { - ...ChildMenuItem - } - } - fragment Menu on Menu { - id - items { - ...ParentMenuItem - } - } -` as const; - -export const HEADER_QUERY = `#graphql - fragment Shop on Shop { - id - name - description - primaryDomain { - url - } - brand { - logo { - image { - url - } - } - } - } - query Header( - $country: CountryCode - $headerMenuHandle: String! - $language: LanguageCode - ) @inContext(language: $language, country: $country) { - shop { - ...Shop - } - menu(handle: $headerMenuHandle) { - ...Menu - } - } - ${MENU_FRAGMENT} -` as const; - -export const FOOTER_QUERY = `#graphql - query Footer( - $country: CountryCode - $footerMenuHandle: String! - $language: LanguageCode - ) @inContext(language: $language, country: $country) { - menu(handle: $footerMenuHandle) { - ...Menu - } - } - ${MENU_FRAGMENT} -` as const; diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx index e45feb5dd576..4e3a2f362bdf 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx @@ -8,7 +8,6 @@ import { Scripts, ScrollRestoration, } from 'react-router'; -import { FOOTER_QUERY, HEADER_QUERY } from '~/lib/fragments'; import { useNonce } from '@shopify/hydrogen'; @@ -56,17 +55,11 @@ export function links() { } export async function loader(args: LoaderFunctionArgs) { - // Start fetching non-critical data without blocking time to first byte - const deferredData = loadDeferredData(args); - - // Await the critical data required to render initial state of the page - const criticalData = await loadCriticalData(args); - - const { env } = args.context; + const { env, customerAccount, cart } = args.context; return { - ...deferredData, - ...criticalData, + cart: cart.get(), + isLoggedIn: customerAccount.isLoggedIn(), ENV: { sentryTrace: env.SENTRY_TRACE, sentryBaggage: env.SENTRY_BAGGAGE, @@ -83,54 +76,6 @@ export async function loader(args: LoaderFunctionArgs) { }; } -/** - * Load data necessary for rendering content above the fold. This is the critical data - * needed to render the page. If it's unavailable, the whole page should 400 or 500 error. - */ -async function loadCriticalData({ context }: LoaderFunctionArgs) { - const { storefront } = context; - - const [header] = await Promise.all([ - storefront.query(HEADER_QUERY, { - cache: storefront.CacheLong(), - variables: { - headerMenuHandle: 'main-menu', // Adjust to your header menu handle - }, - }), - // Add other queries here, so that they are loaded in parallel - ]); - - return { header }; -} - -/** - * Load data for rendering content below the fold. This data is deferred and will be - * fetched after the initial page load. If it's unavailable, the page should still 200. - * Make sure to not throw any errors here, as it will cause the page to 500. - */ -function loadDeferredData({ context }: LoaderFunctionArgs) { - const { storefront, customerAccount, cart } = context; - - // defer the footer query (below the fold) - const footer = storefront - .query(FOOTER_QUERY, { - cache: storefront.CacheLong(), - variables: { - footerMenuHandle: 'footer', // Adjust to your footer menu handle - }, - }) - .catch((error: any) => { - // Log query errors, but don't throw them so the page can still render - console.error(error); - return null; - }); - return { - cart: cart.get(), - isLoggedIn: customerAccount.isLoggedIn(), - footer, - }; -} - export function Layout({ children }: { children?: React.ReactNode }) { const nonce = useNonce(); diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/action-error.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/action-error.tsx new file mode 100644 index 000000000000..083926e454ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/routes/action-error.tsx @@ -0,0 +1,11 @@ +export async function action() { + throw new Error('Action Error'); +} + +export default function ActionError() { + return ( +
+

Action Error

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/server.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/server.ts index 9bb5529320b6..74ef453d062b 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/server.ts +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/server.ts @@ -9,7 +9,7 @@ import { import { type AppLoadContext, createRequestHandler, getStorefrontHeaders } from '@shopify/remix-oxygen'; import { CART_QUERY_FRAGMENT } from '~/lib/fragments'; import { AppSession } from '~/lib/session'; -import { wrapRequestHandler } from '@sentry/cloudflare/request'; +import { httpServerIntegration, wrapRequestHandler } from '@sentry/cloudflare/request'; // Virtual entry point for the app // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error @@ -38,6 +38,7 @@ export default { dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, tunnel: `http://localhost:3031/`, // proxy server + integrations: [httpServerIntegration({ maxRequestBodySize: 'small' })], }, // Need to cast to any because this is not on cloudflare request: request as any, diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-errors.test.ts b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-errors.test.ts new file mode 100644 index 000000000000..3b1030adacec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tests/server-errors.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Applies the options of `httpServerIntegration` imported from `@sentry/cloudflare/request`', async ({ + request, +}) => { + const errorPromise = waitForError('hydrogen-react-router-7', errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Action Error'; + }); + + const body = JSON.stringify({ payload: 'x'.repeat(4_000) }); + await request.post('/action-error', { data: body, headers: { 'content-type': 'application/json' } }); + + const errorEvent = await errorPromise; + + // `maxRequestBodySize: 'small'` truncates at 1,000 bytes, the default (`'medium'`) would keep the full body. + expect(errorEvent.request?.data).toBe(`${body.slice(0, 997)}...`); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx index 918c03de3d0a..21bdce977a7f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx @@ -17,6 +17,15 @@ export default function Page() { router.push() +
  • + +
  • Normal Link
  • diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-span.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-span.test.ts index fbdb962d4262..c3c62b908f2c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-span.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-span.test.ts @@ -81,3 +81,21 @@ test('Creates a navigation span for basePath with prefix', async ({ page expect(await navigationSpanPromise).toBeDefined(); }); + +test('Does not prepend basePath to absolute router.push URLs', async ({ page }) => { + const navigationSpanPromise = waitForStreamedSpan('nextjs-15-basepath', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto('/my-app/navigation'); + await page.waitForTimeout(1000); + await page.getByText('Absolute URL push').click(); + + const navigationSpan = await navigationSpanPromise; + + expect(navigationSpan.name).toBe('/my-app/navigation/:param/router-push'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path': { value: '/my-app/navigation/42/router-push', type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx new file mode 100644 index 000000000000..a61f15ee7e3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; +import { cacheLife } from 'next/cache'; + +// The awaited param puts the request id into the cache key, so a fresh id is a guaranteed miss +// and the entry cannot come from a build-time fill. `children` passes through as an uncached hole. +export default async function CachedMidLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ id: string }>; +}) { + 'use cache'; + cacheLife('hours'); + const { id } = await params; + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +
    +

    + {id}:{Date.now()} +

    + {children} +
    + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx new file mode 100644 index 000000000000..94196e6e5ee8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

    Dynamic leaf: {Date.now()}

    ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx new file mode 100644 index 000000000000..8e7de36d156a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx @@ -0,0 +1,17 @@ +import { cacheLife } from 'next/cache'; + +async function CachedLeaf({ id }: { id: string }) { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +

    + {id}:{Date.now()} +

    + ); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx new file mode 100644 index 000000000000..412587fd8f33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from 'react'; +import { headers } from 'next/headers'; + +export default async function DynamicLayout({ children }: { children: ReactNode }) { + await headers(); + return
    {children}
    ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx new file mode 100644 index 000000000000..84a6395b39ac --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/layout.tsx @@ -0,0 +1,11 @@ +import { Suspense, type ReactNode } from 'react'; + +// The Suspense boundary lets the nested layouts/pages below use request APIs (`headers()`, +// runtime params) without tripping the Cache Components prerender guards. +export default function Layout({ children }: { children: ReactNode }) { + return ( +
    + Loading...
    }>{children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx new file mode 100644 index 000000000000..2b0b77b6e3a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { cacheLife } from 'next/cache'; + +// Hard-expires after 2s while the page's cached component lives for hours, so one request can +// refill the layout while hitting the component: two cached levels with different origin traces. +export default async function ShortLivedLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ id: string }>; +}) { + 'use cache'; + cacheLife({ revalidate: 1, expire: 2 }); + const { id } = await params; + return ( +
    +

    + {id}:{Date.now()} +

    + {children} +
    + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx new file mode 100644 index 000000000000..18913cbe9790 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx @@ -0,0 +1,19 @@ +import { headers } from 'next/headers'; +import { cacheLife } from 'next/cache'; + +async function LongLivedComponent({ id }: { id: string }) { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +

    + {id}:{Date.now()} +

    + ); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await headers(); + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx new file mode 100644 index 000000000000..ec8d5e21d947 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

    Route a: {Date.now()}

    ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx new file mode 100644 index 000000000000..9a1b84b2ca28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

    Route b: {Date.now()}

    ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx new file mode 100644 index 000000000000..220fdcf92ca8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { cacheLife } from 'next/cache'; + +// One cache entry (keyed by id) shared by the sibling routes `a` and `b` below. +export default async function SharedLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ id: string }>; +}) { + 'use cache'; + cacheLife('hours'); + const { id } = await params; + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +
    +

    + {id}:{Date.now()} +

    + {children} +
    + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx new file mode 100644 index 000000000000..c8b8858f7785 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx @@ -0,0 +1,34 @@ +import { Suspense } from 'react'; +import { cacheLife } from 'next/cache'; + +async function CachedSection({ id, label }: { id: string; label: string }) { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +
    + {label}:{id}:{Date.now()} +
    + ); +} + +async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the + // cache handler instead of serving prerendered output. The two sibling components cache as two + // separate entries (the props are part of the cache key). + const { id = 'default-id' } = await searchParams; + return ( + <> + + + + ); +} + +export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + return ( + Loading...}> + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx new file mode 100644 index 000000000000..481d7e5e46e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'react'; +import { cacheLife } from 'next/cache'; + +async function getSlowChangingValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return { id, createdAt: Date.now() }; +} + +async function ShortLivedSection({ id }: { id: string }) { + 'use cache'; + // The component entry hard-expires after 2s while the nested function entry lives on, so a + // delayed request refills the component and reads the nested entry as a hit inside that fill. + cacheLife({ revalidate: 1, expire: 2 }); + const nested = await getSlowChangingValue(id); + return
    {JSON.stringify({ ...nested, renderedAt: Date.now() })}
    ; +} + +async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the + // cache handler instead of serving prerendered output. + const { id = 'default-id' } = await searchParams; + return ; +} + +export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + return ( + Loading...}> + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts new file mode 100644 index 000000000000..c182106f5d10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// TODO: Remove the inverse assertion in cacheComponents.spec.ts ("Prerendered shell does not stitch the pageload onto a stale trace") when this test passes. +test('connects the pageload trace to the runtime server request', async ({ page }) => { + test.fail(); + + // The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`) + // runs on the server on every request and produces the `GET /pageload-tracing` transaction. + // A fully prerendered page would produce no server transaction. + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /pageload-tracing' + ); + }); + + const pageloadTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'pageload' && transactionEvent.transaction === '/pageload-tracing'; + }); + + await page.goto('/pageload-tracing'); + + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + + const [serverTx, pageloadTx] = await Promise.all([serverTxPromise, pageloadTxPromise]); + + expect(serverTx.contexts?.trace?.trace_id).toBeTruthy(); + expect(pageloadTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts new file mode 100644 index 000000000000..5025d7d2e104 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -0,0 +1,221 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the +// filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. +// Not implemented yet — every test is `test.fail()` with the final expected assertions. + +// A `use cache` layout between dynamic segments. The layout entry is keyed by the awaited [id] +// param. If Next serves the entry from the prerendered shell (Resume Data Cache) instead of the +// cache handlers, there is no `cache.get` span at all — then this stays failing until Next +// exposes RDC reads. +test('links a cached layout hit to the trace that filled the layout entry', async ({ request }) => { + test.fail(); + + const id = crypto.randomUUID(); + + const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /cached-mid-layout/[id]' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false) + ); + }); + + const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /cached-mid-layout/[id]' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/cached-mid-layout/${id}`); + const missTx = await missTxPromise; + + await request.get(`/cached-mid-layout/${id}`); + const hitTx = await hitTxPromise; + + // The layout is the only cached entry on this route. + const putSpans = (missTx.spans ?? []).filter(span => span.op === 'cache.put'); + expect(new Set(putSpans.map(span => span.description)).size).toBe(1); + + const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan?.description).toBe(putSpans[0]!.description); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: missTx.contexts?.trace?.trace_id, + span_id: putSpans[0]!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); +}); + +// Inverse nesting: all layouts above are dynamic, only the leaf component is cached — the leaf +// entry is the only span that carries a link. +test('links a cached leaf component under dynamic layouts to the trace that filled the leaf entry', async ({ + request, +}) => { + test.fail(); + + const id = crypto.randomUUID(); + + const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false) + ); + }); + + const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`); + const missTx = await missTxPromise; + + await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`); + const hitTx = await hitTxPromise; + + const putSpans = (missTx.spans ?? []).filter(span => span.op === 'cache.put'); + expect(new Set(putSpans.map(span => span.description)).size).toBe(1); + + const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan?.description).toBe(putSpans[0]!.description); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: missTx.contexts?.trace?.trace_id, + span_id: putSpans[0]!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); +}); + +// Nested levels with different lifetimes: after the layout expired, request 2 refills the layout +// while the component still hits. Request 3 then hits both entries, and its two `cache.get` +// spans point at two different origin traces. +test('links two cached levels to different origin traces after the layout expires', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); + test.fail(); + + const id = crypto.randomUUID(); + + const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /mixed-lifetimes/[id]' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); + }); + + await request.get(`/mixed-lifetimes/${id}`); + const fillTx = await fillTxPromise; + + // Layout + component entry. + const fillPutSpans = (fillTx.spans ?? []).filter(span => span.op === 'cache.put'); + expect(new Set(fillPutSpans.map(span => span.description)).size).toBe(2); + + // Sleep past the layout's `expire` (2s); the component entry stays valid for hours. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /mixed-lifetimes/[id]' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/mixed-lifetimes/${id}`); + const refillTx = await refillTxPromise; + + const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /mixed-lifetimes/[id]' && + (transactionEvent.spans?.filter(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true).length ?? + 0) >= 2 + ); + }); + + await request.get(`/mixed-lifetimes/${id}`); + const hitTx = await hitTxPromise; + + const layoutPutSpan = refillTx.spans?.find(span => span.op === 'cache.put'); + expect(layoutPutSpan).toBeDefined(); + const componentPutSpan = fillPutSpans.find(span => span.description !== layoutPutSpan!.description); + expect(componentPutSpan).toBeDefined(); + expect(refillTx.contexts?.trace?.trace_id).not.toBe(fillTx.contexts?.trace?.trace_id); + + const layoutHitSpan = hitTx.spans?.find( + span => span.op === 'cache.get' && span.description === layoutPutSpan!.description, + ); + expect(layoutHitSpan?.links).toEqual([ + { + trace_id: refillTx.contexts?.trace?.trace_id, + span_id: layoutPutSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); + + const componentHitSpan = hitTx.spans?.find( + span => span.op === 'cache.get' && span.description === componentPutSpan!.description, + ); + expect(componentHitSpan?.links).toEqual([ + { + trace_id: fillTx.contexts?.trace?.trace_id, + span_id: componentPutSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); +}); + +// Routes `a` and `b` share one layout entry: a hit on `b` links to the fill trace of `a`, so the +// origin is a different transaction than the serving one. +test('links a shared layout hit on a sibling route to the trace of the route that filled the entry', async ({ + request, +}) => { + test.fail(); + + const id = crypto.randomUUID(); + + const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /shared-layout/[id]/a' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); + }); + + await request.get(`/shared-layout/${id}/a`); + const fillTx = await fillTxPromise; + + const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /shared-layout/[id]/b' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/shared-layout/${id}/b`); + const hitTx = await hitTxPromise; + + const putSpan = fillTx.spans?.find(span => span.op === 'cache.put'); + expect(putSpan).toBeDefined(); + + const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan?.description).toBe(putSpan!.description); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: fillTx.contexts?.trace?.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts new file mode 100644 index 000000000000..a0acf25df078 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Origin links for `use cache` inside rendered pages (cached components and nested cached +// functions). Target behavior: a cache hit records a `cache.get` span carrying a +// `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace that filled +// the entry. Not implemented yet — every test is `test.fail()`. + +// Two cached sibling components are two cache entries (props are part of the key), so one request +// carries one `cache.get` hit span per section, each linking to its own fill. The components sit +// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach +// the cache handlers and produce no spans until Next.js exposes RDC reads. +test('links each sibling component hit to the fill of its own entry', async ({ request }) => { + test.fail(); + + const id = crypto.randomUUID(); + + const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /cached-sibling-components' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false) + ); + }); + + const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /cached-sibling-components' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/cached-sibling-components?id=${id}`); + const missTx = await missTxPromise; + + await request.get(`/cached-sibling-components?id=${id}`); + const hitTx = await hitTxPromise; + + // Without span streaming, the key digest doubles as the span description, so it pairs a hit + // with the put that filled the same entry. + const putSpansByDigest = new Map( + (missTx.spans ?? []).filter(span => span.op === 'cache.put').map(span => [span.description, span] as const), + ); + expect(putSpansByDigest.size).toBe(2); + + const hitGetSpans = (hitTx.spans ?? []).filter(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + // Deduplicated by entry because dev mode can render (and therefore read) more than once. + expect(new Set(hitGetSpans.map(span => span.description)).size).toBe(2); + + for (const hitSpan of hitGetSpans) { + const putSpan = putSpansByDigest.get(hitSpan.description); + expect(putSpan).toBeDefined(); + expect(hitSpan.links).toEqual([ + { + trace_id: missTx.contexts?.trace?.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); + } + + // The two sections link to two different fill spans, not to one shared origin. + expect(new Set(hitGetSpans.map(span => span.links?.[0]?.span_id)).size).toBe(2); +}); + +test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); + test.fail(); + + const id = crypto.randomUUID(); + + const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /nested-caches' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); + }); + + await request.get(`/nested-caches?id=${id}`); + const missTx = await missTxPromise; + + // Sleep past the component entry's hard `expire` limit (2s); the nested function entry + // (`cacheLife('hours')`) stays valid. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /nested-caches' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/nested-caches?id=${id}`); + const refillTx = await refillTxPromise; + + // The only hit is the nested function entry, read while the expired component entry refills. + const hitGetSpans = (refillTx.spans ?? []).filter( + span => span.op === 'cache.get' && span.data?.['cache.hit'] === true, + ); + expect(hitGetSpans).toHaveLength(1); + + const nestedPutSpan = missTx.spans?.find( + span => span.op === 'cache.put' && span.description === hitGetSpans[0]!.description, + ); + expect(nestedPutSpan).toBeDefined(); + + // Even though the read happens inside the component's isolated fill context, the link still + // points at the trace that originally filled the nested entry. + expect(hitGetSpans[0]!.links).toEqual([ + { + trace_id: missTx.contexts?.trace?.trace_id, + span_id: nestedPutSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts new file mode 100644 index 000000000000..2f604f0402f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Origin links for `use cache` in route handlers. Target behavior: a cache hit records a +// `cache.get` span carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` +// span of the trace that filled the entry; unknown origin means no link. Not implemented yet — +// every test is `test.fail()`; shipping the feature should only require deleting those lines. + +test('links a route handler cache hit to the trace that filled the entry', async ({ request }) => { + test.fail(); + + // A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key) + // even when the test is retried against the same server. + const id = crypto.randomUUID(); + + const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === false) + ); + }); + + const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/api/use-cache?id=${id}`); + const missTx = await missTxPromise; + + await request.get(`/api/use-cache?id=${id}`); + const hitTx = await hitTxPromise; + + const putSpan = missTx.spans?.find(span => span.op === 'cache.put'); + expect(putSpan).toBeDefined(); + + // A miss has no origin, and the SDK never guesses one. + const missGetSpan = missTx.spans?.find(span => span.op === 'cache.get'); + expect(missGetSpan?.links).toBeUndefined(); + expect(putSpan?.links).toBeUndefined(); + + const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: missTx.contexts?.trace?.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); +}); + +test('moves the origin link to the refill trace after the entry expires', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); + test.fail(); + + const id = crypto.randomUUID(); + + const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-expiring' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-expiring?id=${id}`); + const fillTx = await fillTxPromise; + + // Sleep past the entry's hard `expire` limit (2s), so the next read must discard and refill it. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + // Registered after the fill transaction was consumed, so it only matches the refill. + const refillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-expiring' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-expiring?id=${id}`); + const refillTx = await refillTxPromise; + + const hitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-expiring' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/api/use-cache-expiring?id=${id}`); + const hitTx = await hitTxPromise; + + expect(refillTx.contexts?.trace?.trace_id).not.toBe(fillTx.contexts?.trace?.trace_id); + + const refillPutSpan = refillTx.spans?.find(span => span.op === 'cache.put'); + expect(refillPutSpan).toBeDefined(); + + // The hit read the refilled entry, so the link points at the refill trace, not the first fill. + const hitGetSpan = hitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: refillTx.contexts?.trace?.trace_id, + span_id: refillPutSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx new file mode 100644 index 000000000000..a61f15ee7e3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; +import { cacheLife } from 'next/cache'; + +// The awaited param puts the request id into the cache key, so a fresh id is a guaranteed miss +// and the entry cannot come from a build-time fill. `children` passes through as an uncached hole. +export default async function CachedMidLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ id: string }>; +}) { + 'use cache'; + cacheLife('hours'); + const { id } = await params; + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +
    +

    + {id}:{Date.now()} +

    + {children} +
    + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx new file mode 100644 index 000000000000..94196e6e5ee8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

    Dynamic leaf: {Date.now()}

    ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx new file mode 100644 index 000000000000..8e7de36d156a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout-cached-leaf/page.tsx @@ -0,0 +1,17 @@ +import { cacheLife } from 'next/cache'; + +async function CachedLeaf({ id }: { id: string }) { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +

    + {id}:{Date.now()} +

    + ); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx new file mode 100644 index 000000000000..412587fd8f33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from 'react'; +import { headers } from 'next/headers'; + +export default async function DynamicLayout({ children }: { children: ReactNode }) { + await headers(); + return
    {children}
    ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/layout.tsx new file mode 100644 index 000000000000..84a6395b39ac --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/layout.tsx @@ -0,0 +1,11 @@ +import { Suspense, type ReactNode } from 'react'; + +// The Suspense boundary lets the nested layouts/pages below use request APIs (`headers()`, +// runtime params) without tripping the Cache Components prerender guards. +export default function Layout({ children }: { children: ReactNode }) { + return ( +
    + Loading...
    }>{children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx new file mode 100644 index 000000000000..2b0b77b6e3a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { cacheLife } from 'next/cache'; + +// Hard-expires after 2s while the page's cached component lives for hours, so one request can +// refill the layout while hitting the component: two cached levels with different origin traces. +export default async function ShortLivedLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ id: string }>; +}) { + 'use cache'; + cacheLife({ revalidate: 1, expire: 2 }); + const { id } = await params; + return ( +
    +

    + {id}:{Date.now()} +

    + {children} +
    + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx new file mode 100644 index 000000000000..18913cbe9790 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/page.tsx @@ -0,0 +1,19 @@ +import { headers } from 'next/headers'; +import { cacheLife } from 'next/cache'; + +async function LongLivedComponent({ id }: { id: string }) { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +

    + {id}:{Date.now()} +

    + ); +} + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + await headers(); + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx new file mode 100644 index 000000000000..ec8d5e21d947 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

    Route a: {Date.now()}

    ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx new file mode 100644 index 000000000000..9a1b84b2ca28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/b/page.tsx @@ -0,0 +1,6 @@ +import { headers } from 'next/headers'; + +export default async function Page() { + await headers(); + return

    Route b: {Date.now()}

    ; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx new file mode 100644 index 000000000000..220fdcf92ca8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { cacheLife } from 'next/cache'; + +// One cache entry (keyed by id) shared by the sibling routes `a` and `b` below. +export default async function SharedLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ id: string }>; +}) { + 'use cache'; + cacheLife('hours'); + const { id } = await params; + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +
    +

    + {id}:{Date.now()} +

    + {children} +
    + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-expiring/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-expiring/route.ts new file mode 100644 index 000000000000..ca237ca4785f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-expiring/route.ts @@ -0,0 +1,14 @@ +import { cacheLife } from 'next/cache'; +import type { NextRequest } from 'next/server'; + +async function getExpiringValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + // Hard-expires after 2 seconds, so a delayed second request exercises the expired-entry path. + cacheLife({ revalidate: 1, expire: 2 }); + return { id, createdAt: Date.now() }; +} + +export async function GET(request: NextRequest) { + const id = request.nextUrl.searchParams.get('id') ?? 'default-id'; + return Response.json(await getExpiringValue(id)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache/route.ts new file mode 100644 index 000000000000..dd5f63024523 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache/route.ts @@ -0,0 +1,18 @@ +import { cacheLife, cacheTag } from 'next/cache'; +import type { NextRequest } from 'next/server'; + +async function getCachedValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + // The 'hours' profile has a finite `expire` (1 day), so the entry carries a real TTL. + cacheLife('hours'); + cacheTag('e2e-use-cache-tag'); + await new Promise(resolve => setTimeout(resolve, 100)); + return { id, createdAt: Date.now() }; +} + +export async function GET(request: NextRequest) { + // The id ends up in the cache key (it is an argument of the cached function), so tests get a + // guaranteed cache miss by passing a fresh id. + const id = request.nextUrl.searchParams.get('id') ?? 'default-id'; + return Response.json(await getCachedValue(id)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx new file mode 100644 index 000000000000..c8b8858f7785 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx @@ -0,0 +1,34 @@ +import { Suspense } from 'react'; +import { cacheLife } from 'next/cache'; + +async function CachedSection({ id, label }: { id: string; label: string }) { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return ( +
    + {label}:{id}:{Date.now()} +
    + ); +} + +async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the + // cache handler instead of serving prerendered output. The two sibling components cache as two + // separate entries (the props are part of the cache key). + const { id = 'default-id' } = await searchParams; + return ( + <> + + + + ); +} + +export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + return ( + Loading...}> + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx new file mode 100644 index 000000000000..481d7e5e46e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'react'; +import { cacheLife } from 'next/cache'; + +async function getSlowChangingValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + cacheLife('hours'); + await new Promise(resolve => setTimeout(resolve, 100)); + return { id, createdAt: Date.now() }; +} + +async function ShortLivedSection({ id }: { id: string }) { + 'use cache'; + // The component entry hard-expires after 2s while the nested function entry lives on, so a + // delayed request refills the component and reads the nested entry as a hit inside that fill. + cacheLife({ revalidate: 1, expire: 2 }); + const nested = await getSlowChangingValue(id); + return
    {JSON.stringify({ ...nested, renderedAt: Date.now() })}
    ; +} + +async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the + // cache handler instead of serving prerendered output. + const { id = 'default-id' } = await searchParams; + return ; +} + +export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { + return ( + Loading...}> + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts new file mode 100644 index 000000000000..efc35fd92c3d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOrigin-pageloadConnection.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +// TODO: Remove the inverse assertion in cacheComponents.spec.ts ("Prerendered shell does not stitch the pageload onto a stale trace") when this test passes. +test('connects the pageload trace to the runtime server request', async ({ page }) => { + test.fail(); + + // The shell is prerendered, but the page's dynamic part (`DynamicContent` awaits `headers()`) + // runs on the server on every request and produces the `GET /pageload-tracing` root span. + // A fully prerendered page would produce no server span. + const serverSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + return span.name === 'GET /pageload-tracing' && getSpanOp(span) === 'http.server' && span.is_segment; + }); + + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-streaming-cacheComponents', span => { + return span.name === '/pageload-tracing' && getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto('/pageload-tracing'); + + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); + + const [serverSpan, pageloadSpan] = await Promise.all([serverSpanPromise, pageloadSpanPromise]); + + expect(serverSpan.trace_id).toBeTruthy(); + expect(pageloadSpan.trace_id).toBe(serverSpan.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts new file mode 100644 index 000000000000..d456686f24ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -0,0 +1,226 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-utils'; + +// Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the +// filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. +// Not implemented yet — every test is `test.fail()` with the final expected assertions. + +// A `use cache` layout between dynamic segments. The layout entry is keyed by the awaited [id] +// param. If Next serves the entry from the prerendered shell (Resume Data Cache) instead of the +// cache handlers, there is no `cache.get` span at all — then this stays failing until Next +// exposes RDC reads. +test('links a cached layout hit to the trace that filled the layout entry', async ({ request }) => { + test.fail(); + + const id = crypto.randomUUID(); + + const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /cached-mid-layout/[id]' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/cached-mid-layout/${id}`); + const missSpans = await missSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /cached-mid-layout/[id]' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/cached-mid-layout/${id}`); + const hitSpans = await hitSpansPromise; + + // The layout is the only cached entry on this route. + const putSpan = findCacheSpan(missSpans, 'cache.put'); + expect(putSpan).toBeDefined(); + + const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: putSpan!.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); + +// Inverse nesting: all layouts above are dynamic, only the leaf component is cached — the leaf +// entry is the only span that carries a link. +test('links a cached leaf component under dynamic layouts to the trace that filled the leaf entry', async ({ + request, +}) => { + test.fail(); + + const id = crypto.randomUUID(); + + const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`); + const missSpans = await missSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /dynamic-layouts/[id]/layout-cached-leaf' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/dynamic-layouts/${id}/layout-cached-leaf`); + const hitSpans = await hitSpansPromise; + + const putSpan = findCacheSpan(missSpans, 'cache.put'); + expect(putSpan).toBeDefined(); + + const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: putSpan!.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); + +// Nested levels with different lifetimes: after the layout expired, request 2 refills the layout +// while the component still hits. Request 3 then hits both entries, and its two `cache.get` +// spans point at two different origin traces. +test('links two cached levels to different origin traces after the layout expires', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); + test.fail(); + + const id = crypto.randomUUID(); + + const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /mixed-lifetimes/[id]' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.put').length >= 2 + ); + }); + + await request.get(`/mixed-lifetimes/${id}`); + const fillSpans = await fillSpansPromise; + + // Layout + component entry. + const fillPutSpans = fillSpans.filter(span => getSpanOp(span) === 'cache.put'); + expect(new Set(fillPutSpans.map(span => JSON.stringify(span.attributes['cache.key']?.value))).size).toBe(2); + + // Sleep past the layout's `expire` (2s); the component entry stays valid for hours. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const refillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /mixed-lifetimes/[id]' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/mixed-lifetimes/${id}`); + const refillSpans = await refillSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /mixed-lifetimes/[id]' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + .length >= 2 + ); + }); + + await request.get(`/mixed-lifetimes/${id}`); + const hitSpans = await hitSpansPromise; + + const layoutPutSpan = findCacheSpan(refillSpans, 'cache.put'); + expect(layoutPutSpan).toBeDefined(); + const layoutKey = JSON.stringify(layoutPutSpan!.attributes['cache.key']?.value); + const componentPutSpan = fillPutSpans.find(span => JSON.stringify(span.attributes['cache.key']?.value) !== layoutKey); + expect(componentPutSpan).toBeDefined(); + expect(layoutPutSpan!.trace_id).not.toBe(componentPutSpan!.trace_id); + + const layoutHitSpan = hitSpans.find( + span => getSpanOp(span) === 'cache.get' && JSON.stringify(span.attributes['cache.key']?.value) === layoutKey, + ); + expect(layoutHitSpan?.links).toEqual([ + { + trace_id: layoutPutSpan!.trace_id, + span_id: layoutPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); + + const componentHitSpan = hitSpans.find( + span => + getSpanOp(span) === 'cache.get' && + JSON.stringify(span.attributes['cache.key']?.value) === + JSON.stringify(componentPutSpan!.attributes['cache.key']?.value), + ); + expect(componentHitSpan?.links).toEqual([ + { + trace_id: componentPutSpan!.trace_id, + span_id: componentPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); + +// Routes `a` and `b` share one layout entry: a hit on `b` links to the fill trace of `a`, so the +// origin is a different transaction than the serving one. +test('links a shared layout hit on a sibling route to the trace of the route that filled the entry', async ({ + request, +}) => { + test.fail(); + + const id = crypto.randomUUID(); + + const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /shared-layout/[id]/a' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/shared-layout/${id}/a`); + const fillSpans = await fillSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /shared-layout/[id]/b' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/shared-layout/${id}/b`); + const hitSpans = await hitSpansPromise; + + const putSpan = findCacheSpan(fillSpans, 'cache.put'); + expect(putSpan).toBeDefined(); + + const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan!.attributes['cache.key']).toEqual(putSpan!.attributes['cache.key']); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: putSpan!.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts new file mode 100644 index 000000000000..fd8a86a5c848 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { CACHE_ORIGIN_LINK_ATTRIBUTES } from './cacheOriginLinks-utils'; + +// Origin links for `use cache` inside rendered pages (cached components and nested cached +// functions). Target behavior: a cache hit records a `cache.get` span carrying a +// `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace that filled +// the entry. Not implemented yet — every test is `test.fail()`. + +// Two cached sibling components are two cache entries (props are part of the key), so one request +// carries one `cache.get` hit span per section, each linking to its own fill. The components sit +// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach +// the cache handlers and produce no spans until Next.js exposes RDC reads. +test('links each sibling component hit to the fill of its own entry', async ({ request }) => { + test.fail(); + + const id = crypto.randomUUID(); + + const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /cached-sibling-components' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.put').length >= 2 + ); + }); + + await request.get(`/cached-sibling-components?id=${id}`); + const missSpans = await missSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /cached-sibling-components' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + .length >= 2 + ); + }); + + await request.get(`/cached-sibling-components?id=${id}`); + const hitSpans = await hitSpansPromise; + + // The key digest (`cache.key`) pairs a hit with the put that filled the same entry. + const putSpansByKey = new Map( + missSpans + .filter(span => getSpanOp(span) === 'cache.put') + .map(span => [JSON.stringify(span.attributes['cache.key']?.value), span] as const), + ); + expect(putSpansByKey.size).toBe(2); + + const hitGetSpans = hitSpans.filter( + span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true, + ); + // Deduplicated by entry because dev mode can render (and therefore read) more than once. + expect(new Set(hitGetSpans.map(span => JSON.stringify(span.attributes['cache.key']?.value))).size).toBe(2); + + for (const hitSpan of hitGetSpans) { + const putSpan = putSpansByKey.get(JSON.stringify(hitSpan.attributes['cache.key']?.value)); + expect(putSpan).toBeDefined(); + expect(hitSpan.links).toEqual([ + { + trace_id: putSpan!.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); + } + + // The two sections link to two different fill spans, not to one shared origin. + expect(new Set(hitGetSpans.map(span => span.links?.[0]?.span_id)).size).toBe(2); +}); + +test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); + test.fail(); + + const id = crypto.randomUUID(); + + const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /nested-caches' && span.is_segment) && + spansOfTrace.filter(span => getSpanOp(span) === 'cache.put').length >= 2 + ); + }); + + await request.get(`/nested-caches?id=${id}`); + const missSpans = await missSpansPromise; + + // Sleep past the component entry's hard `expire` limit (2s); the nested function entry + // (`cacheLife('hours')`) stays valid. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const refillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /nested-caches' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/nested-caches?id=${id}`); + const refillSpans = await refillSpansPromise; + + // The only hit is the nested function entry, read while the expired component entry refills. + const hitGetSpans = refillSpans.filter( + span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true, + ); + expect(hitGetSpans).toHaveLength(1); + + const nestedPutSpan = missSpans.find( + span => + getSpanOp(span) === 'cache.put' && + JSON.stringify(span.attributes['cache.key']?.value) === + JSON.stringify(hitGetSpans[0]!.attributes['cache.key']?.value), + ); + expect(nestedPutSpan).toBeDefined(); + + // Even though the read happens inside the component's isolated fill context, the link still + // points at the trace that originally filled the nested entry. + expect(hitGetSpans[0]!.links).toEqual([ + { + trace_id: nestedPutSpan!.trace_id, + span_id: nestedPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts new file mode 100644 index 000000000000..09148406915d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-utils'; + +// Origin links for `use cache` in route handlers. Target behavior: a cache hit records a +// `cache.get` span carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` +// span of the trace that filled the entry; unknown origin means no link. Not implemented yet — +// every test is `test.fail()`; shipping the feature should only require deleting those lines. + +test('links a route handler cache hit to the trace that filled the entry', async ({ request }) => { + test.fail(); + + // A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key) + // even when the test is retried against the same server. + const id = crypto.randomUUID(); + + const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/api/use-cache?id=${id}`); + const missSpans = await missSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/api/use-cache?id=${id}`); + const hitSpans = await hitSpansPromise; + + const putSpan = findCacheSpan(missSpans, 'cache.put'); + expect(putSpan).toBeDefined(); + + // A miss has no origin, and the SDK never guesses one. + const missGetSpan = findCacheSpan(missSpans, 'cache.get', false); + expect(missGetSpan?.links).toBeUndefined(); + expect(putSpan?.links).toBeUndefined(); + + const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: putSpan!.trace_id, + span_id: putSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); + +test('moves the origin link to the refill trace after the entry expires', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); + test.fail(); + + const id = crypto.randomUUID(); + + const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-expiring' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-expiring?id=${id}`); + const fillSpans = await fillSpansPromise; + + // Sleep past the entry's hard `expire` limit (2s), so the next read must discard and refill it. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + // Registered after the fill trace was consumed, so it only matches the refill. + const refillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-expiring' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') && + spansOfTrace.every(span => span.trace_id !== fillSpans[0]!.trace_id) + ); + }); + + await request.get(`/api/use-cache-expiring?id=${id}`); + const refillSpans = await refillSpansPromise; + + const hitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-expiring' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + await request.get(`/api/use-cache-expiring?id=${id}`); + const hitSpans = await hitSpansPromise; + + const refillPutSpan = findCacheSpan(refillSpans, 'cache.put'); + expect(refillPutSpan).toBeDefined(); + expect(refillPutSpan!.trace_id).not.toBe(fillSpans[0]!.trace_id); + + // The hit read the refilled entry, so the link points at the refill trace, not the first fill. + const hitGetSpan = findCacheSpan(hitSpans, 'cache.get', true); + expect(hitGetSpan).toBeDefined(); + expect(hitGetSpan?.links).toEqual([ + { + trace_id: refillPutSpan!.trace_id, + span_id: refillPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-utils.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-utils.ts new file mode 100644 index 000000000000..d43ce40324fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-utils.ts @@ -0,0 +1,16 @@ +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp } from '@sentry-internal/test-utils'; + +export const CACHE_ORIGIN_LINK_ATTRIBUTES = { + 'sentry.link.type': { value: 'cache_origin', type: 'string' }, +}; + +export function findCacheSpan( + spans: SerializedStreamedSpan[], + op: 'cache.get' | 'cache.put', + hit?: boolean, +): SerializedStreamedSpan | undefined { + return spans.find( + span => getSpanOp(span) === op && (hit === undefined || span.attributes['cache.hit']?.value === hit), + ); +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/instrument.mjs new file mode 100644 index 000000000000..f3dd95215d03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json new file mode 100644 index 000000000000..d82b94ec77f4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/package.json @@ -0,0 +1,34 @@ +{ + "name": "node-express-mcp-v1-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node --import ./instrument.mjs dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^4.21.2", + "typescript": "~5.0.0", + "zod": "~3.25.0" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "type": "module", + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/app.ts new file mode 100644 index 000000000000..3ed8df4a040a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/app.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import { mcpRouter } from './mcp.js'; + +const app = express(); +const port = 3030; + +app.use(express.json()); +app.use(mcpRouter); + +app.get('/test-success', function (_req, res) { + res.send({ version: 'v1' }); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/mcp.ts new file mode 100644 index 000000000000..c1a9dfea3622 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/src/mcp.ts @@ -0,0 +1,120 @@ +import { randomUUID } from 'node:crypto'; +import express from 'express'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { z } from 'zod'; + +const mcpRouter = express.Router(); + +// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration +// auto-instruments the `McpServer` constructor, so spans must be produced anyway. +const server = new McpServer({ + name: 'Echo-V1', + version: '1.0.0', +}); + +server.registerResource( + 'echo', + new ResourceTemplate('echo://{message}', { list: undefined }), + { title: 'Echo Resource' }, + async (uri, { message }) => ({ + contents: [ + { + uri: uri.href, + text: `Resource echo: ${message}`, + }, + ], + }), +); + +server.registerTool( + 'echo', + { description: 'Echo tool', inputSchema: { message: z.string() } }, + async ({ message }) => ({ + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }), +); + +server.registerPrompt('echo', { description: 'Echo prompt', argsSchema: { message: z.string() } }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}`, + }, + }, + ], +})); + +server.registerTool('always-error', {}, async () => { + throw new Error('intentional error for span status testing'); +}); + +const transports: Record = {}; + +mcpRouter.post('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && req.body?.method === 'initialize') { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sid => { + transports[sid] = transport; + }, + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + } + }; + + await server.connect(transport); + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID provided' }, + id: null, + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null, + }); + } + } +}); + +mcpRouter.get('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + await transports[sessionId].handleRequest(req, res); +}); + +mcpRouter.delete('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + await transports[sessionId].handleRequest(req, res); +}); + +export { mcpRouter }; diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/start-event-proxy.mjs new file mode 100644 index 000000000000..69bb0c645c0a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-mcp-v1', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tests/mcp.test.ts new file mode 100644 index 000000000000..0e34722b1243 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tests/mcp.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +// The server in this app is never wrapped with `wrapMcpServerWithSentry` — spans are produced +// solely by the auto-instrumenting `mcpServer` integration against the legacy v1 SDK. +test('auto-instruments a legacy MCP SDK v1 server (no manual wrap)', async ({ baseURL }) => { + const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); + + const client = new Client({ + name: 'test-client-v1', + version: '1.0.0', + }); + + const initializeSegmentPromise = waitForStreamedSpan( + 'node-express-mcp-v1', + segment => segment.is_segment && segment.name === 'initialize', + ); + + await client.connect(transport); + + await test.step('initialize handshake', async () => { + const initializeSegment = await initializeSegmentPromise; + expect(initializeSegment).toBeDefined(); + expect(getSpanOp(initializeSegment)).toEqual('mcp.server'); + expect(initializeSegment.attributes?.['mcp.method.name']?.value).toEqual('initialize'); + expect(initializeSegment.attributes?.['mcp.client.name']?.value).toEqual('test-client-v1'); + expect(initializeSegment.attributes?.['mcp.server.name']?.value).toEqual('Echo-V1'); + }); + + await test.step('tool call', async () => { + const toolSegmentPromise = waitForStreamedSpan( + 'node-express-mcp-v1', + segment => segment.is_segment && segment.name === 'tools/call echo', + ); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: foobar', + type: 'text', + }, + ], + }); + + const toolSegment = await toolSegmentPromise; + expect(toolSegment).toBeDefined(); + expect(getSpanOp(toolSegment)).toEqual('mcp.server'); + expect(toolSegment.attributes?.['mcp.method.name']?.value).toEqual('tools/call'); + expect(toolSegment.attributes?.['mcp.tool.name']?.value).toEqual('echo'); + }); + + await test.step('resource read', async () => { + const resourceSegmentPromise = waitForStreamedSpan( + 'node-express-mcp-v1', + segment => segment.is_segment && segment.name === 'resources/read', + ); + + const resourceResult = await client.readResource({ + uri: 'echo://foobar', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }], + }); + + const resourceSegment = await resourceSegmentPromise; + expect(resourceSegment).toBeDefined(); + expect(getSpanOp(resourceSegment)).toEqual('mcp.server'); + expect(resourceSegment.attributes?.['mcp.method.name']?.value).toEqual('resources/read'); + }); + + await test.step('error tool sets span status to error', async () => { + const toolSegmentPromise = waitForStreamedSpan( + 'node-express-mcp-v1', + segment => segment.is_segment && segment.name === 'tools/call always-error', + ); + + try { + await client.callTool({ name: 'always-error', arguments: {} }); + } catch { + // Expected: MCP SDK throws when the tool returns a JSON-RPC error + } + + const toolSegment = await toolSegmentPromise; + expect(toolSegment).toBeDefined(); + expect(getSpanOp(toolSegment)).toEqual('mcp.server'); + expect(toolSegment?.status).toEqual('error'); + }); + + await client.close(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tsconfig.json new file mode 100644 index 000000000000..21ecf1357722 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v1/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts index 6034032b46df..86076f743d62 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/src/mcp.ts @@ -3,16 +3,15 @@ import express from 'express'; import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { z } from 'zod'; -import { wrapMcpServerWithSentry } from '@sentry/node'; const mcpRouter = express.Router(); -const server = wrapMcpServerWithSentry( - new McpServer({ - name: 'Echo-V2', - version: '2.0.0', - }), -); +// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration +// auto-instruments the `McpServer` constructor, so spans must be produced anyway. +const server = new McpServer({ + name: 'Echo-V2', + version: '2.0.0', +}); server.registerResource( 'echo', diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts index 747ebda30062..c7e7e2dae2e2 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts @@ -3,7 +3,9 @@ import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; import { Client } from '@modelcontextprotocol/client'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -test('records spans for stable MCP SDK v2 handlers using the register API', async ({ baseURL }) => { +// The server in this app is never wrapped with `wrapMcpServerWithSentry` — spans are produced +// solely by the auto-instrumenting `mcpServer` integration. +test('auto-instruments a stable MCP SDK v2 server (no manual wrap)', async ({ baseURL }) => { const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); const client = new Client({ diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/.gitignore b/dev-packages/e2e-tests/test-applications/node-prisma-8/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/docker-compose.yml b/dev-packages/e2e-tests/test-applications/node-prisma-8/docker-compose.yml new file mode 100644 index 000000000000..ad294fe8d2ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/docker-compose.yml @@ -0,0 +1,21 @@ +services: + db: + # Prisma 8 requires PostgreSQL 15 or newer. + image: postgres:16 + restart: always + container_name: e2e-tests-node-prisma-8 + ports: + - '5438:5432' + environment: + POSTGRES_USER: prisma + POSTGRES_PASSWORD: prisma + POSTGRES_DB: tests + # Dumped from `prisma-cli db init`; the Prisma 8 CLI needs Node 22.18+, so it isn't run at test time. + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U prisma -d tests'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/global-setup.mjs b/dev-packages/e2e-tests/test-applications/node-prisma-8/global-setup.mjs new file mode 100644 index 000000000000..53682b876046 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start PostgreSQL via Docker Compose. `--wait` blocks until the healthcheck + // in docker-compose.yml passes, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/node-prisma-8/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql b/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql new file mode 100644 index 000000000000..655d447ca485 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql @@ -0,0 +1,64 @@ +CREATE SCHEMA prisma_contract; +CREATE TABLE prisma_contract.contract ( + core_hash text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + contract_json jsonb NOT NULL +); +CREATE TABLE prisma_contract.ledger ( + id bigint NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + space text NOT NULL, + migration_name text NOT NULL, + migration_hash text NOT NULL, + origin_core_hash text, + origin_profile_hash text, + destination_core_hash text NOT NULL, + destination_profile_hash text, + operations jsonb NOT NULL +); +CREATE SEQUENCE prisma_contract.ledger_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE prisma_contract.ledger_id_seq OWNED BY prisma_contract.ledger.id; +CREATE TABLE prisma_contract.marker ( + space text DEFAULT 'app'::text NOT NULL, + core_hash text NOT NULL, + profile_hash text NOT NULL, + contract_json jsonb, + canonical_version integer, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + app_tag text, + meta jsonb DEFAULT '{}'::jsonb NOT NULL, + invariants text[] DEFAULT '{}'::text[] NOT NULL +); +CREATE TABLE public."user" ( + email text NOT NULL, + id integer NOT NULL, + name text +); +CREATE SEQUENCE public.user_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id; +ALTER TABLE ONLY prisma_contract.ledger ALTER COLUMN id SET DEFAULT nextval('prisma_contract.ledger_id_seq'::regclass); +ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass); +INSERT INTO prisma_contract.contract VALUES ('f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '2026-09-09 09:15:28.369559+00', '{"meta": {}, "roots": {"user": {"model": "User", "namespace": "public"}}, "domain": {"namespaces": {"public": {"models": {"User": {"fields": {"id": {"type": {"kind": "scalar", "codecId": "pg/int4@1"}, "nullable": false}, "name": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": true}, "email": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": false}}, "storage": {"table": "user", "fields": {"id": {"column": "id"}, "name": {"column": "name"}, "email": {"column": "email"}}, "namespaceId": "public"}, "relations": {}}}}}}, "target": "postgres", "storage": {"namespaces": {"public": {"id": "public", "entries": {"table": {"user": {"columns": {"id": {"codecId": "pg/int4@1", "default": {"kind": "function", "expression": "autoincrement()"}, "nullable": false, "nativeType": "int4"}, "name": {"codecId": "pg/text@1", "nullable": true, "nativeType": "text"}, "email": {"codecId": "pg/text@1", "nullable": false, "nativeType": "text"}}, "indexes": [], "uniques": [{"columns": ["email"]}], "primaryKey": {"columns": ["id"]}, "foreignKeys": []}}}}}, "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5"}, "extensions": {}, "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "capabilities": {"sql": {"enums": true, "lateral": true, "returning": true, "scalarList": true, "checkConstraint": true, "defaultInInsert": true}, "postgres": {"limit": true, "jsonAgg": true, "lateral": true, "orderBy": true, "returning": true, "distinctOn": true}}, "targetFamily": "sql"}'); +INSERT INTO prisma_contract.ledger VALUES (1, '2026-09-09 09:15:28.369559+00', 'app', '', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '', NULL, 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', NULL, '[{"id": "table.user", "label": "Create table \"user\"", "target": {"id": "postgres", "details": {"name": "user", "schema": "public", "objectType": "table"}}, "execute": [{"sql": "CREATE TABLE \"public\".\"user\" (\n \"email\" text NOT NULL,\n \"id\" SERIAL NOT NULL,\n \"name\" text,\n PRIMARY KEY (\"id\")\n)", "params": [], "description": "create table \"user\""}], "summary": "Creates table \"user\"", "precheck": [{"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "ensure table \"user\" does not exist"}], "postcheck": [{"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "verify table \"user\" exists"}], "operationClass": "additive"}, {"id": "unique.user.user_email_key", "label": "Add unique constraint on \"user\" (email)", "target": {"id": "postgres", "details": {"name": "user_email_key", "table": "user", "schema": "public", "objectType": "unique"}}, "execute": [{"sql": "ALTER TABLE \"public\".\"user\" ADD CONSTRAINT \"user_email_key\" UNIQUE (\"email\")", "description": "add unique constraint \"user_email_key\""}], "precheck": [{"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "ensure constraint \"user_email_key\" does not exist"}], "postcheck": [{"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "verify constraint \"user_email_key\" exists"}], "operationClass": "additive"}]'); +INSERT INTO prisma_contract.marker VALUES ('app', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2', NULL, NULL, '2026-09-09 09:15:28.369559+00', NULL, '{}', '{}'); +ALTER TABLE ONLY prisma_contract.contract + ADD CONSTRAINT contract_pkey PRIMARY KEY (core_hash); +ALTER TABLE ONLY prisma_contract.ledger + ADD CONSTRAINT ledger_pkey PRIMARY KEY (id); +ALTER TABLE ONLY prisma_contract.marker + ADD CONSTRAINT marker_pkey PRIMARY KEY (space); +ALTER TABLE ONLY public."user" + ADD CONSTRAINT user_email_key UNIQUE (email); +ALTER TABLE ONLY public."user" + ADD CONSTRAINT user_pkey PRIMARY KEY (id); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json b/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json new file mode 100644 index 000000000000..a6e8bb692e57 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json @@ -0,0 +1,30 @@ +{ + "name": "node-prisma-8", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node --import ./dist/instrument.js dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@prisma/orm-postgres": "8.0.0-rc.8", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@types/express": "^4.17.21", + "@types/node": "^20.19.0", + "express": "^4.21.2", + "typescript": "~5.9.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-prisma-8/playwright.config.mjs new file mode 100644 index 000000000000..d5fd0b394f15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/playwright.config.mjs @@ -0,0 +1,11 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/app.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/app.ts new file mode 100644 index 000000000000..2b90f41a9edf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/app.ts @@ -0,0 +1,30 @@ +import { randomBytes } from 'node:crypto'; +import postgres from '@prisma/orm-postgres/runtime'; +import express from 'express'; +import type { Contract } from './prisma/contract.d.ts'; +import contractJson from './prisma/contract.json' with { type: 'json' }; + +const db = postgres({ + contractJson, + url: 'postgresql://prisma:prisma@localhost:5438/tests', +}); + +const app = express(); +const port = 3030; + +app.get('/test-prisma', async (_req, res) => { + const created = await db.orm.public.User.create({ + name: 'Tilda', + email: `tilda_${randomBytes(4).toString('hex')}@sentry.io`, + }); + + const users = await db.orm.public.User.all(); + + await db.orm.public.User.where(user => user.email.like('%sentry.io')).delete(); + + res.json({ created: created.id, count: users.length }); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/instrument.ts new file mode 100644 index 000000000000..f3dd95215d03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/instrument.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts new file mode 100644 index 000000000000..010a73846882 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts @@ -0,0 +1,390 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma contract emit'. +// To regenerate, run: prisma contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma/orm-postgres/adapter/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + TimeString, + Timestamp, + TimestampString, + Timestamptz, + TimestamptzString, + Timetz, + VarBit, + Varchar, +} from '@prisma/orm-postgres/target/codec-types'; + +import type { ContractWithTypeMaps, TypeMaps as TypeMapsType } from '@prisma/orm-postgres/family-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma/orm-postgres/contract/types'; + +export type StorageHash = StorageHashBase<'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +export type AggregateTypes = { + readonly avg: { + readonly byCodec: { + readonly 'pg/float@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/float4@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/float8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/time-string@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/time-temporal@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'sql/float@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + }; + }; + readonly avgDecimal: { + readonly byCodec: { + readonly 'pg/int@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + }; + }; + readonly count: { + readonly byCodec: {}; + readonly withoutInput: { readonly output: 'pg/int8number@1'; readonly nullable: false }; + readonly anyInput: { readonly output: 'pg/int8number@1'; readonly nullable: false }; + }; + readonly countBigInt: { + readonly byCodec: {}; + readonly withoutInput: { readonly output: 'pg/int8@1'; readonly nullable: false }; + readonly anyInput: { readonly output: 'pg/int8@1'; readonly nullable: false }; + }; + readonly max: { + readonly byCodec: { + readonly 'pg/char@1': { readonly output: 'pg/char@1'; readonly nullable: true }; + readonly 'pg/date-string@1': { readonly output: 'pg/date-string@1'; readonly nullable: true }; + readonly 'pg/date-temporal@1': { + readonly output: 'pg/date-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/enum@1': { readonly output: 'pg/enum@1'; readonly nullable: true }; + readonly 'pg/float@1': { readonly output: 'pg/float@1'; readonly nullable: true }; + readonly 'pg/float4@1': { readonly output: 'pg/float4@1'; readonly nullable: true }; + readonly 'pg/float8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/inet@1': { readonly output: 'pg/inet@1'; readonly nullable: true }; + readonly 'pg/int@1': { readonly output: 'pg/int@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/int2@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/int4@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/text-array@1': { readonly output: 'pg/text-array@1'; readonly nullable: true }; + readonly 'pg/text@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + readonly 'pg/time-string@1': { readonly output: 'pg/time-string@1'; readonly nullable: true }; + readonly 'pg/time-temporal@1': { + readonly output: 'pg/time-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-string@1': { + readonly output: 'pg/timestamp-string@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-temporal@1': { + readonly output: 'pg/timestamp-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timestamptz-string@1': { + readonly output: 'pg/timestamptz-string@1'; + readonly nullable: true; + }; + readonly 'pg/timestamptz-temporal@1': { + readonly output: 'pg/timestamptz-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timetz@1': { readonly output: 'pg/timetz@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { + readonly output: 'pg/unboundedint@1'; + readonly nullable: true; + }; + readonly 'pg/varchar@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + readonly 'sql/char@1': { readonly output: 'sql/char@1'; readonly nullable: true }; + readonly 'sql/float@1': { readonly output: 'sql/float@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'sql/int@1'; readonly nullable: true }; + readonly 'sql/text@1': { readonly output: 'sql/text@1'; readonly nullable: true }; + readonly 'sql/varchar@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + }; + }; + readonly min: { + readonly byCodec: { + readonly 'pg/char@1': { readonly output: 'pg/char@1'; readonly nullable: true }; + readonly 'pg/date-string@1': { readonly output: 'pg/date-string@1'; readonly nullable: true }; + readonly 'pg/date-temporal@1': { + readonly output: 'pg/date-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/enum@1': { readonly output: 'pg/enum@1'; readonly nullable: true }; + readonly 'pg/float@1': { readonly output: 'pg/float@1'; readonly nullable: true }; + readonly 'pg/float4@1': { readonly output: 'pg/float4@1'; readonly nullable: true }; + readonly 'pg/float8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/inet@1': { readonly output: 'pg/inet@1'; readonly nullable: true }; + readonly 'pg/int@1': { readonly output: 'pg/int@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/int2@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/int4@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/text-array@1': { readonly output: 'pg/text-array@1'; readonly nullable: true }; + readonly 'pg/text@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + readonly 'pg/time-string@1': { readonly output: 'pg/time-string@1'; readonly nullable: true }; + readonly 'pg/time-temporal@1': { + readonly output: 'pg/time-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-string@1': { + readonly output: 'pg/timestamp-string@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-temporal@1': { + readonly output: 'pg/timestamp-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timestamptz-string@1': { + readonly output: 'pg/timestamptz-string@1'; + readonly nullable: true; + }; + readonly 'pg/timestamptz-temporal@1': { + readonly output: 'pg/timestamptz-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timetz@1': { readonly output: 'pg/timetz@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { + readonly output: 'pg/unboundedint@1'; + readonly nullable: true; + }; + readonly 'pg/varchar@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + readonly 'sql/char@1': { readonly output: 'sql/char@1'; readonly nullable: true }; + readonly 'sql/float@1': { readonly output: 'sql/float@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'sql/int@1'; readonly nullable: true }; + readonly 'sql/text@1': { readonly output: 'sql/text@1'; readonly nullable: true }; + readonly 'sql/varchar@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + }; + }; + readonly sum: { + readonly byCodec: { + readonly 'pg/float@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/float4@1': { readonly output: 'pg/float4@1'; readonly nullable: true }; + readonly 'pg/float8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/time-string@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/time-temporal@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { + readonly output: 'pg/unboundedint@1'; + readonly nullable: true; + }; + readonly 'sql/float@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + }; + }; + readonly sumBigInt: { + readonly byCodec: { + readonly 'pg/int@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/unboundedint@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/unboundedint@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { + readonly output: 'pg/unboundedint@1'; + readonly nullable: true; + }; + readonly 'sql/int@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + }; + }; +}; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? Encoded extends CodecTypes[CodecId]['json'] + ? Encoded + : CodecTypes[CodecId]['json'] + : Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly User: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly email: CodecTypes['pg/text@1']['output']; + readonly name: CodecTypes['pg/text@1']['output'] | null; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly User: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly email: CodecTypes['pg/text@1']['input']; + readonly name: CodecTypes['pg/text@1']['input'] | null; + }; + }; +}; +export type StorageColumnTypes = { + readonly public: { + readonly user: { + readonly email: CodecTypes['pg/text@1']['output']; + readonly id: CodecTypes['pg/int4@1']['output']; + readonly name: CodecTypes['pg/text@1']['output'] | null; + }; + }; +}; +export type StorageColumnInputTypes = { + readonly public: { + readonly user: { + readonly email: CodecTypes['pg/text@1']['input']; + readonly id: CodecTypes['pg/int4@1']['input']; + readonly name: CodecTypes['pg/text@1']['input'] | null; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes, + StorageColumnTypes, + StorageColumnInputTypes, + AggregateTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly user: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + readonly default: { + readonly kind: 'function'; + readonly expression: 'autoincrement()'; + }; + }; + readonly email: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: true; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly [{ readonly columns: readonly ['email'] }]; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly user: { readonly namespace: 'public' & NamespaceId; readonly model: 'User' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly User: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly email: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly name: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly email: { readonly column: 'email' }; + readonly name: { readonly column: 'name' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly checkConstraint: true; + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + readonly scalarList: true; + }; + }; + readonly extensions: {}; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json new file mode 100644 index 000000000000..3b9090e28776 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json @@ -0,0 +1,132 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", + "roots": { + "user": { + "model": "User", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "User": { + "fields": { + "email": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "name": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "email": { + "column": "email" + }, + "id": { + "column": "id" + }, + "name": { + "column": "name" + } + }, + "namespaceId": "public", + "table": "user" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "user": { + "columns": { + "email": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "pg/int4@1", + "default": { + "expression": "autoincrement()", + "kind": "function" + }, + "nativeType": "int4", + "nullable": false + }, + "name": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": true + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [ + { + "columns": ["email"] + } + ] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "checkConstraint": true, + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true, + "scalarList": true + } + }, + "extensions": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma contract emit\".", + "regenerate": "To regenerate, run: prisma contract emit" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.prisma b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.prisma new file mode 100644 index 000000000000..e443ac76a64e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.prisma @@ -0,0 +1,9 @@ +// use prisma-next + +// Regenerate `contract.json`/`contract.d.ts` with `prisma-cli contract emit` and `../../init.sql` from +// `prisma-cli db init` (Node 22.18+). No `DateTime` column: Prisma 8 decodes timestamps into `Temporal`. +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-prisma-8/start-event-proxy.mjs new file mode 100644 index 000000000000..8095fa121543 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-prisma-8', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts new file mode 100644 index 000000000000..81cc629a2ff8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; + +const OPERATION_METHODS = ['create', 'all', 'delete']; + +function isPrismaOperation(span: SerializedStreamedSpan): boolean { + return span.attributes['sentry.origin']?.value === 'auto.db.prisma'; +} + +function isTestPrismaSegment(span: SerializedStreamedSpan): boolean { + return getSpanOp(span) === 'http.server' && span.is_segment === true && span.name === 'GET /test-prisma'; +} + +test('Prisma 8 ORM calls emit operation spans with the pg queries nested underneath', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + 'node-prisma-8', + spans => spans.some(isTestPrismaSegment) && spans.filter(isPrismaOperation).length >= OPERATION_METHODS.length, + ); + + const res = await fetch(`${baseURL}/test-prisma`); + expect(res.status).toBe(200); + + const spans = await spansPromise; + const segment = spans.find(isTestPrismaSegment)!; + const operationSpans = spans.filter(isPrismaOperation); + const spansById = new Map(spans.map(span => [span.span_id, span])); + // The Express request-handler span sits between the segment and the ORM calls. + const isInSegment = (span: SerializedStreamedSpan): boolean => { + for (let parent = span.parent_span_id; parent; parent = spansById.get(parent)?.parent_span_id) { + if (parent === segment.span_id) { + return true; + } + } + return false; + }; + + expect(operationSpans.map(span => span.attributes['method']?.value)).toEqual(OPERATION_METHODS); + operationSpans.forEach(span => { + expect(span.name).toBe('prisma:client:operation'); + expect(isInSegment(span)).toBe(true); + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.db.prisma', type: 'string' }, + 'sentry.op': { value: 'db', type: 'string' }, + 'db.operation.name': { value: span.attributes['method']?.value, type: 'string' }, + 'db.collection.name': { value: 'user', type: 'string' }, + model: { value: 'User', type: 'string' }, + name: { value: `User.${span.attributes['method']?.value}`, type: 'string' }, + }); + }); + + const queriesUnder = (method: string): unknown[] => { + const operation = operationSpans.find(span => span.attributes['method']?.value === method)!; + // `pg.connect` spans are `db` spans too, but carry no statement. + return spans + .filter( + span => + getSpanOp(span) === 'db' && + span.attributes['db.query.text']?.value && + span.parent_span_id === operation.span_id, + ) + .map(span => span.attributes['db.query.text']?.value); + }; + expect(queriesUnder('create')).toEqual( + expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]), + ); + expect(queriesUnder('all')).toEqual([expect.stringMatching(/^SELECT .* FROM "public"\."user"$/)]); + expect(queriesUnder('delete')).toEqual( + expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]), + ); + + const dbSpans = spans.filter(span => getSpanOp(span) === 'db' && span.attributes['db.query.text']?.value); + dbSpans.forEach(span => { + expect(span.attributes['sentry.origin']?.value).toBe('auto.db.postgres'); + expect(span.attributes['db.system.name']?.value).toBe('postgresql'); + expect(span.parent_span_id).not.toBe(segment.span_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-prisma-8/tsconfig.json new file mode 100644 index 000000000000..6dd09736082f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index cdf4885a9703..36aea2e0b856 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -19,6 +19,9 @@ }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", + "@nuxt/cli is pinned. The CLI nightlies register nitro runtime plugins that live inside the CLI package: https://github.com/nuxt/cli/pull/1570", + "Nuxt's impound guard blocks imports from that package in the dev server runtime, so every SSR request fails in dev mode.", + "TODO: remove the @nuxt/cli override when impound allows the CLI runtime plugins", "TODO: remove nitro from dependencies", "Nuxt generates `fetch.server.mjs` as a virtual module that imports `ofetch`, and since a virtual id is neither absolute nor on disk, Vite resolves from its `root` (the srcDir) instead of the importer's folder.", "This fails because `ofetch` is a dependency of `nuxt` rather than the app, so pnpm leaves it in `.pnpm/`", @@ -36,6 +39,11 @@ "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils" }, + "pnpm": { + "overrides": { + "@nuxt/cli": "npm:@nuxt/cli-nightly@4.0.0-20260927-110353-6eaa0eb" + } + }, "sentryTest": { "optional": true }, diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json index cefd05e58db5..3cfd16a29ae2 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json @@ -45,6 +45,7 @@ "serve": "14.0.1" }, "volta": { + "node": "20.19.5", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts index ccf430475620..e8639a6bfba7 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/lib/fragments.ts @@ -102,73 +102,3 @@ export const CART_QUERY_FRAGMENT = `#graphql } } ` as const; - -const MENU_FRAGMENT = `#graphql - fragment MenuItem on MenuItem { - id - resourceId - tags - title - type - url - } - fragment ChildMenuItem on MenuItem { - ...MenuItem - } - fragment ParentMenuItem on MenuItem { - ...MenuItem - items { - ...ChildMenuItem - } - } - fragment Menu on Menu { - id - items { - ...ParentMenuItem - } - } -` as const; - -export const HEADER_QUERY = `#graphql - fragment Shop on Shop { - id - name - description - primaryDomain { - url - } - brand { - logo { - image { - url - } - } - } - } - query Header( - $country: CountryCode - $headerMenuHandle: String! - $language: LanguageCode - ) @inContext(language: $language, country: $country) { - shop { - ...Shop - } - menu(handle: $headerMenuHandle) { - ...Menu - } - } - ${MENU_FRAGMENT} -` as const; - -export const FOOTER_QUERY = `#graphql - query Footer( - $country: CountryCode - $footerMenuHandle: String! - $language: LanguageCode - ) @inContext(language: $language, country: $country) { - menu(handle: $footerMenuHandle) { - ...Menu - } - } - ${MENU_FRAGMENT} -` as const; diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx index 3f42a000a461..d79ed2379aba 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/app/root.tsx @@ -48,11 +48,7 @@ export function links() { } export async function loader({ context }: { context: LoaderFunctionArgs['context'] }) { - const { storefront, session, cart } = context as { - storefront: { - query: (query: string, options: any) => Promise; - CacheLong: () => any; - }; + const { session, cart } = context as { session: HydrogenSession; cart: unknown; env: unknown; @@ -68,27 +64,9 @@ export async function loader({ context }: { context: LoaderFunctionArgs['context // defer the cart query by not awaiting it const cartPromise = typedCart.get(); - // defer the footer query (below the fold) - const footerPromise = storefront.query(FOOTER_QUERY, { - cache: storefront.CacheLong(), - variables: { - footerMenuHandle: 'footer', // Adjust to your footer menu handle - }, - }); - - // await the header query (above the fold) - const headerPromise = storefront.query(HEADER_QUERY, { - cache: storefront.CacheLong(), - variables: { - headerMenuHandle: 'main-menu', // Adjust to your header menu handle - }, - }); - return defer( { cart: cartPromise, - footer: footerPromise, - header: await headerPromise, isLoggedIn, publicStoreDomain, }, @@ -225,73 +203,3 @@ async function validateCustomerAccessToken(session: HydrogenSession, customerAcc return { isLoggedIn, headers }; } - -const MENU_FRAGMENT = `#graphql - fragment MenuItem on MenuItem { - id - resourceId - tags - title - type - url - } - fragment ChildMenuItem on MenuItem { - ...MenuItem - } - fragment ParentMenuItem on MenuItem { - ...MenuItem - items { - ...ChildMenuItem - } - } - fragment Menu on Menu { - id - items { - ...ParentMenuItem - } - } -` as const; - -const HEADER_QUERY = `#graphql - fragment Shop on Shop { - id - name - description - primaryDomain { - url - } - brand { - logo { - image { - url - } - } - } - } - query Header( - $country: CountryCode - $headerMenuHandle: String! - $language: LanguageCode - ) @inContext(language: $language, country: $country) { - shop { - ...Shop - } - menu(handle: $headerMenuHandle) { - ...Menu - } - } - ${MENU_FRAGMENT} -` as const; - -const FOOTER_QUERY = `#graphql - query Footer( - $country: CountryCode - $footerMenuHandle: String! - $language: LanguageCode - ) @inContext(language: $language, country: $country) { - menu(handle: $footerMenuHandle) { - ...Menu - } - } - ${MENU_FRAGMENT} -` as const; diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx new file mode 100644 index 000000000000..9325143b071d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -0,0 +1,34 @@ +import { createController } from 'remix/router'; +import type { Handle } from 'remix/ui'; + +import { assets, entryHref, entryPreloads } from '../assets.ts'; +import { routes } from '../routes.ts'; + +function HomePage(handle: Handle>) { + return () => ( + + + + Sentry Remix 3 + {entryPreloads.map(href => ( + + ))} + + + +

    Sentry Remix 3

    + + + ); +} + +export default createController(routes, { + actions: { + async assets(context) { + return (await assets.fetch(context.request)) ?? new Response('Not Found', { status: 404 }); + }, + home(context) { + return context.render(); + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts new file mode 100644 index 000000000000..5f0ebdc1248e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -0,0 +1,9 @@ +import { run } from 'remix/ui'; + +// No Sentry here yet: `@sentry/remix/v3/client` does not export `init` until the browser SDK lands. +export const app = run({ + async loadModule(moduleUrl, exportName) { + let mod = await import(moduleUrl); + return mod[exportName]; + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts new file mode 100644 index 000000000000..a3c153578aa6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts @@ -0,0 +1,15 @@ +import { createAssetServer } from 'remix/assets'; + +export const assets = createAssetServer({ + basePath: '/assets', + rootDir: process.cwd(), + allowFiles: ['app/routes.ts', 'app/**/public/**'], + allowPackages: ['remix', '@sentry/remix'], + minify: true, + watch: false, +}); + +const entry = 'app/actions/public/entry.ts'; + +export const entryHref = await assets.getHref(entry); +export const entryPreloads = await assets.getPreloads(entry); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts new file mode 100644 index 000000000000..aef480456066 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/router.ts @@ -0,0 +1,21 @@ +import { createRouter, type MiddlewareContext } from 'remix/router'; +import { render } from 'remix/middleware/render'; + +import controller from './actions/controller.tsx'; +import { assets } from './assets.ts'; +import { routes } from './routes.ts'; + +const renderMiddleware = render({ assets }); +type AppContext = MiddlewareContext<[typeof renderMiddleware]>; + +declare module 'remix/router' { + interface RouterTypes { + context: AppContext; + } +} + +export const router = createRouter({ + middleware: [renderMiddleware], +}); + +router.map(routes, controller); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts new file mode 100644 index 000000000000..1ebc77927332 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts @@ -0,0 +1,6 @@ +import { get, route } from 'remix/routes'; + +export const routes = route({ + assets: get('/assets/*path'), + home: '/', +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/package.json b/dev-packages/e2e-tests/test-applications/remix-v3/package.json new file mode 100644 index 000000000000..7f5e5928e5e4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/package.json @@ -0,0 +1,28 @@ +{ + "name": "remix-v3-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "NODE_ENV=production node --import @sentry/remix/v3/node server.ts", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", + "remix": "3.0.0-rc.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@types/node": "^24.6.0", + "typescript": "~5.9.0" + }, + "volta": { + "node": "24.18.0", + "pnpm": "9.15.9" + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/remix-v3/playwright.config.mjs new file mode 100644 index 000000000000..fe438320a9c1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/playwright.config.mjs @@ -0,0 +1,9 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + port: 3060, + eventProxyPort: 3061, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts new file mode 100644 index 000000000000..56408af2bbe9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/remix/v3'; +import * as http from 'node:http'; +import { createRequestListener } from 'remix/node-fetch-server'; + +import { router } from './app/router.ts'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3061/', + tracesSampleRate: 1.0, +}); + +const port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3060; + +const server = http.createServer( + createRequestListener(async request => { + try { + return await router.fetch(request); + } catch { + return new Response('Internal Server Error', { status: 500 }); + } + }), +); + +server.listen(port, () => { + console.log(`Server listening on http://localhost:${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/remix-v3/start-event-proxy.mjs new file mode 100644 index 000000000000..a1fc09b10ccc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3061, + proxyServerName: 'remix-v3', +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/smoke.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/smoke.test.ts new file mode 100644 index 000000000000..85e4619e49c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/smoke.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan } from '@sentry-internal/test-utils'; + +// There is no instrumentation yet. This app exists so later pull requests add instrumentation and its +// tests together, rather than also introducing a new CI surface. +test('the app boots under the Sentry --import entry', async ({ page }) => { + await page.goto('/'); + + await expect(page.locator('#home')).toBeVisible(); +}); + +test('Sentry.init from the v3 subpath reports a server span', async ({ baseURL }) => { + // Names are still URL based. This only proves the subpath resolves and the SDK is live. + const spanPromise = waitForStreamedSpan('remix-v3', span => span.is_segment === true); + + await fetch(`${baseURL}/`); + + const span = await spanPromise; + expect(span.attributes?.['http.request.method']?.value).toBe('GET'); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-v3/tsconfig.json new file mode 100644 index 000000000000..0c60cc275fbb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "strict": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "types": ["node"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ESNext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "jsxImportSource": "remix/ui", + "noEmit": true + }, + "exclude": ["tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json index 32ba083c62c3..58c2b2b37c00 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-2/package.json @@ -34,8 +34,7 @@ "vitest": "^1.5.0" }, "volta": { - "extends": "../../package.json", - "node": "24.15.0" + "extends": "../../package.json" }, "engines": { "node": ">=24" diff --git a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json index 9aaf9e1de0ce..1077b195acb6 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-static/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-static/package.json @@ -34,6 +34,7 @@ "vitest": "^3.2.7" }, "volta": { + "node": "20.19.5", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 5fa6f2bdb1ca..ed907f5c5a47 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -36,6 +36,7 @@ "vitest": "^3.2.7" }, "volta": { + "node": "20.19.5", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json index c14f48ab84db..aadd142baf49 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/package.json @@ -31,7 +31,6 @@ "wrangler": "^4.61.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json index 18b2fd3d1d5e..5cf6accf0cd1 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json @@ -35,7 +35,6 @@ "wrangler": "^4.68.1" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { 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 cb25a8631317..77277c1e9394 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/package.json +++ b/dev-packages/e2e-tests/test-applications/tsx-express/package.json @@ -30,6 +30,7 @@ "@types/qs": "6.9.17" }, "volta": { + "node": "20.19.5", "extends": "../../package.json" } } 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 e87d8ce706b1..c05e9581f155 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -18,7 +18,10 @@ "test:build-no-options-api": "pnpm install && VUE_OPTIONS_API=false pnpm build", "test:assert-no-options-api": "VUE_OPTIONS_API=false pnpm test:assert", "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\"" + "test:print-version": "node -p \"'Vue version: ' + require('vue/package.json').version\"", + "test:build-vue-router-5": "pnpm install && pnpm add vue-router@5 && pnpm build", + "test:assert-vue-router-5": "pnpm test:print-version && pnpm test:print-router-version && playwright test", + "test:print-router-version": "node -p \"'Vue Router version: ' + require('vue-router/package.json').version\"" }, "dependencies": { "@sentry/vue": "file:../../packed/sentry-vue-packed.tgz", @@ -48,6 +51,11 @@ { "build-command": "pnpm test:build-latest", "label": "vue-3 (latest)" + }, + { + "build-command": "pnpm test:build-vue-router-5", + "assert-command": "pnpm test:assert-vue-router-5", + "label": "vue-3 (vue-router 5)" } ], "optionalVariants": [ diff --git a/dev-packages/node-integration-tests/README.md b/dev-packages/node-integration-tests/README.md index b2d8db2124d1..e8e9aedf5620 100644 --- a/dev-packages/node-integration-tests/README.md +++ b/dev-packages/node-integration-tests/README.md @@ -34,6 +34,13 @@ Nock interceptors are internally used to capture envelope requests by `getEnvelo be used inside the test cases to intercept requests but should be removed before the test ends, as not to cause flakiness. +## Other Runtimes + +`dev-packages/bun-integration-tests` and `dev-packages/deno-integration-tests` run every suite of this package on Bun +and Deno as well. When you add a suite, check that it passes on both. If it cannot, add it to +`node-suites/excludes.ts` of the runtime's package with the reason, or skip the single tests that fail with +`test.skipIf(RUNTIME === 'bun')` or `test.skipIf(RUNTIME === 'deno')`. + ## Running Tests Locally Tests can be run locally with: diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 31054913628e..cadbb4cc7cee 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -108,7 +108,7 @@ }, "devDependencies": { "@opentelemetry/instrumentation-http": "0.220.0", - "@sentry/conventions": "0.23.0", + "@sentry/conventions": "0.24.0", "@sentry-internal/test-utils": "11.0.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", diff --git a/dev-packages/node-integration-tests/suites/consola/test.ts b/dev-packages/node-integration-tests/suites/consola/test.ts index aee250967e64..1afcd97cb025 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createCjsTests } from '../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../utils'; describe('consola integration', () => { afterAll(() => { @@ -22,7 +23,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'info', type: 'string' }, @@ -39,7 +40,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'error', type: 'string' }, @@ -56,7 +57,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'warn', type: 'string' }, @@ -89,7 +90,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'info', type: 'string' }, @@ -106,7 +107,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'error', type: 'string' }, @@ -123,7 +124,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'warn', type: 'string' }, @@ -141,7 +142,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'success', type: 'string' }, @@ -158,7 +159,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'fail', type: 'string' }, @@ -175,7 +176,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'ready', type: 'string' }, @@ -192,7 +193,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'start', type: 'string' }, @@ -209,7 +210,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'box', type: 'string' }, @@ -226,7 +227,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'verbose', type: 'string' }, @@ -242,7 +243,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'debug', type: 'string' }, @@ -259,7 +260,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'trace', type: 'string' }, @@ -291,7 +292,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'info', type: 'string' }, @@ -308,7 +309,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'debug', type: 'string' }, @@ -352,7 +353,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.tag': { value: 'api', type: 'string' }, @@ -370,7 +371,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.tag': { value: 'api', type: 'string' }, @@ -404,7 +405,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'warn', type: 'string' }, @@ -422,7 +423,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'error', type: 'string' }, @@ -454,7 +455,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'fatal', type: 'string' }, @@ -471,7 +472,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'warn', type: 'string' }, @@ -488,7 +489,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'info', type: 'string' }, @@ -520,7 +521,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'info', type: 'string' }, @@ -539,7 +540,7 @@ describe('consola integration', () => { 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, 'consola.type': { value: 'info', type: 'string' }, diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts index 63683b9ab7fd..8873d746159a 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts @@ -2,6 +2,7 @@ import { mkdirSync, rmdirSync, unlinkSync, writeFileSync } from 'fs'; import * as path from 'path'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { RUNTIME } from '../../../utils'; const EXPECTED_LOCAL_VARIABLES_EVENT = { exception: { @@ -79,14 +80,15 @@ module.exports = { out_of_app_function };`, .completed(); }); - test('Should include local variables when enabled', async () => { + // Bun and Deno: the error events have no local variables. + test.skipIf(RUNTIME !== 'node')('Should include local variables when enabled', async () => { await createRunner(__dirname, 'local-variables.js') .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) .start() .completed(); }); - test('Should include local variables when instrumenting via --import', async () => { + test.skipIf(RUNTIME !== 'node')('Should include local variables when instrumenting via --import', async () => { const instrumentPath = path.resolve(__dirname, 'local-variables-instrument.cjs'); await createRunner(__dirname, 'local-variables-no-sentry.js') @@ -96,7 +98,7 @@ module.exports = { out_of_app_function };`, .completed(); }); - test('Should include local variables with ESM', async () => { + test.skipIf(RUNTIME !== 'node')('Should include local variables with ESM', async () => { await createRunner(__dirname, 'local-variables-caught.mjs') .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) .start() @@ -107,36 +109,39 @@ module.exports = { out_of_app_function };`, await createRunner(__dirname, 'deny-inspector.mjs').ensureNoErrorOutput().start().completed(); }); - test('Should retain original local variables when error is re-thrown', async () => { + test.skipIf(RUNTIME !== 'node')('Should retain original local variables when error is re-thrown', async () => { await createRunner(__dirname, 'local-variables-rethrow.js') .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) .start() .completed(); }); - test('Includes local variables for caught exceptions when enabled', async () => { + test.skipIf(RUNTIME !== 'node')('Includes local variables for caught exceptions when enabled', async () => { await createRunner(__dirname, 'local-variables-caught.js') .expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT }) .start() .completed(); }); - test('Filters local variables by name via dataCollection.stackFrameVariables', async () => { - await createRunner(__dirname, 'local-variables-filtered.js') - .expect({ - event: event => { - const frame = event.exception?.values?.[0]?.stacktrace?.frames?.find(frame => frame.function === 'one'); - - expect(frame?.vars).toEqual({ - name: 'some name', - keepVar: 'keep me', - secretVar: '[Filtered]', - }); - }, - }) - .start() - .completed(); - }); + test.skipIf(RUNTIME !== 'node')( + 'Filters local variables by name via dataCollection.stackFrameVariables', + async () => { + await createRunner(__dirname, 'local-variables-filtered.js') + .expect({ + event: event => { + const frame = event.exception?.values?.[0]?.stacktrace?.frames?.find(frame => frame.function === 'one'); + + expect(frame?.vars).toEqual({ + name: 'some name', + keepVar: 'keep me', + secretVar: '[Filtered]', + }); + }, + }) + .start() + .completed(); + }, + ); test('Does not attach local variables when dataCollection.stackFrameVariables is false', async () => { await createRunner(__dirname, 'local-variables-disabled.js') @@ -151,7 +156,7 @@ module.exports = { out_of_app_function };`, .completed(); }); - test('Should handle different function name formats', async () => { + test.skipIf(RUNTIME !== 'node')('Should handle different function name formats', async () => { await createRunner(__dirname, 'local-variables-name-matching.js') .expect({ event: { @@ -177,30 +182,33 @@ module.exports = { out_of_app_function };`, .completed(); }); - test('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => { - await createRunner(__dirname, 'local-variables-out-of-app.js') - .expect({ - event: event => { - const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; - - const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); - const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); - - expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); - expect(inAppFrame?.in_app).toEqual(true); - - expect(outOfAppFrame?.vars).toEqual({ - outOfAppVar: 'out of app value modified value', - passedArg: 'in app value modified value', - }); - expect(outOfAppFrame?.in_app).toEqual(false); - }, - }) - .start() - .completed(); - }); + test.skipIf(RUNTIME !== 'node')( + 'adds local variables to out of app frames when includeOutOfAppFrames is true', + async () => { + await createRunner(__dirname, 'local-variables-out-of-app.js') + .expect({ + event: event => { + const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; + + const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); + const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); + + expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); + expect(inAppFrame?.in_app).toEqual(true); + + expect(outOfAppFrame?.vars).toEqual({ + outOfAppVar: 'out of app value modified value', + passedArg: 'in app value modified value', + }); + expect(outOfAppFrame?.in_app).toEqual(false); + }, + }) + .start() + .completed(); + }, + ); - test('does not add local variables to out of app frames by default', async () => { + test.skipIf(RUNTIME !== 'node')('does not add local variables to out of app frames by default', async () => { await createRunner(__dirname, 'local-variables-out-of-app-default.js') .expect({ event: event => { 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 55e22ba46558..ef52edf7059d 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 @@ -2,6 +2,7 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { describe, expect, test } from 'vitest'; import { createRunner } from '../../../utils/runner'; +import { RUNTIME } from '../../../utils'; describe('OnUncaughtException integration', () => { test('should close process on uncaught error with no additional listeners registered', () => @@ -122,7 +123,8 @@ describe('OnUncaughtException integration', () => { .completed(); }); - describe('Worker thread error handling', () => { + // Bun and Deno: the worker thread errors are not handled as on Node. + describe.skipIf(RUNTIME !== 'node')('Worker thread error handling', () => { 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('--import', path.join(__dirname, `worker-thread/instrument.${extension}`)) diff --git a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts index d81b602e0527..f02c1fc2d384 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logger/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logger/test.ts @@ -1,6 +1,7 @@ import type { SerializedLog } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../utils'; const commonAttributes: SerializedLog['attributes'] = { 'sentry.environment': { @@ -13,7 +14,7 @@ const commonAttributes: SerializedLog['attributes'] = { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', diff --git a/dev-packages/node-integration-tests/suites/public-api/logs/test.ts b/dev-packages/node-integration-tests/suites/public-api/logs/test.ts index 8afc4402475d..cbbb6d309ce7 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logs/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logs/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../utils'; describe('logger public API', () => { afterAll(() => { @@ -21,7 +22,7 @@ describe('logger public API', () => { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', @@ -62,7 +63,7 @@ describe('logger public API', () => { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', @@ -103,7 +104,7 @@ describe('logger public API', () => { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts index dfb3094f1bb9..05dc9da2ae0a 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../../utils'; describe('metrics server.address', () => { afterAll(() => { @@ -23,7 +24,7 @@ describe('metrics server.address', () => { 'server.address': { value: 'mi-servidor.com', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts index 86eb295e0c5d..4b59653855aa 100644 --- a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../../utils'; describe('metrics server.address', () => { afterAll(() => { @@ -23,7 +24,7 @@ describe('metrics server.address', () => { 'server.address': { value: expect.any(String), type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.timestamp.sequence': { value: expect.any(Number), type: 'integer' }, }, 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 9b266552b052..1e4db38e3f3c 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 @@ -1,5 +1,6 @@ import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../utils'; describe('metrics', () => { afterAll(() => { @@ -22,7 +23,7 @@ describe('metrics', () => { endpoint: { value: '/api/test', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -37,7 +38,7 @@ describe('metrics', () => { server: { value: 'test-1', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -52,7 +53,7 @@ describe('metrics', () => { priority: { value: 'high', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -66,7 +67,7 @@ describe('metrics', () => { operation: { value: 'test', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -83,7 +84,7 @@ describe('metrics', () => { 'user.name': { value: 'testuser', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, }, @@ -121,7 +122,7 @@ describe('metrics', () => { }, 'sentry.sdk.name': { type: 'string', - value: 'sentry.javascript.node', + value: EXPECTED_SDK_NAME, }, 'sentry.sdk.version': { type: 'string', diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts index 2b90612ed505..0cc15ce97173 100644 --- a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts @@ -3,6 +3,7 @@ import * as childProcess from 'child_process'; import * as path from 'path'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { RUNTIME } from '../../../utils'; describe('onUnhandledRejectionIntegration', () => { afterAll(() => { @@ -141,7 +142,8 @@ test rejection`); .completed(); }); - test('handles unhandled rejection in spans', async () => { + // Bun: the error event arrives before the transaction, or has a different span id. + test.skipIf(RUNTIME === 'bun')('handles unhandled rejection in spans', async () => { let transactionEvent: Event | undefined; let errorEvent: Event | undefined; @@ -168,7 +170,7 @@ test rejection`); expect(transactionEvent!.contexts!.trace!.span_id).toBe(errorEvent!.contexts!.trace!.span_id); }); - test('handles unhandled rejection in spans that are ended early', async () => { + test.skipIf(RUNTIME === 'bun')('handles unhandled rejection in spans that are ended early', async () => { let transactionEvent: Event | undefined; let errorEvent: Event | undefined; diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index 3aef47e9c9ba..1d2bd3cd8bfc 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -17,6 +17,7 @@ import { } from '@sentry/conventions/attributes'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../../../utils'; test('sends a streamed span envelope with correct envelope header', async () => { await createRunner(__dirname, 'scenario.ts') @@ -24,7 +25,7 @@ test('sends a streamed span envelope with correct envelope header', async () => span: { sent_at: expect.any(String), sdk: { - name: 'sentry.javascript.node', + name: EXPECTED_SDK_NAME, version: SDK_VERSION, }, trace: expect.objectContaining({ @@ -63,7 +64,7 @@ test('sends a streamed span envelope with correct spans for a manually started s type: 'string', value: 'test-child', }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, @@ -87,7 +88,7 @@ test('sends a streamed span envelope with correct spans for a manually started s attributes: { [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, 'sentry.is_localhost': { type: 'boolean', value: false }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, @@ -124,7 +125,7 @@ test('sends a streamed span envelope with correct spans for a manually started s attributes: { [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, 'sentry.is_localhost': { type: 'boolean', value: false }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, @@ -147,7 +148,7 @@ test('sends a streamed span envelope with correct spans for a manually started s 'sentry.is_localhost': { type: 'boolean', value: false }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.node' }, + [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { type: 'array', diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts index 301f5520f553..fade84b952a4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts @@ -49,8 +49,9 @@ describeWithDockerCompose('amqplib auto-instrumentation', { workingDirectory: [_ }); describe.each([ + ['v0', {}], ['v1', { amqplib: '^1.0.0' }], - ['v2', {}], + ['v2', { amqplib: '^2.0.0' }], ])('%s', (_version, additionalDependencies) => { createEsmAndCjsTests( __dirname, diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/scenario.ts index 254aa9adc579..5890e9c365cf 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/scenario.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracePropagationTargets: [/\/v0/, 'v1'], diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/test.ts index 131c5b208239..6c568715f905 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span-unsampled/test.ts @@ -3,7 +3,7 @@ import { createRunner } from '../../../../utils/runner'; test('envelope header for error event during active unsampled span is correct', async () => { await createRunner(__dirname, 'scenario.ts') - .ignore('transaction') + .ignore('span') .expectHeader({ event: { trace: { diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span/scenario.ts index 020b687d2e22..b45aed2e5572 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span/scenario.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracePropagationTargets: [/\/v0/, 'v1'], diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span/test.ts index 4b103c7e7d79..cd00e51ea958 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error-active-span/test.ts @@ -3,7 +3,7 @@ import { createRunner } from '../../../../utils/runner'; test('envelope header for error event during active span is correct', async () => { await createRunner(__dirname, 'scenario.ts') - .ignore('transaction') + .ignore('span') .expectHeader({ event: { trace: { diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error/scenario.ts index 16d7d686b2f1..e87cf53b8c80 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/error/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/error/scenario.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracePropagationTargets: [/\/v0/, 'v1'], diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/server.js b/dev-packages/node-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/server.js index 9d1af464ce56..6f7ecfa269f0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/server.js @@ -2,7 +2,6 @@ const { loggingTransport } = require('@sentry-internal/node-integration-tests'); const Sentry = require('@sentry/node'); Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', // disable attaching headers to /test/* endpoints diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/test.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/test.ts index 63db6ff4e820..cdb264095446 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/sampleRate-propagation/test.ts @@ -8,11 +8,12 @@ describe('tracesSampleRate propagation', () => { const traceId = '12345678123456781234567812345678'; - test('uses sample rate from incoming baggage header in trace envelope item', async () => { + test('uses the incoming sample rate in the streamed span envelope header', async () => { const runner = createRunner(__dirname, 'server.js') .expectHeader({ - transaction: { + span: { trace: { + public_key: 'public', sample_rate: '0.05', sampled: 'true', trace_id: traceId, @@ -22,10 +23,10 @@ describe('tracesSampleRate propagation', () => { }, }) .start(); - runner.makeRequest('get', '/test', { + await runner.makeRequest('get', '/test', { headers: { 'sentry-trace': `${traceId}-1234567812345678-1`, - baggage: `sentry-sample_rate=0.05,sentry-trace_id=${traceId},sentry-sampled=true,sentry-transaction=myTransaction,sentry-sample_rand=0.42`, + baggage: `sentry-public_key=public,sentry-sample_rate=0.05,sentry-trace_id=${traceId},sentry-sampled=true,sentry-transaction=myTransaction,sentry-sample_rand=0.42`, }, }); await runner.completed(); diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-route/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-route/scenario.ts index 87f2a025a57c..4d67fee0a0d2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-route/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-route/scenario.ts @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracePropagationTargets: [/\/v0/, 'v1'], diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-route/test.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-route/test.ts index 9798194b0c1c..63883ec44577 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-route/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-route/test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -test('envelope header for transaction event of route correct', async () => { +test('includes the route name in the streamed span envelope header', async () => { await createRunner(__dirname, 'scenario.ts') .expectHeader({ - transaction: { + span: { trace: { trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-url/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-url/scenario.ts index 093856dd1d12..69b76f248555 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-url/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-url/scenario.ts @@ -3,7 +3,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracePropagationTargets: [/\/v0/, 'v1'], diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-url/test.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-url/test.ts index 7b6b07191c44..c448336148a1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-url/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction-url/test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -test('envelope header for transaction event with source=url correct', async () => { +test('omits the URL-sourced name from the streamed span envelope header', async () => { await createRunner(__dirname, 'scenario.ts') .expectHeader({ - transaction: { + span: { trace: { trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction/scenario.ts index f8e208bd56d7..05b2e5d48de2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction/scenario.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ - traceLifecycle: 'static', dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracePropagationTargets: [/\/v0/, 'v1'], diff --git a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction/test.ts b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction/test.ts index 24276f57f212..8b7cdfbf8a18 100644 --- a/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/envelope-header/transaction/test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; -test('envelope header for transaction event is correct', async () => { +test('envelope header for streamed spans is correct', async () => { await createRunner(__dirname, 'scenario.ts') .expectHeader({ - transaction: { + span: { trace: { trace_id: expect.stringMatching(/[a-f\d]{32}/), public_key: 'public', diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 80e6b9c25b33..6f51960996fa 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -199,7 +199,13 @@ describe('Google GenAI integration', () => { expect(nonStreamingToolsSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content'); expect(nonStreamingToolsSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined(); expect(nonStreamingToolsSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined(); - expect(nonStreamingToolsSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeDefined(); + expect(JSON.parse(nonStreamingToolsSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS].value as string)).toEqual([ + { + id: 'call_light_control_1', + name: 'controlLight', + args: { brightness: 0.3, colorTemperature: 'warm' }, + }, + ]); expect(nonStreamingToolsSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toBe( JSON.stringify([ { @@ -231,7 +237,13 @@ describe('Google GenAI integration', () => { expect(streamingToolsSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('generate_content'); expect(streamingToolsSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeDefined(); expect(streamingToolsSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeDefined(); - expect(streamingToolsSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS]).toBeDefined(); + expect(JSON.parse(streamingToolsSpan!.attributes[GEN_AI_RESPONSE_TOOL_CALLS].value as string)).toEqual([ + { + id: 'call_light_stream_1', + name: 'controlLight', + args: { brightness: 0.5, colorTemperature: 'cool' }, + }, + ]); // The text arrives either side of the tool call, so it stays two parts. expect(streamingToolsSpan!.attributes[GEN_AI_OUTPUT_MESSAGES].value).toBe( JSON.stringify([ 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 76c62fdf7bd3..afd5cbe927a6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -2,6 +2,7 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; +import { RUNTIME } from '../../../utils'; function getCommonHttpRequestHeaders(): Record { return { @@ -173,7 +174,8 @@ describe('httpIntegration', () => { }); }); - describe('custom server.emit', () => { + // Deno: the requests sometimes get a 500 response when `server.emit` is overwritten. + describe.skipIf(RUNTIME === 'deno')('custom server.emit', () => { createEsmAndCjsTests( __dirname, 'scenario-overwrite-server-emit.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts index ac7e950b58cb..5aa058ff4d7c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts @@ -1,5 +1,6 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { afterAll, describe, expect } from 'vitest'; +import { RUNTIME } from '../../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; describe('ignoring a continued server segment (streaming)', () => { @@ -40,7 +41,10 @@ describe('ignoring a continued server segment (streaming)', () => { }; test('propagates a negative sampling decision to outgoing fetch requests', () => testPropagation('/ignored')); - test('propagates a negative sampling decision to outgoing node:http requests', () => - testPropagation('/ignored-http')); + // Bun 1.3.14 does not instrument outgoing `node:http` requests. + // See https://github.com/getsentry/sentry-javascript/issues/23881 + test.skipIf(RUNTIME === 'bun')('propagates a negative sampling decision to outgoing node:http requests', () => + testPropagation('/ignored-http'), + ); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/instrument.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/instrument.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario-start-v2.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario-start-v2.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario-v1.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario-v1.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario.mjs rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/scenario.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts similarity index 97% rename from dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts rename to dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts index d53f5756a4b6..ecf2f6e2ef7c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/manual-instrumentation/test.ts @@ -1,6 +1,6 @@ import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; function mcpSpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpanContainer['items'] { return container.items.filter(item => item.attributes['sentry.op']?.value === 'mcp.server'); @@ -20,7 +20,7 @@ function assertInitializeSpan(container: SerializedStreamedSpanContainer): void expect(initializeSpan.attributes['test.mcp.initialize_spans_started']).toEqual({ type: 'integer', value: 1 }); } -describe('MCP server spans (streamed)', () => { +describe('MCP server spans (streamed, manual instrumentation)', () => { afterAll(() => { cleanupChildProcesses(); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/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', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/scenario.mjs new file mode 100644 index 000000000000..03b2198132f2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/scenario.mjs @@ -0,0 +1,28 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; + +// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration +// auto-instruments the `McpServer` constructor via orchestrion, so spans must appear anyway. +const server = new McpServer({ name: 'Echo', version: '1.0.0' }); + +server.registerResource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), {}, async uri => ({ + contents: [{ uri: uri.href, text: 'Resource echo' }], +})); + +server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'Tool echo' }] })); + +async function run() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + await client.readResource({ uri: 'echo://foobar' }); + await client.callTool({ name: 'echo', arguments: {} }); + + await client.close(); + await server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts new file mode 100644 index 000000000000..178858873426 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v1/test.ts @@ -0,0 +1,79 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +function mcpSpan( + container: SerializedStreamedSpanContainer, + method: string, +): SerializedStreamedSpanContainer['items'][number] { + const span = container.items.find( + item => + item.attributes['sentry.op']?.value === 'mcp.server' && item.attributes['mcp.method.name']?.value === method, + ); + // Throwing here makes the (unordered) runner treat this container as "not the one" and wait for + // the next — streaming batches several segments per container, so the target may be elsewhere. + expect(span, `expected an mcp.server span for ${method}`).toBeDefined(); + return span!; +} + +// The `McpServer` is never manually wrapped — these assertions only pass if the `mcpServer` +// integration auto-instrumented the constructor of the legacy (`@modelcontextprotocol/sdk` v1) SDK. +// Each span type is asserted in its own runner so batched span containers can't consume an envelope +// another assertion still needs. +describe('MCP server spans (streamed, auto-instrumentation, v1)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments the initialize handshake', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const initialize = mcpSpan(container, 'initialize'); + expect(initialize.name).toBe('initialize'); + expect(initialize.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.function.mcp_server', + }); + }, + }) + .start() + .completed(); + }); + + test('auto-instruments a resource read', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const resource = mcpSpan(container, 'resources/read'); + expect(resource.name).toBe('resources/read'); + expect(resource.attributes['mcp.resource.uri']?.value).toBe('echo://foobar'); + }, + }) + .start() + .completed(); + }); + + test('auto-instruments a tool call', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const tool = mcpSpan(container, 'tools/call'); + expect(tool.name).toBe('tools/call echo'); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/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', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/scenario.mjs new file mode 100644 index 000000000000..8abcb9b54ac4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/scenario.mjs @@ -0,0 +1,27 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; + +// Intentionally NOT wrapped with `wrapMcpServerWithSentry`: the `mcpServer` integration +// auto-instruments the `McpServer` constructor via orchestrion, so spans must appear anyway. +const server = new McpServer({ name: 'Echo', version: '1.0.0' }); + +server.registerResource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), {}, async uri => ({ + contents: [{ uri: uri.href, text: 'Resource echo' }], +})); + +server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'Tool echo' }] })); + +async function run() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + await client.readResource({ uri: 'echo://foobar' }); + await client.callTool({ name: 'echo', arguments: {} }); + + await client.close(); + await server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/test.ts new file mode 100644 index 000000000000..2b1a56785f5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server/v2/test.ts @@ -0,0 +1,72 @@ +import type { SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +function mcpSpan( + container: SerializedStreamedSpanContainer, + method: string, +): SerializedStreamedSpanContainer['items'][number] { + const span = container.items.find( + item => + item.attributes['sentry.op']?.value === 'mcp.server' && item.attributes['mcp.method.name']?.value === method, + ); + // Throwing here makes the (unordered) runner treat this container as "not the one" and wait for + // the next — streaming batches several segments per container, so the target may be elsewhere. + expect(span, `expected an mcp.server span for ${method}`).toBeDefined(); + return span!; +} + +// The `McpServer` is never manually wrapped — these assertions only pass if the `mcpServer` +// integration auto-instrumented the constructor. Each span type is asserted in its own runner so +// batched span containers can't consume an envelope another assertion still needs. +describe('MCP server spans (streamed, auto-instrumentation, v2)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('auto-instruments the initialize handshake', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const initialize = mcpSpan(container, 'initialize'); + expect(initialize.name).toBe('initialize'); + expect(initialize.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.function.mcp_server', + }); + }, + }) + .start() + .completed(); + }); + + test('auto-instruments a resource read', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const resource = mcpSpan(container, 'resources/read'); + expect(resource.name).toBe('resources/read'); + expect(resource.attributes['mcp.resource.uri']?.value).toBe('echo://foobar'); + }, + }) + .start() + .completed(); + }); + + test('auto-instruments a tool call', async () => { + await createTestRunner() + .unordered() + .expect({ + span: container => { + const tool = mcpSpan(container, 'tools/call'); + expect(tool.name).toBe('tools/call echo'); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts index 4ad1b3150f2c..e0b7c43abb1d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/no-parent-span-client-report/test.ts @@ -1,4 +1,5 @@ import { afterAll, describe, expect } from 'vitest'; +import { RUNTIME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('no_parent_span client report', () => { @@ -7,24 +8,29 @@ describe('no_parent_span client report', () => { }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('records no_parent_span outcome for an outgoing http request without a local parent', async () => { - const runner = createRunner() - .unignore('client_report') - .expect({ - client_report: report => { - expect(report.discarded_events).toEqual([ - { - category: 'span', - quantity: 1, - reason: 'no_parent_span', - }, - ]); - }, - }) - .start(); + // Bun 1.3.14 does not instrument outgoing `node:http` requests. + // See https://github.com/getsentry/sentry-javascript/issues/23881 + test.skipIf(RUNTIME === 'bun')( + 'records no_parent_span outcome for an outgoing http request without a local parent', + async () => { + const runner = createRunner() + .unignore('client_report') + .expect({ + client_report: report => { + expect(report.discarded_events).toEqual([ + { + category: 'span', + quantity: 1, + reason: 'no_parent_span', + }, + ]); + }, + }) + .start(); - await runner.completed(); - }); + await runner.completed(); + }, + ); }); createEsmAndCjsTests(__dirname, 'scenario-fetch.mjs', 'instrument.mjs', (createRunner, test) => { 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 4b166d7a38d2..0a2be19dbc3f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -322,6 +322,16 @@ describe('OpenAI integration', () => { type: 'string', value: 'auto.ai.openai', }); + + // GenAI inputs and outputs must never be recorded when `dataCollection.genAI` disables them. + // Asserting over every span in the envelope catches attributes leaking onto + // spans which are not individually inspected above. + for (const span of container.items) { + expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined(); + expect(span.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + expect(span.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + } }, }) .start() @@ -845,6 +855,16 @@ describe('OpenAI integration', () => { type: 'integer', value: 10, }); + + // GenAI inputs and outputs must never be recorded when `dataCollection.genAI` disables them. + // Asserting over every span in the envelope catches attributes leaking onto + // spans which are not individually inspected above. + for (const span of container.items) { + expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(span.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBeUndefined(); + expect(span.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + expect(span.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + } }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index 645fe2ff8d12..92a48d809282 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -227,7 +227,8 @@ describeWithDockerCompose('postgres auto instrumentation (streamed)', { workingD }); }); - conditionalTest({ max: 25 })('pg-native', () => { + // Deno: with a module load hook installed, Deno compiles a native addon (`libpq`) as JavaScript. + conditionalTest({ max: 25, skipRuntimes: ['deno'] })('pg-native', () => { createEsmAndCjsTests( __dirname, 'scenario-native.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts index ce96a147a315..304af1aaffb0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -276,7 +276,8 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ }); }); - conditionalTest({ max: 25 })('pg-native', () => { + // Deno: with a module load hook installed, Deno compiles a native addon (`libpq`) as JavaScript. + conditionalTest({ max: 25, skipRuntimes: ['deno'] })('pg-native', () => { const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ diff --git a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts index 7e99d58dedff..d893e5200004 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgresjs/test.ts @@ -488,7 +488,9 @@ describeWithDockerCompose('postgresjs auto instrumentation', { workingDirectory: createEsmAndCjsTests(__dirname, 'scenario-unsafe.mjs', 'instrument.mjs', (createTestRunner, test) => { test('should instrument sql.unsafe() queries', { timeout: 90_000 }, async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + // The last query fails on purpose, and its unhandled rejection also sends an error event, which can + // arrive before the transaction. + await createTestRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/docker-compose.yml new file mode 100644 index 000000000000..9acf7180d21c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.9' + +services: + db: + # Prisma 8 requires PostgreSQL 15 or newer. + image: postgres:16 + restart: always + ports: + - '5436:5432' + environment: + POSTGRES_USER: prisma + POSTGRES_PASSWORD: prisma + POSTGRES_DB: tests + # Dumped from `prisma-cli db init`; the Prisma 8 CLI needs Node 22.18+, so it isn't run at test time. + volumes: + - ./prisma/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U prisma -d tests'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/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', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.json b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.json new file mode 100644 index 000000000000..3b9090e28776 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.json @@ -0,0 +1,132 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", + "roots": { + "user": { + "model": "User", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "User": { + "fields": { + "email": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "name": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "email": { + "column": "email" + }, + "id": { + "column": "id" + }, + "name": { + "column": "name" + } + }, + "namespaceId": "public", + "table": "user" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "user": { + "columns": { + "email": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "pg/int4@1", + "default": { + "expression": "autoincrement()", + "kind": "function" + }, + "nativeType": "int4", + "nullable": false + }, + "name": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": true + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [ + { + "columns": ["email"] + } + ] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "checkConstraint": true, + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true, + "scalarList": true + } + }, + "extensions": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma contract emit\".", + "regenerate": "To regenerate, run: prisma contract emit" + } +} diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.prisma b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.prisma new file mode 100644 index 000000000000..11706f4db019 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.prisma @@ -0,0 +1,9 @@ +// use prisma-next + +// Regenerate `contract.json` with `prisma-cli contract emit` and `init.sql` from `prisma-cli db init` (Node 22.18+). +// No `DateTime` column: Prisma 8 decodes timestamps into `Temporal`, global only on Node 26. +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? +} diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/init.sql b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/init.sql new file mode 100644 index 000000000000..655d447ca485 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/init.sql @@ -0,0 +1,64 @@ +CREATE SCHEMA prisma_contract; +CREATE TABLE prisma_contract.contract ( + core_hash text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + contract_json jsonb NOT NULL +); +CREATE TABLE prisma_contract.ledger ( + id bigint NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + space text NOT NULL, + migration_name text NOT NULL, + migration_hash text NOT NULL, + origin_core_hash text, + origin_profile_hash text, + destination_core_hash text NOT NULL, + destination_profile_hash text, + operations jsonb NOT NULL +); +CREATE SEQUENCE prisma_contract.ledger_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE prisma_contract.ledger_id_seq OWNED BY prisma_contract.ledger.id; +CREATE TABLE prisma_contract.marker ( + space text DEFAULT 'app'::text NOT NULL, + core_hash text NOT NULL, + profile_hash text NOT NULL, + contract_json jsonb, + canonical_version integer, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + app_tag text, + meta jsonb DEFAULT '{}'::jsonb NOT NULL, + invariants text[] DEFAULT '{}'::text[] NOT NULL +); +CREATE TABLE public."user" ( + email text NOT NULL, + id integer NOT NULL, + name text +); +CREATE SEQUENCE public.user_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id; +ALTER TABLE ONLY prisma_contract.ledger ALTER COLUMN id SET DEFAULT nextval('prisma_contract.ledger_id_seq'::regclass); +ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass); +INSERT INTO prisma_contract.contract VALUES ('f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '2026-09-09 09:15:28.369559+00', '{"meta": {}, "roots": {"user": {"model": "User", "namespace": "public"}}, "domain": {"namespaces": {"public": {"models": {"User": {"fields": {"id": {"type": {"kind": "scalar", "codecId": "pg/int4@1"}, "nullable": false}, "name": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": true}, "email": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": false}}, "storage": {"table": "user", "fields": {"id": {"column": "id"}, "name": {"column": "name"}, "email": {"column": "email"}}, "namespaceId": "public"}, "relations": {}}}}}}, "target": "postgres", "storage": {"namespaces": {"public": {"id": "public", "entries": {"table": {"user": {"columns": {"id": {"codecId": "pg/int4@1", "default": {"kind": "function", "expression": "autoincrement()"}, "nullable": false, "nativeType": "int4"}, "name": {"codecId": "pg/text@1", "nullable": true, "nativeType": "text"}, "email": {"codecId": "pg/text@1", "nullable": false, "nativeType": "text"}}, "indexes": [], "uniques": [{"columns": ["email"]}], "primaryKey": {"columns": ["id"]}, "foreignKeys": []}}}}}, "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5"}, "extensions": {}, "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "capabilities": {"sql": {"enums": true, "lateral": true, "returning": true, "scalarList": true, "checkConstraint": true, "defaultInInsert": true}, "postgres": {"limit": true, "jsonAgg": true, "lateral": true, "orderBy": true, "returning": true, "distinctOn": true}}, "targetFamily": "sql"}'); +INSERT INTO prisma_contract.ledger VALUES (1, '2026-09-09 09:15:28.369559+00', 'app', '', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '', NULL, 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', NULL, '[{"id": "table.user", "label": "Create table \"user\"", "target": {"id": "postgres", "details": {"name": "user", "schema": "public", "objectType": "table"}}, "execute": [{"sql": "CREATE TABLE \"public\".\"user\" (\n \"email\" text NOT NULL,\n \"id\" SERIAL NOT NULL,\n \"name\" text,\n PRIMARY KEY (\"id\")\n)", "params": [], "description": "create table \"user\""}], "summary": "Creates table \"user\"", "precheck": [{"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "ensure table \"user\" does not exist"}], "postcheck": [{"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "verify table \"user\" exists"}], "operationClass": "additive"}, {"id": "unique.user.user_email_key", "label": "Add unique constraint on \"user\" (email)", "target": {"id": "postgres", "details": {"name": "user_email_key", "table": "user", "schema": "public", "objectType": "unique"}}, "execute": [{"sql": "ALTER TABLE \"public\".\"user\" ADD CONSTRAINT \"user_email_key\" UNIQUE (\"email\")", "description": "add unique constraint \"user_email_key\""}], "precheck": [{"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "ensure constraint \"user_email_key\" does not exist"}], "postcheck": [{"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "verify constraint \"user_email_key\" exists"}], "operationClass": "additive"}]'); +INSERT INTO prisma_contract.marker VALUES ('app', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2', NULL, NULL, '2026-09-09 09:15:28.369559+00', NULL, '{}', '{}'); +ALTER TABLE ONLY prisma_contract.contract + ADD CONSTRAINT contract_pkey PRIMARY KEY (core_hash); +ALTER TABLE ONLY prisma_contract.ledger + ADD CONSTRAINT ledger_pkey PRIMARY KEY (id); +ALTER TABLE ONLY prisma_contract.marker + ADD CONSTRAINT marker_pkey PRIMARY KEY (space); +ALTER TABLE ONLY public."user" + ADD CONSTRAINT user_email_key UNIQUE (email); +ALTER TABLE ONLY public."user" + ADD CONSTRAINT user_pkey PRIMARY KEY (id); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/scenario.mjs new file mode 100644 index 000000000000..a588cda77b05 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/scenario.mjs @@ -0,0 +1,36 @@ +// ESM-only package: the CJS variant gets the namespace from `require(esm)`, so the factory is read off `default`. +import * as prismaPostgres from '@prisma/orm-postgres/runtime'; +import * as Sentry from '@sentry/node'; +import { randomBytes } from 'crypto'; +import contractJson from './prisma/contract.json' with { type: 'json' }; + +const url = 'postgresql://prisma:prisma@localhost:5436/tests'; + +async function run() { + const db = prismaPostgres.default({ contractJson, url }); + + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + // With `require(esm)` the module-injection notice arrives on a later tick, so a call in the same + // tick as the require would run before the SDK subscribes to the channels. + await new Promise(resolve => setImmediate(resolve)); + + await db.orm.public.User.create({ + name: 'Tilda', + email: `tilda_${randomBytes(4).toString('hex')}@sentry.io`, + }); + + await db.orm.public.User.all(); + + await db.orm.public.User.where(user => user.email.like('%sentry.io')).delete(); + }, + ); + + await db.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts new file mode 100644 index 000000000000..0604374c83d6 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts @@ -0,0 +1,132 @@ +import type { SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +function getSegment(container: SerializedStreamedSpanContainer): SerializedStreamedSpan { + const segment = container.items.find(item => item.is_segment); + expect(segment?.name).toBe('Test Transaction'); + return segment!; +} + +function getQuerySpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpan[] { + const querySpans = container.items.filter( + item => item.attributes['sentry.origin']?.value === 'auto.db.postgres' && item.attributes['db.query.text']?.value, + ); + expect(querySpans.map(span => span.name)).toEqual( + expect.arrayContaining(['INSERT "public"."user"', 'SELECT "public"."user"', 'DELETE "public"."user"']), + ); + querySpans.forEach(span => { + expect(span.attributes['sentry.op']?.value).toBe('db'); + expect(span.attributes['db.system.name']?.value).toBe('postgresql'); + }); + return querySpans; +} + +function getOperationSpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpan[] { + return container.items.filter(item => item.attributes['sentry.origin']?.value === 'auto.db.prisma'); +} + +// Prisma 8 has no tracing surface: the operation spans come from the orchestrion channels the runtime hook +// injects, and the `pg` spans underneath stand in for v7's `db_query` spans. +describe('Prisma ORM v8 Tests', () => { + describeWithDockerCompose('Prisma ORM v8', { workingDirectory: [__dirname] }, () => { + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test, mode) => { + const testInstrumentedOperations = (): void => { + test('should instrument Prisma ORM operations and nest their queries', { timeout: 75_000 }, async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const segment = getSegment(container); + const querySpans = getQuerySpans(container); + const operationSpans = getOperationSpans(container); + + expect(operationSpans.map(span => span.name)).toEqual([ + 'prisma:client:operation', + 'prisma:client:operation', + 'prisma:client:operation', + ]); + expect(operationSpans.map(span => span.attributes['method']?.value)).toEqual([ + 'create', + 'all', + 'delete', + ]); + operationSpans.forEach(span => { + const method = span.attributes['method']?.value; + expect(span.parent_span_id).toBe(segment.span_id); + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.db.prisma', type: 'string' }, + 'sentry.op': { value: 'db', type: 'string' }, + 'db.operation.name': { value: method, type: 'string' }, + 'db.collection.name': { value: 'user', type: 'string' }, + model: { value: 'User', type: 'string' }, + name: { value: `User.${method}`, type: 'string' }, + }); + }); + + const queriesByOperation = (method: string): unknown[] => { + const operation = operationSpans.find(span => span.attributes['method']?.value === method); + return querySpans + .filter(span => span.parent_span_id === operation?.span_id) + .map(span => span.attributes['db.query.text']?.value); + }; + expect(queriesByOperation('create')).toEqual( + expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]), + ); + expect(queriesByOperation('all')).toEqual([ + expect.stringMatching(/^SELECT .* FROM "public"\."user"$/), + ]); + expect(queriesByOperation('delete')).toEqual( + expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]), + ); + expect(querySpans.filter(span => span.parent_span_id === segment.span_id)).toEqual([]); + }, + }) + .start() + .completed(); + }); + }; + + if (mode === 'esm') { + testInstrumentedOperations(); + return; + } + + // The CJS scenario loads the ESM-only package via `require(esm)`, which Node's module hooks only see + // from Node 22 on; on Node 20 the ORM terminals load uninstrumented. + conditionalTest({ min: 22 })('with require(esm) reaching the module hooks', testInstrumentedOperations); + + conditionalTest({ max: 21 })('with require(esm) bypassing the module hooks', () => { + test('should keep the pg spans on the transaction', { timeout: 75_000 }, async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const segment = getSegment(container); + expect(getOperationSpans(container)).toEqual([]); + getQuerySpans(container).forEach(span => expect(span.parent_span_id).toBe(segment.span_id)); + }, + }) + .start() + .completed(); + }); + }); + }, + { + additionalDependencies: { + '@prisma/orm-postgres': '8.0.0-rc.8', + }, + copyPaths: ['prisma'], + }, + ); + }); +}); 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 b8c3f8946f94..1367c3ed3a07 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,5 +1,6 @@ import { createTestServer } from '@sentry-internal/test-utils'; import { describe, expect } from 'vitest'; +import { RUNTIME } from '../../../../utils'; import { createEsmAndCjsTests } from '../../../../utils/runner'; describe('outgoing traceparent', () => { @@ -39,7 +40,9 @@ describe('outgoing traceparent', () => { }); createEsmAndCjsTests(__dirname, 'scenario-http.mjs', 'instrument.mjs', (createRunner, test) => { - test('outgoing http requests should get traceparent headers', async () => { + // Bun 1.3.14 does not instrument outgoing `node:http` requests. + // See https://github.com/getsentry/sentry-javascript/issues/23881 + test.skipIf(RUNTIME === 'bun')('outgoing http requests should get traceparent headers', async () => { expect.assertions(5); const [SERVER_URL, closeTestServer] = await createTestServer() diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts index 9e9d6b7e3291..eb5c81a0a49d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts @@ -1,4 +1,5 @@ import { afterAll, expect } from 'vitest'; +import { conditionalTest } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [__dirname] }, () => { @@ -92,6 +93,46 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_ }); }); + // tedious 20 requires Node >= 22. + conditionalTest({ min: 22 })('tedious v20', () => { + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-span-streaming.mjs', + (createTestRunner, test) => { + test('should auto-instrument `tedious` package', async () => { + await createTestRunner() + .expect({ + span: container => { + const dbSpans = container.items.filter(item => item.attributes['sentry.origin']?.value === ORIGIN); + + expect(dbSpans.map(span => span.name)).toEqual( + expect.arrayContaining([ + 'SELECT', + 'callProcedure [dbo].[test_proced]', + 'INSERT [dbo].[test_prepared]', + 'execBulkLoad test_bulk', + 'SELECT [dbo].[test_bulk]', + ]), + ); + expect(dbSpans.find(span => span.name === 'select')?.status).toBe('error'); + expect(dbSpans[0]?.attributes).toMatchObject({ + 'db.system.name': { value: 'mssql' }, + 'db.namespace': { value: 'master' }, + 'db.user': { value: 'sa' }, + 'server.address': { value: '127.0.0.1' }, + 'server.port': { value: 1433 }, + }); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { tedious: '^20' } }, + ); + }); + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createTestRunner, test) => { test('should name spans after the query summary with span streaming', async () => { await createTestRunner() diff --git a/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/test.ts b/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/test.ts index 64809c28aa24..c315cf866900 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tracer-start-active-span-error/test.ts @@ -16,6 +16,9 @@ describe('tracer.startActiveSpan errors', () => { // `catch` inside the callback. test('does NOT mark span errored when uncaught error escapes raw tracer.startActiveSpan callback', async () => { await createRunner() + // The rejection is captured as an error event, which can reach the transport before the + // transaction. + .ignore('event') .expect({ transaction: { transaction: 'test span name', diff --git a/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument-manual-no-recording.mjs b/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument-manual-no-recording.mjs new file mode 100644 index 000000000000..605a0ca368f2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument-manual-no-recording.mjs @@ -0,0 +1,13 @@ +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, + dataCollection: { genAI: { inputs: false, outputs: false } }, + // `instrumentTypeSafeClient` is the manual path for runtimes without the orchestrion hook. + // Drop the automatic integration so the scenario exercises it alone. + integrations: integrations => integrations.filter(integration => integration.name !== 'TypeSafe'), +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument-manual.mjs b/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument-manual.mjs new file mode 100644 index 000000000000..b405a672e86c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument-manual.mjs @@ -0,0 +1,12 @@ +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, + // `instrumentTypeSafeClient` is the manual path for runtimes without the orchestrion hook. + // Drop the automatic integration so the scenario exercises it alone. + integrations: integrations => integrations.filter(integration => integration.name !== 'TypeSafe'), +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument-no-recording.mjs b/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument-no-recording.mjs new file mode 100644 index 000000000000..ed7a6cfe9f0a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument-no-recording.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, + dataCollection: { genAI: { inputs: false, outputs: false } }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/typesafe/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/typesafe/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', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/typesafe/scenario-manual.mjs b/dev-packages/node-integration-tests/suites/tracing/typesafe/scenario-manual.mjs new file mode 100644 index 000000000000..30dcd790a49d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/typesafe/scenario-manual.mjs @@ -0,0 +1,87 @@ +import * as Sentry from '@sentry/node'; +import { choice, noul, score, TypeSafeClient } from '@typesafe-ai/sdk'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/systemone', (req, res) => { + if (req.body.model === 'error-model') { + res.status(400).json({ error: 'Unknown model' }); + return; + } + + res.json({ + model: 'jev-1.13.0', + answers: { + authIssue: { type: 'noul', noul: 0.98 }, + department: { + type: 'choice', + choice: 'billing', + confidence: 0.28, + probabilities: { billing: 0.64, technical: 0.36 }, + }, + urgency: { + type: 'score', + score: 1.58, + confidence: 0.9, + legend: { 0: 'low', 1: 'medium', 2: 'high' }, + probabilities: { 0: 0, 1: 0.42, 2: 0.58 }, + }, + }, + usage: { input_tokens: 275, output_tokens: 20 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + const client = Sentry.instrumentTypeSafeClient( + new TypeSafeClient({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}`, + retry: { maxRetries: 0 }, + }), + ); + const state = 'I cannot log in, and I also want a refund for last month.'; + const questions = { + authIssue: noul('Is there a login problem?'), + department: choice('Which team should handle this?', { + billing: 'Charges and refunds', + technical: 'Bugs and outages', + }), + urgency: score('How urgent is this ticket?', ['low', 'medium', 'high']), + }; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await client.systemOne({ state, questions }); + + // The instrumentation must not read the body the caller gets from `asResponse()`. + const response = await client.systemOne({ model: 'jev-1.13', state, questions }).asResponse(); + await response.json(); + + try { + await client.systemOne({ model: 'error-model', state, questions }); + } catch { + // expected + } + + // The SDK rejects empty questions with a `TypeSafeError` before sending the request. + try { + await client.systemOne({ model: 'validation-error', state, questions: {} }); + } catch { + // expected + } + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/typesafe/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/typesafe/scenario.mjs new file mode 100644 index 000000000000..694a9fd97d59 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/typesafe/scenario.mjs @@ -0,0 +1,85 @@ +import * as Sentry from '@sentry/node'; +import { choice, noul, score, TypeSafeClient } from '@typesafe-ai/sdk'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/systemone', (req, res) => { + if (req.body.model === 'error-model') { + res.status(400).json({ error: 'Unknown model' }); + return; + } + + res.json({ + model: 'jev-1.13.0', + answers: { + authIssue: { type: 'noul', noul: 0.98 }, + department: { + type: 'choice', + choice: 'billing', + confidence: 0.28, + probabilities: { billing: 0.64, technical: 0.36 }, + }, + urgency: { + type: 'score', + score: 1.58, + confidence: 0.9, + legend: { 0: 'low', 1: 'medium', 2: 'high' }, + probabilities: { 0: 0, 1: 0.42, 2: 0.58 }, + }, + }, + usage: { input_tokens: 275, output_tokens: 20 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + const client = new TypeSafeClient({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}`, + retry: { maxRetries: 0 }, + }); + const state = 'I cannot log in, and I also want a refund for last month.'; + const questions = { + authIssue: noul('Is there a login problem?'), + department: choice('Which team should handle this?', { + billing: 'Charges and refunds', + technical: 'Bugs and outages', + }), + urgency: score('How urgent is this ticket?', ['low', 'medium', 'high']), + }; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await client.systemOne({ state, questions }); + + // The instrumentation must not read the body the caller gets from `asResponse()`. + const response = await client.systemOne({ model: 'jev-1.13', state, questions }).asResponse(); + await response.json(); + + try { + await client.systemOne({ model: 'error-model', state, questions }); + } catch { + // expected + } + + // The SDK rejects empty questions with a `TypeSafeError` before sending the request. + try { + await client.systemOne({ model: 'validation-error', state, questions: {} }); + } catch { + // expected + } + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/typesafe/test.ts b/dev-packages/node-integration-tests/suites/tracing/typesafe/test.ts new file mode 100644 index 000000000000..e5feb8919745 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/typesafe/test.ts @@ -0,0 +1,161 @@ +import { + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, + GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MODEL, + GEN_AI_RESPONSE_MODEL, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_OP, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('TypeSafe integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe.each([ + ['automatic', 'scenario.mjs', 'instrument.mjs', 'instrument-no-recording.mjs'], + ['manual', 'scenario-manual.mjs', 'instrument-manual.mjs', 'instrument-manual-no-recording.mjs'], + ])('%s instrumentation', (_, scenarioFile, instrumentFile, noRecordingInstrumentFile) => { + createEsmAndCjsTests( + __dirname, + scenarioFile, + instrumentFile, + (createRunner, test) => { + test('creates evaluate spans for systemOne', async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const evaluateSpans = container.items.filter( + span => span.attributes[SENTRY_OP]?.value === 'gen_ai.evaluate', + ); + expect(evaluateSpans).toHaveLength(4); + for (const span of evaluateSpans) { + expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.ai.typesafe'); + expect(span.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('evaluate'); + expect(span.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('typesafe'); + } + + const defaultModelSpan = evaluateSpans.find(span => span.name === 'evaluate jev-latest')!; + expect(defaultModelSpan).toBeDefined(); + expect(defaultModelSpan.status).toBe('ok'); + expect(defaultModelSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('jev-latest'); + expect(defaultModelSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('jev-1.13.0'); + expect(defaultModelSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(275); + expect(defaultModelSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(20); + expect(defaultModelSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(295); + expect(JSON.parse(defaultModelSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value as string)).toEqual([ + { + type: 'evaluation', + state: 'I cannot log in, and I also want a refund for last month.', + questions: { + authIssue: { type: 'noul', instructions: 'Is there a login problem?' }, + department: { + type: 'choice', + instructions: 'Which team should handle this?', + criteria: { billing: 'Charges and refunds', technical: 'Bugs and outages' }, + }, + urgency: { + type: 'score', + instructions: 'How urgent is this ticket?', + criteria: ['low', 'medium', 'high'], + }, + }, + }, + ]); + expect(JSON.parse(defaultModelSpan.attributes[GEN_AI_OUTPUT_MESSAGES]?.value as string)).toEqual([ + { + type: 'evaluation', + answers: { + authIssue: { type: 'noul', noul: 0.98 }, + department: { + type: 'choice', + choice: 'billing', + confidence: 0.28, + probabilities: { billing: 0.64, technical: 0.36 }, + }, + urgency: { + type: 'score', + score: 1.58, + confidence: 0.9, + legend: { 0: 'low', 1: 'medium', 2: 'high' }, + probabilities: { 0: 0, 1: 0.42, 2: 0.58 }, + }, + }, + }, + ]); + + const asResponseSpan = evaluateSpans.find(span => span.name === 'evaluate jev-1.13')!; + expect(asResponseSpan).toBeDefined(); + expect(asResponseSpan.status).toBe('ok'); + expect(asResponseSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('jev-1.13.0'); + + const errorSpan = evaluateSpans.find(span => span.name === 'evaluate error-model')!; + expect(errorSpan).toBeDefined(); + expect(errorSpan.status).toBe('error'); + expect(errorSpan.attributes[GEN_AI_RESPONSE_MODEL]).toBeUndefined(); + expect(errorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined(); + + const validationErrorSpan = evaluateSpans.find(span => span.name === 'evaluate validation-error')!; + expect(validationErrorSpan).toBeDefined(); + expect(validationErrorSpan.status).toBe('error'); + expect(validationErrorSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined(); + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + '@typesafe-ai/sdk': '^0.6.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + scenarioFile, + noRecordingInstrumentFile, + (createRunner, test) => { + test('does not record inputs or outputs when recording is off', async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const evaluateSpans = container.items.filter( + span => span.attributes[SENTRY_OP]?.value === 'gen_ai.evaluate', + ); + expect(evaluateSpans).toHaveLength(4); + expect(evaluateSpans.filter(span => span.status === 'error')).toHaveLength(2); + for (const span of evaluateSpans) { + expect(span.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(span.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined(); + // State, questions and answers must not come back through another attribute (e.g. an error message). + // Only the attributes are checked (timestamps could match a number), and `probabilities` only + // occurs in answers. + expect(JSON.stringify(span.attributes)).not.toMatch( + /cannot log in|Charges and refunds|probabilities/, + ); + } + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + '@typesafe-ai/sdk': '^0.6.0', + }, + }, + ); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-evaluate-no-recording.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-evaluate-no-recording.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-evaluate-no-recording.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, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-evaluate.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-evaluate.mjs new file mode 100644 index 000000000000..852786895514 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/instrument-evaluate.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, + dataCollection: {}, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-evaluate.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-evaluate.mjs new file mode 100644 index 000000000000..42fc5ead26c7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/scenario-evaluate.mjs @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/node'; +import { experimental_evaluate } from 'ai'; +import { Experimental_EvaluationMockModelV4 } from 'ai/test'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await experimental_evaluate({ + model: new Experimental_EvaluationMockModelV4({ + provider: 'gateway', + modelId: 'typesafe-ai/jev', + doEvaluate: async () => ({ + answers: { + authIssue: { type: 'boolean', probability: 0.97 }, + department: { + type: 'choice', + choice: 'billing', + probabilities: { billing: 0.64, technical: 0.36 }, + }, + wantsRefund: { type: 'boolean', probability: 0.99 }, + urgency: { type: 'score', score: 1.8, probabilities: { 0: 0, 1: 0.2, 2: 0.8 } }, + }, + usage: { inputTokens: 275, outputTokens: 20 }, + // What the AI SDK TypeSafe provider returns: answer confidence moves into provider metadata. + providerMetadata: { typesafe: { confidence: { department: 0.28 } } }, + warnings: [], + }), + }), + state: 'I cannot log in, and I also want a refund for last month.', + questions: { + authIssue: { type: 'boolean', instructions: 'Is there a login problem?' }, + department: { + type: 'choice', + instructions: 'Which team should handle this?', + criteria: { billing: 'Charges and refunds', technical: 'Bugs and outages' }, + }, + wantsRefund: { type: 'boolean', instructions: 'Is a refund requested?' }, + urgency: { type: 'score', instructions: 'How urgent is this ticket?', criteria: ['low', 'medium', 'high'] }, + }, + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts index 2468f974af55..f8a26a410833 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v6_v7/test.ts @@ -4,6 +4,7 @@ import { GEN_AI_CONVERSATION_ID, GEN_AI_EMBEDDINGS_INPUT, GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_MODEL, @@ -997,3 +998,116 @@ describe.each(matrix)('Vercel AI integration (version %s)', (version, vercelAiVe }, ); }); + +describe('Vercel AI integration experimental_evaluate', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmTests( + __dirname, + 'scenario-evaluate.mjs', + 'instrument-evaluate.mjs', + (createRunner, test) => { + test('creates an evaluate span', async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const evaluateSpan = container.items.find( + span => span.attributes['sentry.op']?.value === 'gen_ai.evaluate', + )!; + expect(evaluateSpan).toBeDefined(); + expect(evaluateSpan.name).toBe('evaluate typesafe-ai/jev'); + expect(evaluateSpan.status).toBe('ok'); + expect(evaluateSpan.attributes['sentry.origin']?.value).toBe('auto.vercelai.channel'); + expect(evaluateSpan.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('evaluate'); + expect(evaluateSpan.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe('gateway'); + expect(evaluateSpan.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('typesafe-ai/jev'); + expect(evaluateSpan.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('typesafe-ai/jev'); + expect(evaluateSpan.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(275); + expect(evaluateSpan.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(20); + expect(evaluateSpan.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(295); + expect(JSON.parse(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES]?.value as string)).toEqual([ + { + type: 'evaluation', + state: 'I cannot log in, and I also want a refund for last month.', + questions: { + authIssue: { type: 'boolean', instructions: 'Is there a login problem?' }, + department: { + type: 'choice', + instructions: 'Which team should handle this?', + criteria: { billing: 'Charges and refunds', technical: 'Bugs and outages' }, + }, + wantsRefund: { type: 'boolean', instructions: 'Is a refund requested?' }, + urgency: { + type: 'score', + instructions: 'How urgent is this ticket?', + criteria: ['low', 'medium', 'high'], + }, + }, + }, + ]); + expect(JSON.parse(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES]?.value as string)).toEqual([ + { + type: 'evaluation', + answers: { + authIssue: { type: 'boolean', probability: 0.97 }, + department: { + type: 'choice', + choice: 'billing', + probabilities: { billing: 0.64, technical: 0.36 }, + confidence: 0.28, + }, + wantsRefund: { type: 'boolean', probability: 0.99 }, + urgency: { type: 'score', score: 1.8, probabilities: { 0: 0, 1: 0.2, 2: 0.8 } }, + }, + }, + ]); + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + ai: '^7.0.111', + }, + }, + ); + + createEsmTests( + __dirname, + 'scenario-evaluate.mjs', + 'instrument-evaluate-no-recording.mjs', + (createRunner, test) => { + test('does not record inputs or outputs when recording is off', async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const evaluateSpan = container.items.find( + span => span.attributes['sentry.op']?.value === 'gen_ai.evaluate', + )!; + expect(evaluateSpan).toBeDefined(); + expect(evaluateSpan.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(evaluateSpan.attributes[GEN_AI_OUTPUT_MESSAGES]).toBeUndefined(); + // State, questions and answers must not come back through another attribute. Only the attributes + // are checked (timestamps could match a number), and `probabilities` only occurs in answers. + expect(JSON.stringify(evaluateSpan.attributes)).not.toMatch( + /cannot log in|Charges and refunds|probabilities/, + ); + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + ai: '^7.0.111', + }, + }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/winston/test.ts b/dev-packages/node-integration-tests/suites/winston/test.ts index 2555a4eddb03..b36e128497de 100644 --- a/dev-packages/node-integration-tests/suites/winston/test.ts +++ b/dev-packages/node-integration-tests/suites/winston/test.ts @@ -1,5 +1,6 @@ import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createCjsTests } from '../../utils/runner'; +import { EXPECTED_SDK_NAME } from '../../utils'; describe('winston integration', () => { afterAll(() => { @@ -22,7 +23,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -37,7 +38,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -66,7 +67,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -81,7 +82,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -96,7 +97,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -111,7 +112,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -140,7 +141,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -155,7 +156,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -170,7 +171,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -205,7 +206,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -220,7 +221,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -235,7 +236,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, foo: { value: 'bar', type: 'string' }, @@ -267,7 +268,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -282,7 +283,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -298,7 +299,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -334,7 +335,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -349,7 +350,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -365,7 +366,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -380,7 +381,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, @@ -395,7 +396,7 @@ describe('winston integration', () => { 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.name': { value: EXPECTED_SDK_NAME, type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'server.address': { value: expect.any(String), type: 'string' }, }, diff --git a/dev-packages/node-integration-tests/utils/assertions.ts b/dev-packages/node-integration-tests/utils/assertions.ts index b6c45ebc043c..cf1c289e05a6 100644 --- a/dev-packages/node-integration-tests/utils/assertions.ts +++ b/dev-packages/node-integration-tests/utils/assertions.ts @@ -12,6 +12,7 @@ import type { } from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { expect } from 'vitest'; +import { EXPECTED_SDK_NAME } from './index'; export type DeepPartial = T extends object ? { @@ -108,7 +109,7 @@ export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial const x = require('y') + // Import attributes (`with { type: 'json' }`) are dropped; `require` reads JSON natively. newContent = newContent.replace( // eslint-disable-next-line regexp/optimal-quantifier-concatenation, regexp/no-super-linear-backtracking - /import\s+([\w*{}\s,]+)\s+from\s+['"]([^'"]+)['"]/g, + /import\s+([\w*{}\s,]+)\s+from\s+['"]([^'"]+)['"](?:\s+with\s*\{[^}]*\})?/g, (_, imports: string, module: string) => { if (imports.includes('* as')) { // Handle namespace imports: import * as x from 'y' -> const x = require('y') @@ -384,11 +385,15 @@ function wrapTestApi( return Reflect.apply(target, thisArg, args); }, - get: (target, prop: 'only' | 'skip' | 'each' | 'for') => { + get: (target, prop: 'only' | 'skip' | 'each' | 'for' | 'runIf' | 'skipIf') => { if (prop === 'only' || prop === 'skip') { return wrapTestApi(target[prop], suffix); } + if (prop === 'runIf' || prop === 'skipIf') { + return (condition: boolean) => wrapTestApi((target as TestAPI)[prop](condition), suffix); + } + if (prop === 'each' || prop === 'for') { return wrapTestEachApi(target[prop], suffix); } diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index bd237a36cae7..e737cfa4be8e 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -19,6 +19,7 @@ import { existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { inspect } from 'util'; +import { onTestFailed } from 'vitest'; import type { DeepPartial } from './../assertions'; import { assertEnvelopeHeader, @@ -108,12 +109,36 @@ const NODE_MAJOR = Number(process.versions.node.split('.')[0]); const COMPILE_CACHE_ENV: Record = NODE_MAJOR >= 22 ? { NODE_COMPILE_CACHE: join(tmpdir(), 'sentry-node-it-compile-cache') } : {}; +// The Bun and Deno packages run these suites from their own folder. Scenarios read the working +// directory (e.g. `modulesIntegration` reads its `package.json`), so it is always this package. +const PACKAGE_ROOT = join(__dirname, '..', '..'); + /** Node flags that preload a module before the entry point. */ const PRELOAD_FLAGS = ['--import', '--require', '-r']; +/** + * How long a scenario may take to report its server port. Bun and Deno start the SDK more slowly + * than Node, and under load they passed 10 seconds, which failed tests that were only slow. + */ +const PORT_TIMEOUT = process.env.RUNTIME && process.env.RUNTIME !== 'node' ? 30_000 : 10_000; + /** tsx's CommonJS require hook, preloaded for `.ts` scenarios. */ const TS_LOADER = 'tsx/cjs'; +type Runtime = 'node' | 'bun' | 'deno'; + +/** + * The runtime that runs the scenario child process, from the `RUNTIME` env var (`node` when unset). + * The Bun and Deno integration test packages set it to run selected Node suites on their runtime. + */ +function getRuntime(): Runtime { + const runtime = process.env.RUNTIME || 'node'; + if (runtime !== 'node' && runtime !== 'bun' && runtime !== 'deno') { + throw new Error(`Unsupported RUNTIME: ${runtime}`); + } + return runtime; +} + export const CLEANUP_STEPS = new Set(); export function cleanupChildProcesses(): void { @@ -257,7 +282,11 @@ export function createRunner(...paths: string[]) { let envelopeCount = 0; let scenarioServerPort: number | undefined; let hasExited = false; + let exitStatus: string | undefined; let child: ReturnType | undefined; + let spawnedAt: number | undefined; + let lastOutputAt: number | undefined; + let logsDumped = false; // Resolved the moment `complete()` runs, so `completed()` can await the result directly // instead of polling — see the comment on `waitForEvent`. @@ -265,6 +294,14 @@ export function createRunner(...paths: string[]) { // Resolved once the scenario reports its server port, so `makeRequest` can await it directly. const portReady = createDeferred(); + // Vitest stops a test at its own timeout before `completed()` gives up, so print the child + // output then too. `completed()` prints it for the failures it reports itself. + onTestFailed(() => { + if (!isComplete) { + dumpCapturedLogs(); + } + }); + function complete(error?: Error): void { if (isComplete) { return; @@ -272,10 +309,28 @@ export function createRunner(...paths: string[]) { isComplete = true; completeError = error || undefined; - child?.kill(); + stopChild(); completedDeferred.resolve(); } + /** + * Sends `SIGTERM`, and `SIGKILL` if the child still runs 2 seconds later. A scenario (or the + * SDK, e.g. on Vercel) can listen for `SIGTERM`, and on Deno a signal listener keeps the + * process alive, so `SIGTERM` alone left such children running after the test run. + */ + function stopChild(): void { + if (!child || hasExited) { + return; + } + child.kill(); + const killTimer = setTimeout(() => { + if (!hasExited) { + child?.kill('SIGKILL'); + } + }, 2_000); + killTimer.unref(); + } + /** * Print everything the child process wrote to stdout/stderr. Called when a test fails or * times out so the captured output is visible in CI logs. Skipped when `DEBUG` is set, since @@ -284,12 +339,23 @@ export function createRunner(...paths: string[]) { function dumpCapturedLogs(): void { // Skip when the failure is expected (`test.fails` variants) — the output would just be noise. // In debug mode the same lines are already streamed live, so skip then too. - if (process.env.DEBUG || suppressErrorLogs) { + if (process.env.DEBUG || suppressErrorLogs || logsDumped) { return; } + logsDumped = true; + const now = Date.now(); + const state = [ + `runtime=${getRuntime()}`, + `pid=${child?.pid ?? 'none'}`, + hasExited ? `exited (${exitStatus})` : 'running', + `envelopes=${envelopeCount}/${expectedEnvelopeCount}`, + `ms since spawn=${spawnedAt ? now - spawnedAt : 'not spawned'}`, + `ms since last output=${lastOutputAt ? now - lastOutputAt : 'no output'}`, + ].join(', '); + // eslint-disable-next-line no-console - console.log(`\n--- Captured child process output for ${testPath} ---`); + console.log(`\n--- Captured child process output for ${testPath} (${state}) ---`); if (logs.length === 0) { // eslint-disable-next-line no-console console.log('(no output captured)'); @@ -433,9 +499,11 @@ export function createRunner(...paths: string[]) { // requests to the fake DSN. const wantsAutoFlush = !ensureNoErrorOutput && (expectedEnvelopes.length > 0 || (expectedEnvelopeHeaders?.length ?? 0) > 0); - const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath), ...flags] : flags; + const runtime = getRuntime(); + const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath, runtime), ...flags] : flags; - child = spawn('node', [...childFlags, testPath], { env }); + child = spawn(runtime, buildRuntimeArgs(runtime, childFlags, testPath), { env, cwd: PACKAGE_ROOT }); + spawnedAt = Date.now(); child.on('error', e => { // eslint-disable-next-line no-console @@ -443,11 +511,16 @@ export function createRunner(...paths: string[]) { complete(e); }); + // Cleanup runs after the tests (and at process exit, where timers never fire), so there is no + // graceful stop to wait for. registerCleanupStep(() => { - child?.kill(); + if (!hasExited) { + child?.kill('SIGKILL'); + } }); child.stderr?.on('data', (data: Buffer) => { + lastOutputAt = Date.now(); const output = data.toString(); logs.push(output.trim()); @@ -461,6 +534,7 @@ export function createRunner(...paths: string[]) { child.on('close', (code, signal) => { hasExited = true; + exitStatus = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`; if (ensureNoErrorOutput) { complete(); @@ -519,6 +593,7 @@ export function createRunner(...paths: string[]) { let buffer = Buffer.alloc(0); child.stdout?.on('data', (data: Buffer) => { + lastOutputAt = Date.now(); // This is horribly memory inefficient but it's only for tests buffer = Buffer.concat([buffer, data]); @@ -570,7 +645,7 @@ export function createRunner(...paths: string[]) { options: { headers?: Record; data?: BodyInit; expectError?: boolean } = {}, ): Promise { try { - await waitForEvent(portReady.promise, 10_000, 'Timed out waiting for server port'); + await waitForEvent(portReady.promise, PORT_TIMEOUT, 'Timed out waiting for server port'); } catch (e) { complete(e as Error); return; @@ -697,17 +772,68 @@ function getPreloadPaths(flags: readonly string[]): string[] { * The CJS loader stays on `--require`: any `--import` makes Node resolve the entry point * through the ESM loader, which rejects the `.ts` scenarios that `tsx/cjs` handles. Unlike * an instrument file it never calls `Sentry.init()`, so the loader thread is not a concern. + * + * Bun and Deno run `.ts` files natively as ES modules, so their `import` of the SDK resolves + * to the ESM build. */ -function buildAutoFlushFlags(existingFlags: readonly string[], testPath: string): string[] { +function buildAutoFlushFlags(existingFlags: readonly string[], testPath: string, runtime: Runtime): string[] { const initPath = getPreloadPaths(existingFlags) .filter(path => path !== TS_LOADER) .at(-1) ?? testPath; - const isEsm = initPath.endsWith('.mjs') || !/\.[cm]?[jt]s$/.test(initPath); + const isEsm = + initPath.endsWith('.mjs') || (runtime !== 'node' && initPath.endsWith('.ts')) || !/\.[cm]?[jt]s$/.test(initPath); return isEsm ? ['--import', join(__dirname, 'auto-flush.mjs')] : ['--require', join(__dirname, 'auto-flush.cjs')]; } +/** + * Returns the arguments for the runtime binary. Bun and Deno get Node's preload flags as + * `--preload`, and no tsx loader. `RUNTIME_PRELOAD` is a module path that Bun and Deno preload + * before all other modules. Deno also gets all permissions and the import map from + * `DENO_IMPORT_MAP`. Any other Node flag throws, so a suite that needs one fails with a clear + * message instead of running with different behavior. + */ +function buildRuntimeArgs(runtime: Runtime, flags: readonly string[], testPath: string): string[] { + if (runtime === 'node') { + return [...flags, testPath]; + } + + const args: string[] = process.env.RUNTIME_PRELOAD ? ['--preload', process.env.RUNTIME_PRELOAD] : []; + for (let i = 0; i < flags.length; i++) { + const flag = flags[i] as string; + const [name, ...rest] = flag.split('='); + + if (!PRELOAD_FLAGS.includes(name as string)) { + throw new Error(`Node flag "${flag}" has no ${runtime} equivalent in the test runner (${testPath})`); + } + + const path = rest.length ? rest.join('=') : (flags[++i] as string); + if (path !== TS_LOADER) { + args.push('--preload', path); + } + } + + if (runtime === 'bun') { + return [...args, testPath]; + } + + const importMap = process.env.DENO_IMPORT_MAP ? ['--import-map', process.env.DENO_IMPORT_MAP] : []; + // Deno loads a `.js` file as ESM unless its package.json has `"type": "commonjs"`, and this + // package sets no `"type"`, so the CJS scenarios need CommonJS detection. + // The workspace symlinks resolve the SDK packages outside `node_modules`, where Deno 2.8.3 + // (the CI version) rejects Node builtins without the `node:` prefix (e.g. `url` in `@sentry/node`). + return [ + 'run', + '--allow-all', + '--unstable-detect-cjs', + '--unstable-bare-node-builtins', + ...importMap, + ...args, + testPath, + ]; +} + function expectErrorEvent(item: Event, expected: ExpectedEvent): void { if (typeof expected === 'function') { expected(item); diff --git a/package.json b/package.json index a25b4dd72738..5fce1bf3ac5a 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "yalc:publish": "nx run-many -t yalc:publish" }, "volta": { - "node": "20.19.5", + "node": "24.21.0", "yarn": "1.22.22", "pnpm": "9.15.9" }, @@ -133,7 +133,7 @@ "nodemon": "^3.1.10", "npm-run-all2": "^6.2.0", "nx": "22.7.7", - "oxfmt": "^0.63.0", + "oxfmt": "^0.70.0", "oxlint": "^1.75.0", "oxlint-tsgolint": "7", "rimraf": "^5.0.10", diff --git a/packages/angular/README.md b/packages/angular/README.md index 8251d0b226f7..77ac7ab11ebf 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -10,219 +10,15 @@ [![npm dm](https://img.shields.io/npm/dm/@sentry/angular.svg)](https://www.npmjs.com/package/@sentry/angular) [![npm dt](https://img.shields.io/npm/dt/@sentry/angular.svg)](https://www.npmjs.com/package/@sentry/angular) -## Links +The official Sentry SDK for monitoring Angular applications. -- [Official SDK Docs](https://docs.sentry.io/platforms/javascript/angular/) +## Documentation -## Angular Version Compatibility +- [Getting started](https://docs.sentry.io/platforms/javascript/guides/angular/) +- [Configuration](https://docs.sentry.io/platforms/javascript/guides/angular/configuration/) +- [Angular-specific features](https://docs.sentry.io/platforms/javascript/guides/angular/features/) -This SDK officially supports Angular 14 to 20. +## Support -If you're using an older Angular version please check the -[compatibility table in the docs](https://docs.sentry.io/platforms/javascript/guides/angular/#angular-version-compatibility). - -If you're using an older version of Angular and experience problems with the Angular SDK, we recommend downgrading the -SDK to version 7.x. Please note that we don't provide any support for Angular versions below 10. - -## General - -This package is a wrapper around `@sentry/browser`, with added functionality related to Angular. All methods available -in `@sentry/browser` can be imported from `@sentry/angular`. - -To use this SDK, call `Sentry.init(options)` before you bootstrap your Angular application. - -```javascript -import { bootstrapApplication } from '@angular/platform-browser'; -import { init } from '@sentry/angular'; - -import { AppComponent } from './app/app.component'; - -init({ - dsn: '__DSN__', - // ... -}); - -bootstrapApplication(AppComponent, appConfig); -``` - -### ErrorHandler - -`@sentry/angular` exports a function to instantiate an ErrorHandler provider that will automatically send Javascript -errors captured by the Angular's error handler. - -```ts -import { ApplicationConfig, NgModule, ErrorHandler } from '@angular/core'; -import { createErrorHandler } from '@sentry/angular'; - -export const appConfig: ApplicationConfig = { - providers: [ - { - provide: ErrorHandler, - useValue: createErrorHandler({ - showDialog: true, - }), - }, - ], -}; - -// Or using an old module approach: -@NgModule({ - // ... - providers: [ - { - provide: ErrorHandler, - useValue: createErrorHandler({ - showDialog: true, - }), - }, - ], - // ... -}) -export class AppModule {} -``` - -Additionally, `createErrorHandler` accepts a set of options that allows you to configure its behavior. For more details -see `ErrorHandlerOptions` interface in `src/errorhandler.ts`. - -### Tracing - -`@sentry/angular` exports a Trace Service, Directive and Decorators that leverage the tracing features to add -Angular-related spans to transactions. If tracing is not enabled, this functionality will not work. The SDK's -`TraceService` itself tracks route changes and durations, while directive and decorators are tracking components -initializations. - -#### Install - -Registering a Trace Service is a 3-step process. - -1. Register and configure the `BrowserTracing` integration, including custom Angular routing instrumentation: - -```javascript -import { init, browserTracingIntegration } from '@sentry/angular'; - -init({ - dsn: '__DSN__', - integrations: [browserTracingIntegration()], - tracePropagationTargets: ['localhost', 'https://yourserver.io/api'], - tracesSampleRate: 1, -}); -``` - -2. Inject the `TraceService` in the `APP_INITIALIZER`: - -```ts -import { ApplicationConfig, APP_INITIALIZER, provideAppInitializer } from '@angular/core'; - -export const appConfig: ApplicationConfig = { - providers: [ - { - provide: APP_INITIALIZER, - useFactory: () => () => {}, - deps: [TraceService], - multi: true, - }, - - // Starting with Angular 19, we can use `provideAppInitializer` - // instead of directly providing `APP_INITIALIZER` (deprecated): - provideAppInitializer(() => inject(TraceService)), - ], -}; - -// Or using an old module approach: -@NgModule({ - // ... - providers: [ - { - provide: APP_INITIALIZER, - useFactory: () => () => {}, - deps: [TraceService], - multi: true, - }, - - // Starting with Angular 19, we can use `provideAppInitializer` - // instead of directly providing `APP_INITIALIZER` (deprecated): - provideAppInitializer(() => inject(TraceService)), - ], - // ... -}) -export class AppModule {} -``` - -#### Use - -To track Angular components as part of your transactions, you have 3 options. - -_TraceDirective:_ used to track a duration between `OnInit` and `AfterViewInit` lifecycle hooks in template: - -```ts -import { TraceModule } from '@sentry/angular'; - -@Component({ - selector: 'some-component', - imports: [TraceModule], - // ... -}) -export class SomeComponentThatUsesTraceDirective {} -``` - -Then, inside your component's template (keep in mind that the directive's name attribute is required): - -```html - - - -``` - -_TraceClass:_ used to track a duration between `OnInit` and `AfterViewInit` lifecycle hooks in components: - -```javascript -import { Component } from '@angular/core'; -import { TraceClass } from '@sentry/angular'; - -@Component({ - selector: 'layout-header', - templateUrl: './header.component.html', -}) -@TraceClass() -export class HeaderComponent { - // ... -} -``` - -_TraceMethod:_ used to track a specific lifecycle hooks as point-in-time spans in components: - -```javascript -import { Component, OnInit } from '@angular/core'; -import { TraceMethod } from '@sentry/angular'; - -@Component({ - selector: 'app-footer', - templateUrl: './footer.component.html', -}) -export class FooterComponent implements OnInit { - @TraceMethod() - ngOnInit() {} -} -``` - -You can also add your own custom spans via `startSpan()`. For example, if you'd like to track the duration of Angular -boostraping process, you can do it as follows: - -```javascript -import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; -import { init, startSpan } from '@sentry/angular'; - -import { AppModule } from './app/app.module'; - -// ... -startSpan( - { - name: 'platform-browser-dynamic', - op: 'ui.mount', - }, - async () => { - await platformBrowserDynamic().bootstrapModule(AppModule); - }, -); -``` +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/angular/package.json b/packages/angular/package.json index 4c8785c43d4a..65159b67e13b 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -23,7 +23,7 @@ "dependencies": { "@sentry/browser": "11.0.0", "@sentry/core": "11.0.0", - "@sentry/conventions": "^0.23.0", + "@sentry/conventions": "^0.24.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/README.md b/packages/astro/README.md index c4994a685987..203680b44c58 100644 --- a/packages/astro/README.md +++ b/packages/astro/README.md @@ -10,137 +10,17 @@ [![npm dm](https://img.shields.io/npm/dm/@sentry/astro.svg)](https://www.npmjs.com/package/@sentry/astro) [![npm dt](https://img.shields.io/npm/dt/@sentry/astro.svg)](https://www.npmjs.com/package/@sentry/astro) -## Links - -- [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/astro/) - -## SDK Status +The official Sentry SDK for monitoring Astro applications. This SDK is in Beta and not yet fully stable. If you have feedback or encounter any bugs, feel free to [open an issue](https://github.com/getsentry/sentry-javascript/issues/new/choose). -## General - -This package is a wrapper around `@sentry/node` for the server and `@sentry/browser` for the client side. - -## Installation and Setup - -Install the Sentry Astro SDK with the `astro` CLI: - -```bash -npx astro add @sentry/astro -``` - -### Build-time Configuration - -Register the Sentry integration in your `astro.config.mjs`. This is where you configure build-time options such as -source maps upload (`org`, `project`, `authToken`). Runtime SDK options (`dsn`, `environment`, `tracesSampleRate`, Replay -sample rates, and so on) belong in dedicated init files, not on `sentry()`. - -```javascript -import { defineConfig } from 'astro/config'; -import sentry from '@sentry/astro'; - -export default defineConfig({ - integrations: [ - sentry({ - org: 'your-org-slug', - project: 'your-sentry-project-slug', - authToken: process.env.SENTRY_AUTH_TOKEN, - }), - ], -}); -``` - -Follow [this guide](https://docs.sentry.io/product/accounts/auth-tokens/#organization-auth-tokens) to create an auth -token and add it to your environment variables: - -```bash -SENTRY_AUTH_TOKEN="your-token" -``` - -### Runtime SDK Configuration - -If you do not add `sentry.client.config.ts` or `sentry.server.config.ts`, Sentry still initializes with default options -and reads the DSN from `PUBLIC_SENTRY_DSN`. Add those files at the project root when you want to customize client or -server options, and pass them to `Sentry.init()`. - -Set `PUBLIC_SENTRY_DSN` in your environment (for example in `.env`): - -```bash -PUBLIC_SENTRY_DSN="__DSN__" -``` - -```javascript -// sentry.client.config.ts -import * as Sentry from '@sentry/astro'; - -Sentry.init({ - dsn: '__DSN__', - tracesSampleRate: 1.0, - integrations: [Sentry.replayIntegration()], - replaysSessionSampleRate: 0.1, - replaysOnErrorSampleRate: 1.0, -}); -``` - -```javascript -// sentry.server.config.ts -import * as Sentry from '@sentry/astro'; - -Sentry.init({ - dsn: '__DSN__', - tracesSampleRate: 1.0, -}); -``` - -### Server Instrumentation - -For Astro apps configured for (hybrid) Server Side Rendering (SSR), the Sentry integration will automatically add -middleware to your server to instrument incoming requests. - -The Sentry middleware enhances the data collected by Sentry on the server side by: - -- Enabling distributed tracing between client and server -- Collecting performance spans for incoming requests -- Enhancing captured errors with additional information - -#### Disable Automatic Server Instrumentation - -You can opt out of using the automatic sentry server instrumentation in your `astro.config.mjs` file: - -```javascript -import { defineConfig } from 'astro/config'; -import sentry from '@sentry/astro'; - -export default defineConfig({ - integrations: [ - sentry({ - autoInstrumentation: { - requestHandler: false, - }, - }), - ], -}); -``` - -If you opt out but still want the middleware, add it manually to your `src/middleware.js` file: - -```javascript -// src/middleware.js -import { sequence } from 'astro:middleware'; -import * as Sentry from '@sentry/astro'; - -export const onRequest = sequence( - Sentry.handleRequest(), - // Add your other handlers after Sentry.handleRequest() -); -``` +## Documentation -## Configuration +- [Getting started](https://docs.sentry.io/platforms/javascript/guides/astro/) +- [Configuration](https://docs.sentry.io/platforms/javascript/guides/astro/configuration/) -Check out our docs for configuring your SDK setup: +## Support -- [Getting Started](https://docs.sentry.io/platforms/javascript/guides/astro/) -- [Manual Setup and Configuration](https://docs.sentry.io/platforms/javascript/guides/astro/manual-setup/) -- [Source Maps Upload](https://docs.sentry.io/platforms/javascript/guides/astro/sourcemaps/) +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/astro/package.json b/packages/astro/package.json index 3e975ac7ac85..38ee7c8b5750 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -53,7 +53,7 @@ "dependencies": { "@sentry/browser": "11.0.0", "@sentry/core": "11.0.0", - "@sentry/conventions": "^0.23.0", + "@sentry/conventions": "^0.24.0", "@sentry/node": "11.0.0", "@sentry/server-runtime-injection": "11.0.0", "@sentry/server-utils": "11.0.0", diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 8f765fc367f8..a791eac011a3 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -95,10 +95,12 @@ export { openAIIntegration, groqIntegration, togetherAIIntegration, + typesafeIntegration, langChainIntegration, langGraphIntegration, createFlueInstrumentation, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, parameterize, pinoIntegration, @@ -161,6 +163,7 @@ export { supabaseIntegration, instrumentSupabaseClient, instrumentMistralAiClient, + instrumentTypeSafeClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/aws-serverless/README.md b/packages/aws-serverless/README.md index 340e9d11263d..5f77ea674cbd 100644 --- a/packages/aws-serverless/README.md +++ b/packages/aws-serverless/README.md @@ -6,126 +6,14 @@ # Official Sentry SDK for AWS Lambda -## Links +The official Sentry SDK for monitoring AWS Lambda functions. -- [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/aws-lambda) +## Documentation -## General +- [Getting started](https://docs.sentry.io/platforms/javascript/guides/aws-lambda/) +- [Configuration](https://docs.sentry.io/platforms/javascript/guides/aws-lambda/configuration/) -This package is a wrapper around `@sentry/node`, with added functionality related to AWS Lambda. All -methods available in `@sentry/node` can be imported from `@sentry/aws-serverless`. +## Support -### Automatic Setup - -To use this SDK with an automatic setup, set the following environment variables in your Lambda function configuration: - -```bash -NODE_OPTIONS="--import @sentry/aws-serverless/awslambda-auto" -SENTRY_DSN="__DSN__" -# Add Tracing by setting tracesSampleRate and adding integration -# Set tracesSampleRate to 1.0 to capture 100% of transactions -# We recommend adjusting this value in production -# Learn more at -# https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate -SENTRY_TRACES_SAMPLE_RATE="1.0" -``` - -### Manual Setup - -Alternatively, to further customize the SDK setup, you can also manually initialize the SDK in your lambda function. The benefit of this installation method is that you can fully customize your Sentry SDK setup in a Sentry.init call. - -Create a new file, for example `instrument.js` to initialize the SDK: - -```js -import * as Sentry from '@sentry/aws-serverless'; - -Sentry.init({ - dsn: '__DSN__', - // Adds HTTP request headers and IP for users, for more info visit: - // https://docs.sentry.io/platforms/javascript/guides/aws-lambda/configuration/options/#dataCollection - dataCollection: { userInfo: true, httpHeaders: { request: true } }, - // Add Tracing by setting tracesSampleRate and adding integration - // Set tracesSampleRate to 1.0 to capture 100% of transactions - // We recommend adjusting this value in production - // Learn more at - // https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate - tracesSampleRate: 1.0, -}); -``` - -And then load the SDK before your function starts by importing the instrument.js file via a NODE_OPTIONS environment variable: - -```bash -NODE_OPTIONS="--import ./instrument.js" -``` - -## Verify - -```js -// async (recommended) -export const handler = async (event, context) => { - throw new Error('oh, hello there!'); -}; - -// sync -export const handler = (event, context, callback) => { - throw new Error('oh, hello there!'); -}; -``` - -## Container Image-based Lambda Functions - -When using container image-based Lambda functions (e.g., with [Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter) for frameworks like SvelteKit, Next.js, or Remix), Lambda layers cannot be attached. Instead, you can install the Sentry Lambda extension directly into your Docker image. The extension tunnels Sentry events through a local proxy, improving event delivery reliability during Lambda freezes. - -### Setup - -1. Install `@sentry/aws-serverless` as a dependency — even if you use a different Sentry SDK in your application (e.g., `@sentry/sveltekit`), this package contains the extension files needed for the Docker image. - -2. Copy the extension files from the npm package into your Docker image: - -```dockerfile -FROM public.ecr.aws/lambda/nodejs:22 - -# Copy the Sentry Lambda extension -RUN mkdir -p /opt/sentry-extension -COPY node_modules/@sentry/aws-serverless/build/lambda-extension/sentry-extension /opt/extensions/sentry-extension -COPY node_modules/@sentry/aws-serverless/build/lambda-extension/index.mjs /opt/sentry-extension/index.mjs -RUN chmod +x /opt/extensions/sentry-extension /opt/sentry-extension/index.mjs - -# ... rest of your Dockerfile -``` - -3. Point your Sentry SDK at the extension using the `tunnel` option. The extension always listens on `http://localhost:9000/envelope` — this URL is fixed and must be used exactly as shown: - -```js -import * as Sentry from '@sentry/aws-serverless'; - -Sentry.init({ - dsn: '__DSN__', - tunnel: 'http://localhost:9000/envelope', -}); -``` - -This works with any Sentry SDK: - -```js -import * as Sentry from '@sentry/sveltekit'; - -Sentry.init({ - dsn: '__DSN__', - tunnel: 'http://localhost:9000/envelope', -}); -``` - -## Integrate Sentry using the Sentry Lambda layer - -Another much simpler way to integrate Sentry to your AWS Lambda function is to add the official layer. - -1. Choose Layers -> Add Layer. -2. Specify an ARN: `arn:aws:lambda:us-west-1:943013980633:layer:SentryNodeServerlessSDKv10:19`. Get the latest ARN from the [docs](https://docs.sentry.io/platforms/javascript/guides/aws-lambda/install/layer). -3. Go to Environment variables and add: - - `NODE_OPTIONS`: `--import @sentry/aws-serverless/awslambda-auto`. - - `SENTRY_DSN`: `your dsn`. - - `SENTRY_TRACES_SAMPLE_RATE`: a number between 0 and 1 representing the chance a transaction is sent to Sentry. For - more information, see - [docs](https://docs.sentry.io/platforms/javascript/guides/aws-lambda/configuration/options/#tracesSampleRate). +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 6625669ca9b2..83608c3b23fc 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -56,7 +56,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.23.0", + "@sentry/conventions": "^0.24.0", "@sentry/core": "11.0.0", "@sentry/node": "11.0.0", "@sentry/server-runtime-injection": "11.0.0", diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index c467d1b2266f..1687e2c16a10 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -63,9 +63,11 @@ export { openAIIntegration, groqIntegration, togetherAIIntegration, + typesafeIntegration, langChainIntegration, langGraphIntegration, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, modulesIntegration, @@ -144,6 +146,7 @@ export { supabaseIntegration, instrumentSupabaseClient, instrumentMistralAiClient, + instrumentTypeSafeClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/browser-utils/README.md b/packages/browser-utils/README.md index 58b19de5d5a6..0e8f9a34a390 100644 --- a/packages/browser-utils/README.md +++ b/packages/browser-utils/README.md @@ -10,14 +10,17 @@ [![npm dm](https://img.shields.io/npm/dm/@sentry/browser-utils.svg)](https://www.npmjs.com/package/@sentry/browser-utils) [![npm dt](https://img.shields.io/npm/dt/@sentry/browser-utils.svg)](https://www.npmjs.com/package/@sentry/browser-utils) +Shared browser utilities used by Sentry’s JavaScript SDKs. + > [!NOTE] > This package is an internal library published for use by Sentry-owned JavaScript SDK packages. It is not part of the > public API contract and may change in any release. Do not rely on SemVer compatibility if you depend on it directly. -## Links +## Documentation -- [Official SDK Docs](https://docs.sentry.io/quickstart/) +- [JavaScript SDK documentation](https://docs.sentry.io/platforms/javascript/) -## General +## Support -Common utilities used by the Sentry JavaScript SDKs. +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index e667f915e909..127dfcf07e4b 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@sentry/core": "11.0.0", - "@sentry/conventions": "^0.23.0", + "@sentry/conventions": "^0.24.0", "web-vitals": "^6.1.1" }, "scripts": { diff --git a/packages/browser-utils/src/performance/interactions.ts b/packages/browser-utils/src/performance/interactions.ts index 2be7d7a52272..7a3cc89a981d 100644 --- a/packages/browser-utils/src/performance/interactions.ts +++ b/packages/browser-utils/src/performance/interactions.ts @@ -1,4 +1,5 @@ import { + BROWSER_WEB_VITAL_INP_TARGET, HTTP_ROUTE, ROUTER_NAVIGATION_ROUTE_ID, SENTRY_IDLE_SPAN_FINISH_REASON, @@ -254,7 +255,7 @@ function trackInteractionsAsSpans(client: Client): void { attributes: { [SENTRY_OP]: UI_INTERACTION_CLICK, [SENTRY_ORIGIN]: 'auto.browser.interactions', - ...(selector && selector !== UNKNOWN_ELEMENT_NAME && { 'browser.web_vital.inp.target': selector }), + ...(selector && selector !== UNKNOWN_ELEMENT_NAME && { [BROWSER_WEB_VITAL_INP_TARGET]: selector }), }, }; diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index faa727642899..424180427141 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -30,7 +30,12 @@ import { addLcpInstrumentationHandler, } from '../instrumentation/performanceObserver'; import type { LargestContentfulPaint, LayoutShift } from './emitSpan'; -import { BROWSER_NAVIGATION_TYPE, UI_COMPONENT_NAME } from '@sentry/conventions/attributes'; +import { + BROWSER_NAVIGATION_TYPE, + BROWSER_WEB_VITAL_INP_INTERACTION_TYPE, + BROWSER_WEB_VITAL_INP_TARGET, + UI_COMPONENT_NAME, +} from '@sentry/conventions/attributes'; import { _emitWebVitalSpan } from './emitSpan'; import { isValidLcpMetric } from './lcp'; import { listenForWebVitalReportEvents } from './reportEvents'; @@ -437,9 +442,8 @@ export function _sendInpSpan( // say whether there was an interaction to describe. These attributes can: they are only set for // what was actually observed. The name no longer holds the selector either, now that it is the // component name or the op's fallback under span streaming. - // TODO: use the `@sentry/conventions` constants once getsentry/sentry-conventions#641 is released. - selector && selector !== UNKNOWN_ELEMENT_NAME && (attributes['browser.web_vital.inp.target'] = selector); - entryInteractionType && (attributes['browser.web_vital.inp.interaction_type'] = entryInteractionType); + selector && selector !== UNKNOWN_ELEMENT_NAME && (attributes[BROWSER_WEB_VITAL_INP_TARGET] = selector); + entryInteractionType && (attributes[BROWSER_WEB_VITAL_INP_INTERACTION_TYPE] = entryInteractionType); if (componentName) { attributes[UI_COMPONENT_NAME] = componentName; diff --git a/packages/browser/README.md b/packages/browser/README.md index 91f3cde774d9..3d4c16df67dc 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -11,48 +11,14 @@ [![npm dm](https://img.shields.io/npm/dm/@sentry/browser.svg)](https://www.npmjs.com/package/@sentry/browser) [![npm dt](https://img.shields.io/npm/dt/@sentry/browser.svg)](https://www.npmjs.com/package/@sentry/browser) -## Links +The official Sentry SDK for monitoring JavaScript applications in the browser. -- [Official SDK Docs](https://docs.sentry.io/quickstart/) +## Documentation -## Usage +- [Getting started](https://docs.sentry.io/platforms/javascript/) +- [Configuration](https://docs.sentry.io/platforms/javascript/configuration/) -To use this SDK, call `Sentry.init(options)` as early as possible after loading the page. This will initialize the SDK -and hook into the environment. Note that you can turn off almost all side effects using the respective options. +## Support -```javascript -import * as Sentry from '@sentry/browser'; - -Sentry.init({ - dsn: '__DSN__', - // ... -}); -``` - -To set context information or send manual events, use the exported functions of `@sentry/browser`. Note that these -functions will not perform any action before you have called `Sentry.init()`: - -```javascript -import * as Sentry from '@sentry/browser'; - -// Set user information, as well as tags and further extras -Sentry.setExtra('battery', 0.7); -Sentry.setTag('user_mode', 'admin'); -Sentry.setUser({ id: '4711' }); - -// Add a breadcrumb for future events -Sentry.addBreadcrumb({ - message: 'My Breadcrumb', - // ... -}); - -// Capture exceptions, messages or manual events -Sentry.captureMessage('Hello, world!'); -Sentry.captureException(new Error('Good bye')); -Sentry.captureEvent({ - message: 'Manual', - stacktrace: [ - // ... - ], -}); -``` +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/browser/package.json b/packages/browser/package.json index b96e6ee851aa..25829399515b 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@sentry/browser-utils": "11.0.0", - "@sentry/conventions": "^0.23.0", + "@sentry/conventions": "^0.24.0", "@sentry/feedback": "11.0.0", "@sentry/replay": "11.0.0", "@sentry/replay-canvas": "11.0.0", diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index a00494c5a237..b90c9c44ae2e 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -1,7 +1,13 @@ import { _INTERNAL_filterKeyValueData, defineIntegration, safeSetSpanJSONAttributes } from '@sentry/core'; import { getHttpRequestData, WINDOW } from '../helpers'; import { filterCollectedUrl } from '@sentry/core'; -import { HTTP_REQUEST_HEADER_KEY_BASE, SENTRY_OP, URL_FULL, USER_AGENT_ORIGINAL } from '@sentry/conventions/attributes'; +import { + HTTP_REQUEST_HEADER_KEY_BASE, + SENTRY_IS_LOCALHOST, + SENTRY_OP, + URL_FULL, + USER_AGENT_ORIGINAL, +} from '@sentry/conventions/attributes'; /** * Collects information about HTTP request headers and @@ -61,7 +67,7 @@ export const httpContextIntegration = defineIntegration(() => { // Likewise for the "Filter out localhost events" feature. Deliberately inlined rather than // sharing the server-side helper, which costs bundle size for request headers and IPs that // don't exist here. Mirrors Relay's localhost filter (relay-filter/src/localhost.rs). - 'sentry.is_localhost': + [SENTRY_IS_LOCALHOST]: protocol === 'file:' || hostname === 'localhost' || hostname === '127.0.0.1' || diff --git a/packages/browser/src/integrations/spotlight.ts b/packages/browser/src/integrations/spotlight.ts index a5db70ce2e50..d4714ce1afbc 100644 --- a/packages/browser/src/integrations/spotlight.ts +++ b/packages/browser/src/integrations/spotlight.ts @@ -1,6 +1,7 @@ import type { Client, Envelope, IntegrationFn } from '@sentry/core'; import { debug, defineIntegration, serializeEnvelope } from '@sentry/core'; import { getNativeImplementation } from '@sentry/browser-utils'; +import { BROWSER_WEB_VITAL_INP_TARGET } from '@sentry/conventions/attributes'; import { UI_INTERACTION_CLICK } from '@sentry/conventions/op'; import { DEBUG_BUILD } from '../debug-build'; import type { WINDOW } from '../helpers'; @@ -17,7 +18,7 @@ export const INTEGRATION_NAME = 'SpotlightBrowser' as const; export const SPOTLIGHT_IGNORE_SPANS = [ { op: UI_INTERACTION_CLICK, name: '#sentry-spotlight' }, - { op: UI_INTERACTION_CLICK, attributes: { 'browser.web_vital.inp.target': '#sentry-spotlight' } }, + { op: UI_INTERACTION_CLICK, attributes: { [BROWSER_WEB_VITAL_INP_TARGET]: '#sentry-spotlight' } }, ]; const _spotlightIntegration = ((options: Partial = {}) => { diff --git a/packages/browser/src/report-dialog.ts b/packages/browser/src/report-dialog.ts index 03255a7db91d..7abf68f5ea18 100644 --- a/packages/browser/src/report-dialog.ts +++ b/packages/browser/src/report-dialog.ts @@ -36,17 +36,30 @@ export function showReportDialog(options: ReportDialogOptions = {}): void { eventId: options.eventId || lastEventId(), }; + const { onLoad, onClose, onError } = mergedOptions; + + // The endpoint rejects requests without an event ID, and a failed script load hides the reason + if (!mergedOptions.eventId) { + DEBUG_BUILD && debug.error('[showReportDialog] No event ID'); + onError?.(new Error('No event ID to show the report dialog for')); + return; + } + const script = WINDOW.document.createElement('script'); script.async = true; script.crossOrigin = 'anonymous'; script.src = getReportDialogEndpoint(dsn, mergedOptions); - const { onLoad, onClose } = mergedOptions; - if (onLoad) { script.onload = onLoad; } + if (onError) { + script.onerror = () => { + onError(new Error('Failed to load the report dialog script')); + }; + } + if (onClose) { const reportDialogClosedMessageHandler = (event: MessageEvent): void => { if (event.data === '__sentry_reportdialog_closed__') { @@ -58,6 +71,10 @@ export function showReportDialog(options: ReportDialogOptions = {}): void { } }; WINDOW.addEventListener('message', reportDialogClosedMessageHandler); + // A dialog that never loads never closes, so the listener would stay forever + script.addEventListener('error', () => { + WINDOW.removeEventListener('message', reportDialogClosedMessageHandler); + }); } injectionPoint.appendChild(script); diff --git a/packages/browser/test/index.test.ts b/packages/browser/test/index.test.ts index 4a0986048cf1..e9836c90308b 100644 --- a/packages/browser/test/index.test.ts +++ b/packages/browser/test/index.test.ts @@ -97,7 +97,7 @@ describe('SentryBrowser', () => { getCurrentScope().setUser(EX_USER); setCurrentClient(client); - showReportDialog(); + showReportDialog({ eventId: 'foobar' }); expect(getReportDialogEndpoint).toHaveBeenCalledTimes(1); expect(getReportDialogEndpoint).toHaveBeenCalledWith( @@ -136,7 +136,7 @@ describe('SentryBrowser', () => { setCurrentClient(client); const DIALOG_OPTION_USER = { email: 'option@example.com' }; - showReportDialog({ user: DIALOG_OPTION_USER }); + showReportDialog({ eventId: 'foobar', user: DIALOG_OPTION_USER }); expect(getReportDialogEndpoint).toHaveBeenCalledTimes(1); expect(getReportDialogEndpoint).toHaveBeenCalledWith( @@ -170,7 +170,7 @@ describe('SentryBrowser', () => { it('should call `onClose` when receiving `__sentry_reportdialog_closed__` MessageEvent', async () => { const onClose = vi.fn(); - showReportDialog({ onClose }); + showReportDialog({ eventId: 'foobar', onClose }); await waitForPostMessage('__sentry_reportdialog_closed__'); expect(onClose).toHaveBeenCalledTimes(1); @@ -185,7 +185,7 @@ describe('SentryBrowser', () => { throw new Error(); }); - showReportDialog({ onClose }); + showReportDialog({ eventId: 'foobar', onClose }); await waitForPostMessage('__sentry_reportdialog_closed__'); expect(onClose).toHaveBeenCalledTimes(1); @@ -198,7 +198,7 @@ describe('SentryBrowser', () => { it('should not call `onClose` for other MessageEvents', async () => { const onClose = vi.fn(); - showReportDialog({ onClose }); + showReportDialog({ eventId: 'foobar', onClose }); await waitForPostMessage('some_message'); expect(onClose).not.toHaveBeenCalled(); @@ -206,6 +206,45 @@ describe('SentryBrowser', () => { await waitForPostMessage('__sentry_reportdialog_closed__'); expect(onClose).toHaveBeenCalledTimes(1); }); + + it('should remove the `onClose` listener when the script fails to load', async () => { + const onClose = vi.fn(); + + showReportDialog({ eventId: 'foobar', onClose }); + + const script = WINDOW.document.head.lastElementChild as HTMLScriptElement; + script.dispatchEvent(new Event('error')); + + await waitForPostMessage('__sentry_reportdialog_closed__'); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('onError', () => { + it('should call `onError` when the script fails to load', () => { + const onError = vi.fn(); + + showReportDialog({ eventId: 'foobar', onError }); + + const script = WINDOW.document.head.lastElementChild as HTMLScriptElement; + script.dispatchEvent(new Event('error')); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error('Failed to load the report dialog script')); + }); + + it('should call `onError` and not inject the script without an event ID', () => { + const onError = vi.fn(); + const appendChildSpy = vi.spyOn(WINDOW.document.head, 'appendChild'); + + showReportDialog({ onError }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error('No event ID to show the report dialog for')); + expect(appendChildSpy).not.toHaveBeenCalled(); + + appendChildSpy.mockRestore(); + }); }); }); diff --git a/packages/browser/test/integrations/httpcontext.test.ts b/packages/browser/test/integrations/httpcontext.test.ts index e02221301ea1..524c8742bf3a 100644 --- a/packages/browser/test/integrations/httpcontext.test.ts +++ b/packages/browser/test/integrations/httpcontext.test.ts @@ -8,20 +8,20 @@ const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'; describe('httpContextIntegration', () => { - globalThis.navigator = { + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - } as unknown as Navigator; - globalThis.location = { + } as unknown as Navigator); + vi.stubGlobal('location', { href: 'https://example.com', - } as unknown as Location; - globalThis.document = { + } as unknown as Location); + vi.stubGlobal('document', { referrer: 'https://example.com', addEventListener: vi.fn(), location: { href: 'https://example.com', }, - } as unknown as Document; + } as unknown as Document); it("doesn't attach url.full to http.client segment spans", () => { const integration = httpContextIntegration(); diff --git a/packages/bun/.oxlintrc.json b/packages/bun/.oxlintrc.json index ef16f327fffe..52d4c4ff8acf 100644 --- a/packages/bun/.oxlintrc.json +++ b/packages/bun/.oxlintrc.json @@ -14,7 +14,24 @@ { "files": ["**/src/**"], "rules": { - "sdk/no-class-field-initializers": "off" + "sdk/no-class-field-initializers": "off", + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:diagnostics_channel", + "allowTypeImports": true, + "message": "Bun garbage-collects a channel that no code references, together with its subscribers. Use `subscribeDiagnosticsChannel` or `diagnosticsTracingChannel` from `@sentry/server-utils` instead. See https://github.com/oven-sh/bun/issues/43086" + }, + { + "name": "diagnostics_channel", + "allowTypeImports": true, + "message": "Bun garbage-collects a channel that no code references, together with its subscribers. Use `subscribeDiagnosticsChannel` or `diagnosticsTracingChannel` from `@sentry/server-utils` instead. See https://github.com/oven-sh/bun/issues/43086" + } + ] + } + ] } } ] diff --git a/packages/bun/README.md b/packages/bun/README.md index 1867f5a0fef9..5e9c8df00672 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -10,52 +10,17 @@ [![npm dm](https://img.shields.io/npm/dm/@sentry/bun.svg)](https://www.npmjs.com/package/@sentry/bun) [![npm dt](https://img.shields.io/npm/dt/@sentry/bun.svg)](https://www.npmjs.com/package/@sentry/bun) -## Links - -- [Official SDK Docs](https://docs.sentry.io/quickstart/) +The official Sentry SDK for monitoring Bun applications. The Sentry Bun SDK is in beta. Please help us improve the SDK by [reporting any issues or giving us feedback](https://github.com/getsentry/sentry-javascript/issues). -## Usage - -To use this SDK, call `init(options)` as early as possible in the main entry module. This will initialize the SDK and -hook into the environment. Note that you can turn off almost all side effects using the respective options. - -```javascript -// CJS Syntax -const Sentry = require('@sentry/bun'); -// ESM Syntax -import * as Sentry from '@sentry/bun'; - -Sentry.init({ - dsn: '__DSN__', - // ... -}); -``` - -To set context information or send manual events, use the exported functions of `@sentry/bun`. Note that these functions -will not perform any action before you have called `init()`: - -```javascript -// Set user information, as well as tags and further extras -Sentry.setExtra('battery', 0.7); -Sentry.setTag('user_mode', 'admin'); -Sentry.setUser({ id: '4711' }); - -// Add a breadcrumb for future events -Sentry.addBreadcrumb({ - message: 'My Breadcrumb', - // ... -}); - -// Capture exceptions, messages or manual events -Sentry.captureMessage('Hello, world!'); -Sentry.captureException(new Error('Good bye')); -Sentry.captureEvent({ - message: 'Manual', - stacktrace: [ - // ... - ], -}); -``` +## Documentation + +- [Getting started](https://docs.sentry.io/platforms/javascript/guides/bun/) +- [Configuration](https://docs.sentry.io/platforms/javascript/guides/bun/configuration/) + +## Support + +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/bun/package.json b/packages/bun/package.json index 53e16d4524df..da0dbb2d6725 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "@sentry/core": "11.0.0", - "@sentry/conventions": "^0.23.0", + "@sentry/conventions": "^0.24.0", "@sentry/node": "11.0.0", "@sentry/server-utils": "11.0.0" }, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 10aa2d422844..e4b5cd64f60b 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -85,9 +85,11 @@ export { openAIIntegration, groqIntegration, togetherAIIntegration, + typesafeIntegration, langChainIntegration, langGraphIntegration, mastraIntegration, + mcpServerIntegration, SentryMastraExporter, createFlueInstrumentation, modulesIntegration, @@ -161,6 +163,7 @@ export { supabaseIntegration, instrumentSupabaseClient, instrumentMistralAiClient, + instrumentTypeSafeClient, instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index a61f7dbf1a3f..11977876d040 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -21,8 +21,11 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; -import type { ServeOptions } from 'bun'; +import type { Server, ServeOptions } from 'bun'; import { + CLIENT_ADDRESS, + CLIENT_PORT, + NETWORK_PROTOCOL_NAME, SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, URL_DOMAIN, @@ -242,6 +245,24 @@ function wrapRequestHandler( const client = getClient(); const dataCollection = client?.getDataCollectionOptions(); + if (dataCollection?.userInfo) { + // `client.address` is the originating client, so a forwarding header wins over the socket, which + // behind a proxy holds the proxy's address. + const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + // Bun passes the `Server` as the second argument to both `fetch` and route handlers, except + // when the handler runs through `server.fetch()`. + const socketAddress = getRequestIP(args[1], request); + if (forwardedFor || socketAddress?.address) { + attributes[CLIENT_ADDRESS] = forwardedFor || socketAddress?.address; + } + if (socketAddress?.port) { + attributes[CLIENT_PORT] = socketAddress.port; + } + } + + // describes the OSI application-layer protocol (http), not the scheme (might be https) + attributes[NETWORK_PROTOCOL_NAME] = 'http'; + if (dataCollection) { Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON(), dataCollection)); } @@ -308,6 +329,18 @@ function wrapRequestHandler( }); } +function getRequestIP(server: unknown, request: Request): { address: string; port: number } | undefined { + if (typeof (server as Partial | undefined)?.requestIP !== 'function') { + return undefined; + } + try { + return (server as Server).requestIP(request) ?? undefined; + } catch { + // Defensive: never let a failed lookup break the user's handler. + return undefined; + } +} + function getSpanAttributesFromParsedUrl( parsedUrl: ReturnType, request: Request, diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 27edcc0d88e3..d3ed401e095c 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -521,6 +521,148 @@ describe('Bun Serve Integration', () => { }); describe('data collection', () => { + test('captures client address, port and protocol by default', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'client.address': expect.stringMatching(/^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/), + 'client.port': expect.any(Number), + 'network.protocol.name': 'http', + }), + }), + expect.any(Function), + ); + }); + + test('captures client address on route handlers', async () => { + const server = Bun.serve({ + routes: { + '/users/:id': req => new Response(`User ${req.params.id}`), + }, + port, + }); + + await fetch(`http://localhost:${port}/users/123`); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'client.address': expect.stringMatching(/^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/), + 'client.port': expect.any(Number), + }), + }), + expect.any(Function), + ); + }); + + test('prefers the first x-forwarded-for address over the socket address', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'X-Forwarded-For': '203.0.113.7, 10.0.0.1' }, + }); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'client.address': '203.0.113.7', + }), + }), + expect.any(Function), + ); + }); + + test('does not capture client address when userInfo collection is disabled', async () => { + setupClient({ dataCollection: { userInfo: false } }); + + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'X-Forwarded-For': '203.0.113.7' }, + }); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'client.address': expect.anything(), + 'client.port': expect.anything(), + }), + }), + expect.any(Function), + ); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'network.protocol.name': 'http', + }), + }), + expect.any(Function), + ); + }); + + test('leaves client address unset when the handler gets no server', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + // `server.fetch()` calls the handler with the request only. + const response = await server.fetch(new Request(`http://localhost:${port}/`)); + + await server.stop(); + + expect(await response.text()).toBe('Bun!'); + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'client.address': expect.anything(), + 'client.port': expect.anything(), + }), + }), + expect.any(Function), + ); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ 'network.protocol.name': 'http' }), + }), + expect.any(Function), + ); + }); + test('keeps PII request headers when dataCollection enables full header collection', async () => { setupClient({ dataCollection: { httpHeaders: { request: true, response: true } } }); diff --git a/packages/bun/test/integrations/diagnosticsChannelGc.test.ts b/packages/bun/test/integrations/diagnosticsChannelGc.test.ts new file mode 100644 index 000000000000..3034f4005493 --- /dev/null +++ b/packages/bun/test/integrations/diagnosticsChannelGc.test.ts @@ -0,0 +1,26 @@ +import { channel } from 'node:diagnostics_channel'; +import { describe, expect, test } from 'bun:test'; +import { init } from '../../src'; + +const EXPRESS_HANDLE_START = 'tracing:orchestrion:express:handle:start'; + +function nextTask(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +describe('channel-based integrations', () => { + // Bun garbage-collects a diagnostics channel that no code references, together with its + // subscribers. A GC in the same task does not collect it, so the check runs in a later task. + // See https://github.com/oven-sh/bun/issues/43086 + test('stay subscribed after a garbage collection', async () => { + init({ dsn: 'https://username@domain/123', tracesSampleRate: 1 }); + await nextTask(); + + expect(channel(EXPRESS_HANDLE_START).hasSubscribers).toBe(true); + + Bun.gc(true); + await nextTask(); + + expect(channel(EXPRESS_HANDLE_START).hasSubscribers).toBe(true); + }); +}); diff --git a/packages/bundler-plugins/README.md b/packages/bundler-plugins/README.md index 9d316ee7e99a..e9387aab6f40 100644 --- a/packages/bundler-plugins/README.md +++ b/packages/bundler-plugins/README.md @@ -15,17 +15,11 @@ Check out the individual packages for more information and examples: - [esbuild](https://www.npmjs.com/package/@sentry/esbuild-plugin) - [Webpack](https://www.npmjs.com/package/@sentry/webpack-plugin) -### Features +## Documentation -The Sentry bundler plugin package contains the following functionality: +- [Uploading source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/) -- Sourcemap upload -- Release creation in Sentry -- Automatic release name discovery (based on CI environment - Vercel, AWS, Heroku, CircleCI, or current Git SHA) -- Automatically associate errors with releases (Release injection) +## Support -### More information - -- [Sentry Documentation](https://docs.sentry.io/quickstart/) -- [Sentry Discord](https://discord.gg/Ww9hbqr) -- [Sentry Stackoverflow](http://stackoverflow.com/questions/tagged/sentry) +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index d11b2c1e8784..64cc612ddc2c 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -44,6 +44,11 @@ "import": "./build/esm/core/index.js", "require": "./build/cjs/core/index.js" }, + "./loader-utils": { + "types": "./build/types/loader-utils/index.d.ts", + "import": "./build/esm/loader-utils/index.js", + "require": "./build/cjs/loader-utils/index.js" + }, "./babel-plugin": { "types": "./build/types/babel-plugin/index.d.ts", "import": "./build/esm/babel-plugin/index.js", @@ -105,7 +110,6 @@ } }, "dependencies": { - "@babel/core": "^7.18.5", "@sentry/core": "11.0.0", "dotenv": "^17.4.2", "glob": "^13.0.6", @@ -115,10 +119,14 @@ "supports-color": "^8.1.1" }, "peerDependencies": { + "@babel/core": "^7.18.5", "rollup": ">=3.2.0", "webpack": ">=5.1.0" }, "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, "rollup": { "optional": true }, @@ -127,6 +135,7 @@ } }, "devDependencies": { + "@babel/core": "^7.18.5", "@babel/preset-react": "^7.23.3", "@types/babel__core": "^7.20.5", "@types/node": "^18.6.3", diff --git a/packages/bundler-plugins/rollup.npm.config.mjs b/packages/bundler-plugins/rollup.npm.config.mjs index fe1cb44cbe76..65f39a6a8d22 100644 --- a/packages/bundler-plugins/rollup.npm.config.mjs +++ b/packages/bundler-plugins/rollup.npm.config.mjs @@ -4,6 +4,7 @@ export default makeNPMConfigVariants( makeBaseNPMConfig({ entrypoints: [ 'src/core/index.ts', + 'src/loader-utils/index.ts', 'src/rollup/index.ts', 'src/vite/index.ts', 'src/esbuild/index.ts', diff --git a/packages/bundler-plugins/src/core/component-annotate-hooks.ts b/packages/bundler-plugins/src/core/component-annotate-hooks.ts new file mode 100644 index 000000000000..00898f9942cc --- /dev/null +++ b/packages/bundler-plugins/src/core/component-annotate-hooks.ts @@ -0,0 +1,43 @@ +import { createOxcComponentNameAnnotateHooks, getOxcParseAstAsync } from './component-annotation-oxc'; +import type { ComponentAnnotationTransformMeta, ParseAstAsync } from './component-annotation-oxc-ast'; +import type { Logger } from './logger'; + +const PARSER_UNAVAILABLE_MESSAGE = + 'Could not load `oxc-parser` for this platform. React components will not be annotated.'; + +// Module level, because the Turbopack loader creates new hooks for +// every file. +let warnedParserUnavailable = false; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createComponentNameAnnotateHooks( + ignoredComponents: string[], + injectIntoHtml: boolean, + options: { getParseAstAsync?: () => Promise; logger?: Logger } = {}, +) { + const hooks = createOxcComponentNameAnnotateHooks( + ignoredComponents, + async () => { + const parseAstAsync = (await options.getParseAstAsync?.()) ?? (await getOxcParseAstAsync()); + + if (!parseAstAsync && !warnedParserUnavailable) { + warnedParserUnavailable = true; + if (options.logger) { + options.logger.warn(PARSER_UNAVAILABLE_MESSAGE); + } else { + // eslint-disable-next-line no-console + console.warn(`[@sentry/bundler-plugins] ${PARSER_UNAVAILABLE_MESSAGE}`); + } + } + + return parseAstAsync; + }, + injectIntoHtml, + ); + + return { + transform(this: void, code: string, id: string, meta?: ComponentAnnotationTransformMeta) { + return hooks.transform(code, id, meta); + }, + }; +} diff --git a/packages/bundler-plugins/src/core/component-annotation-oxc-ast.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-ast.ts index e44234c7b1e0..d4bf47387ad7 100644 --- a/packages/bundler-plugins/src/core/component-annotation-oxc-ast.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-ast.ts @@ -59,13 +59,10 @@ export type ComponentAnnotationTransformMeta = { magicString?: MagicStringLike; }; -export type ComponentAnnotationTransformResult = - | { - code: string; - map?: SourceMap | string; - } - | null - | undefined; +export type ComponentAnnotationTransformResult = { + code: string; + map?: SourceMap | string; +} | null; export function isAstNode(value: unknown): value is AstNode { return isObjectLike(value) && typeof value.type === 'string'; diff --git a/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts index 78fd40d99ae6..5e5c2cd4c638 100644 --- a/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts @@ -16,6 +16,28 @@ import type { import { isAstNode, isObjectLike } from './component-annotation-oxc-ast'; const UNKNOWN_ELEMENT_NAME = 'unknown'; +const REACT_NATIVE_ELEMENTS = new Set([ + 'Image', + 'Text', + 'View', + 'ScrollView', + 'TextInput', + 'TouchableOpacity', + 'TouchableHighlight', + 'TouchableWithoutFeedback', + 'FlatList', + 'SectionList', + 'ActivityIndicator', + 'Button', + 'Switch', + 'Modal', + 'SafeAreaView', + 'StatusBar', + 'KeyboardAvoidingView', + 'RefreshControl', + 'Picker', + 'Slider', +]); const WEB_ATTRIBUTE_NAMES = [WEB_ELEMENT_NAME, WEB_COMPONENT_NAME, WEB_SOURCE_FILE_NAME] as const; const WEB_ATTRIBUTE_NAME_SET = new Set(WEB_ATTRIBUTE_NAMES); @@ -140,20 +162,79 @@ export function addPendingAttributes( return; } - const insertion = - pendingInsertion ?? - insertionsByOffset - .set(offset, { - offset, - attributeValues: new Map(), - }) - .get(offset); + const insertion = getOrCreateInsertion(insertionsByOffset, offset); for (const [name, value] of attributes) { - insertion?.attributeValues.set(name, value); + insertion.attributeValues.set(name, value); } } +/** + * Returns `true` when the element is an HTML element, because HTML mode only + * annotates the first HTML elements below a component root. + */ +export function addPendingHtmlAttribute( + code: string, + openingElement: JSXOpeningElementNode, + componentName: string, + ignoredComponents: string[], + fragmentContext: FragmentContext, + insertionsByOffset: Map, +): boolean { + if (isReactFragment(openingElement, fragmentContext)) { + return false; + } + + const elementName = getJSXName(openingElement.name); + + if (!isHtmlElement(elementName)) { + return false; + } + + if (ignoredComponents.includes(componentName) || ignoredComponents.includes(elementName)) { + return true; + } + + const offset = getInsertionOffset(code, openingElement); + if (offset === null) { + return true; + } + + if ( + getExistingAttributeNames(openingElement).has(WEB_COMPONENT_NAME) || + insertionsByOffset.get(offset)?.attributeValues.has(WEB_COMPONENT_NAME) + ) { + return true; + } + + getOrCreateInsertion(insertionsByOffset, offset).attributeValues.set(WEB_COMPONENT_NAME, componentName); + + return true; +} + +function isHtmlElement(elementName: string): boolean { + if (elementName === UNKNOWN_ELEMENT_NAME) { + return false; + } + + if (elementName.charAt(0) === elementName.charAt(0).toLowerCase()) { + return true; + } + + return REACT_NATIVE_ELEMENTS.has(elementName); +} + +function getOrCreateInsertion(insertionsByOffset: Map, offset: number): PendingInsertion { + let insertion = insertionsByOffset.get(offset); + + if (!insertion) { + insertion = { offset, attributeValues: new Map() }; + insertionsByOffset.set(offset, insertion); + } + + return insertion; +} + export function toAttributeInsertions(insertionsByOffset: Map): AttributeInsertion[] { return [...insertionsByOffset.values()].map(({ offset, attributeValues }) => ({ offset, @@ -181,7 +262,8 @@ function getOrderedAttributes(attributeValues: ReadonlyMap): Com for (const name of WEB_ATTRIBUTE_NAMES) { const value = attributeValues.get(name); - if (value) { + // HTML mode writes an empty component name for anonymous classes. + if (value !== undefined) { attributes.push([name, value]); } } diff --git a/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts index 7425d145dd4e..9386b0489fda 100644 --- a/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts @@ -1,6 +1,7 @@ import type { AstNode, AttributeInsertion, FragmentContext, JSXRootNode } from './component-annotation-oxc-ast'; import { addPendingAttributes, + addPendingHtmlAttribute, getStringName, isJSXElement, isJSXRoot, @@ -16,6 +17,7 @@ export function collectOxcComponentAnnotationInsertions( ast: AstNode, ignoredComponents: string[], sourceFileName: string, + injectIntoHtml: boolean, ): AttributeInsertion[] { const fragmentContext = collectFragmentContext(ast); const components = collectComponentJSXRoots(ast); @@ -23,13 +25,46 @@ export function collectOxcComponentAnnotationInsertions( for (const component of components) { for (const root of component.roots) { - processJSX(code, root, component.name, ignoredComponents, fragmentContext, sourceFileName, insertionsByOffset); + if (injectIntoHtml) { + processHtmlJSX(code, root, component.name, ignoredComponents, fragmentContext, insertionsByOffset); + } else { + processJSX(code, root, component.name, ignoredComponents, fragmentContext, sourceFileName, insertionsByOffset); + } } } return toAttributeInsertions(insertionsByOffset); } +function processHtmlJSX( + code: string, + node: JSXRootNode, + componentName: string, + ignoredComponents: string[], + fragmentContext: FragmentContext, + insertionsByOffset: Map }>, +): void { + if ( + isJSXElement(node) && + addPendingHtmlAttribute( + code, + node.openingElement, + componentName, + ignoredComponents, + fragmentContext, + insertionsByOffset, + ) + ) { + return; + } + + for (const child of node.children ?? []) { + if (isJSXRoot(child)) { + processHtmlJSX(code, child, componentName, ignoredComponents, fragmentContext, insertionsByOffset); + } + } +} + function getJSXRootsFromReturnArgument(argument: unknown): JSXRootNode[] { if (isJSXRoot(argument)) { return [argument]; diff --git a/packages/bundler-plugins/src/core/component-annotation-oxc.ts b/packages/bundler-plugins/src/core/component-annotation-oxc.ts index 8bb27f8a7963..3efa49e929a4 100644 --- a/packages/bundler-plugins/src/core/component-annotation-oxc.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc.ts @@ -43,7 +43,7 @@ export function getOxcParseAstAsync(): Promise { return oxcParseAstAsyncPromise; } -// Keep this as a superset of JSX tag starts Babel can annotate, because a miss suppresses Babel fallback. +// Keep this as a superset of all JSX tag starts, because a miss skips the file. const JSX_TAG_START_REGEXP = /<[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}.:-]*|<>/u; const JSX_FILE_REGEXP = /\.[jt]sx$/; @@ -98,30 +98,18 @@ function getMagicString( async function annotateWithOxcParser( code: string, - id: string, + idWithoutQueryAndHash: string, ignoredComponents: string[], parseAstAsync: ParseAstAsync, + injectIntoHtml: boolean, meta?: ComponentAnnotationTransformMeta, ): Promise { - const idWithoutQueryAndHash = stripQueryAndHashFromPath(id); - - if ( - !idWithoutQueryAndHash || - !isAnnotationFile(idWithoutQueryAndHash) || - !shouldTryParse(code) || - shouldSkipIncompatibleFile(idWithoutQueryAndHash) - ) { - return null; - } - const ast = await parseAstAsync(code, { lang: idWithoutQueryAndHash.endsWith('.jsx') ? 'jsx' : 'tsx', }); - // Fall through to the Babel fallback (return undefined, not null) when the parser yields an - // unexpected result, mirroring the catch path — null would suppress Babel and drop annotations. if (!isAstNode(ast)) { - return undefined; + return null; } const insertions = collectOxcComponentAnnotationInsertions( @@ -129,6 +117,7 @@ async function annotateWithOxcParser( ast, ignoredComponents, path.basename(idWithoutQueryAndHash), + injectIntoHtml, ); if (insertions.length === 0) { @@ -147,8 +136,9 @@ async function annotateWithOxcParser( return { code: magicString.toString(), + // No `file`, because magic-string would then make `source` relative to it + // and drop the directory from `sources`. map: magicString.generateMap?.({ - file: id, source: idWithoutQueryAndHash, includeContent: true, hires: true, @@ -159,6 +149,7 @@ async function annotateWithOxcParser( export function createOxcComponentNameAnnotateHooks( ignoredComponents: string[], getParseAstAsync: () => Promise, + injectIntoHtml = false, ): { transform( code: string, @@ -168,16 +159,35 @@ export function createOxcComponentNameAnnotateHooks( } { return { async transform(code, id, meta) { + const idWithoutQueryAndHash = stripQueryAndHashFromPath(id); + + if ( + !idWithoutQueryAndHash || + !isAnnotationFile(idWithoutQueryAndHash) || + !shouldTryParse(code) || + shouldSkipIncompatibleFile(idWithoutQueryAndHash) + ) { + return null; + } + try { const parseAstAsync = await getParseAstAsync(); if (!parseAstAsync) { - return undefined; + return null; } - return await annotateWithOxcParser(code, id, ignoredComponents, parseAstAsync, meta); + return await annotateWithOxcParser( + code, + idWithoutQueryAndHash, + ignoredComponents, + parseAstAsync, + injectIntoHtml, + meta, + ); } catch { - return undefined; + // Leave files the parser cannot read unchanged. The bundler reports the syntax error. + return null; } }, }; diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index 9c5008bf8587..fd645190fb51 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -1,34 +1,4 @@ -import { debug } from '@sentry/core'; import { CodeInjection, containsOnlyImports, stripQueryAndHashFromPath } from './utils'; -import type { transformAsync as babelTransformAsync } from '@babel/core'; -import type componentNameAnnotatePlugin from '../babel-plugin'; -import type { experimentalComponentNameAnnotatePlugin } from '../babel-plugin'; - -type BabelTransformAsync = typeof babelTransformAsync; -type BabelParserPlugins = NonNullable[1]>['parserOpts']>['plugins']; -type BabelAnnotationRuntime = { - transformAsync: BabelTransformAsync; - componentNameAnnotatePlugin: typeof componentNameAnnotatePlugin; - experimentalComponentNameAnnotatePlugin: typeof experimentalComponentNameAnnotatePlugin; -}; - -let babelAnnotationRuntimePromise: Promise | undefined; - -function loadBabelAnnotationRuntime(): Promise { - if (!babelAnnotationRuntimePromise) { - babelAnnotationRuntimePromise = Promise.all([import('@babel/core'), import('../babel-plugin')]).then( - ([babel, babelPlugin]) => { - return { - transformAsync: babel.transformAsync, - componentNameAnnotatePlugin: babelPlugin.default, - experimentalComponentNameAnnotatePlugin: babelPlugin.experimentalComponentNameAnnotatePlugin, - }; - }, - ); - } - - return babelAnnotationRuntimePromise; -} /** * Checks if a file is a JavaScript file based on its extension. @@ -70,61 +40,7 @@ export function shouldSkipCodeInjection(code: string, facadeModuleId: string | n export { globFiles } from './glob'; export { getCodeInjectionPosition } from './get-code-injection-position'; -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function createComponentNameAnnotateHooks(ignoredComponents: string[], injectIntoHtml: boolean) { - return { - async transform(this: void, code: string, id: string) { - // id may contain query and hash which will trip up our file extension logic below - const idWithoutQueryAndHash = stripQueryAndHashFromPath(id); - - if (idWithoutQueryAndHash.match(/\\node_modules\\|\/node_modules\//)) { - return null; - } - - // We will only apply this plugin on jsx and tsx files - if (!['.jsx', '.tsx'].some(ending => idWithoutQueryAndHash.endsWith(ending))) { - return null; - } - - const parserPlugins: BabelParserPlugins = []; - if (idWithoutQueryAndHash.endsWith('.jsx')) { - parserPlugins.push('jsx'); - } else if (idWithoutQueryAndHash.endsWith('.tsx')) { - parserPlugins.push('jsx', 'typescript'); - } - - const { transformAsync, componentNameAnnotatePlugin, experimentalComponentNameAnnotatePlugin } = - await loadBabelAnnotationRuntime(); - const plugin = injectIntoHtml ? experimentalComponentNameAnnotatePlugin : componentNameAnnotatePlugin; - - try { - const result = await transformAsync(code, { - plugins: [[plugin, { ignoredComponents }]], - filename: id, - sourceFileName: idWithoutQueryAndHash, - parserOpts: { - sourceType: 'module', - allowAwaitOutsideFunction: true, - plugins: parserPlugins, - }, - generatorOpts: { - decoratorsBeforeExport: true, - }, - sourceMaps: true, - }); - - return { - code: result?.code ?? code, - map: result?.map, - }; - } catch (e) { - debug.error(`Failed to apply react annotate plugin`, e); - } - - return { code }; - }, - }; -} +export { createComponentNameAnnotateHooks } from './component-annotate-hooks'; export function getDebugIdSnippet(debugId: string): CodeInjection { return new CodeInjection( diff --git a/packages/bundler-plugins/src/loader-utils/index.ts b/packages/bundler-plugins/src/loader-utils/index.ts new file mode 100644 index 000000000000..96fe7cd50bf0 --- /dev/null +++ b/packages/bundler-plugins/src/loader-utils/index.ts @@ -0,0 +1,4 @@ +// Internal to Sentry SDKs, not public API. Kept separate from `./core` so bundler loaders +// don't pull in the build plugin manager and the `sentry` CLI. +export { getCodeInjectionPosition } from '../core/get-code-injection-position'; +export { createComponentNameAnnotateHooks } from '../core/component-annotate-hooks'; diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index 9b41176701d9..d9bbb517daad 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -15,10 +15,7 @@ import { stampDebugId, getCodeInjectionPosition, } from '../core'; -import type { - ComponentAnnotationTransformMeta, - ComponentAnnotationTransformResult, -} from '../core/component-annotation-oxc'; +import type { ComponentAnnotationTransformMeta } from '../core/component-annotation-oxc'; import type { SourceMap } from 'magic-string'; import MagicString from 'magic-string'; import * as path from 'node:path'; @@ -41,13 +38,6 @@ type ViteModule = { }; type ViteParseAstAsync = NonNullable; -type FastAnnotationHooks = { - transform( - code: string, - id: string, - meta?: ComponentAnnotationTransformMeta, - ): Promise; -}; let viteParseAstAsyncPromise: Promise | undefined; @@ -166,33 +156,13 @@ export function _rollupPluginInternal( ? createComponentNameAnnotateHooks( options.reactComponentAnnotation?.ignoredComponents || [], !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, + { + // Vite 8 already loads an oxc-based parser, so reuse it. + getParseAstAsync: buildTool === 'vite' && buildToolMajorVersion === '8' ? getViteParseAstAsync : undefined, + logger, + }, ) : undefined; - const transformFastAnnotations = - options.reactComponentAnnotation?.enabled && !options.reactComponentAnnotation?._experimentalInjectIntoHtml - ? (() => { - let fastAnnotationHooksPromise: Promise | undefined; - - return { - transform(code: string, id: string, meta?: ComponentAnnotationTransformMeta) { - if (!fastAnnotationHooksPromise) { - fastAnnotationHooksPromise = import('../core/component-annotation-oxc').then( - ({ createOxcComponentNameAnnotateHooks, getOxcParseAstAsync }) => - createOxcComponentNameAnnotateHooks( - options.reactComponentAnnotation?.ignoredComponents || [], - // Vite 8 already loads an oxc-based parser, so reuse it. - buildTool === 'vite' && buildToolMajorVersion === '8' - ? getViteParseAstAsync - : getOxcParseAstAsync, - ), - ); - } - - return fastAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta)); - }, - }; - })() - : undefined; const transformReplace = Object.keys(replacementValues).length > 0; const shouldTransform = transformAnnotations || transformReplace; @@ -210,21 +180,8 @@ export function _rollupPluginInternal( ): Promise { // Component annotations are only in user code and boolean flag replacements are // only in Sentry code. If we successfully add annotations, we can return early. - let shouldRunBabelAnnotations = true; - - if (transformFastAnnotations?.transform) { - const result = await transformFastAnnotations.transform(code, id, meta); - if (result) { - return result; - } - - if (result === null) { - shouldRunBabelAnnotations = false; - } - } - - if (shouldRunBabelAnnotations && transformAnnotations?.transform) { - const result = await transformAnnotations.transform(code, id); + if (transformAnnotations) { + const result = await transformAnnotations.transform(code, id, meta); if (result) { return result; } diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index 2adcd4ddc0b6..a86f81894821 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -223,6 +223,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) ? createComponentNameAnnotateHooks( options.reactComponentAnnotation?.ignoredComponents || [], !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, + { logger }, ) : undefined; diff --git a/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts b/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts index 2529460dfa3f..c2c57bd07799 100644 --- a/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts +++ b/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts @@ -3,7 +3,7 @@ import { parse } from '@babel/parser'; import MagicString from 'magic-string'; import { describe, expect, it, vi } from 'vitest'; -import componentNameAnnotatePlugin from '../../src/babel-plugin'; +import componentNameAnnotatePlugin, { experimentalComponentNameAnnotatePlugin } from '../../src/babel-plugin'; import { createOxcComponentNameAnnotateHooks, getOxcParseAstAsync, @@ -61,12 +61,18 @@ function collectAnnotations(code: string, id: string): Annotation[] { return annotations; } -async function annotateWithBabel(code: string, id: string, ignoredComponents: string[]): Promise { +async function annotateWithBabel( + code: string, + id: string, + ignoredComponents: string[], + injectIntoHtml = false, +): Promise { + const plugin = injectIntoHtml ? experimentalComponentNameAnnotatePlugin : componentNameAnnotatePlugin; const result = await transformAsync(code, { filename: id, configFile: false, babelrc: false, - plugins: [[componentNameAnnotatePlugin, { ignoredComponents }]], + plugins: [[plugin, { ignoredComponents }]], parserOpts: { sourceType: 'module', allowAwaitOutsideFunction: true, @@ -87,8 +93,9 @@ async function annotateWithOxc( id: string, ignoredComponents: string[] = [], getParseAstAsync: () => Promise = async () => parseAstAsync, + injectIntoHtml = false, ): Promise { - const hooks = createOxcComponentNameAnnotateHooks(ignoredComponents, getParseAstAsync); + const hooks = createOxcComponentNameAnnotateHooks(ignoredComponents, getParseAstAsync, injectIntoHtml); return hooks.transform(code, id); } @@ -304,6 +311,178 @@ export const List = (props: Props) => { }, ]); }); + + it.each([ + [ + 'HTML roots', + '/src/html-root.jsx', + `export default function App() { + return ( +
    + nested html + +
    + ); +}`, + [], + ], + [ + 'component wrappers', + '/src/wrappers.jsx', + `const Page = () => ( + + +
    content
    +