diff --git a/.agents/skills/e2e/SKILL.md b/.agents/skills/e2e/SKILL.md index bc9f3efe42f4..aa7dcde7ad5a 100644 --- a/.agents/skills/e2e/SKILL.md +++ b/.agents/skills/e2e/SKILL.md @@ -118,6 +118,7 @@ All tests completed successfully. Your SDK changes work correctly with this test - **Test app not found**: List available apps and ask user to clarify - **Packed tarballs missing**: Run `yarn build:tarball` at the repo root, then `yarn test:prepare` in `dev-packages/e2e-tests` - **Build failures**: Fix build errors before running tests +- **pnpm errors such as `packages field missing or empty`**: The app needs the pnpm version in its `volta.pnpm`. Run with Volta, which installs that version ## Common Test Applications diff --git a/.craft.yml b/.craft.yml index c5e537806bcb..a6a739ead8bc 100644 --- a/.craft.yml +++ b/.craft.yml @@ -145,6 +145,18 @@ targets: - name: npm id: '@sentry/bundler-plugins' includeNames: /^sentry-bundler-plugins-\d.*\.tgz$/ + - name: npm + id: '@sentry/esbuild-plugin' + includeNames: /^sentry-esbuild-plugin-\d.*\.tgz$/ + - name: npm + id: '@sentry/rollup-plugin' + includeNames: /^sentry-rollup-plugin-\d.*\.tgz$/ + - name: npm + id: '@sentry/vite-plugin' + includeNames: /^sentry-vite-plugin-\d.*\.tgz$/ + - name: npm + id: '@sentry/webpack-plugin' + includeNames: /^sentry-webpack-plugin-\d.*\.tgz$/ - name: npm id: '@sentry/typescript' includeNames: /^sentry-typescript-\d.*\.tgz$/ diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 499244434f82..5f6a5a8fdf55 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -64,6 +64,10 @@ body: - '@sentry/tanstackstart-react' - '@sentry/vue' - '@sentry/wasm' + - '@sentry/esbuild-plugin' + - '@sentry/rollup-plugin' + - '@sentry/vite-plugin' + - '@sentry/webpack-plugin' - Sentry Browser Loader - Sentry Browser CDN bundle validations: diff --git a/.github/actions/install-pnpm/action.yml b/.github/actions/install-pnpm/action.yml new file mode 100644 index 000000000000..d2d28a09dbb4 --- /dev/null +++ b/.github/actions/install-pnpm/action.yml @@ -0,0 +1,30 @@ +name: 'Install pnpm' +description: 'Installs the pnpm version that Volta pins for a package.' +inputs: + package-json: + description: 'The package.json to read `volta.pnpm` from. `volta.extends` is followed until a version is found.' + default: 'package.json' + +runs: + using: 'composite' + steps: + - name: Resolve pnpm version + id: pnpm-version + shell: bash + env: + PACKAGE_JSON: ${{ inputs.package-json }} + # Keep in sync with `getPnpmMajor` in `dev-packages/e2e-tests/lib/pnpmOverrides.ts`. It writes the overrides in + # the format of the version it finds, so a different lookup breaks the install. + run: | + package_json="$PACKAGE_JSON" + until version=$(jq -er '.volta.pnpm' "$package_json"); do + extends=$(jq -er '.volta.extends' "$package_json") || { + echo "::error::No volta.pnpm in $PACKAGE_JSON or the files it extends" + exit 1 + } + package_json="$(dirname "$package_json")/$extends" + done + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: ${{ steps.pnpm-version.outputs.version }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfe174349582..99ad92d4515d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -892,9 +892,8 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} # The fixtures install the locally-packed `@sentry/bundler-plugins` tarball with pnpm. - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - with: - version: 9.15.9 + - name: Install pnpm + uses: ./.github/actions/install-pnpm - name: Set up Node uses: actions/setup-node@v7 with: @@ -1056,9 +1055,10 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - name: Install pnpm + uses: ./.github/actions/install-pnpm with: - version: ${{ matrix.test-application == 'nuxt-5' && '11.10.0' || '9.15.9' }} + package-json: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Node uses: actions/setup-node@v7 with: @@ -1197,6 +1197,10 @@ jobs: E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks' E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' E2E_OPENROUTER_API_KEY: ${{ secrets.E2E_OPENROUTER_API_KEY }} + # Only this app talks to OpenAI directly (OpenRouter has no Decisions route), so only it gets the key. + E2E_OPENAI_DECISIONS_API_KEY: + ${{ matrix.test-application == 'node-openai-decisions-send-to-sentry' && secrets.E2E_OPENAI_DECISIONS_API_KEY || + '' }} # Used by test apps that deploy a real Cloudflare Worker, e.g. cloudflare-workers-send-to-sentry CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -1211,9 +1215,10 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - name: Install pnpm + uses: ./.github/actions/install-pnpm with: - version: ${{ matrix.test-application == 'nuxt-5' && '11.10.0' || '9.15.9' }} + package-json: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Node uses: actions/setup-node@v7 with: diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index fd9c8d6ce524..f3ddfbf71bd8 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -117,9 +117,10 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - name: Install pnpm + uses: ./.github/actions/install-pnpm with: - version: 9.15.9 + package-json: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Node uses: actions/setup-node@v7 with: diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index fb77747f336f..8ac1c7987801 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -122,6 +122,18 @@ jobs: "@sentry.wasm": { "label": "WASM" }, + "@sentry.esbuild-plugin": { + "label": "Feature: Source Maps" + }, + "@sentry.rollup-plugin": { + "label": "Feature: Source Maps" + }, + "@sentry.vite-plugin": { + "label": "Feature: Source Maps" + }, + "@sentry.webpack-plugin": { + "label": "Feature: Source Maps" + }, "Sentry.Browser.Loader": { "label": "Browser" }, diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 6928d5d4073e..e67fa7c7cd66 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -19,9 +19,8 @@ jobs: VITE_E2E_TEST_DSN: 'https://username@domain/123' steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - with: - version: 9.15.9 + - name: Install pnpm + uses: ./.github/actions/install-pnpm - uses: actions/setup-node@v7 with: node-version-file: 'package.json' diff --git a/CHANGELOG.md b/CHANGELOG.md index 0727144b61f9..66ad8165bd35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,68 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 11.8.0 + +### Important Changes + +- **feat(bundler-plugins): Release `@sentry/webpack-plugin`, `@sentry/vite-plugin`, `@sentry/rollup-plugin` and `@sentry/esbuild-plugin` from the SDK repository ([#25095](https://github.com/getsentry/sentry-javascript/pull/25095))** + + The four standalone bundler plugins now release together with the SDK, so their version jumps from 5.4.1 to the SDK version. Each package re-exports the matching `@sentry/bundler-plugins` entry point, which we recommend for new projects. The plugin options do not change, but the update brings these breaking changes: + + - Node.js 20.19.0 or higher is required. + - `@sentry/webpack-plugin` requires webpack 5.1 or higher. The `@sentry/webpack-plugin/webpack5` entry point still works and exports the same plugin as `@sentry/webpack-plugin`. + - The plugins use the `sentry` CLI SDK instead of `@sentry/cli`. Sentry self-hosted 26.4.2 or higher is supported. Lower versions may continue to work. + - Deploys that the plugins create on Vercel use the value of `VERCEL_TARGET_ENV` (`production`, `preview` or a custom name) as their environment, instead of `vercel-production` or `vercel-preview`. To keep the old value, set `release.deploy.env`. + +- **feat(server-utils): Instrument the OpenAI decisions API as `gen_ai.decide` spans ([#25285](https://github.com/getsentry/sentry-javascript/pull/25285))** + + OpenAI `decisions.create` calls now create `gen_ai.decide` spans. + +### Other Changes + +- feat(core): Support SuppressedError in LinkedErrors integration ([#25246](https://github.com/getsentry/sentry-javascript/pull/25246)) +- fix(astro): Delete generated source maps for static output ([#25310](https://github.com/getsentry/sentry-javascript/pull/25310)) +- fix(astro): Upload source maps of Astro 7's prerender build ([#25309](https://github.com/getsentry/sentry-javascript/pull/25309)) +- fix(browser): Apply `urlQueryParams` to the Referer header ([#25324](https://github.com/getsentry/sentry-javascript/pull/25324)) +- fix(cloudflare): auto-instrument when wrangler `main` is a package ([#25343](https://github.com/getsentry/sentry-javascript/pull/25343)) +- fix(hono): Expose runtime-specific SDK options ([#25290](https://github.com/getsentry/sentry-javascript/pull/25290)) +- fix(node): Instrument Prisma 8 CollectionBase from rc.15 ([#25301](https://github.com/getsentry/sentry-javascript/pull/25301)) +- fix(nuxt): Skip 3xx/4xx Nuxt errors in the vue:error hook ([#25341](https://github.com/getsentry/sentry-javascript/pull/25341)) +- fix(server-utils): Read pi-durable 1.2.0 tool results from `output` ([#25312](https://github.com/getsentry/sentry-javascript/pull/25312)) +- fix(server-utils): Transform modules once when two Sentry Vite plugins add orchestrion ([#25043](https://github.com/getsentry/sentry-javascript/pull/25043)) +- fix(sveltekit): add op and origin to Kit's `sveltekit.endpoint` span ([#25376](https://github.com/getsentry/sentry-javascript/pull/25376)) +- fix(tanstackstart-react): Keep parametrized route names in Nitro 3 builds ([#25307](https://github.com/getsentry/sentry-javascript/pull/25307)) +- fix(tanstackstart-react): Rewrite source paths of split route modules ([#25361](https://github.com/getsentry/sentry-javascript/pull/25361)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#25375](https://github.com/getsentry/sentry-javascript/pull/25375)) +- ci: Install the pnpm version that Volta pins ([#25287](https://github.com/getsentry/sentry-javascript/pull/25287)) +- license: Add cli FSL notice to package licenses ([#25314](https://github.com/getsentry/sentry-javascript/pull/25314)) +- ref(core): use shared MCP attribute conventions ([#25059](https://github.com/getsentry/sentry-javascript/pull/25059)) +- ref(tanstackstart-react): Reuse shared component annotation options ([#25313](https://github.com/getsentry/sentry-javascript/pull/25313)) +- test(browser): Keep whitespace text out of the handlers-lcp page ([#25280](https://github.com/getsentry/sentry-javascript/pull/25280)) +- test(bun): Restore @sentry/core spies after the Bun.serve tests ([#25276](https://github.com/getsentry/sentry-javascript/pull/25276)) +- test(bundler-plugins): Disable CLI telemetry in the CLI contract tests ([#25308](https://github.com/getsentry/sentry-javascript/pull/25308)) +- test(e2e): Add node-openai-decisions-send-to-sentry test app ([#25331](https://github.com/getsentry/sentry-javascript/pull/25331)) +- test(e2e): Add node-openai-send-to-sentry test app ([#24824](https://github.com/getsentry/sentry-javascript/pull/24824)) +- test(e2e): Add nuxt-5-sourcemaps test ([#25268](https://github.com/getsentry/sentry-javascript/pull/25268)) +- test(e2e): Inherit root Volta config in all e2e test apps ([#25369](https://github.com/getsentry/sentry-javascript/pull/25369)) +- test(e2e): Use an external host address for the Lambda DSN on loopback hostnames ([#25282](https://github.com/getsentry/sentry-javascript/pull/25282)) +- test(nextjs): Run nextjs-16-tunnel on Cloudflare ([#25161](https://github.com/getsentry/sentry-javascript/pull/25161)) +- test(nextjs): Switch DEBUG_BUILD with a getter instead of re-importing the SDK ([#25279](https://github.com/getsentry/sentry-javascript/pull/25279)) +- test(node): Await the child exit in the "should exit" integration tests ([#25277](https://github.com/getsentry/sentry-javascript/pull/25277)) +- test(node): Cover ioredis v6 in the diagnostics_channel suite ([#25300](https://github.com/getsentry/sentry-javascript/pull/25300)) +- test(node): Cover LangChain model calls made from a tool without config ([#25326](https://github.com/getsentry/sentry-javascript/pull/25326)) +- test(node): Log slow and failed Docker Compose starts in integration tests ([#25278](https://github.com/getsentry/sentry-javascript/pull/25278)) +- test(node): Unflake captureMessage with_level test ([#25358](https://github.com/getsentry/sentry-javascript/pull/25358)) +- test(sveltekit): Pin rollup to 4.64.5 ([#25328](https://github.com/getsentry/sentry-javascript/pull/25328)) + +
+ +Work in this release was contributed by @kunal649 and @mketiku. Thank you for your contributions! + ## 11.7.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpContext/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpContext/test.ts index e50a658b4c9a..40cce0c2ee28 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpContext/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpContext/test.ts @@ -37,6 +37,40 @@ sentryTest('httpContextIntegration filters sensitive query params in the URL', a expect(errorEvent.request?.url).toBe(`${url}?jwt=[Filtered]&page=1`); }); +sentryTest( + 'httpContextIntegration filters sensitive query params in the Referer header', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const errorEventPromise = waitForErrorRequest(page); + + await page.goto(url, { referer: `${url}?jwt=sensitive-jwt-value&page=1` }); + + const errorEvent = envelopeRequestParser(await errorEventPromise); + + expect(errorEvent.request?.headers?.Referer).toBe(`${url}?jwt=[Filtered]&page=1`); + }, +); + +sentryTest( + 'httpContextIntegration filters sensitive query params in the referer span attribute', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spanPromise = waitForStreamedSpan(page, span => span.is_segment && getSpanOp(span) === 'pageload'); + + await page.goto(url, { referer: `${url}?jwt=sensitive-jwt-value&page=1` }); + + const pageloadSpan = await spanPromise; + + expect(pageloadSpan.attributes['http.request.header.referer']).toEqual({ + type: 'array', + value: [`${url}?jwt=[Filtered]&page=1`], + }); + }, +); + sentryTest('httpContextIntegration captures url, user-agent, and referer', async ({ getLocalTestUrl, page }) => { sentryTest.skip(shouldSkipTracingTest()); const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-sensitive-query-params-static/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-sensitive-query-params-static/test.ts index 8bfc5a6df874..c20135d73286 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-sensitive-query-params-static/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-sensitive-query-params-static/test.ts @@ -16,10 +16,11 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === PAGELOAD); - await page.goto(`${url}?jwt=sensitive-jwt-value&page=1`); + await page.goto(`${url}?jwt=sensitive-jwt-value&page=1`, { referer: `${url}?jwt=sensitive-jwt-value&page=2` }); const eventData = envelopeRequestParser(await pageloadRequestPromise); expect(eventData.request?.url).toBe(`${url}?jwt=[Filtered]&page=1`); + expect(eventData.request?.headers?.Referer).toBe(`${url}?jwt=[Filtered]&page=2`); expect(eventData.spans?.find(({ op }) => op === BROWSER_REQUEST)?.description).toBe(`${url}?jwt=[Filtered]&page=1`); expect(eventData.spans?.find(({ op }) => op === RESOURCE_IMG)?.description).toBe( 'https://sentry-test-site.example/path/to/image.svg?token=[Filtered]&size=2', diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/handlers-lcp/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/handlers-lcp/template.html index ff4754b3ebb6..dc24329539fe 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/handlers-lcp/template.html +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/handlers-lcp/template.html @@ -6,6 +6,8 @@
- + +
diff --git a/dev-packages/e2e-tests/.env.example b/dev-packages/e2e-tests/.env.example index 180fb07078a1..3815cae556f0 100644 --- a/dev-packages/e2e-tests/.env.example +++ b/dev-packages/e2e-tests/.env.example @@ -16,6 +16,10 @@ E2E_TEST_SENTRY_PROJECT= # Only needed to run that test app locally. E2E_OPENROUTER_API_KEY= +# An OpenAI API key for the Decisions API (OpenRouter has no Decisions route). +# Only needed to run node-openai-decisions-send-to-sentry locally. +E2E_OPENAI_DECISIONS_API_KEY= + # Cloudflare credentials for E2E tests that deploy a real Worker (e.g. cloudflare-workers-send-to-sentry). # The API token needs "Workers Scripts: Edit" on the account; "Workers KV Storage: Read" additionally silences a # warning when a worker is deleted. Leave it empty to use a `wrangler login` session instead. diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 824784c63492..1c1da7180faf 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -118,6 +118,9 @@ Make sure to add a `test:build` and `test:assert` command to the new app's `pack Sentry packages are automatically resolved to the local build via pnpm overrides injected at test time, so no manual registry configuration is needed. +Apps use the root pnpm version. To use another one, set `volta.pnpm` in the app's `package.json`. Volta (locally) and +CI (`.github/actions/install-pnpm`) both install that version. + ### Test apps that deploy a real Cloudflare Worker Apps like `cloudflare-workers-send-to-sentry` deploy the built app as a real Worker and check what arrives in Sentry: diff --git a/dev-packages/e2e-tests/lib/pnpmOverrides.ts b/dev-packages/e2e-tests/lib/pnpmOverrides.ts index 51fa793f5eae..e9f79d05f71e 100644 --- a/dev-packages/e2e-tests/lib/pnpmOverrides.ts +++ b/dev-packages/e2e-tests/lib/pnpmOverrides.ts @@ -57,6 +57,7 @@ export async function addPnpmOverrides(tmpDirPath: string, packedDirPath: string console.log(`Added ${packageNames.length} internal Sentry package overrides`); } +// Keep in sync with `.github/actions/install-pnpm`, which installs the version this finds in CI. async function getPnpmMajor( packageJsonPath: string, packageJson: { volta?: { extends?: string; pnpm?: string } }, diff --git a/dev-packages/e2e-tests/test-applications/angular-21/package.json b/dev-packages/e2e-tests/test-applications/angular-21/package.json index 86b8fd27796b..fd9a6cd181e1 100644 --- a/dev-packages/e2e-tests/test-applications/angular-21/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-21/package.json @@ -47,7 +47,6 @@ "typescript": "~6.0.0" }, "volta": { - "node": "22.22.3", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/angular-22/package.json b/dev-packages/e2e-tests/test-applications/angular-22/package.json index d96d2a0f2016..4f8edc6fab7b 100644 --- a/dev-packages/e2e-tests/test-applications/angular-22/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-22/package.json @@ -47,7 +47,6 @@ "typescript": "~6.0.0" }, "volta": { - "node": "22.22.3", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/astro-6/package.json b/dev-packages/e2e-tests/test-applications/astro-6/package.json index 4261956d39d5..68a872f29f37 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6/package.json @@ -19,7 +19,6 @@ "astro": "^6.2.0" }, "volta": { - "node": "22.22.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/.gitignore new file mode 100644 index 000000000000..713fa3d27405 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/.gitignore @@ -0,0 +1,8 @@ +dist/ +.astro/ +node_modules/ +.DS_Store + +# Mock Sentry server artifacts +.tmp_* +/tmp_mock_chunks diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..aeb76ee19e32 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts @@ -0,0 +1,97 @@ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + findInjectedDebugIds, + findSourceMapFiles, + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, +} from '@sentry-internal/test-utils'; + +const isStatic = process.env.E2E_ASTRO_OUTPUT === 'static'; + +const BUILD_OUTPUT = 'dist'; +const OUTPUT_DIRS = isStatic ? [BUILD_OUTPUT] : [path.join(BUILD_OUTPUT, 'client'), path.join(BUILD_OUTPUT, 'server')]; + +/** All markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; +const PRERENDER_MARKER = 'SOURCEMAP_MARKER_PRERENDER'; + +/** Written by the build, see `recordChunksWithoutSourceMap` in `astro.config.mjs`. */ +const debugIdsWithoutSourceMap = new Set( + fs.existsSync('.tmp_debug_ids_without_source_map') + ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) + : [], +); + +const requests = loadMockServerResults(); + +console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); +for (const request of requests) { + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); +} +console.log(''); + +assert.ok( + requests.some(r => r.authorization.includes('fake-auth-token')), + 'Expected requests with the configured auth token', +); + +assert.ok( + getChunkUploadPosts(requests).some(r => r.bodySize > 0), + 'Expected at least one chunk upload POST with a non-empty body', +); + +const assembleRequests = getAssembleRequests(requests); +assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); +for (const request of assembleRequests) { + assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); + assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); +} + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); + +const sourcemaps = getSourcemaps(bundles); + +const containsMarker = (marker: string): boolean => + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source'); +if (!isStatic) { + assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source'); +} +// Astro 7 writes the prerender build to a hidden `.prerender` folder, which a plain `dist/**/*` glob skips +assert.ok(containsMarker(PRERENDER_MARKER), 'Expected an uploaded sourcemap carrying the prerendered page source'); + +const buildOutput = ['.tmp_build_stdout', '.tmp_build_stderr'] + .filter(file => fs.existsSync(file)) + .map(file => fs.readFileSync(file, 'utf8')) + .join('\n'); +assert.ok( + !buildOutput.includes("Didn't find any matching sources for debug ID upload"), + 'Expected every Vite build to find sources for the debug ID upload', +); + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); + +for (const outputDir of OUTPUT_DIRS) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + + const unuploaded = injectedDebugIds.filter( + debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), + ); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); +} + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs new file mode 100644 index 000000000000..3b859eeb1c75 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs @@ -0,0 +1,54 @@ +import node from '@astrojs/node'; +import sentry from '@sentry/astro'; +import { defineConfig } from 'astro/config'; +import * as fs from 'node:fs'; + +const isStatic = process.env.E2E_ASTRO_OUTPUT === 'static'; + +// Rolldown emits no map for chunks without mappable source (e.g. virtual modules or its runtime), so their debug +// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build', + enforce: 'post', + writeBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1].toLowerCase()}\n`); + } + } + }, + }; +} + +// Static output allows no on-demand routes, so the SSR page is only added for server output +const ssrRoute = { + name: 'ssr-route', + hooks: { + 'astro:config:setup': ({ injectRoute }) => { + injectRoute({ pattern: '/ssr', entrypoint: './src/routes/ssr.astro' }); + }, + }, +}; + +export default defineConfig({ + integrations: [ + sentry({ + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + debug: true, + }), + ...(isStatic ? [] : [ssrRoute]), + ], + // Keeps Astro from inlining the small client script into the HTML, where it would get no source map + vite: { build: { assetsInlineLimit: 0 }, plugins: [recordChunksWithoutSourceMap()] }, + output: isStatic ? 'static' : 'server', + adapter: isStatic ? undefined : node({ mode: 'standalone' }), +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json new file mode 100644 index 000000000000..f31dd0e34bce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json @@ -0,0 +1,35 @@ +{ + "name": "astro-7-sourcemaps", + "description": "E2E test app asserting what the Astro SDK uploads to Sentry and what it leaves behind in `dist`.", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "node start-mock-sentry-server.mjs & astro build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "test:build": "pnpm install && pnpm build", + "test:assert": "node assert-build.ts", + "test:build:static": "E2E_ASTRO_OUTPUT=static pnpm test:build", + "test:assert:static": "E2E_ASTRO_OUTPUT=static pnpm test:assert" + }, + "dependencies": { + "@astrojs/node": "^11.1.4", + "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", + "astro": "^7.2.8" + }, + "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^22.20.0" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:static", + "assert-command": "pnpm test:assert:static", + "label": "astro-7-sourcemaps (static output)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.client.config.js b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.client.config.js new file mode 100644 index 000000000000..a372de45b50d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.client.config.js @@ -0,0 +1,5 @@ +import * as Sentry from '@sentry/astro'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.server.config.js b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.server.config.js new file mode 100644 index 000000000000..a372de45b50d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.server.config.js @@ -0,0 +1,5 @@ +import * as Sentry from '@sentry/astro'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/client.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/client.ts new file mode 100644 index 000000000000..92ab780d2a46 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/client.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT +document.querySelector('button')?.addEventListener('click', () => { + throw new Error('Client error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/prerender.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/prerender.ts new file mode 100644 index 000000000000..28c73e7021fc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/prerender.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_PRERENDER +export function getPrerenderGreeting(): string { + return `Prerendered at ${new Date().toISOString()}`; +} diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/server.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/server.ts new file mode 100644 index 000000000000..c85840477623 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/server.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_SERVER +export function getServerGreeting(): string { + return `Rendered on request at ${new Date().toISOString()}`; +} diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/pages/index.astro b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/pages/index.astro new file mode 100644 index 000000000000..6be5fe86d77a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/pages/index.astro @@ -0,0 +1,15 @@ +--- +import { getPrerenderGreeting } from '../lib/prerender'; + +export const prerender = true; +--- + + + +

{getPrerenderGreeting()}

+ + + + diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/routes/ssr.astro b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/routes/ssr.astro new file mode 100644 index 000000000000..46be8dec4c71 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/routes/ssr.astro @@ -0,0 +1,11 @@ +--- +import { getServerGreeting } from '../lib/server'; + +export const prerender = false; +--- + + + +

{getServerGreeting()}

+ + diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..8c85ae3329dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org', outputDir: 'tmp_mock_chunks' }); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..8bf91d3bb997 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/astro-7-static/package.json b/dev-packages/e2e-tests/test-applications/astro-7-static/package.json index ce0452bf7e37..f10815c6a9a7 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-static/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7-static/package.json @@ -24,7 +24,6 @@ "mysql": "^2.18.1" }, "volta": { - "node": "22.22.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index 33126242d1f6..10618dfc3227 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -24,7 +24,6 @@ "mysql": "^2.18.1" }, "volta": { - "node": "22.22.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts index df5a076ef56d..6ed4cd16440f 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts @@ -118,14 +118,31 @@ export class LocalLambdaStack extends Stack { export async function getHostIp() { if (process.env.GITHUB_ACTIONS) { - const host = await dns.lookup(os.hostname()); - return host.address; + return getExternalHostIp(); } if (platform === 'darwin' || platform === 'win32') { return 'host.docker.internal'; } + return getExternalHostIp(); +} + +/** + * The Lambda container reaches the event proxy on the host through an address of the host's network interface. + * Some runners (e.g. Namespace) map the hostname to a loopback address, which inside the container is the container + * itself, so fall back to the first external IPv4 address that is not a Docker bridge. + */ +async function getExternalHostIp(): Promise { const host = await dns.lookup(os.hostname()); - return host.address; + if (!host.address.startsWith('127.') && host.address !== '::1') { + return host.address; + } + + const external = Object.entries(os.networkInterfaces()) + .filter(([name]) => !name.startsWith('docker') && !name.startsWith('br-') && !name.startsWith('veth')) + .flatMap(([, addresses]) => addresses ?? []) + .find(address => address.family === 'IPv4' && !address.internal); + + return external?.address ?? host.address; } diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts index f314643572c2..c4eb3658afd0 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts @@ -159,14 +159,31 @@ export class LocalLambdaStack extends Stack { export async function getHostIp() { if (process.env.GITHUB_ACTIONS) { - const host = await dns.lookup(os.hostname()); - return host.address; + return getExternalHostIp(); } if (platform === 'darwin' || platform === 'win32') { return 'host.docker.internal'; } + return getExternalHostIp(); +} + +/** + * The Lambda container reaches the event proxy on the host through an address of the host's network interface. + * Some runners (e.g. Namespace) map the hostname to a loopback address, which inside the container is the container + * itself, so fall back to the first external IPv4 address that is not a Docker bridge. + */ +async function getExternalHostIp(): Promise { const host = await dns.lookup(os.hostname()); - return host.address; + if (!host.address.startsWith('127.') && host.address !== '::1') { + return host.address; + } + + const external = Object.entries(os.networkInterfaces()) + .filter(([name]) => !name.startsWith('docker') && !name.startsWith('br-') && !name.startsWith('veth')) + .flatMap(([, addresses]) => addresses ?? []) + .find(address => address.family === 'IPv4' && !address.internal); + + return external?.address ?? host.address; } diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json index 7c1e27f8e43a..9a2bf4d39eca 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json @@ -22,8 +22,6 @@ "vite": "^7.3.2" }, "volta": { - "node": "20.19.2", - "yarn": "1.22.22", - "pnpm": "9.15.9" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json index da4e9cba0a8c..9cdc87c04d8d 100644 --- a/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json @@ -18,8 +18,6 @@ "concurrently": "^8.2.2" }, "volta": { - "node": "20.19.2", - "yarn": "1.22.22", - "pnpm": "9.15.9" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json index 0aa7157512c5..58fae8fb3df3 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -22,8 +22,6 @@ "@sentry/vite-plugin": "^5.4.1" }, "volta": { - "node": "20.19.2", - "yarn": "1.22.22", - "pnpm": "9.15.9" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json index 0d25c711360a..26b44523be92 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json @@ -45,7 +45,6 @@ "optional": true }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore new file mode 100644 index 000000000000..3f64191fe034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore @@ -0,0 +1,4 @@ +dist +.wrangler +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts new file mode 100644 index 000000000000..10d0f17fbcdb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts @@ -0,0 +1,10 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +// The worker entry is a package, so the plugin reads this file from beside +// the wrangler config. +export default defineCloudflareOptions((env: { E2E_TEST_DSN: string }) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json new file mode 100644 index 000000000000..2211b7f3455b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json @@ -0,0 +1,29 @@ +{ + "name": "cloudflare-autoinstrument-package-entry", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "worker-entry": "file:../../test-applications/cloudflare-autoinstrument-package-entry/worker-entry" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.47.0", + "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "~1.63.0", + "@sentry/conventions": "0.27.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts new file mode 100644 index 000000000000..9633de63b272 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts @@ -0,0 +1,12 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig( + { + startCommand: 'pnpm preview', + port: 8787, + }, + { + workers: '100%', + retries: 0, + }, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs new file mode 100644 index 000000000000..553dac81dc3c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-autoinstrument-package-entry', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts new file mode 100644 index 000000000000..7baf4af55892 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { SENTRY_ENVIRONMENT, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +// The worker entry is a package with no Sentry calls. Any event that arrives +// proves the plugin wrapped the module that wrangler's `main` resolved to. + +test('wraps a package entry with withSentry (options from instrument.server.ts)', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan( + 'cloudflare-autoinstrument-package-entry', + span => getSpanOp(span) === HTTP_SERVER && span.is_segment, + ); + + const res = await fetch(baseURL!); + expect(res.status).toBe(200); + + const span = await spanPromise; + expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.http.cloudflare'); + // Only `instrument.server.ts` sets this environment. + expect(span.attributes[SENTRY_ENVIRONMENT]?.value).toBe('qa'); +}); + +test('captures an error thrown by a package entry', async ({ baseURL }) => { + const errorPromise = waitForError( + 'cloudflare-autoinstrument-package-entry', + event => event.exception?.values?.[0]?.value === 'Thrown from a package entry', + ); + + const res = await fetch(`${baseURL}/throw`); + expect(res.status).toBe(500); + + const error = await errorPromise; + expect(error.environment).toBe('qa'); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js new file mode 100644 index 000000000000..1d8a976634e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js @@ -0,0 +1,11 @@ +// Shaped like bundler output (e.g. `@astrojs/cloudflare`): the default export +// is a specifier, not `export default `. +var fetch_handler_default = { + async fetch(request) { + if (new URL(request.url).pathname === '/throw') { + throw new Error('Thrown from a package entry'); + } + return new Response('ok'); + }, +}; +export { fetch_handler_default as default }; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json new file mode 100644 index 000000000000..08018b205e6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json @@ -0,0 +1,9 @@ +{ + "name": "worker-entry", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./fetch-handler": "./fetch-handler.js" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc new file mode 100644 index 000000000000..4f9bc84616ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-autoinstrument-package-entry", + // A package export, not a file path. The Sentry plugin resolves it with Vite. + "main": "worker-entry/fetch-handler", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json index 7f7be894c7e6..d24873e57136 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json @@ -33,7 +33,6 @@ "wrangler": "^4.114.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json index 9b45bb26c8bb..c8880b0e3bf5 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json @@ -34,7 +34,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json index 6699912023e6..80c16c600e11 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json @@ -12,8 +12,8 @@ "test:assert": "playwright test" }, "dependencies": { - "@earendil-works/pi-ai": "1.0.0", - "@earendil-works/pi-durable": "1.0.0", + "@earendil-works/pi-ai": "1.2.0", + "@earendil-works/pi-durable": "1.2.0", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", "agents": "0.26.0" }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts index bd0d25a8c5b3..93e1236190cf 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts @@ -47,7 +47,7 @@ export class Assistant extends Agent { // The manual span should nest under the SDK's `execute_tool` span. execute: async args => Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({ - content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], + output: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], })), }), defineTool({ @@ -67,7 +67,7 @@ export class Assistant extends Agent { execute: async args => { const marker = `crashed:${args.job}`; if (await this.ctx.storage.get(marker)) { - return { content: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; + return { output: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; } await this.ctx.storage.put(marker, true); await this.ctx.storage.sync(); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json index 0bb10a6be700..4adbc345fceb 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json @@ -34,7 +34,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { 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 3bebd9caebf1..1c2069e8c9d4 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 @@ -26,7 +26,6 @@ "wrangler": "^4.114.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/package.json b/dev-packages/e2e-tests/test-applications/elysia-node/package.json index e1ca655b6d02..2cf414460220 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-node/package.json +++ b/dev-packages/e2e-tests/test-applications/elysia-node/package.json @@ -20,7 +20,6 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json", - "node": "24.11.0" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json index 89364cbbe423..2ea5cee00536 100644 --- a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json @@ -50,7 +50,6 @@ ] }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json index 2c0d72b3b1d6..c707ee5f2608 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json @@ -30,7 +30,6 @@ "wrangler": "^4.61.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index 99b2e1ce111e..4efa27a21f54 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -35,7 +35,6 @@ "wrangler": "^4.114.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/open-next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/open-next.config.ts new file mode 100644 index 000000000000..7a3d17172639 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/open-next.config.ts @@ -0,0 +1,3 @@ +import { defineCloudflareConfig } from '@opennextjs/cloudflare'; + +export default defineCloudflareConfig(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index a532785bd351..0b8ace0062d4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -20,11 +20,14 @@ "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", + "test:build:cloudflare": "pnpm install && _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ opennextjs-cloudflare build", + "start:cloudflare": "opennextjs-cloudflare preview" }, "dependencies": { - "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@opennextjs/cloudflare": "^1.14.9", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "ai": "^3.0.0", "import-in-the-middle": "^1", "next": "16.3.8", @@ -41,7 +44,8 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^16", - "typescript": "^5" + "typescript": "^5", + "wrangler": "^4.61.0" }, "volta": { "extends": "../../package.json" @@ -58,6 +62,14 @@ "label": "nextjs-16-tunnel (turbopack)", "assert-command": "pnpm test:assert" } + ], + "optionalVariants": [ + { + "runtime": "cloudflare", + "build-command": "pnpm test:build:cloudflare", + "assert-command": "pnpm test:prod", + "label": "nextjs-16-tunnel (cloudflare)" + } ] } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs index 797418b8cf7d..4da87c8ca509 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs @@ -1,4 +1,4 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { getPlaywrightConfig, getRuntime } from '@sentry-internal/test-utils'; const testEnv = process.env.TEST_ENV; if (!testEnv) { @@ -15,7 +15,7 @@ const getStartCommand = () => { } if (testEnv === 'production') { - return 'pnpm next start -p 3030'; + return getRuntime() === 'cloudflare' ? 'pnpm start:cloudflare --port 3030' : 'pnpm next start -p 3030'; } throw new Error(`Unknown test env: ${testEnv}`); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts new file mode 100644 index 000000000000..522d8c460dfd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/nextjs/cloudflare'; +// @ts-ignore the file exists only after `opennextjs-cloudflare build` +import handler from './.open-next/worker.js'; + +export default Sentry.withSentry( + () => ({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // Sends the server spans to the proxy server, so the tests see whether a tunnel request was sampled. + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, + }), + handler, +); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/wrangler.jsonc new file mode 100644 index 000000000000..58acae9276b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nextjs-16-tunnel", + "main": "./worker.ts", + "compatibility_date": "2026-02-19", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "binding": "ASSETS", + "directory": ".open-next/assets", + }, +} diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json index ef1b771dd545..2f194ff478dd 100644 --- a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json @@ -34,7 +34,6 @@ ] }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-eve/package.json b/dev-packages/e2e-tests/test-applications/node-eve/package.json index a82ea0bedbbc..0165ca0f8bf2 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/package.json +++ b/dev-packages/e2e-tests/test-applications/node-eve/package.json @@ -41,7 +41,6 @@ "node": "24.x" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json index f9c37b7f636c..cf104f24b932 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json @@ -22,7 +22,6 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "node": "22.22.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/node-flue/package.json b/dev-packages/e2e-tests/test-applications/node-flue/package.json index 7f6afaeff6b8..f3876f611d92 100644 --- a/dev-packages/e2e-tests/test-applications/node-flue/package.json +++ b/dev-packages/e2e-tests/test-applications/node-flue/package.json @@ -35,7 +35,6 @@ "node": "24.x" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/package.json b/dev-packages/e2e-tests/test-applications/node-mastra/package.json index a00ed28ea209..686eae393ed3 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/package.json +++ b/dev-packages/e2e-tests/test-applications/node-mastra/package.json @@ -37,7 +37,6 @@ "node": "24.x" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/.gitignore new file mode 100644 index 000000000000..795f6b6d40d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +node_modules +pnpm-lock.yaml +dist +test-results +playwright-report diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json new file mode 100644 index 000000000000..f0924e7f3193 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json @@ -0,0 +1,43 @@ +{ + "name": "node-openai-decisions-send-to-sentry", + "description": "The OpenAI integration for the Decisions API as a user runs it: a plain Sentry.init on an express app, real decisions.create requests against OpenAI, and the gen_ai.decide spans read back from a real Sentry project", + "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 dist pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm install && pnpm add openai@latest && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^4.21.2", + "openai": "7.32.0" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/conventions": "0.27.0", + "@types/express": "^4.17.21", + "@types/node": "^24.0.0", + "sentry": "~0.44.1", + "typescript": "~5.9.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "node-openai-decisions-send-to-sentry (latest)" + } + ] + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..af5ea8f0b951 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@playwright/test'; + +const port = 3030; + +export default defineConfig({ + testDir: './tests', + /* + * Each Sentry poll can take up to 180s. The first poll also waits the ~2min until the span can be + * queried, so the later polls usually pass at once. This leaves room for two full polls. + */ + timeout: 400_000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + // Every test spends most of its time polling Sentry, so run them all at once. + workers: '100%', + reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', + use: { + baseURL: `http://localhost:${port}`, + }, + webServer: { + command: 'pnpm start', + port, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/app.ts new file mode 100644 index 000000000000..f37d3198784c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/app.ts @@ -0,0 +1,85 @@ +// `instrument.ts` is preloaded via `node --import`, so Sentry is already initialised here. +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +// OpenRouter has no `/decisions` route, so this app calls OpenAI directly. CI only passes this key to +// this app. +const apiKey = process.env.E2E_OPENAI_DECISIONS_API_KEY; +if (!apiKey) { + throw new Error('E2E_OPENAI_DECISIONS_API_KEY is not set'); +} + +const client = new OpenAI({ apiKey }); + +const MODEL = 'gpt-6-luna'; + +// A 1x1 red PNG, so the image request is cheap. +const RED_PIXEL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; + +const app = express(); + +// Each route makes one `decisions.create` call and answers with the trace id, so the test can find +// the request's spans in Sentry. + +app.get('/decide', async (_req, res, next) => { + try { + const decision = await client.decisions.create({ + model: MODEL, + input: 'I was charged twice this month and want a refund.', + questions: [ + { type: 'predicate', name: 'wants_refund', instructions: 'Is a refund requested?' }, + { + type: 'choice', + name: 'department', + instructions: 'Which team should handle this?', + choices: [ + { value: 'billing', description: 'Charges and refunds' }, + { value: 'other', description: 'Anything else' }, + ], + }, + { + type: 'score', + name: 'urgency', + instructions: 'How urgent is this ticket?', + levels: [{ label: 'low' }, { label: 'high' }], + }, + ], + }); + res.send({ traceId: Sentry.getActiveSpan()?.spanContext().traceId, answers: decision.answers }); + } catch (error) { + next(error); + } +}); + +app.get('/decide-image', async (_req, res, next) => { + try { + const decision = await client.decisions.create({ + model: MODEL, + input: [ + { + role: 'user', + content: [ + { type: 'input_text', text: 'Is this image mostly red?' }, + { type: 'input_image', image_url: RED_PIXEL }, + ], + }, + ], + questions: [{ type: 'predicate', name: 'is_red', instructions: 'Is the image mostly red?' }], + }); + res.send({ traceId: Sentry.getActiveSpan()?.spanContext().traceId, answers: decision.answers }); + } catch (error) { + next(error); + } +}); + +app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).send({ message: error.message }); +}); + +const port = Number(process.env.PORT ?? 3030); +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`node-openai-decisions-send-to-sentry listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts new file mode 100644 index 000000000000..e0a0c1807f75 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..5116ba2ebe5b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from '@playwright/test'; +import { + SEARCH_GEN_AI__INPUT__MESSAGES, + SEARCH_GEN_AI__OPERATION__NAME, + SEARCH_GEN_AI__OUTPUT__MESSAGES, + SEARCH_GEN_AI__PROVIDER__NAME, + SEARCH_GEN_AI__REQUEST__MODEL, + SEARCH_GEN_AI__RESPONSE__MODEL, + SEARCH_GEN_AI__USAGE__INPUT_TOKENS, + SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS, + SEARCH_GEN_AI__USAGE__TOTAL_TOKENS, + SEARCH_ORIGIN, + SEARCH_SPAN__OP, + SEARCH_SPAN__STATUS, +} from '@sentry/conventions/attributes/search'; +import { GEN_AI_DECIDE, HTTP_SERVER } from '@sentry/conventions/op'; +import { + EVENT_POLLING_OPTIONS, + fetchSpanAttributes, + fetchTrace, + findSpanInTrace, + flattenTrace, + traceTarget, +} from '@sentry-internal/test-utils/cli'; + +// Mirrors src/app.ts. +const MODEL = 'gpt-6-luna'; + +/** + * What every decide span carries once Sentry has stored it, whatever the model answers. The keys are the + * search names, e.g. `span.op` and `origin` rather than the `sentry.*` attributes the SDK sends. + * `span.description` stays empty: ingest only derives it for ops it knows, and `gen_ai.decide` is new. + */ +const DECIDE_SPAN_ATTRIBUTES = { + 'span.name': `decide ${MODEL}`, + [SEARCH_SPAN__OP]: GEN_AI_DECIDE, + [SEARCH_SPAN__STATUS]: 'ok', + [SEARCH_ORIGIN]: 'auto.ai.openai', + [SEARCH_GEN_AI__OPERATION__NAME]: 'decide', + [SEARCH_GEN_AI__PROVIDER__NAME]: 'openai', + [SEARCH_GEN_AI__REQUEST__MODEL]: MODEL, + [SEARCH_GEN_AI__RESPONSE__MODEL]: MODEL, +}; + +test('records a text decision with its state, questions and answers', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/decide`); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + const { traceId }: { traceId: string } = body; + + console.log(`Polling for gen_ai.decide span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + await expect + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + let attributes: Record | undefined; + await expect + .poll(() => (attributes = fetchSpanAttributes(traceId, spanId!)), EVENT_POLLING_OPTIONS) + .toMatchObject(DECIDE_SPAN_ATTRIBUTES); + + // Integers come back as strings. Decisions bills input tokens only, so there are no output tokens. + expect(Number(attributes![SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS])).toBe(0); + expect(Number(attributes![SEARCH_GEN_AI__USAGE__INPUT_TOKENS])).toBeGreaterThan(0); + expect(Number(attributes![SEARCH_GEN_AI__USAGE__TOTAL_TOKENS])).toBe( + Number(attributes![SEARCH_GEN_AI__USAGE__INPUT_TOKENS]), + ); + + // The request span is the segment; it ends last, so it can land after the decide span. + await expect + .poll(() => { + const requestSpan = flattenTrace(fetchTrace(traceId)).find(item => item.op === HTTP_SERVER); + return flattenTrace(requestSpan?.children ?? []).some(item => item.event_id === spanId); + }, EVENT_POLLING_OPTIONS) + .toBe(true); + + expect(JSON.parse(attributes![SEARCH_GEN_AI__INPUT__MESSAGES] as string)).toEqual([ + { + type: 'evaluation', + state: 'I was charged twice this month and want a refund.', + questions: { + wants_refund: { type: 'predicate', instructions: 'Is a refund requested?' }, + department: { + type: 'choice', + instructions: 'Which team should handle this?', + criteria: { billing: 'Charges and refunds', other: 'Anything else' }, + }, + urgency: { + type: 'score', + instructions: 'How urgent is this ticket?', + criteria: ['low', 'high'], + }, + }, + }, + ]); + + // The answers depend on the model, the shape does not. + expect(JSON.parse(attributes![SEARCH_GEN_AI__OUTPUT__MESSAGES] as string)).toEqual([ + { + type: 'evaluation', + answers: { + wants_refund: { type: 'predicate', probability: expect.any(Number) }, + department: { + type: 'choice', + choice: expect.stringMatching(/^(billing|other)$/), + probabilities: { billing: expect.any(Number), other: expect.any(Number) }, + confidence: expect.any(Number), + }, + urgency: { + type: 'score', + score: expect.any(Number), + probabilities: { 0: expect.any(Number), 1: expect.any(Number) }, + legend: { 0: 'low', 1: 'high' }, + confidence: expect.any(Number), + }, + }, + }, + ]); +}); + +test('records an image decision with its image data', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/decide-image`); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + const { traceId }: { traceId: string } = body; + + console.log(`Polling for gen_ai.decide span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + await expect + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + let attributes: Record | undefined; + await expect + .poll(() => (attributes = fetchSpanAttributes(traceId, spanId!)), EVENT_POLLING_OPTIONS) + .toMatchObject(DECIDE_SPAN_ATTRIBUTES); + + expect(JSON.parse(attributes![SEARCH_GEN_AI__INPUT__MESSAGES] as string)).toEqual([ + { + type: 'evaluation', + state: [ + { + role: 'user', + content: [ + { type: 'input_text', text: 'Is this image mostly red?' }, + { type: 'input_image', image_url: expect.stringMatching(/^data:image\/png;base64,/) }, + ], + }, + ], + questions: { is_red: { type: 'predicate', instructions: 'Is the image mostly red?' } }, + }, + ]); + + expect(JSON.parse(attributes![SEARCH_GEN_AI__OUTPUT__MESSAGES] as string)).toEqual([ + { type: 'evaluation', answers: { is_red: { type: 'predicate', probability: expect.any(Number) } } }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..795574fd9df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/.gitignore new file mode 100644 index 000000000000..795f6b6d40d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +node_modules +pnpm-lock.yaml +dist +test-results +playwright-report diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json new file mode 100644 index 000000000000..dcb65009d4f8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json @@ -0,0 +1,41 @@ +{ + "name": "node-openai-send-to-sentry", + "description": "The OpenAI integration as a user runs it: a plain Sentry.init on an express app, real chat, streaming, tool-call, Responses API and embeddings requests through OpenRouter, and the gen_ai spans read back from a real Sentry project", + "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 dist pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm install && pnpm add openai@latest && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^4.21.2", + "openai": "7.5.0" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/express": "^4.17.21", + "@types/node": "^24.0.0", + "sentry": "~0.44.1", + "typescript": "~5.9.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "node-openai-send-to-sentry (latest)" + } + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..e7621a23cc8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from '@playwright/test'; + +const port = 3030; + +export default defineConfig({ + testDir: './tests', + /* Each test runs two Sentry polls of up to 180s each, after a live model call. */ + timeout: 400_000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + // Every test spends most of its time polling Sentry, so run them all at once. + workers: '100%', + reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', + use: { + baseURL: `http://localhost:${port}`, + }, + webServer: { + command: 'pnpm start', + port, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/app.ts new file mode 100644 index 000000000000..ac99c6d18eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/app.ts @@ -0,0 +1,151 @@ +// `instrument.ts` is preloaded via `node --import`, so Sentry is already initialised here. +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +const apiKey = process.env.E2E_OPENROUTER_API_KEY; +if (!apiKey) { + throw new Error('E2E_OPENROUTER_API_KEY is not set'); +} + +// OpenRouter serves the OpenAI API shape (chat completions, responses, embeddings), so the stock +// client only needs a different base URL. The model is incidental: what is under test is the SDK's own +// request/response code path, which is what Sentry instruments. +const client = new OpenAI({ apiKey, baseURL: 'https://openrouter.ai/api/v1' }); + +const CHAT_MODEL = 'openai/gpt-4o-mini'; +const EMBEDDINGS_MODEL = 'openai/text-embedding-3-small'; + +const SHORT_ANSWER = 'Answer in at most five words.'; +const CHAT_PROMPT = `What is the capital of France? ${SHORT_ANSWER}`; +// Deliberately does not name the tool: `tool_choice: 'required'` forces the call. +const WEATHER_PROMPT = `What is the weather in Paris? ${SHORT_ANSWER}`; +const SYSTEM = 'You are a helpful assistant used by an automated test.'; + +const WEATHER_TOOL = { + type: 'function' as const, + function: { + name: 'get_weather', + description: 'Get the current weather for a city.', + parameters: { + type: 'object', + properties: { city: { type: 'string', description: 'The city name' } }, + required: ['city'], + }, + }, +}; + +const app = express(); + +/** The trace this request is recorded under, so the test can look its spans up in Sentry. */ +function currentTraceId(): string | undefined { + return Sentry.getActiveSpan()?.spanContext().traceId; +} + +// Each route makes one call through the `openai` client and answers with the trace id, so the test can +// find the request's spans in Sentry. + +app.get('/chat', async (_req, res, next) => { + try { + const completion = await client.chat.completions.create({ + model: CHAT_MODEL, + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: CHAT_PROMPT }, + ], + temperature: 0, + max_tokens: 32, + }); + res.send({ + traceId: currentTraceId(), + answer: completion.choices[0]?.message.content ?? '', + }); + } catch (error) { + next(error); + } +}); + +app.get('/chat-stream', async (_req, res, next) => { + try { + const stream = await client.chat.completions.create({ + model: CHAT_MODEL, + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: CHAT_PROMPT }, + ], + temperature: 0, + max_tokens: 32, + stream: true, + // Ask for the usage in the final chunk, so the streamed span carries token counts. + stream_options: { include_usage: true }, + }); + + let answer = ''; + for await (const chunk of stream) { + answer += chunk.choices[0]?.delta.content ?? ''; + } + res.send({ traceId: currentTraceId(), answer }); + } catch (error) { + next(error); + } +}); + +app.get('/tools', async (_req, res, next) => { + try { + const completion = await client.chat.completions.create({ + model: CHAT_MODEL, + messages: [{ role: 'user', content: WEATHER_PROMPT }], + tools: [WEATHER_TOOL], + tool_choice: 'required', + max_tokens: 64, + }); + res.send({ + traceId: currentTraceId(), + toolCalls: completion.choices[0]?.message.tool_calls ?? [], + }); + } catch (error) { + next(error); + } +}); + +app.get('/responses', async (_req, res, next) => { + try { + const response = await client.responses.create({ + model: CHAT_MODEL, + instructions: SYSTEM, + input: CHAT_PROMPT, + temperature: 0, + max_output_tokens: 32, + }); + res.send({ traceId: currentTraceId(), answer: response.output_text }); + } catch (error) { + next(error); + } +}); + +app.get('/embeddings', async (_req, res, next) => { + try { + const embedding = await client.embeddings.create({ + model: EMBEDDINGS_MODEL, + input: 'The capital of France is Paris.', + }); + res.send({ + traceId: currentTraceId(), + dimensions: embedding.data[0]?.embedding.length ?? 0, + }); + } catch (error) { + next(error); + } +}); + +Sentry.setupExpressErrorHandler(app); + +app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).send({ message: error.message }); +}); + +const port = Number(process.env.PORT ?? 3030); +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`node-openai-send-to-sentry listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/instrument.ts new file mode 100644 index 000000000000..c6c15df960cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/instrument.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +// The setup from the docs, nothing more: preloaded with `node --import`, no tunnel, so the spans go to +// the real Sentry project behind `E2E_TEST_DSN`, where the tests read them back. The OpenAI integration +// is on by default; its runtime channel injection is what picks up the `openai` client in app.ts. +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..eee3e75aeafb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,172 @@ +import { expect, test } from '@playwright/test'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { + EVENT_POLLING_OPTIONS, + fetchSpanAttributes, + fetchTrace, + findSpanInTrace, + flattenTrace, +} from '@sentry-internal/test-utils/cli'; + +// Mirrors src/app.ts. +const CHAT_MODEL = 'openai/gpt-4o-mini'; +const EMBEDDINGS_MODEL = 'openai/text-embedding-3-small'; + +type Attributes = Record; + +/** Sends one request to the app and returns the trace its request span was recorded under. */ +async function requestTrace(baseURL: string | undefined, path: string): Promise { + const response = await fetch(`${baseURL}${path}`); + const body = await response.text(); + expect(response.status, `${path} answered ${response.status}: ${body}`).toBe(200); + + const { traceId } = JSON.parse(body) as { traceId?: string }; + expect(traceId, `${path} did not report a trace id: ${body}`).toMatch(/^[0-9a-f]{32}$/); + + console.log(`${path}: https://${process.env.E2E_TEST_SENTRY_ORG_SLUG}.sentry.io/explore/traces/trace/${traceId}/`); + return traceId!; +} + +/** + * Polls Sentry until the trace holds a span with `op`, then returns that span and its attributes. The + * trace endpoint only carries the span's op, name and place in the tree; the attributes need a second + * lookup, which is polled as well since the two can land apart. + */ +async function waitForSpan(traceId: string, op: string): Promise<{ span: TraceItem; attributes: Attributes }> { + let found: { span: TraceItem; attributes: Attributes } | undefined; + + await expect + .poll(() => { + const span = findSpanInTrace(traceId, op); + const attributes = span?.event_id ? fetchSpanAttributes(traceId, span.event_id) : undefined; + found = span && attributes ? { span, attributes } : undefined; + return found; + }, EVENT_POLLING_OPTIONS) + .toBeDefined(); + + return found!; +} + +/** The ids of the spans with `op` below the request span (`http.server`) of the trace. */ +function spanIdsUnderRequestSpan(traceId: string, op: string): string[] { + const requestSpan = flattenTrace(fetchTrace(traceId)).find(item => item.op === 'http.server'); + return flattenTrace(requestSpan?.children ?? []) + .filter(item => item.op === op) + .map(item => item.event_id!); +} + +/** + * The request span is the segment; it ends last, so it can land after its children. Expecting exactly + * one span also catches the call being instrumented twice. + */ +async function expectOnlySpanUnderRequestSpan(traceId: string, op: string, span: TraceItem): Promise { + await expect.poll(() => spanIdsUnderRequestSpan(traceId, op), EVENT_POLLING_OPTIONS).toEqual([span.event_id]); +} + +/** + * The attributes every successful model-call span carries once Sentry has stored it, whatever the + * model answers. Model-dependent values (token counts, response id and model) are checked for shape, + * not content. Note the names are the stored ones: `origin` and `span.status` rather than the + * `sentry.*` attributes the SDK sends. + */ +function expectModelCallAttributes(attributes: Attributes, operation: 'chat' | 'embeddings', model: string): void { + expect(attributes).toMatchObject({ + 'span.op': `gen_ai.${operation}`, + 'span.status': 'ok', + origin: 'auto.ai.openai', + 'gen_ai.operation.name': operation, + 'gen_ai.provider.name': 'openai', + 'gen_ai.request.model': model, + }); + // OpenAI embeddings responses carry no id, so only chat calls are guaranteed one. + if (operation === 'chat') { + expect(typeof attributes['gen_ai.response.id']).toBe('string'); + } + expect(typeof attributes['gen_ai.response.model']).toBe('string'); + // The Sentry API returns int attributes as strings. + expect(Number(attributes['gen_ai.usage.input_tokens'])).toBeGreaterThan(0); + expect(Number(attributes['gen_ai.usage.total_tokens'])).toBeGreaterThan(0); +} + +/** `gen_ai.response.finish_reasons` is a JSON array; a streamed answer can repeat the reason per chunk. */ +function finishReasons(attributes: Attributes): string[] { + return JSON.parse(attributes['gen_ai.response.finish_reasons'] as string); +} + +test('Sends a chat completion to Sentry as a gen_ai.chat span under the request span', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/chat'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expect(span.description).toBe(`chat ${CHAT_MODEL}`); + expectModelCallAttributes(attributes, 'chat', CHAT_MODEL); + expect(Number(attributes['gen_ai.usage.output_tokens'])).toBeGreaterThan(0); + expect(finishReasons(attributes)).toEqual(['stop']); + expect(attributes['gen_ai.response.streaming']).toBeUndefined(); + + // The SDK records prompts and answers by default, and they have to survive the trip to Sentry. The + // answer is sent as `gen_ai.response.text` and stored as `gen_ai.output.messages`. + expect(attributes['gen_ai.system_instructions']).toContain('automated test'); + expect(attributes['gen_ai.input.messages']).toContain('capital of France'); + expect(attributes['gen_ai.output.messages']).toMatch(/paris/i); + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span); +}); + +test('Sends a streamed chat completion to Sentry with its token usage', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/chat-stream'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expect(span.description).toBe(`chat ${CHAT_MODEL}`); + expectModelCallAttributes(attributes, 'chat', CHAT_MODEL); + expect(attributes['gen_ai.response.streaming']).toBe(true); + // The usage only arrives in the final chunk, which the streaming instrumentation has to pick up. + expect(Number(attributes['gen_ai.usage.output_tokens'])).toBeGreaterThan(0); + expect(finishReasons(attributes)).toContain('stop'); + expect(attributes['gen_ai.output.messages']).toMatch(/paris/i); + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span); +}); + +test('Sends a chat completion that returned a tool call to Sentry', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/tools'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expectModelCallAttributes(attributes, 'chat', CHAT_MODEL); + expect(finishReasons(attributes)).toEqual(['tool_calls']); + expect(attributes['gen_ai.tool.definitions']).toContain('get_weather'); + // The SDK also sends the returned call as `gen_ai.response.tool_calls`, but that attribute is not + // stored as such, so the finish reason is what proves the tool call made it. + // TODO: Assert the call in `gen_ai.output.messages` once the OpenAI integration records it there. + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span); +}); + +test('Sends a Responses API call to Sentry as a gen_ai.chat span', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/responses'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expect(span.description).toBe(`chat ${CHAT_MODEL}`); + expectModelCallAttributes(attributes, 'chat', CHAT_MODEL); + expect(Number(attributes['gen_ai.usage.output_tokens'])).toBeGreaterThan(0); + expect(finishReasons(attributes)).toEqual(['completed']); + expect(attributes['gen_ai.input.messages']).toContain('capital of France'); + expect(attributes['gen_ai.output.messages']).toMatch(/paris/i); + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span); +}); + +test('Sends an embeddings call to Sentry as a gen_ai.embeddings span', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/embeddings'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.embeddings'); + + expect(span.description).toBe(`embeddings ${EMBEDDINGS_MODEL}`); + expectModelCallAttributes(attributes, 'embeddings', EMBEDDINGS_MODEL); + expect(attributes['gen_ai.embeddings.input']).toContain('Paris'); + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.embeddings', span); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..795574fd9df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json index eb9822667b3a..8be685f10351 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json @@ -11,9 +11,9 @@ "test:assert": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY playwright test" }, "dependencies": { - "@earendil-works/chord": "1.0.0", - "@earendil-works/pi-ai": "1.0.0", - "@earendil-works/pi-durable": "1.0.0", + "@earendil-works/chord": "1.2.0", + "@earendil-works/pi-ai": "1.2.0", + "@earendil-works/pi-durable": "1.2.0", "@sentry/node": "file:../../packed/sentry-node-packed.tgz" }, "devDependencies": { @@ -27,7 +27,6 @@ "node": "24.x" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs index 79892991f03d..c71c9f4096bf 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs @@ -38,7 +38,7 @@ const tools = [ // The manual span should nest under the SDK's `execute_tool` span. execute: async args => Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({ - content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], + output: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], })), }), defineTool({ @@ -60,7 +60,7 @@ const tools = [ try { writeFileSync(`${dataDir}crashed-${args.job}`, 'crashed once', { flag: 'wx' }); } catch { - return { content: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; + return { output: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; } process.exit(CRASH_EXIT_CODE); }, @@ -80,14 +80,14 @@ const tools = [ const request = { type: 'input', content: args.task, requestId: `delegate:${api.taskId}` }; const settled = await (await child.submit(request, toolContext)).wait(toolContext); if (settled.status !== 'done') { - return { isError: true, content: [{ type: 'text', text: `Subagent ended: ${settled.reason}` }] }; + return { isError: true, output: [{ type: 'text', text: `Subagent ended: ${settled.reason}` }] }; } const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), toolContext); const text = (answer?.model?.[0]?.content ?? []) .filter(part => part.type === 'text') .map(part => part.text) .join(''); - return { content: [{ type: 'text', text }] }; + return { output: [{ type: 'text', text }] }; }, }), ]; 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 index 655d447ca485..ac93565c65b2 100644 --- a/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql @@ -34,31 +34,31 @@ CREATE TABLE prisma_contract.marker ( meta jsonb DEFAULT '{}'::jsonb NOT NULL, invariants text[] DEFAULT '{}'::text[] NOT NULL ); -CREATE TABLE public."user" ( +CREATE TABLE public."User" ( email text NOT NULL, id integer NOT NULL, name text ); -CREATE SEQUENCE public.user_id_seq +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 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 public."User" ALTER COLUMN id SET DEFAULT nextval('public."User_id_seq"'::regclass); +INSERT INTO prisma_contract.contract VALUES ('a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '2026-10-08 14:56:22.624077+00', '{"meta": {}, "roots": {"User": {"model": "User", "namespace": "public"}}, "domain": {"namespaces": {"public": {"models": {"User": {"fields": {"id": {"many": false, "type": {"kind": "scalar", "codecId": "pg/int4@1"}, "nullable": false}, "name": {"many": false, "type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": true}, "email": {"many": false, "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": {"many": false, "codecId": "pg/int4@1", "default": {"kind": "function", "expression": "autoincrement()"}, "dataType": "pg/int4", "nullable": false}, "name": {"many": false, "codecId": "pg/text@1", "dataType": "pg/text", "nullable": true}, "email": {"many": false, "codecId": "pg/text@1", "dataType": "pg/text", "nullable": false}}, "indexes": [], "uniques": [{"columns": ["email"]}], "primaryKey": {"columns": ["id"]}, "foreignKeys": []}}}}}, "storageHash": "a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889"}, "extensions": {}, "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "capabilities": {"sql": {"enums": true, "lockOf": true, "lateral": true, "forShare": true, "forUpdate": true, "returning": true, "lockNowait": true, "scalarList": true, "lockSkipLocked": true, "checkConstraint": true, "defaultInInsert": true, "insertOnConflictSkip": true, "insertOnConflictWithoutTarget": true}, "postgres": {"limit": true, "jsonAgg": true, "lateral": true, "orderBy": true, "returning": true, "distinctOn": true, "forKeyShare": true, "forNoKeyUpdate": true}}, "targetFamily": "sql"}'); +INSERT INTO prisma_contract.ledger VALUES (1, '2026-10-08 14:56:22.624077+00', 'app', '', 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '', NULL, 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', 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', 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2', NULL, NULL, '2026-10-08 14:56:22.624077+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); +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 index a6e8bb692e57..6b42d3ee2e7c 100644 --- a/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json @@ -12,7 +12,7 @@ "test:assert": "pnpm test" }, "dependencies": { - "@prisma/orm-postgres": "8.0.0-rc.8", + "@prisma/orm-postgres": "8.0.0-rc.17", "@sentry/node": "file:../../packed/sentry-node-packed.tgz", "@types/express": "^4.17.21", "@types/node": "^20.19.0", 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 index 010a73846882..ecfad3dac136 100644 --- 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 @@ -1,26 +1,14 @@ // ⚠️ 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 { CodecTypes as PgTypes } from '@prisma/orm-postgres/target/codec-types'; +import type { QueryOperationTypes as PgTargetQueryOps } from '@prisma/orm-postgres/target/operation-types'; -import type { ContractWithTypeMaps, TypeMaps as TypeMapsType } from '@prisma/orm-postgres/family-contract/types'; +import type { + ContractWithTypeMaps, + RelationKeys, + TypeMaps as TypeMapsType, +} from '@prisma/orm-postgres/family-contract/types'; import type { Contract as ContractType, ExecutionHashBase, @@ -29,13 +17,13 @@ import type { StorageHashBase, } from '@prisma/orm-postgres/contract/types'; -export type StorageHash = StorageHashBase<'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5'>; +export type StorageHash = StorageHashBase<'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = PgTargetQueryOps; export type AggregateTypes = { readonly avg: { readonly byCodec: { @@ -113,6 +101,10 @@ export type AggregateTypes = { readonly output: 'pg/timestamp-temporal@1'; readonly nullable: true; }; + readonly 'pg/timestamptz-date@1': { + readonly output: 'pg/timestamptz-date@1'; + readonly nullable: true; + }; readonly 'pg/timestamptz-string@1': { readonly output: 'pg/timestamptz-string@1'; readonly nullable: true; @@ -169,6 +161,10 @@ export type AggregateTypes = { readonly output: 'pg/timestamp-temporal@1'; readonly nullable: true; }; + readonly 'pg/timestamptz-date@1': { + readonly output: 'pg/timestamptz-date@1'; + readonly nullable: true; + }; readonly 'pg/timestamptz-string@1': { readonly output: 'pg/timestamptz-string@1'; readonly nullable: true; @@ -236,8 +232,8 @@ type DefaultLiteralValue = CodecId extends keyo export type FieldOutputTypes = { readonly public: { readonly User: { - readonly id: CodecTypes['pg/int4@1']['output']; readonly email: CodecTypes['pg/text@1']['output']; + readonly id: CodecTypes['pg/int4@1']['output']; readonly name: CodecTypes['pg/text@1']['output'] | null; }; }; @@ -245,15 +241,15 @@ export type FieldOutputTypes = { export type FieldInputTypes = { readonly public: { readonly User: { - readonly id: CodecTypes['pg/int4@1']['input']; readonly email: CodecTypes['pg/text@1']['input']; + readonly id: CodecTypes['pg/int4@1']['input']; readonly name: CodecTypes['pg/text@1']['input'] | null; }; }; }; export type StorageColumnTypes = { readonly public: { - readonly user: { + readonly User: { readonly email: CodecTypes['pg/text@1']['output']; readonly id: CodecTypes['pg/int4@1']['output']; readonly name: CodecTypes['pg/text@1']['output'] | null; @@ -262,13 +258,29 @@ export type StorageColumnTypes = { }; export type StorageColumnInputTypes = { readonly public: { - readonly user: { + 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 namespace Models { + export type public_User = { + email: CodecTypes['pg/text@1']['output']; + id: CodecTypes['pg/int4@1']['output']; + name: CodecTypes['pg/text@1']['output'] | null; + readonly [RelationKeys]?: never; + }; +} + +export declare const models: { + public: { + User: Models.public_User; + }; +}; + export type TypeMaps = TypeMapsType< CodecTypes, QueryOperationTypes, @@ -287,26 +299,29 @@ type ContractBase = Omit< readonly kind: 'postgres-schema'; readonly entries: { readonly table: { - readonly user: { + readonly User: { columns: { + readonly email: { + readonly dataType: 'pg/text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + readonly many: false; + }; readonly id: { - readonly nativeType: 'int4'; + readonly dataType: 'pg/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 many: false; }; readonly name: { - readonly nativeType: 'text'; + readonly dataType: 'pg/text'; readonly codecId: 'pg/text@1'; readonly nullable: true; + readonly many: false; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -325,7 +340,7 @@ type ContractBase = Omit< readonly target: 'postgres'; readonly targetFamily: 'sql'; readonly roots: { - readonly user: { readonly namespace: 'public' & NamespaceId; readonly model: 'User' }; + readonly User: { readonly namespace: 'public' & NamespaceId; readonly model: 'User' }; }; readonly domain: { readonly namespaces: { @@ -333,14 +348,14 @@ type ContractBase = Omit< 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 id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; readonly name: { readonly nullable: true; readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; @@ -348,11 +363,11 @@ type ContractBase = Omit< }; readonly relations: Record; readonly storage: { - readonly table: 'user'; + readonly table: 'User'; readonly namespaceId: 'public'; readonly fields: { - readonly id: { readonly column: 'id' }; readonly email: { readonly column: 'email' }; + readonly id: { readonly column: 'id' }; readonly name: { readonly column: 'name' }; }; }; @@ -364,6 +379,8 @@ type ContractBase = Omit< readonly capabilities: { readonly postgres: { readonly distinctOn: true; + readonly forKeyShare: true; + readonly forNoKeyUpdate: true; readonly jsonAgg: true; readonly lateral: true; readonly limit: true; @@ -374,7 +391,14 @@ type ContractBase = Omit< readonly checkConstraint: true; readonly defaultInInsert: true; readonly enums: true; + readonly forShare: true; + readonly forUpdate: true; + readonly insertOnConflictSkip: true; + readonly insertOnConflictWithoutTarget: true; readonly lateral: true; + readonly lockNowait: true; + readonly lockOf: true; + readonly lockSkipLocked: true; readonly returning: true; readonly scalarList: true; }; 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 index 3b9090e28776..51b72b543a0b 100644 --- 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 @@ -4,7 +4,7 @@ "target": "postgres", "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "roots": { - "user": { + "User": { "model": "User", "namespace": "public" } @@ -51,7 +51,7 @@ } }, "namespaceId": "public", - "table": "user" + "table": "User" } } } @@ -63,25 +63,25 @@ "public": { "entries": { "table": { - "user": { + "User": { "columns": { "email": { "codecId": "pg/text@1", - "nativeType": "text", + "dataType": "pg/text", "nullable": false }, "id": { "codecId": "pg/int4@1", + "dataType": "pg/int4", "default": { "expression": "autoincrement()", "kind": "function" }, - "nativeType": "int4", "nullable": false }, "name": { "codecId": "pg/text@1", - "nativeType": "text", + "dataType": "pg/text", "nullable": true } }, @@ -102,11 +102,13 @@ "kind": "postgres-schema" } }, - "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5" + "storageHash": "a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889" }, "capabilities": { "postgres": { "distinctOn": true, + "forKeyShare": true, + "forNoKeyUpdate": true, "jsonAgg": true, "lateral": true, "limit": true, @@ -117,7 +119,14 @@ "checkConstraint": true, "defaultInInsert": true, "enums": true, + "forShare": true, + "forUpdate": true, + "insertOnConflictSkip": true, + "insertOnConflictWithoutTarget": true, "lateral": true, + "lockNowait": true, + "lockOf": true, + "lockSkipLocked": true, "returning": true, "scalarList": true } 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 index 81cc629a2ff8..4f581ff6d9ca 100644 --- 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 @@ -43,7 +43,7 @@ test('Prisma 8 ORM calls emit operation spans with the pg queries nested underne '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' }, + 'db.collection.name': { value: 'User', type: 'string' }, model: { value: 'User', type: 'string' }, name: { value: `User.${span.attributes['method']?.value}`, type: 'string' }, }); @@ -62,11 +62,11 @@ test('Prisma 8 ORM calls emit operation spans with the pg queries nested underne .map(span => span.attributes['db.query.text']?.value); }; expect(queriesUnder('create')).toEqual( - expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]), + expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."User" /)]), ); - expect(queriesUnder('all')).toEqual([expect.stringMatching(/^SELECT .* FROM "public"\."user"$/)]); + expect(queriesUnder('all')).toEqual([expect.stringMatching(/^SELECT .* FROM "public"\."User"$/)]); expect(queriesUnder('delete')).toEqual( - expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]), + expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."User" /)]), ); const dbSpans = spans.filter(span => getSpanOp(span) === 'db' && span.attributes['db.query.text']?.value); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index 73e9d6386b54..7213c3eaf121 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -33,7 +33,6 @@ ] }, "volta": { - "extends": "../../package.json", - "node": "22.20.0" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json index 1a008da130ee..9947f20ef982 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json @@ -22,7 +22,6 @@ "wrangler": "^4.72.0" }, "volta": { - "node": "22.20.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json index 98beb8756ea7..f417c74e5448 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json @@ -25,8 +25,7 @@ "typescript": "~5.0.0" }, "volta": { - "extends": "../../package.json", - "node": "22.20.0" + "extends": "../../package.json" }, "sentryTest": { "variants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json index 9e7ef0d268ca..26ba31d66c7f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json @@ -30,8 +30,7 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json", - "node": "22.20.0" + "extends": "../../package.json" }, "sentryTest": { "variants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore new file mode 100644 index 000000000000..1dca2550f7e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore @@ -0,0 +1,23 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Mock Sentry server artifacts +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr + +# Logs +logs +*.log + +# Misc +.DS_Store diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue new file mode 100644 index 000000000000..eb66eddd8a33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue @@ -0,0 +1,12 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue new file mode 100644 index 000000000000..bc19868e4488 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..5bf988c300ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts @@ -0,0 +1,313 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + findDebugIdsWithConflictingSourcemaps, + findFilesWithForeignSourcemaps, + findFilesWithMultipleDebugIds, + findInjectedDebugIds, + findSourceMapFiles, + findSourceMappingUrlComments, + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, + startNodeServer, +} from '@sentry-internal/test-utils'; + +/** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ +const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; + +/** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */ +const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; + +/** Nuxt 5 writes the client build here directly, so Sentry deletes the maps in Vite's only output directory. */ +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; + +const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; + +function filesContaining(dir: string, needle: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => path.join(entry.parentPath, entry.name)) + .filter(file => fs.readFileSync(file, 'utf8').includes(needle)); +} + +/** + * Rolldown emits no source map for a chunk without mappable source, such as Vue's export helper. + * Such a chunk still gets a debug ID, but there is no map to upload for it. + */ +function debugIdsOfChunksWithoutSourceMap(dir: string): Set { + const debugIds = new Set(); + + for (const file of filesContaining(dir, 'sentry-dbid-')) { + if (file.endsWith('.map') || fs.existsSync(`${file}.map`)) { + continue; + } + + for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + debugIds.add((match[1] as string).toLowerCase()); + } + } + + return debugIds; +} + +// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob +// characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally. +function debugIdsOfFilesWithGlobCharacters(dir: string): Set { + const debugIds = new Set(); + + for (const file of filesContaining(dir, 'sentry-dbid-')) { + if (!/[[\]{}()*?]/.test(path.relative(dir, file))) { + continue; + } + + for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + debugIds.add((match[1] as string).toLowerCase()); + } + } + + return debugIds; +} + +console.log( + `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, +); + +const requests = loadMockServerResults(); + +console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); +for (const request of requests) { + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); +} +console.log(''); + +// --- The upload reached Sentry --- + +assert.ok( + requests.some(r => r.authorization.includes('fake-auth-token')), + 'Expected requests with the configured auth token', +); + +// Sentry rejects a release that names no project with a 400, and the mock server accepts anything, +// so assert on the body rather than on the call alone. +const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST'); +assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release'); +for (const request of releaseCreates) { + const body = request.jsonBody as { version?: string; projects?: string[] } | undefined; + assert.equal( + body?.version, + 'test-release', + `Expected the release create body to carry the version, got ${JSON.stringify(body)}`, + ); + assert.deepEqual( + body?.projects, + ['test-project'], + `Expected the release create body to name the project, got ${JSON.stringify(body)}`, + ); +} + +assert.ok( + requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'), + 'Expected a PUT to finalize the release', +); + +assert.ok( + requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'), + 'Expected a GET for the chunk-upload options', +); + +const chunkPosts = getChunkUploadPosts(requests); +assert.ok( + chunkPosts.some(r => r.bodySize > 0), + 'Expected at least one chunk upload POST with a non-empty body', +); + +const assembleRequests = getAssembleRequests(requests); +assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); +for (const request of assembleRequests) { + assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); + assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); + assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); + for (const chunk of request.assembleBody?.chunks ?? []) { + assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); + } +} + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); + +// --- Both bundlers uploaded --- + +const sourcemaps = getSourcemaps(bundles); +assert.ok( + sourcemaps.some(map => map.sourcemap.mappings?.length), + 'Expected at least one uploaded sourcemap with non-empty mappings', +); + +const containsMarker = (marker: string): boolean => + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +// Vite builds the client and Nitro builds the server. Counting bundles would still pass with either +// plugin dropped, so each side is pinned to a marker only that side's source supplies. +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); +// TODO: Nitro 3 builds the server with Rolldown, and the uploaded server maps carry no `sourcesContent`: the +// module's `sourcemapExcludeSources: false` does not reach that build. Assert the server marker when that is fixed: +// assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); + +// `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. +const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( + source => source.startsWith('../') || path.isAbsolute(source), +); +assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); + +// --- Debug IDs tie the shipped bundle to the uploaded map --- + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); + +const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); +assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); + +// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting +// the upload alone cannot show this. +// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk +// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the +// maps are kept. The upload is the same in all variants. +const outputDirsWithSourceMaps = [ + ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), + ...(isStaticBuild ? [] : [SERVER_OUTPUT]), +]; +const debugIdsWithoutSourceMap = new Set( + outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), +); +const debugIdsSkippedByUploader = isStaticBuild ? new Set() : debugIdsOfFilesWithGlobCharacters(SERVER_OUTPUT); + +for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + // The exemption for chunks without a source map must not let a build without any uploaded map pass. + assert.ok( + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), + `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, + ); + + if (outputDirsWithSourceMaps.includes(outputDir)) { + const unuploaded = injectedDebugIds.filter( + debugId => + !uploadedDebugIds.has(debugId) && + !debugIdsWithoutSourceMap.has(debugId) && + !debugIdsSkippedByUploader.has(debugId), + ); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); + } + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); + + // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. + // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 + const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); + assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`); + + // A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on + // Vite's map even if Nitro uploaded nothing. + const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps); + assert.deepEqual( + filesWithForeignSourcemaps, + [], + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + ); +} + +// If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one. +const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps); +assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map'); + +// TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output +// (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach +// `.vue` files. Assert this when the server maps reach the original sources: +// const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); +// const serverSources = sourcemaps +// .filter(map => map.debugId && serverDebugIds.has(map.debugId)) +// .flatMap(map => getSourcemapSources(map.sourcemap)); +// assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue'))); +console.log(''); + +// --- What the build leaves behind in the client output --- + +const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }); + +if (keepClientSourceMaps) { + assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`); + console.log(` ${clientSourceMaps.length} source map(s) kept in ${CLIENT_OUTPUT}, as configured\n`); +} else { + // This directory is served to the internet, so a surviving `.map` hands out the original source. + assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`); + + // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. + const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }); + assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`); + + // Catches source maps inlined as `data:` URIs, which the reference check above skips by design. + const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER); + assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`); + + console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); +} + +// --- What the built server answers for the client source maps --- + +// `nuxt generate` emits no server, and a static host serves only the files that exist. +if (!isStaticBuild) { + const clientChunkUrls = fs + .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.js')) + .map( + entry => `/${path.relative(CLIENT_OUTPUT, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')}`, + ); + assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); + + const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); + + try { + // Without this, a server that serves no public files at all would pass the 404 checks. + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); + + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that + // stays on the list, and Nitro answers its request with a 500 instead of a 404. + const responses = await Promise.all( + clientChunkUrls.map(async chunkUrl => { + const mapUrl = `${chunkUrl}.map`; + const expectedStatus = fs.existsSync(path.join(CLIENT_OUTPUT, mapUrl)) ? 200 : 404; + const { status } = await fetch(`${server.baseUrl}${mapUrl}`); + return { mapUrl, status, expectedStatus }; + }), + ); + const unexpectedStatuses = responses + .filter(({ status, expectedStatus }) => status !== expectedStatus) + .map(({ mapUrl, status, expectedStatus }) => `${mapUrl} → ${status}, expected ${expectedStatus}`); + assert.deepEqual( + unexpectedStatuses, + [], + 'Expected the server to answer each client source map URL with 200 if the map is in the output, else 404', + ); + + console.log(` The server answers all ${clientChunkUrls.length} client source map URL(s) as expected\n`); + } finally { + await server.close(); + } +} + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts new file mode 100644 index 000000000000..f2f14911dde3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts @@ -0,0 +1,33 @@ +// Nuxt defaults `sourcemap.client` to `false`, which the SDK respects as a deliberate opt-out, so +// an app that never mentions `sourcemap`, uploads nothing client-side. `'hidden'` is what the SDK's +// own warning tells users to set, which makes it the setup worth regression-testing. +const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; + +export default defineNuxtConfig({ + compatibilityDate: '2025-06-06', + imports: { autoImport: false }, + + sourcemap: { client: 'hidden' }, + + modules: ['@sentry/nuxt/module'], + + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + + sentry: { + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + // Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should + // upload the maps and leave the emitted files alone. + sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, + debug: true, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json new file mode 100644 index 000000000000..ccc0df377bcd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json @@ -0,0 +1,47 @@ +{ + "name": "nuxt-5-sourcemaps", + "description": "E2E test app asserting what the Nuxt SDK uploads to Sentry and what it leaves behind in `.output` on Nuxt 5 (Nitro 3).", + "private": true, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & nuxt ${NUXT_COMMAND:-build} > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "clean": "npx nuxi cleanup", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts", + "test:build:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:build", + "test:assert:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:assert", + "test:build:static": "NUXT_COMMAND=generate pnpm test:build", + "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert" + }, + "dependencies": { + "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "nitro": "latest", + "nuxt": "npm:nuxt-nightly@5x" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^22.20.0", + "ts-node": "10.9.1", + "typescript": "~5.0.0" + }, + "volta": { + "extends": "../../package.json", + "pnpm": "11.10.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build:keep-client-sourcemaps", + "assert-command": "pnpm test:assert:keep-client-sourcemaps", + "label": "nuxt-5-sourcemaps (client source maps kept)" + }, + { + "build-command": "pnpm test:build:static", + "assert-command": "pnpm test:assert:static", + "label": "nuxt-5-sourcemaps (static / nuxt generate)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml new file mode 100644 index 000000000000..4210a62a804d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@sentry/cli': true +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts new file mode 100644 index 000000000000..795fc10a560c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nuxt'; +import { useRuntimeConfig } from '#imports'; + +Sentry.init({ + dsn: useRuntimeConfig().public.sentry.dsn, + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts new file mode 100644 index 000000000000..38ed95a35801 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts new file mode 100644 index 000000000000..dba169666ee1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'nitro'; + +// SOURCEMAP_MARKER_SERVER — the server counterpart of the client marker. Nitro defaults to +// `sourcemapExcludeSources: true`, which would drop this from the uploaded map; the Sentry module +// flips it to `false`, so finding this marker in `sourcesContent` is what proves that still works. +export default defineHandler(() => { + throw new Error('Server error from the Nuxt source map E2E app'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..1680a6360165 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org' }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..a2aac493d878 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.vue"], + "exclude": ["node_modules", ".output", ".nuxt"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json index 0689caa3bbba..6edc8f0335d8 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json @@ -51,7 +51,6 @@ ] }, "volta": { - "extends": "../../package.json", - "node": "22.17.0" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 4cf3a981b6be..552e505b3457 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -65,8 +65,7 @@ ] }, "volta": { - "extends": "../../package.json", - "node": "22.22.0" + "extends": "../../package.json" }, "sentryTest": { "variants": [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts index 8a905ba1e382..002bb3a77fbe 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts @@ -10,8 +10,7 @@ export default defineConfig(async config => ({ reactRouter(), sentryCloudflareVitePlugin(), ...((await sentryReactRouter( - // Both Sentry plugins inject the orchestrion snippet, and injecting it twice fails the build. - { sourcemaps: { disable: true }, buildTimeInstrumentation: false }, + { sourcemaps: { disable: true } }, config, // eslint-disable-next-line @typescript-eslint/no-explicit-any )) as any[]), 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 3cfd16a29ae2..cefd05e58db5 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,7 +45,6 @@ "serve": "14.0.1" }, "volta": { - "node": "20.19.5", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/package.json b/dev-packages/e2e-tests/test-applications/remix-v3/package.json index 5cb015c44c9a..6e27d54be58b 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-v3/package.json @@ -22,7 +22,6 @@ "typescript": "~5.9.0" }, "volta": { - "node": "24.18.0", - "pnpm": "9.15.9" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json index 1d04116bde34..95f11c2bd094 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json @@ -15,7 +15,10 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "test:dev": "TEST_ENV=development playwright test db" }, - "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "//": [ + "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "Rollup 4.64.6 keeps SvelteKit's Svelte 5 `$app/state` code in the Svelte 4 server build, which then throws `$state is not defined` on every page render" + ], "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz", "ioredis": "5.10.1", @@ -33,6 +36,11 @@ "typescript": "^5.0.0", "vite": "^5.4.11" }, + "pnpm": { + "overrides": { + "rollup": "4.64.5" + } + }, "volta": { "extends": "../../package.json" }, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json index 75914024924e..73adf2924524 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json @@ -15,7 +15,10 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "test:dev": "TEST_ENV=development playwright test db" }, - "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "//": [ + "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "Rollup 4.64.6 keeps SvelteKit's Svelte 5 `$app/state` code in the Svelte 4 server build, which then throws `$state is not defined` on every page render" + ], "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz", "ioredis": "5.10.1", @@ -33,6 +36,11 @@ "typescript": "^5.0.0", "vite": "^5.4.11" }, + "pnpm": { + "overrides": { + "rollup": "4.64.5" + } + }, "volta": { "extends": "../../package.json" }, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json index 29b9ccc3e3f5..1844e0e7b5cd 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json @@ -29,7 +29,6 @@ "wrangler": "^4.118.0" }, "volta": { - "node": "22.20.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json index 93da7029d773..a58195028424 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json @@ -24,7 +24,6 @@ "vite": "^8.0.12" }, "volta": { - "node": "22.20.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json index 6bd39bf23423..7bf8da3e4e00 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json @@ -29,7 +29,6 @@ "vite": "^8.0.12" }, "volta": { - "node": "22.20.0", "extends": "../../package.json" }, "type": "module" diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts index 3e5d13e187e6..f92dfad104f4 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts @@ -26,7 +26,7 @@ test('server pageload request span has nested request span for sub request', asy const spans = getSegmentChildSpans(serverTraceSpans, serverSpan); - expect(spans).toHaveLength(6); + expect(spans).toHaveLength(7); expect(spans).toEqual( expect.arrayContaining([ @@ -102,6 +102,19 @@ test('server pageload request span has nested request span for sub request', asy 'http.route': { value: '/api/users', type: 'string' }, }), }), + + // sub request `+server` handler span (emitted by Kit since 3.1.0): + expect.objectContaining({ + name: 'sveltekit.endpoint', + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.endpoint', type: 'string' }, + 'sentry.description': { value: 'sveltekit.endpoint', type: 'string' }, + 'http.route': { value: '/api/users', type: 'string' }, + 'http.method': { value: 'GET', type: 'string' }, + }), + }), ]), ); }); 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 5cf6accf0cd1..688094b3d237 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 @@ -28,10 +28,10 @@ "@sentry-internal/test-utils": "link:../../../test-utils", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^4.5.0", + "@vitejs/plugin-react": "^6.1.2", "typescript": "^5.9.0", - "vite": "7.3.2", - "vite-tsconfig-paths": "^5.1.4", + "vite": "8.3.4", + "vite-tsconfig-paths": "^6.1.1", "wrangler": "^4.68.1" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 89dad11c3985..410f41baecbc 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -14,7 +14,7 @@ "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", - "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", + "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", "test:assert": "pnpm test:assert:proxy", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index ce38c3e936f8..39fb5d4d34cf 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -77,6 +77,7 @@ "http-terminator": "^3.2.0", "ioredis": "5.10.1", "ioredis-5": "npm:ioredis@^5.11.0", + "ioredis-6": "npm:ioredis@^6.0.0", "kafkajs": "2.2.4", "knex": "^2.5.1", "koa": "^2.15.2", diff --git a/dev-packages/node-integration-tests/suites/anr/test.ts b/dev-packages/node-integration-tests/suites/anr/test.ts index b1aabd2eb001..04907dd8b925 100644 --- a/dev-packages/node-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-integration-tests/suites/anr/test.ts @@ -2,17 +2,6 @@ import type { Event } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; -/** Avoid flakes on slow CI: fixed sleeps can fire before the child process has finished exiting. */ -async function waitForChildExit(childHasExited: () => boolean, timeoutMs = 30_000): Promise { - const start = Date.now(); - while (!childHasExited()) { - if (Date.now() - start > timeoutMs) { - throw new Error('Timed out waiting for child process to exit'); - } - await new Promise(resolve => setTimeout(resolve, 100)); - } -} - const ANR_EVENT = { // Ensure we have context contexts: { @@ -187,19 +176,11 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => }); test('should exit', async () => { - const runner = createRunner(__dirname, 'should-exit.js').start(); - - await waitForChildExit(() => runner.childHasExited()); - - expect(runner.childHasExited()).toBe(true); + await createRunner(__dirname, 'should-exit.js').start().exited(); }); test('should exit forced', async () => { - const runner = createRunner(__dirname, 'should-exit-forced.js').start(); - - await waitForChildExit(() => runner.childHasExited()); - - expect(runner.childHasExited()).toBe(true); + await createRunner(__dirname, 'should-exit-forced.js').start().exited(); }); test('With session', async () => { diff --git a/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts new file mode 100644 index 000000000000..9d6526337eb4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts @@ -0,0 +1,27 @@ +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', + transport: loggingTransport, +}); + +function createResource() { + return { + [Symbol.dispose]() { + throw new Error('error from dispose'); + }, + }; +} + +function run(): void { + using _resource = createResource(); + throw new Error('original error'); +} + +try { + run(); +} catch (error) { + Sentry.captureException(error); +} diff --git a/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts new file mode 100644 index 000000000000..a482241adf20 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts @@ -0,0 +1,38 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { supports } from '../../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe.runIf(supports({ min: 22 }))('SuppressedError', () => { + test('captures both errors of a SuppressedError as chained exceptions', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'original error', + mechanism: { type: 'chained', source: 'suppressed', handled: true, exception_id: 2, parent_id: 0 }, + }, + { + type: 'Error', + value: 'error from dispose', + mechanism: { type: 'chained', source: 'error', handled: true, exception_id: 1, parent_id: 0 }, + }, + { + type: 'SuppressedError', + value: expect.any(String), + mechanism: { type: 'generic', handled: true, exception_id: 0 }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/captureMessage/with_level/test.ts b/dev-packages/node-integration-tests/suites/public-api/captureMessage/with_level/test.ts index a44af6931d1f..2e74a0ef2c80 100644 --- a/dev-packages/node-integration-tests/suites/public-api/captureMessage/with_level/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/captureMessage/with_level/test.ts @@ -13,6 +13,7 @@ test('should capture with different severity levels', async () => { .expect({ event: { message: 'error_message', level: 'error' } }) .expect({ event: { message: 'fatal_message', level: 'fatal' } }) .expect({ event: { message: 'log_message', level: 'log' } }) + .unordered() .start() .completed(); }); diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index bf39f9d7c1ad..54cfe8b356a0 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -162,19 +162,11 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { }); test('should exit', async () => { - const runner = createRunner(__dirname, 'should-exit.js').start(); - - await new Promise(resolve => setTimeout(resolve, 5_000)); - - expect(runner.childHasExited()).toBe(true); + await createRunner(__dirname, 'should-exit.js').start().exited(); }); test('should exit forced', async () => { - const runner = createRunner(__dirname, 'should-exit-forced.js').start(); - - await new Promise(resolve => setTimeout(resolve, 5_000)); - - expect(runner.childHasExited()).toBe(true); + await createRunner(__dirname, 'should-exit-forced.js').start().exited(); }); test('can be disabled with disableBlockDetectionForCallback', async () => { diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs index 499bff7fb206..402adb6f364f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs @@ -8,8 +8,8 @@ async function run() { await Sentry.startSpan( { - name: 'Test Span IORedis 5.11 DC', - op: 'test-span-ioredis-5-11-dc', + name: 'Test Span IORedis DC', + op: 'test-span-ioredis-dc', }, async () => { try { diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-6.mjs b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-6.mjs new file mode 100644 index 000000000000..994c679d9d3a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-6.mjs @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/node'; + +async function run() { + const { default: Redis } = await import('ioredis-6'); + const redisClient = new Redis({ host: '127.0.0.1', port: 6382, lazyConnect: true }); + + await redisClient.connect(); + + await Sentry.startSpan( + { + name: 'Test Span IORedis DC', + op: 'test-span-ioredis-dc', + }, + async () => { + try { + await redisClient.set('dc-test-key', 'test-value'); + await redisClient.set('dc-cache:test-key', 'test-value'); + + await redisClient.set('dc-cache:test-key-ex', 'test-value', 'EX', 10); + + await redisClient.get('dc-test-key'); + await redisClient.get('dc-cache:test-key'); + await redisClient.get('dc-cache:unavailable-data'); + + await redisClient.mget('dc-test-key', 'dc-cache:test-key', 'dc-cache:unavailable-data'); + } finally { + await redisClient.disconnect(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts index d854e0386637..71db70a5425a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts @@ -27,7 +27,7 @@ import { EXPECTED_SDK_NAME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose( - 'ioredis v5.11 diagnostics_channel auto instrumentation', + 'ioredis >=5.11 diagnostics_channel auto instrumentation', { workingDirectory: [__dirname] }, () => { afterAll(() => { @@ -35,7 +35,7 @@ describeWithDockerCompose( }); const ORIGIN = 'auto.db.redis.diagnostic_channel'; - const SEGMENT_NAME = 'Test Span IORedis 5.11 DC'; + const SEGMENT_NAME = 'Test Span IORedis DC'; const HOST = '127.0.0.1'; const PORT = 6382; @@ -89,69 +89,77 @@ describeWithDockerCompose( attributes: Record, ): unknown => streamedSpan(op, op, { ...PEER, [CACHE_OPERATION]: op.slice('cache.'.length), ...attributes }); - createEsmAndCjsTests(__dirname, 'scenario-ioredis-5-11.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('creates spans for ioredis v5.11 commands via diagnostics_channel', { timeout: 75_000 }, async () => { - await createTestRunner() - .expect({ - span: container => { - // The connect span opens its own segment but shares the trace with the test span, - // so both segments arrive in the same container. - expect(container.items.filter(item => item.is_segment).map(item => item.name)).toEqual([ - 'redis-connect', - SEGMENT_NAME, - ]); + const clients = [ + { label: 'v5.11', scenario: 'scenario-ioredis-5-11.mjs' }, + // v6 defaults to RESP3 and adds a `hello` handshake; the channel payload is unchanged. + { label: 'v6', scenario: 'scenario-ioredis-6.mjs' }, + ]; - // ioredis' own handshake commands (`client SETINFO`, `info`) are emitted on the - // channel too, but belong to the connect segment — the test span's children are the - // commands the scenario issues. - const spans = container.items.filter( - item => !item.is_segment && item.attributes[SENTRY_SEGMENT_NAME]?.value === SEGMENT_NAME, - ); + for (const { label, scenario } of clients) { + createEsmAndCjsTests(__dirname, scenario, 'instrument.mjs', (createTestRunner, test) => { + test(`creates spans for ioredis ${label} commands via diagnostics_channel`, { timeout: 75_000 }, async () => { + await createTestRunner() + .expect({ + span: container => { + // The connect span opens its own segment but shares the trace with the test span, + // so both segments arrive in the same container. + expect(container.items.filter(item => item.is_segment).map(item => item.name)).toEqual([ + 'redis-connect', + SEGMENT_NAME, + ]); - expect(spans).toEqual([ - streamedSpan(`set ${HOST}:${PORT}`, DB_QUERY, { - [DB_OPERATION_NAME]: 'set', - [DB_QUERY_TEXT]: 'set dc-test-key ?', - }), - cacheSpan(CACHE_PUT, { - [DB_OPERATION_NAME]: 'set', - [DB_QUERY_TEXT]: 'set dc-cache:test-key ?', - [CACHE_KEY]: ['dc-cache:test-key'], - [CACHE_ITEM_SIZE]: 2, - }), - cacheSpan(CACHE_PUT, { - [DB_OPERATION_NAME]: 'set', - [DB_QUERY_TEXT]: 'set dc-cache:test-key-ex ? ? ?', - [CACHE_KEY]: ['dc-cache:test-key-ex'], - [CACHE_ITEM_SIZE]: 2, - }), - streamedSpan(`get ${HOST}:${PORT}`, DB_QUERY, { - [DB_OPERATION_NAME]: 'get', - [DB_QUERY_TEXT]: 'get dc-test-key', - }), - cacheSpan(CACHE_GET, { - [DB_OPERATION_NAME]: 'get', - [DB_QUERY_TEXT]: 'get dc-cache:test-key', - [CACHE_KEY]: ['dc-cache:test-key'], - [CACHE_HIT]: true, - [CACHE_ITEM_SIZE]: 10, - }), - cacheSpan(CACHE_GET, { - [DB_OPERATION_NAME]: 'get', - [DB_QUERY_TEXT]: 'get dc-cache:unavailable-data', - [CACHE_KEY]: ['dc-cache:unavailable-data'], - [CACHE_HIT]: false, - }), - streamedSpan(`mget ${HOST}:${PORT}`, DB_QUERY, { - [DB_OPERATION_NAME]: 'mget', - [DB_QUERY_TEXT]: 'mget ? ? ?', - }), - ]); - }, - }) - .start() - .completed(); + // ioredis' own handshake commands (`client SETINFO`, `info`) are emitted on the + // channel too, but belong to the connect segment — the test span's children are the + // commands the scenario issues. + const spans = container.items.filter( + item => !item.is_segment && item.attributes[SENTRY_SEGMENT_NAME]?.value === SEGMENT_NAME, + ); + + expect(spans).toEqual([ + streamedSpan(`set ${HOST}:${PORT}`, DB_QUERY, { + [DB_OPERATION_NAME]: 'set', + [DB_QUERY_TEXT]: 'set dc-test-key ?', + }), + cacheSpan(CACHE_PUT, { + [DB_OPERATION_NAME]: 'set', + [DB_QUERY_TEXT]: 'set dc-cache:test-key ?', + [CACHE_KEY]: ['dc-cache:test-key'], + [CACHE_ITEM_SIZE]: 2, + }), + cacheSpan(CACHE_PUT, { + [DB_OPERATION_NAME]: 'set', + [DB_QUERY_TEXT]: 'set dc-cache:test-key-ex ? ? ?', + [CACHE_KEY]: ['dc-cache:test-key-ex'], + [CACHE_ITEM_SIZE]: 2, + }), + streamedSpan(`get ${HOST}:${PORT}`, DB_QUERY, { + [DB_OPERATION_NAME]: 'get', + [DB_QUERY_TEXT]: 'get dc-test-key', + }), + cacheSpan(CACHE_GET, { + [DB_OPERATION_NAME]: 'get', + [DB_QUERY_TEXT]: 'get dc-cache:test-key', + [CACHE_KEY]: ['dc-cache:test-key'], + [CACHE_HIT]: true, + [CACHE_ITEM_SIZE]: 10, + }), + cacheSpan(CACHE_GET, { + [DB_OPERATION_NAME]: 'get', + [DB_QUERY_TEXT]: 'get dc-cache:unavailable-data', + [CACHE_KEY]: ['dc-cache:unavailable-data'], + [CACHE_HIT]: false, + }), + streamedSpan(`mget ${HOST}:${PORT}`, DB_QUERY, { + [DB_OPERATION_NAME]: 'mget', + [DB_QUERY_TEXT]: 'mget ? ? ?', + }), + ]); + }, + }) + .start() + .completed(); + }); }); - }); + } }, ); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs new file mode 100644 index 000000000000..a5bcb97adc9a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs @@ -0,0 +1,96 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { RunnableLambda, RunnableSequence } from '@langchain/core/runnables'; +import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons'; +import { tool } from '@langchain/core/tools'; +import * as Sentry from '@sentry/node'; +import express from 'express'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +// LangGraph does this on import. Plain `@langchain/core` keeps a no-op store, so runs started +// without a config would see no ambient config at all. +AsyncLocalStorageProviderSingleton.initializeGlobalInstance(new AsyncLocalStorage()); + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_nested_test', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Sunny.' }], + model: req.body.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + const model = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0, + maxTokens: 50, + apiKey: 'mock-api-key', + clientOptions: { baseURL: baseUrl }, + }); + + // The nested chain gets no config, so the conversation id and parent run only reach the model + // through LangChain's ambient runnable config. + const lookupWeather = tool( + async ({ city }) => { + const nested = RunnableSequence.from([RunnableLambda.from(c => `Weather in ${c}?`), model]).withConfig({ + runName: 'nested_chain', + }); + const result = await nested.invoke(city); + return result.content; + }, + { + name: 'lookup_weather', + description: 'Look up the weather', + schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] }, + }, + ); + + const outer = RunnableLambda.from(input => lookupWeather.invoke({ city: input.city })).withConfig({ + runName: 'outer', + }); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await Sentry.startSpan({ op: 'function', name: 'auto' }, async () => { + const stream = await outer.stream({ city: 'Paris' }, { configurable: { thread_id: 'thread_auto' } }); + for await (const _chunk of stream) { + // drain + } + }); + + await Sentry.startSpan({ op: 'function', name: 'handler' }, async () => { + const stream = await outer.stream( + { city: 'Paris' }, + { + callbacks: [Sentry.createLangChainCallbackHandler()], + configurable: { thread_id: 'thread_handler' }, + }, + ); + for await (const _chunk of stream) { + // drain + } + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts index f6de0aecca94..4260da49bdc4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts @@ -20,7 +20,7 @@ import { SENTRY_OP, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; -import { GEN_AI_DECIDE } from '@sentry/conventions/op'; +import { GEN_AI_CHAT, GEN_AI_DECIDE } from '@sentry/conventions/op'; import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; import { createEsmTests } from '../../../../utils/runner/createEsmAndCjsTests'; @@ -236,6 +236,61 @@ describe('LangChain integration (v1)', () => { }, ); + createEsmAndCjsTests( + __dirname, + 'scenario-nested-runtime-context.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('keeps the conversation id and trace for a model called from a tool without config', async () => { + const runner = createRunner().ignore('event'); + const spansPromise = runner.collectStreamedSpansUntilSegment('main'); + + await runner.start().completed(); + + const spans = await spansPromise; + const byId = new Map(spans.map(span => [span.span_id, span])); + const ancestorNames = (span: (typeof spans)[number]): string[] => { + const names: string[] = []; + for (let p = byId.get(span.parent_span_id ?? ''); p; p = byId.get(p.parent_span_id ?? '')) { + names.push(p.name); + } + return names; + }; + const chatSpans = spans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_CHAT); + + expect(chatSpans).toHaveLength(2); + expect( + chatSpans.map(span => [span.attributes[GEN_AI_CONVERSATION_ID]?.value, ancestorNames(span)[0]]).sort(), + ).toEqual([ + ['thread_auto', 'auto'], + ['thread_handler', 'handler'], + ]); + + const handlerRunSpans = spans.filter( + span => + span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain' && + span.attributes[SENTRY_OP]?.value !== GEN_AI_CHAT, + ); + expect(handlerRunSpans.map(span => span.name).sort()).toEqual([ + 'execute_tool lookup_weather', + 'invoke_agent RunnableLambda', + 'invoke_agent nested_chain', + 'invoke_agent outer', + ]); + for (const span of handlerRunSpans) { + expect(ancestorNames(span)).toContain('handler'); + } + }); + }, + { + additionalDependencies: { + langchain: '^1.0.0', + '@langchain/core': '^1.0.0', + '@langchain/anthropic': '^1.0.0', + }, + }, + ); + createEsmAndCjsTests( __dirname, 'scenario-typesafe-classifier.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-with-recording.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-with-recording.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-with-recording.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/openai/v7/scenario-decisions.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-decisions.mjs new file mode 100644 index 000000000000..0934cebcd554 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-decisions.mjs @@ -0,0 +1,115 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json({ limit: '1mb' })); + + app.post('/openai/decisions', (req, res) => { + const { model } = req.body; + + if (model === 'error-model') { + res.status(500).set('x-request-id', 'mock-request-error').end('Internal server error'); + return; + } + + // Shaped like a real `gpt-6-luna` response. Decisions bills input tokens only. + res.send({ + model: 'gpt-6-luna', + answers: [ + { type: 'predicate', name: 'wants_refund', probability: 0.97 }, + { + type: 'choice', + name: 'department', + choice: 'billing', + probabilities: [ + { value: 'billing', probability: 0.82 }, + { value: 'other', probability: 0.18 }, + ], + confidence: 0.82, + }, + { + type: 'score', + name: 'urgency', + score: 0.95, + probabilities: [ + { value: 0, label: 'low', probability: 0.05 }, + { value: 1, label: 'high', probability: 0.95 }, + ], + confidence: 0.93, + }, + ], + usage: { + input_tokens: 382, + input_tokens_details: { cached_tokens: 256, cache_write_tokens: 64 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 382, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +const questions = [ + { type: 'predicate', name: 'wants_refund', instructions: 'Is a refund requested?' }, + { + type: 'choice', + name: 'department', + instructions: 'Which team should handle this?', + choices: [{ value: 'billing', description: 'Charges and refunds' }, { value: 'other' }], + }, + { + type: 'score', + name: 'urgency', + instructions: 'How urgent is this ticket?', + levels: [{ label: 'low' }, { label: 'high', description: 'Blocks the customer' }], + }, +]; + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + await client.decisions.create({ + model: 'gpt-6-luna', + input: 'I was charged twice and want a refund.', + questions, + }); + + await client.decisions.create({ + model: 'gpt-6-luna', + input: [ + { + role: 'user', + content: [ + { type: 'input_text', text: 'Is the package damaged?' }, + { type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==' }, + ], + }, + ], + questions: [{ type: 'predicate', name: 'damaged', instructions: 'Is there visible damage?' }], + }); + + try { + await client.decisions.create({ model: 'error-model', input: 'Error test', questions }); + } catch { + // Error is expected and handled + } + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts index d24c08130d84..cea32de437ab 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts @@ -1,19 +1,25 @@ import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { + GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_MODEL, GEN_AI_RESPONSE_FINISH_REASONS, GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_STREAMING, + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, + GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, SENTRY_OP, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; +import { GEN_AI_DECIDE } from '@sentry/conventions/op'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; import { supports } from '../../../../utils/index'; @@ -158,4 +164,103 @@ describe.runIf(supports({ min: 22 }))('OpenAI integration (V7)', () => { }, }, ); + + // The decisions API (`openai` >= 7.30.0) returns typed answers, so it is recorded like a Jev call. + createEsmAndCjsTests( + __dirname, + 'scenario-decisions.mjs', + 'instrument-with-recording.mjs', + (createRunner, test) => { + test('instruments the decisions API as decide spans', async () => { + const runner = createRunner().ignore('event'); + const spansPromise = runner.collectStreamedSpansUntilSegment('main'); + + await runner.start().completed(); + + const spans = (await spansPromise).filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE); + expect(spans).toHaveLength(3); + + // Both successful spans are named `decide gpt-6-luna` and may arrive in any order, so match on the input. + const inputOf = (span: (typeof spans)[number]): string => String(span.attributes[GEN_AI_INPUT_MESSAGES]?.value); + const textSpan = spans.find(span => inputOf(span).includes('charged twice')); + const imageSpan = spans.find(span => inputOf(span).includes('input_image')); + expect(textSpan!.name).toBe('decide gpt-6-luna'); + expect(textSpan!.attributes).toMatchObject({ + [SENTRY_ORIGIN]: { value: 'auto.ai.openai' }, + [GEN_AI_OPERATION_NAME]: { value: 'decide' }, + [GEN_AI_PROVIDER_NAME]: { value: 'openai' }, + [GEN_AI_REQUEST_MODEL]: { value: 'gpt-6-luna' }, + [GEN_AI_RESPONSE_MODEL]: { value: 'gpt-6-luna' }, + // OpenAI's input tokens already include the cached ones, so they are not added again. + [GEN_AI_USAGE_INPUT_TOKENS]: { value: 382 }, + [GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: { value: 256 }, + [GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: { value: 64 }, + [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 0 }, + [GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]: { value: 0 }, + [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 382 }, + }); + expect(JSON.parse(textSpan!.attributes[GEN_AI_INPUT_MESSAGES]!.value as string)).toEqual([ + { + type: 'evaluation', + state: 'I was charged twice and want a refund.', + questions: { + wants_refund: { type: 'predicate', instructions: 'Is a refund requested?' }, + department: { + type: 'choice', + instructions: 'Which team should handle this?', + // `other` has no description, so it gets an empty one. + criteria: { billing: 'Charges and refunds', other: '' }, + }, + urgency: { + type: 'score', + instructions: 'How urgent is this ticket?', + criteria: ['low', 'high'], + // Keyed by score value; `low` has no description, so it is left out. + descriptions: { 1: 'Blocks the customer' }, + }, + }, + }, + ]); + expect(JSON.parse(textSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]!.value as string)).toEqual([ + { + type: 'evaluation', + answers: { + wants_refund: { type: 'predicate', probability: 0.97 }, + department: { + type: 'choice', + choice: 'billing', + probabilities: { billing: 0.82, other: 0.18 }, + confidence: 0.82, + }, + urgency: { + type: 'score', + score: 0.95, + probabilities: { 0: 0.05, 1: 0.95 }, + legend: { 0: 'low', 1: 'high' }, + confidence: 0.93, + }, + }, + }, + ]); + + expect(JSON.parse(imageSpan!.attributes[GEN_AI_INPUT_MESSAGES]!.value as string)[0].state).toEqual([ + { + role: 'user', + content: [ + { type: 'input_text', text: 'Is the package damaged?' }, + { type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==' }, + ], + }, + ]); + + const errorSpan = spans.find(span => span.name === 'decide error-model'); + expect(errorSpan!.status).not.toBe('ok'); + }); + }, + { + additionalDependencies: { + openai: '7.30.0', + }, + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs index f0efbea32483..9d15ed6f3a47 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs @@ -65,7 +65,7 @@ registry.install( await new Promise((_, reject) => toolContext.abortSignal.addEventListener('abort', () => reject(new Error('tool aborted'))), ); - return { content: [] }; + return { output: [] }; }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs index dc84d6387ba9..8887587a07c2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs @@ -46,7 +46,7 @@ registry.install( tx => tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } }), toolContext, ); - return { content: [{ type: 'text', text: 'Subagent created.' }] }; + return { output: [{ type: 'text', text: 'Subagent created.' }] }; }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs index da80b508f252..7e6f3413d3dd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs @@ -70,7 +70,7 @@ registry.install( const settled = await ( await child.submit({ type: 'input', content: args.task }, toolContext) ).wait(toolContext); - return { content: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; + return { output: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; }, }), defineTool({ @@ -89,14 +89,14 @@ registry.install( const settled = await ( await child.submit({ type: 'input', content: 'Forked task.' }, toolContext) ).wait(toolContext); - return { content: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; + return { output: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; }, }), defineTool({ name: 'inner', description: 'A plain tool.', parameters: Type.Object({}), - execute: async () => ({ content: [{ type: 'text', text: 'INNER RESULT' }] }), + execute: async () => ({ output: [{ type: 'text', text: 'INNER RESULT' }] }), }), defineTool({ name: 'batch', @@ -105,7 +105,7 @@ registry.install( execute: async (_args, api, toolContext) => { const inner = (await api.agent(toolContext)).tools.find(tool => tool.name === 'inner'); await inner.execute({}, api, toolContext); - return { content: [{ type: 'text', text: 'OUTER RESULT' }] }; + return { output: [{ type: 'text', text: 'OUTER RESULT' }] }; }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs index fc42ebf2091f..fd8a977149fc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs @@ -73,7 +73,7 @@ registry.install( name: 'secret', description: 'Returns a secret.', parameters: Type.Object({}), - execute: async () => ({ content: [{ type: 'text', text: 'original secret value' }] }), + execute: async () => ({ output: [{ type: 'text', text: 'original secret value' }] }), }), defineTool({ name: 'fail_now', @@ -87,9 +87,7 @@ registry.install( hooks: [ hook(ToolTask, { afterTool: (call, result) => - call.name === 'secret' - ? { ...result, content: [{ type: 'text', text: 'redacted by afterTool' }] } - : undefined, + call.name === 'secret' ? { ...result, output: [{ type: 'text', text: 'redacted by afterTool' }] } : undefined, }), ], }), diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs index 42309d5ba2d4..4c2dc1c87172 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs @@ -38,7 +38,7 @@ registry.install( name: 'get_weather', description: 'Get the current weather for a city.', parameters: Type.Object({ city: Type.String() }), - execute: async args => ({ content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }] }), + execute: async args => ({ output: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }] }), }), defineTool({ name: 'broken', diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts index 4333c9e0f415..068b389c60d2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts @@ -30,9 +30,9 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // instead, guarded by the `min: 22` skip below. const PI_DURABLE_DEPENDENCIES = { additionalDependencies: { - '@earendil-works/pi-durable': '^1.0.0', - '@earendil-works/pi-ai': '^1.0.0', - '@earendil-works/chord': '^1.0.0', + '@earendil-works/pi-durable': '^1.2.0', + '@earendil-works/pi-ai': '^1.2.0', + '@earendil-works/chord': '^1.2.0', }, }; 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 index 3b9090e28776..51b72b543a0b 100644 --- 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 @@ -4,7 +4,7 @@ "target": "postgres", "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "roots": { - "user": { + "User": { "model": "User", "namespace": "public" } @@ -51,7 +51,7 @@ } }, "namespaceId": "public", - "table": "user" + "table": "User" } } } @@ -63,25 +63,25 @@ "public": { "entries": { "table": { - "user": { + "User": { "columns": { "email": { "codecId": "pg/text@1", - "nativeType": "text", + "dataType": "pg/text", "nullable": false }, "id": { "codecId": "pg/int4@1", + "dataType": "pg/int4", "default": { "expression": "autoincrement()", "kind": "function" }, - "nativeType": "int4", "nullable": false }, "name": { "codecId": "pg/text@1", - "nativeType": "text", + "dataType": "pg/text", "nullable": true } }, @@ -102,11 +102,13 @@ "kind": "postgres-schema" } }, - "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5" + "storageHash": "a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889" }, "capabilities": { "postgres": { "distinctOn": true, + "forKeyShare": true, + "forNoKeyUpdate": true, "jsonAgg": true, "lateral": true, "limit": true, @@ -117,7 +119,14 @@ "checkConstraint": true, "defaultInInsert": true, "enums": true, + "forShare": true, + "forUpdate": true, + "insertOnConflictSkip": true, + "insertOnConflictWithoutTarget": true, "lateral": true, + "lockNowait": true, + "lockOf": true, + "lockSkipLocked": true, "returning": true, "scalarList": true } 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 index 655d447ca485..ac93565c65b2 100644 --- 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 @@ -34,31 +34,31 @@ CREATE TABLE prisma_contract.marker ( meta jsonb DEFAULT '{}'::jsonb NOT NULL, invariants text[] DEFAULT '{}'::text[] NOT NULL ); -CREATE TABLE public."user" ( +CREATE TABLE public."User" ( email text NOT NULL, id integer NOT NULL, name text ); -CREATE SEQUENCE public.user_id_seq +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 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 public."User" ALTER COLUMN id SET DEFAULT nextval('public."User_id_seq"'::regclass); +INSERT INTO prisma_contract.contract VALUES ('a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '2026-10-08 14:56:22.624077+00', '{"meta": {}, "roots": {"User": {"model": "User", "namespace": "public"}}, "domain": {"namespaces": {"public": {"models": {"User": {"fields": {"id": {"many": false, "type": {"kind": "scalar", "codecId": "pg/int4@1"}, "nullable": false}, "name": {"many": false, "type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": true}, "email": {"many": false, "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": {"many": false, "codecId": "pg/int4@1", "default": {"kind": "function", "expression": "autoincrement()"}, "dataType": "pg/int4", "nullable": false}, "name": {"many": false, "codecId": "pg/text@1", "dataType": "pg/text", "nullable": true}, "email": {"many": false, "codecId": "pg/text@1", "dataType": "pg/text", "nullable": false}}, "indexes": [], "uniques": [{"columns": ["email"]}], "primaryKey": {"columns": ["id"]}, "foreignKeys": []}}}}}, "storageHash": "a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889"}, "extensions": {}, "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "capabilities": {"sql": {"enums": true, "lockOf": true, "lateral": true, "forShare": true, "forUpdate": true, "returning": true, "lockNowait": true, "scalarList": true, "lockSkipLocked": true, "checkConstraint": true, "defaultInInsert": true, "insertOnConflictSkip": true, "insertOnConflictWithoutTarget": true}, "postgres": {"limit": true, "jsonAgg": true, "lateral": true, "orderBy": true, "returning": true, "distinctOn": true, "forKeyShare": true, "forNoKeyUpdate": true}}, "targetFamily": "sql"}'); +INSERT INTO prisma_contract.ledger VALUES (1, '2026-10-08 14:56:22.624077+00', 'app', '', 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '', NULL, 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', 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', 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2', NULL, NULL, '2026-10-08 14:56:22.624077+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); +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/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts index e27358581b28..d7adfe06da27 100644 --- 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 @@ -18,7 +18,7 @@ function getQuerySpans(container: SerializedStreamedSpanContainer): SerializedSt 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"']), + expect.arrayContaining(['INSERT "public"."User"', 'SELECT "public"."User"', 'DELETE "public"."User"']), ); querySpans.forEach(span => { expect(span.attributes['sentry.op']?.value).toBe('db'); @@ -67,7 +67,7 @@ describe('Prisma ORM v8 Tests', () => { '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' }, + 'db.collection.name': { value: 'User', type: 'string' }, model: { value: 'User', type: 'string' }, name: { value: `User.${method}`, type: 'string' }, }); @@ -80,13 +80,13 @@ describe('Prisma ORM v8 Tests', () => { .map(span => span.attributes['db.query.text']?.value); }; expect(queriesByOperation('create')).toEqual( - expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]), + expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."User" /)]), ); expect(queriesByOperation('all')).toEqual([ - expect.stringMatching(/^SELECT .* FROM "public"\."user"$/), + expect.stringMatching(/^SELECT .* FROM "public"\."User"$/), ]); expect(queriesByOperation('delete')).toEqual( - expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]), + expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."User" /)]), ); expect(querySpans.filter(span => span.parent_span_id === segment.span_id)).toEqual([]); }, @@ -126,7 +126,7 @@ describe('Prisma ORM v8 Tests', () => { }, { additionalDependencies: { - '@prisma/orm-postgres': '8.0.0-rc.8', + '@prisma/orm-postgres': '8.0.0-rc.17', }, copyPaths: ['prisma'], }, diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/tedious/docker-compose.yml index ef4db33b7357..f85441012119 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/docker-compose.yml +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/docker-compose.yml @@ -14,4 +14,4 @@ services: interval: 2s timeout: 3s retries: 30 - start_period: 20s + start_period: 60s diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index 9a1dcf8ba4a0..d9fd1d5f149b 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -98,7 +98,7 @@ type ExpectedEnvelopeHeader = type StartResult = { completed(): Promise; - childHasExited(): boolean; + exited(): Promise; getLogs(): string[]; getPort(): number | undefined; sendSignal(signal: NodeJS.Signals): void; @@ -380,6 +380,8 @@ export function createRunner(...paths: string[]) { const completedDeferred = createDeferred(); // Resolved once the scenario reports its server port, so `makeRequest` can await it directly. const portReady = createDeferred(); + // Resolved when the scenario process closes, so exit tests can await it instead of polling. + const exitedDeferred = 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. @@ -666,6 +668,7 @@ export function createRunner(...paths: string[]) { child.on('close', (code, signal) => { hasExited = true; exitStatus = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`; + exitedDeferred.resolve(); if (ensureNoErrorOutput) { complete(); @@ -759,8 +762,8 @@ export function createRunner(...paths: string[]) { throw completeError; } }, - childHasExited: function (): boolean { - return hasExited; + exited: function (): Promise { + return exitedDeferred.promise; }, getLogs(): string[] { return logs; diff --git a/dev-packages/node-integration-tests/utils/runner/describeWithDockerCompose.ts b/dev-packages/node-integration-tests/utils/runner/describeWithDockerCompose.ts index 07a19489ee1a..0ab245e48cb9 100644 --- a/dev-packages/node-integration-tests/utils/runner/describeWithDockerCompose.ts +++ b/dev-packages/node-integration-tests/utils/runner/describeWithDockerCompose.ts @@ -6,6 +6,8 @@ import { afterAll, beforeAll, describe } from 'vitest'; type DefineTests = () => void; +const SLOW_START_MS = 60_000; + interface DockerOptions { /** * The working directory to run docker compose in @@ -94,9 +96,28 @@ async function runDockerCompose(options: DockerOptions): Promise { }); }; + const output = (result: ReturnType): string => + `${result.stderr?.toString() ?? ''}${result.stdout?.toString() ?? ''}`; + // Surface container logs to make healthcheck failures easier to diagnose in CI + const containerLogs = (): string => spawnSync('docker', composeArgs('logs'), { cwd }).stdout?.toString() ?? ''; + // ensure we're starting fresh close(); + // Pull on its own, so a slow image pull on a runner without cached images can be told apart from + // a slow container start. + const pullStartedAt = Date.now(); + const pull = spawnSync('docker', composeArgs('pull', '--policy', 'missing', '--quiet'), { + cwd, + stdio: process.env.DEBUG ? 'inherit' : 'pipe', + }); + const pullMs = Date.now() - pullStartedAt; + if (pull.status !== 0) { + // `up` pulls missing images too, so only report this + // eslint-disable-next-line no-console + console.warn(`[docker compose] pull failed after ${pullMs}ms (exit ${pull.status}) in ${cwd}\n${output(pull)}`); + } + const composeUp = (): ReturnType => spawnSync('docker', composeArgs('up', '-d', '--wait'), { cwd, @@ -108,22 +129,33 @@ async function runDockerCompose(options: DockerOptions): Promise { // found" right after the network was created). A clean teardown plus retry // clears these, while genuine healthcheck failures stay red on every attempt. const maxAttempts = 3; + const upStartedAt = Date.now(); + let attemptStartedAt = upStartedAt; let result = composeUp(); for (let attempt = 1; attempt < maxAttempts && result.status !== 0; attempt++) { + // eslint-disable-next-line no-console + console.warn( + `[docker compose] up --wait attempt ${attempt} failed after ${Date.now() - attemptStartedAt}ms ` + + `(exit ${result.status}) in ${cwd}\n${output(result)}\n--- container logs ---\n${containerLogs()}`, + ); close(); + attemptStartedAt = Date.now(); result = composeUp(); } + const upMs = Date.now() - upStartedAt; if (result.status !== 0) { - const stderr = result.stderr?.toString() ?? ''; - const stdout = result.stdout?.toString() ?? ''; - // Surface container logs to make healthcheck failures easier to diagnose in CI - const logs = spawnSync('docker', composeArgs('logs'), { cwd }).stdout?.toString() ?? ''; + const logs = containerLogs(); close(); throw new Error( - `docker compose up --wait failed (exit ${result.status})\n${stderr}${stdout}\n--- container logs ---\n${logs}`, + `docker compose up --wait failed (exit ${result.status})\n${output(result)}\n--- container logs ---\n${logs}`, ); } + if (pullMs + upMs > SLOW_START_MS) { + // eslint-disable-next-line no-console + console.warn(`[docker compose] slow start in ${cwd}: pull ${pullMs}ms, up --wait ${upMs}ms`); + } + return close; } diff --git a/package.json b/package.json index d009220ac089..3e0beef30732 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "packages/effect", "packages/ember", "packages/elysia", + "packages/esbuild-plugin", "packages/eslint-config-sdk", "packages/eslint-plugin-sdk", "packages/feedback", @@ -88,6 +89,7 @@ "packages/replay-internal", "packages/replay-canvas", "packages/replay-worker", + "packages/rollup-plugin", "packages/server-runtime-injection", "packages/server-utils", "packages/solid", @@ -97,8 +99,10 @@ "packages/tanstackstart-react", "packages/typescript", "packages/vercel-edge", + "packages/vite-plugin", "packages/vue", "packages/wasm", + "packages/webpack-plugin", "dev-packages/browser-integration-tests", "dev-packages/bundler-plugin-integration-tests", "dev-packages/e2e-tests", diff --git a/packages/astro/LICENSE b/packages/astro/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/astro/LICENSE +++ b/packages/astro/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index cef232d2f4a6..b4e54545a8d3 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -71,8 +71,15 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { typeof sourcemaps?.filesToDeleteAfterUpload === 'undefined' && computedSourceMapSettings.previousUserSourceMapSetting === 'unset' ) { - // This also works for adapters, as the source maps are also copied to e.g. the .vercel folder - updatedFilesToDeleteAfterUpload = ['./dist/**/client/**/*.map', './dist/**/server/**/*.map']; + // This also works for adapters, as the source maps are also copied to e.g. the .vercel folder. + // Static output has no client folder and writes client assets to `/` instead. + // Astro removes the hidden `.prerender` folder itself, but we don't want to rely on that. + updatedFilesToDeleteAfterUpload = [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + `./dist/**/${config.build?.assets ?? '_astro'}/**/*.map`, + './dist/**/.prerender/**/*.map', + ]; debug && logger.info( @@ -109,7 +116,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { release, sourcemaps: { ...sourcemaps, - assets: sourcemaps?.assets ?? [getSourcemapsAssetsGlob(config)], + assets: sourcemaps?.assets ?? getSourcemapsAssetsGlobs(config), filesToDeleteAfterUpload: sourcemaps?.filesToDeleteAfterUpload ?? updatedFilesToDeleteAfterUpload, }, bundleSizeOptimizations: { @@ -273,7 +280,14 @@ function isCloudflarePages(): boolean { return false; } -function getSourcemapsAssetsGlob(config: AstroConfig): string { +function getSourcemapsAssetsGlobs(config: AstroConfig): string[] { + const outDirGlob = getOutDirGlob(config); + // Astro 7 writes the prerender build to a hidden `.prerender` folder, which `**` does not enter. + // Its chunks only run at build time to render static pages, but we still upload them for errors captured then. + return [`${outDirGlob}/**/*`, `${outDirGlob}/**/.prerender/**/*`]; +} + +function getOutDirGlob(config: AstroConfig): string { // The vercel adapter puts the output into its .vercel directory // However, the way this adapter is written, the config.outDir value is update too late for // us to reliably detect it. Also, server files are first temporarily written to /dist and then @@ -281,7 +295,7 @@ function getSourcemapsAssetsGlob(config: AstroConfig): string { // So we glob on both of these directories. // Another case of "it ain't pretty but it works":( if (config.adapter?.name?.startsWith('@astrojs/vercel')) { - return '{.vercel,dist}/**/*'; + return '{.vercel,dist}'; } // paths are stored as "file://" URLs @@ -289,12 +303,11 @@ function getSourcemapsAssetsGlob(config: AstroConfig): string { const rootDirName = path.resolve(config.root?.pathname || process.cwd()); if (outDirPathname) { - const relativePath = path.relative(rootDirName, outDirPathname); - return `${relativePath}/**/*`; + return path.relative(rootDirName, outDirPathname); } // fallback to default output dir - return 'dist/**/*'; + return 'dist'; } /** diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index d3063f14d8bc..54b199086c7c 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -111,8 +111,13 @@ describe('sentryAstro integration', () => { debug: false, bundleSizeOptimizations: {}, sourcemaps: { - assets: ['out/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], + assets: ['out/**/*', 'out/**/.prerender/**/*'], + filesToDeleteAfterUpload: [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + './dist/**/_astro/**/*.map', + './dist/**/.prerender/**/*.map', + ], }, _metaOptions: { telemetry: { @@ -142,8 +147,13 @@ describe('sentryAstro integration', () => { debug: false, bundleSizeOptimizations: {}, sourcemaps: { - assets: ['dist/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], + assets: ['dist/**/*', 'dist/**/.prerender/**/*'], + filesToDeleteAfterUpload: [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + './dist/**/_astro/**/*.map', + './dist/**/.prerender/**/*.map', + ], }, _metaOptions: { telemetry: { @@ -181,8 +191,13 @@ describe('sentryAstro integration', () => { debug: false, bundleSizeOptimizations: {}, sourcemaps: { - assets: ['{.vercel,dist}/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], + assets: ['{.vercel,dist}/**/*', '{.vercel,dist}/**/.prerender/**/*'], + filesToDeleteAfterUpload: [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + './dist/**/_astro/**/*.map', + './dist/**/.prerender/**/*.map', + ], }, _metaOptions: { telemetry: { @@ -221,7 +236,12 @@ describe('sentryAstro integration', () => { bundleSizeOptimizations: {}, sourcemaps: { assets: ['dist/server/**/*, dist/client/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], + filesToDeleteAfterUpload: [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + './dist/**/_astro/**/*.map', + './dist/**/.prerender/**/*.map', + ], }, _metaOptions: { telemetry: { diff --git a/packages/aws-serverless/LICENSE b/packages/aws-serverless/LICENSE index b956a1944c7b..84c94253c8cf 100644 --- a/packages/aws-serverless/LICENSE +++ b/packages/aws-serverless/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 4b060abafc71..65255d987e7d 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -1,9 +1,10 @@ -import type { HandlerDataError, Mechanism, WrappedFunction } from '@sentry/core'; +import type { Client, HandlerDataError, Mechanism, WrappedFunction } from '@sentry/core'; import { addExceptionMechanism, addExceptionTypeValue, addNonEnumerableProperty, captureException, + filterCollectedUrl, getOriginalFunction, GLOBAL_OBJ, markFunctionWrapped, @@ -221,15 +222,18 @@ export function wrap( /** * Get HTTP request data from the current page. + * + * The `Referer` header has `dataCollection.urlQueryParams` of `client` applied. `url` is unfiltered, so + * callers must pass it through `filterCollectedUrl`. */ -export function getHttpRequestData(): { url: string; headers: Record } { +export function getHttpRequestData(client: Client): { url: string; headers: Record } { // grab as much info as exists and add it to the event const url = getLocationHref(); const { referrer } = WINDOW.document || {}; const { userAgent } = WINDOW.navigator || {}; const headers = { - ...(referrer && { Referer: referrer }), + ...(referrer && { Referer: filterCollectedUrl(referrer, client) }), ...(userAgent && { 'User-Agent': userAgent }), }; const request = { diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 45961e307b15..1f20ffb9e843 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -22,7 +22,7 @@ export const httpContextIntegration = defineIntegration(() => { return; } - const { url, headers: collectedHeaders } = getHttpRequestData(); + const { url, headers: collectedHeaders } = getHttpRequestData(client); // We only filter the headers we collected ourselves, so headers the user set are left alone. Going by key // means we also catch these headers when `browserTracingIntegration` already put them on the event. @@ -47,7 +47,7 @@ export const httpContextIntegration = defineIntegration(() => { return; } - const reqData = getHttpRequestData(); + const reqData = getHttpRequestData(client); // `httpHeadersToSpanAttributes` would also work here, but its cookie and array handling never runs for these // two headers and costs every browser bundle ~400B gzip. diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index a900309bf992..b02a5d5ebcca 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -574,7 +574,7 @@ export const browserTracingIntegration = ((options: Partial { expect(event.request?.url).toBe('https://example.com/?jwt=[Filtered]&page=1'); }); + it('filters sensitive query params in the Referer header attached to events', () => { + const client = new BrowserClient(getDefaultBrowserClientOptions()); + const event: Event = {}; + + document.referrer = 'https://example.com/?jwt=baz&page=1'; + try { + httpContextIntegration().preprocessEvent!(event, {}, client); + } finally { + document.referrer = 'https://example.com'; + } + + expect(event.request?.headers?.Referer).toBe('https://example.com/?jwt=[Filtered]&page=1'); + }); + + it('removes the query from the Referer header attached to events when `urlQueryParams` is disabled', () => { + const client = new BrowserClient(getDefaultBrowserClientOptions({ dataCollection: { urlQueryParams: false } })); + const event: Event = {}; + + document.referrer = 'https://example.com/?jwt=baz&page=1'; + try { + httpContextIntegration().preprocessEvent!(event, {}, client); + } finally { + document.referrer = 'https://example.com'; + } + + expect(event.request?.headers?.Referer).toBe('https://example.com/'); + }); + + it('filters sensitive query params in the referer attribute of segment spans', () => { + const client = new BrowserClient(getDefaultBrowserClientOptions()); + const span: Partial = { + is_segment: true, + attributes: { [SENTRY_OP]: 'pageload' }, + }; + + document.referrer = 'https://example.com/?jwt=baz&page=1'; + try { + httpContextIntegration().processSpan!(span as StreamedSpanJSON, client); + } finally { + document.referrer = 'https://example.com'; + } + + expect(span.attributes?.['http.request.header.referer']).toEqual(['https://example.com/?jwt=[Filtered]&page=1']); + }); + it('leaves a user-supplied event URL untouched', () => { const client = new BrowserClient(getDefaultBrowserClientOptions()); const event: Event = { request: { url: 'https://example.com/?jwt=user-set' } }; diff --git a/packages/bun/LICENSE b/packages/bun/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/bun/LICENSE +++ b/packages/bun/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 0a25b655a892..bec2b892ea3a 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,6 +1,6 @@ import type { RequestEventData } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import type { BunOptions } from '../../src'; import { bunServerIntegration, getDefaultIntegrationsWithoutPerformance, init } from '../../src'; import { instrumentBunServe } from '../../src/integrations/bunserver'; @@ -53,6 +53,13 @@ describe('Bun Serve Integration', () => { port += 1; }); + // The spies replace `@sentry/core` exports for all test files that `bun test` runs after this one + afterAll(() => { + captureExceptionSpy.mockRestore(); + continueTraceSpy.mockRestore(); + startSpanSpy.mockRestore(); + }); + test.each(['fetch', 'route'])( 'keeps a streaming %s response span open until the last chunk is consumed', async mode => { diff --git a/packages/bundler-plugins/LICENSE b/packages/bundler-plugins/LICENSE index c23a90edc9be..fde36681396d 100644 --- a/packages/bundler-plugins/LICENSE +++ b/packages/bundler-plugins/LICENSE @@ -1,29 +1,30 @@ -# MIT License +MIT License -Copyright (c) 2022-2026, Sentry -All rights reserved. +Copyright (c) 2022 Functional Software, Inc. dba Sentry -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: -- Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -- Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. -- Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. +Third-Party Dependency Notice -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/bundler-plugins/test/core/cli-contract.test.ts b/packages/bundler-plugins/test/core/cli-contract.test.ts index 2786e9a868eb..f6c1a1d4c50d 100644 --- a/packages/bundler-plugins/test/core/cli-contract.test.ts +++ b/packages/bundler-plugins/test/core/cli-contract.test.ts @@ -89,6 +89,10 @@ beforeAll(() => { process.env['SENTRY_CONFIG_DIR'] = join(workDir, 'sentry-home'); mkdirSync(process.env['SENTRY_CONFIG_DIR']); + // Every command otherwise waits up to ~3s to flush its own telemetry to sentry.io, which makes + // the suite depend on that endpoint being reachable and pushes slow CI runs past the timeout. + process.env['SENTRY_CLI_NO_TELEMETRY'] = '1'; + // The mock server writes its recording relative to cwd. previousCwd = process.cwd(); process.chdir(workDir); @@ -100,6 +104,7 @@ afterAll(() => { server.close(); process.chdir(previousCwd); delete process.env['SENTRY_CONFIG_DIR']; + delete process.env['SENTRY_CLI_NO_TELEMETRY']; rmSync(workDir, { recursive: true, force: true }); }); diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index c4145caf6c47..27494f3c6059 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -61,12 +61,16 @@ }, "peerDependencies": { "@cloudflare/workers-types": "^4.x || ^5.x", + "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", "wrangler": "^4.x" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { "optional": true }, + "vite": { + "optional": true + }, "wrangler": { "optional": true } diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts index c4550f2fa12f..0a725d224a0e 100644 --- a/packages/cloudflare/src/vite/autoInstrument.ts +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -1,4 +1,5 @@ -import { basename } from 'node:path'; +import { basename, dirname, isAbsolute } from 'node:path'; +import { createIdResolver, type ResolvedConfig } from 'vite'; import { collectAgentCandidates, detectAgentClasses, type ModuleResolver } from './agentClass'; import { buildOptionsImport, ENV_FALLBACK_OPTIONS_FN, resolveInstrumentFile } from './instrumentFile'; import { applyAutoInstrumentTransforms, type ClassWrapperKind, type ProgramBody } from './transform'; @@ -30,14 +31,48 @@ async function resolvesNextjsCloudflare(resolver: ModuleResolver, importer: stri } } +type TransformContext = ModuleResolver & { + warn?(msg: string): void; + environment?: Parameters>[0]; +}; + export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPath?: string } = {}) { let wranglerConfig: WranglerConfig | undefined; let entryFilePath: string | undefined; - - let optionsFn = ENV_FALLBACK_OPTIONS_FN; - let optionsImport: string | undefined; + // A `main` that is not a file path (a package export or virtual module). + let entrySpecifier: string | undefined; + let wranglerConfigPath: string | undefined; + let resolveId: ReturnType | undefined; + // Each environment resolves with its own conditions, so the entry may differ between them. + const resolvedEntries = new Map>(); + + let instrumentFilePath: string | undefined; let isVinext = false; + function resolveEntrySpecifier(ctx: TransformContext, specifier: string): Promise { + const environment = ctx.environment; + if (!environment || !resolveId) return Promise.resolve(undefined); + + let entry = resolvedEntries.get(environment.name); + if (!entry) { + // Resolved from the wrangler config, as wrangler does. Without an importer, Vite's resolver + // tries the specifier as a path below the root first. + entry = resolveId(environment, specifier, wranglerConfigPath) + .catch(() => undefined) + .then(id => { + if (!id) { + ctx.warn?.( + `[sentry] Could not resolve the wrangler \`main\` "${specifier}". Auto-instrumentation disabled.`, + ); + return undefined; + } + return normalizePath(id.replace(/[?#].*$/, '')); + }); + resolvedEntries.set(environment.name, entry); + } + return entry; + } + return { name: 'sentry-cloudflare-auto-instrument', @@ -64,40 +99,43 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa } wranglerConfig = result.config; - if (wranglerConfig.main) { - // `main` is already absolute (wrangler resolves it); just normalize - // separators so the entry-module comparison holds on Windows. - entryFilePath = normalizePath(wranglerConfig.main); - } - - if (entryFilePath) { - const instrumentFilePath = resolveInstrumentFile(entryFilePath); - if (instrumentFilePath) { - const built = buildOptionsImport(entryFilePath, instrumentFilePath); - optionsFn = built.optionsFn; - optionsImport = built.importStmt; - } + const main = wranglerConfig.main; + if (main && isAbsolute(main)) { + // Normalize separators so the entry-module comparison holds on Windows. + entryFilePath = normalizePath(main); + instrumentFilePath = resolveInstrumentFile(dirname(main)); + } else if (main) { + entrySpecifier = main; + wranglerConfigPath = result.configPath; + // Not `this.resolve()`: Rollup's commonjs resolver loads the module it resolves, so a + // resolve of the entry from the entry's own `transform` never finishes. + resolveId = createIdResolver(config as ResolvedConfig); + // A package entry has no project directory of its own. + instrumentFilePath = resolveInstrumentFile(result.configDir); } }, async transform( - this: ModuleResolver & { warn?(msg: string): void; environment?: { name?: string } }, + this: TransformContext, code: string, id: string, ): Promise<{ code: string; map: unknown } | undefined> { - if (!wranglerConfig || !entryFilePath) return undefined; + if (!wranglerConfig || (!entryFilePath && !entrySpecifier)) return undefined; // The worker entry never belongs to the client (browser) environment. // Skipping it keeps a same-basename sibling (e.g. a `src/index.tsx` // client entry next to a `src/index.ts` worker) out of the browser bundle. if (this.environment?.name === 'client') return undefined; + const entryPath = entrySpecifier ? await resolveEntrySpecifier(this, entrySpecifier) : entryFilePath; + if (!entryPath) return undefined; + // Vite may append query/hash params to the module ID. const normalizedId = normalizePath(id.replace(/[?#].*$/, '')); - if (normalizedId !== entryFilePath) { + if (normalizedId !== entryPath) { // Tolerate a differing JS-flavored extension (e.g. `.js` vs `.ts`). - if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryFilePath)) return undefined; - if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryFilePath.replace(JS_EXTENSION_REGEX, '')) { + if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryPath)) return undefined; + if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryPath.replace(JS_EXTENSION_REGEX, '')) { return undefined; } } @@ -145,6 +183,10 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa const sentryModule = isVinext && (await resolvesNextjsCloudflare(this, normalizedId)) ? NEXTJS_CLOUDFLARE_MODULE : undefined; + const { optionsFn, importStmt: optionsImport } = instrumentFilePath + ? buildOptionsImport(entryPath, instrumentFilePath) + : { optionsFn: ENV_FALLBACK_OPTIONS_FN, importStmt: undefined }; + // No registration import is injected here: the orchestrion plugin's // subscribe-injection makes each bundled package self-register its channel // subscriber on the global marker, so wrapping the entry with `withSentry` diff --git a/packages/cloudflare/src/vite/instrumentFile.ts b/packages/cloudflare/src/vite/instrumentFile.ts index ffd79ca0104c..91c8e9c057f5 100644 --- a/packages/cloudflare/src/vite/instrumentFile.ts +++ b/packages/cloudflare/src/vite/instrumentFile.ts @@ -10,17 +10,17 @@ export const ENV_FALLBACK_OPTIONS_FN = '() => undefined'; const OPTIONS_IMPORT_IDENTIFIER = '__SENTRY_OPTIONS_CALLBACK__'; // Conventional, non-configurable name of the Sentry options module. It is -// looked up next to the worker entry file; its default export is the options +// looked up next to the worker entry file, or next to the wrangler config +// when `main` is not a file path; its default export is the options // callback `(env) => CloudflareOptions`. const INSTRUMENT_FILE_BASENAME = 'instrument.server'; const INSTRUMENT_FILE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs', 'cjs']; /** - * Locate the conventional `instrument.server.*` module sitting next to the - * worker entry file. Returns its absolute path, or `undefined` when absent. + * Locate the conventional `instrument.server.*` module in `dir`. Returns its + * absolute path, or `undefined` when absent. */ -export function resolveInstrumentFile(entryFilePath: string): string | undefined { - const dir = dirname(entryFilePath); +export function resolveInstrumentFile(dir: string): string | undefined { for (const ext of INSTRUMENT_FILE_EXTENSIONS) { const candidate = resolve(dir, `${INSTRUMENT_FILE_BASENAME}.${ext}`); if (existsSync(candidate)) return candidate; diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 3674ed5dd032..49b623d6c431 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -61,6 +61,10 @@ interface VariableDeclarationNode extends BaseNode { declarations?: VariableDeclaratorNode[]; } +interface ImportDeclarationNode extends BaseNode { + specifiers?: Array<{ local?: { type: string; name?: string } }>; +} + function isCallToMethod(node: BaseNode, methodName: string): boolean { if (node.type !== 'CallExpression') return false; const callee = (node as CallExpressionNode).callee; @@ -169,6 +173,7 @@ export function applyAutoInstrumentTransforms( optionsFn: sameWorkerBindings.length > 0 ? MERGED_OPTIONS_IDENTIFIER : ctx.optionsFn, autoWrapped: new Set(), manuallyWrappedLocals: collectManuallyWrappedLocals(ast), + declaringStatements: collectDeclaringStatements(ast), }; const { wrappedClasses } = state; @@ -214,7 +219,7 @@ interface TransformState { needsImport: boolean; wrappedClasses: Set; /** - * Top-level (non-exported) class declarations, so specifier exports like + * Top-level class declarations, exported or not, so specifier exports like * `export { MyDO }` can locate the class they refer to. */ topLevelClasses: Map; @@ -245,6 +250,8 @@ interface TransformState { * specifier must report it as wrapped rather than wrap it a second time. */ manuallyWrappedLocals: Set; + /** Top-level binding name → the statement that declares it. */ + declaringStatements: Map; } /** @@ -319,8 +326,9 @@ function collectManuallyWrappedLocals(ast: ProgramBody): Set { const locals = new Set(); for (const node of ast.body) { - if (node.type !== 'VariableDeclaration') continue; - for (const declarator of (node as VariableDeclarationNode).declarations ?? []) { + const declaration = declarationOf(node); + if (declaration?.type !== 'VariableDeclaration') continue; + for (const declarator of (declaration as VariableDeclarationNode).declarations ?? []) { const name = declarator.id?.type === 'Identifier' ? declarator.id.name : undefined; const init = declarator.init; if (name && init && wrapperMethods.some(method => isCallToMethod(init, method))) { @@ -332,16 +340,40 @@ function collectManuallyWrappedLocals(ast: ProgramBody): Set { return locals; } +function collectDeclaringStatements(ast: ProgramBody): Map { + const statements = new Map(); + for (const node of ast.body) { + const declaration = declarationOf(node); + if (!declaration) continue; + const ids = + declaration.type === 'VariableDeclaration' + ? ((declaration as VariableDeclarationNode).declarations ?? []).map(declarator => declarator.id) + : declaration.type === 'ImportDeclaration' + ? ((declaration as ImportDeclarationNode).specifiers ?? []).map(specifier => specifier.local) + : [(declaration as ClassDeclarationNode).id]; + for (const id of ids) { + if (id?.type === 'Identifier' && id.name) statements.set(id.name, node); + } + } + return statements; +} + function collectTopLevelClasses(ast: ProgramBody): Map { const classes = new Map(); for (const node of ast.body) { - if (node.type !== 'ClassDeclaration') continue; - const classNode = node as ClassDeclarationNode; + const declaration = declarationOf(node); + if (declaration?.type !== 'ClassDeclaration') continue; + const classNode = declaration as ClassDeclarationNode; if (classNode.id?.name) classes.set(classNode.id.name, classNode); } return classes; } +/** The declaration a top-level statement makes, looking through `export` (`export const x = ...`). */ +function declarationOf(node: BaseNode): BaseNode | null | undefined { + return node.type === 'ExportNamedDeclaration' ? (node as ExportNamedNode).declaration : node; +} + function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state: TransformState): void { const decl = node.declaration; @@ -503,6 +535,37 @@ function wrapSpecifierExports(node: ExportNamedNode, ctx: TransformContext, stat state.ms.overwrite(node.start, node.end, statements.join('\n')); } +/** + * `export { handler as default }`, the form bundlers emit for a default export. The export is set + * in two steps. Right after `handler` is declared, it is bound to `handler` as-is, so a circular + * import that reads it during module evaluation gets the handler, as before the transform. At the + * end of the module, it is rebound to the wrapper, so a handler assigned or given methods later is + * wrapped as it ends up. The binding is a `var`: a read before `handler` is declared gets + * `undefined`, as it would from the bundler's own `var`, where a `const` would throw and stop the + * Worker from starting. + */ +function wrapDefaultSpecifier(localName: string, state: TransformState): string | undefined { + if (state.manuallyWrappedLocals.has(localName)) return undefined; + + // Same as `export default Foo`: re-point at the wrapper a named export already made. + const wrappedName = state.wrappedLocals.get(localName); + if (wrappedName) { + state.autoWrapped.add(DEFAULT_EXPORT); + return `${wrappedName} as default`; + } + + const declaringStatement = state.declaringStatements.get(localName); + if (declaringStatement) { + state.ms.appendLeft(declaringStatement.end, `\nvar __SENTRY_DEFAULT_EXPORT__ = ${localName};`); + } + state.ms.append( + `\n${declaringStatement ? '' : 'var '}__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(${state.optionsFn}, ${localName});`, + ); + state.needsImport = true; + state.autoWrapped.add(DEFAULT_EXPORT); + return '__SENTRY_DEFAULT_EXPORT__ as default'; +} + /** * Handle one export specifier, returning the `Wrapped as Exported` pair to emit for a class that * gets wrapped. The import/wrapper statements a cross-module pair depends on are pushed onto @@ -528,7 +591,10 @@ function wrapSpecifier( ? resolveWrapperKind(exportedName, sourceLiteral ? undefined : localName, state) : undefined; - if (!exportedName || !localName || !kind) return undefined; + if (!exportedName || !localName) return undefined; + if (!kind) { + return exportedName === DEFAULT_IMPORT && !sourceLiteral ? wrapDefaultSpecifier(localName, state) : undefined; + } // Without a `from` clause the specifier points at a module-local binding. if (!sourceLiteral) { diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts index 9838f0609e46..8e02d2036f30 100644 --- a/packages/cloudflare/src/vite/wranglerConfig.ts +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -4,10 +4,13 @@ import { type Unstable_Config, unstable_readConfig } from 'wrangler'; /** * The slice of the wrangler configuration the auto-instrument plugin cares - * about. `main` is an absolute path (wrangler resolves it against the config - * file's directory). + * about. */ export interface WranglerConfig { + /** + * An absolute path when `main` names a JS or TS file, otherwise the + * original value (a package export or virtual module) for Vite to resolve. + */ main?: string; durableObjects: Array<{ name: string; className: string }>; workflows: Array<{ name: string; className: string }>; @@ -25,6 +28,9 @@ export interface WranglerConfig { sameWorkerBindings: SameWorkerBinding[]; } +// Same rule as `@cloudflare/vite-plugin`: only these extensions mark `main` as a file path. +const ENTRY_FILE_REGEX = /\.(?:m?[jt]s|[jt]sx)$/; + /** Stands in for a class name where a binding targets the module's default export. */ export const DEFAULT_EXPORT = Symbol('defaultExport'); @@ -42,10 +48,10 @@ export interface SameWorkerBinding { * * We only locate the file (probing `wrangler.json`, `.jsonc`, `.toml` inside * `root` with wrangler's own precedence, since it discovers from `cwd` rather - * than an arbitrary root); wrangler then parses it, flattens the active - * environment (honoring `CLOUDFLARE_ENV`), and resolves `main` to an absolute - * path. Durable Object and Workflow bindings are the active environment's, - * matching what the deployed Worker actually binds. + * than an arbitrary root); wrangler then parses it and flattens the active + * environment (honoring `CLOUDFLARE_ENV`). Durable Object and Workflow + * bindings are the active environment's, matching what the deployed Worker + * actually binds. * * Returns `undefined` when no config file is found or it can't be read/parsed * (the caller warns and disables auto-instrumentation rather than failing the @@ -54,7 +60,7 @@ export interface SameWorkerBinding { export function resolveWranglerConfig( root: string, explicitPath?: string, -): { config: WranglerConfig; configDir: string } | undefined { +): { config: WranglerConfig; configPath: string; configDir: string } | undefined { const configPath = explicitPath ? resolve(root, explicitPath) : ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'].map(name => resolve(root, name)).find(existsSync); @@ -67,20 +73,24 @@ export function resolveWranglerConfig( try { // `hideWarnings` keeps wrangler's config diagnostics (e.g. missing DO // migrations) out of the Vite build output. - raw = unstable_readConfig({ config: configPath }, { hideWarnings: true }); + raw = unstable_readConfig({ config: configPath }, { hideWarnings: true, preserveOriginalMain: true }); } catch { return undefined; } + const resolvedConfigPath = raw.configPath ?? configPath; + const configDir = dirname(resolvedConfigPath); + return { config: { - main: raw.main, + main: raw.main && ENTRY_FILE_REGEX.test(raw.main) ? resolve(configDir, raw.main) : raw.main, durableObjects: collectClassBindings(raw.durable_objects?.bindings), workflows: collectClassBindings(raw.workflows), workerEntrypoints: collectSelfBoundEntrypoints(raw), sameWorkerBindings: collectSameWorkerBindings(raw), }, - configDir: dirname(raw.configPath ?? configPath), + configPath: resolvedConfigPath, + configDir, }; } diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts index f9654b0d3b5d..9f987f6ea78a 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -2,9 +2,12 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parse } from 'acorn'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { sentryCloudflareAutoInstrumentPlugin } from '../../src/vite/autoInstrument'; +const { idResolver } = vi.hoisted(() => ({ idResolver: vi.fn() })); +vi.mock('vite', () => ({ createIdResolver: () => idResolver })); + function parseJS(code: string) { return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; } @@ -562,6 +565,84 @@ describe('wranglerConfigPath option', () => { }); }); +describe('package specifier as wrangler `main`', () => { + const MAIN = 'vinext/server/fetch-handler'; + const code = 'export default { fetch() { return new Response("ok"); } };'; + const ssr = { name: 'ssr' }; + + beforeEach(() => { + idResolver.mockReset(); + }); + + function createPlugin(files: Record = {}) { + const dir = writeTempDir({ 'wrangler.jsonc': JSON.stringify({ main: MAIN }), ...files }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const entryPath = join(dir, 'node_modules/vinext/dist/server/fetch-handler.js'); + idResolver.mockImplementation(async (_env: unknown, id: string) => (id === MAIN ? entryPath : undefined)); + const warn = vi.fn(); + const tx = (id: string, environment = ssr) => + plugin.transform.call({ parse: (c: string) => parseJS(c), warn, environment }, code, id); + + return { tx, entryPath, dir, warn }; + } + + it('wraps the module the specifier resolves to', async () => { + const { tx, entryPath, dir } = createPlugin(); + + const result = await tx(entryPath); + expect(idResolver).toHaveBeenCalledWith(ssr, MAIN, join(dir, 'wrangler.jsonc')); + expect(result.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };', + 'export default __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', + '', + ].join('\n'), + ); + }); + + it('resolves the specifier once per environment', async () => { + const { tx, dir } = createPlugin(); + + await tx(join(dir, 'a.js')); + await tx(join(dir, 'b.js')); + await tx(join(dir, 'a.js'), { name: 'rsc' }); + expect(idResolver).toHaveBeenCalledTimes(2); + expect(idResolver).toHaveBeenCalledWith({ name: 'rsc' }, MAIN, join(dir, 'wrangler.jsonc')); + }); + + it('does not treat the specifier as a path relative to the config', async () => { + const { tx, dir } = createPlugin(); + + expect(await tx(join(dir, MAIN))).toBeUndefined(); + expect(await tx(join(dir, `${MAIN}.js`))).toBeUndefined(); + }); + + it('imports instrument.server.* from beside the wrangler config', async () => { + const { tx, entryPath } = createPlugin({ + 'instrument.server.ts': 'export default (env) => ({ dsn: env.SENTRY_DSN });', + }); + + const result = await tx(entryPath); + expect(result.code).toContain("import __SENTRY_OPTIONS_CALLBACK__ from '../../../../instrument.server.ts';"); + expect(result.code).toContain('__SENTRY__.withSentry(__SENTRY_OPTIONS_CALLBACK__,'); + }); + + it('warns once when the specifier does not resolve', async () => { + const { tx, dir, warn } = createPlugin(); + idResolver.mockResolvedValue(undefined); + + expect(await tx(join(dir, 'a.js'))).toBeUndefined(); + expect(await tx(join(dir, 'b.js'))).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[sentry] Could not resolve the wrangler `main` "vinext/server/fetch-handler". Auto-instrumentation disabled.', + ); + }); +}); + // --------------------------------------------------------------------------- // instrument.server.* auto-detection (config from a conventional module) // --------------------------------------------------------------------------- diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 7e26dd77465b..92396bf21bae 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -1,3 +1,7 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { parse } from 'acorn'; import { describe, expect, it } from 'vitest'; import { applyAutoInstrumentTransforms, type ClassWrapperKind, type TransformContext } from '../../src/vite/transform'; @@ -75,6 +79,161 @@ describe('default export wrapping', () => { expect(result.code).toContain('__SENTRY__.withSentry('); }); + it('wraps a local binding exported as default by a specifier', () => { + const code = ['var server_default = { fetch: handle };', 'export { server_default as default };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'var server_default = { fetch: handle };', + 'var __SENTRY_DEFAULT_EXPORT__ = server_default;', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), server_default);', + ].join('\n'), + ); + }); + + it('binds the default export after a local declared below the export', () => { + const code = ['export { handler as default };', 'const handler = { fetch() {} };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + 'const handler = { fetch() {} };', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + ].join('\n'), + ); + }); + + it('binds the default export after a function declaration', () => { + const code = ['function handler() {}', 'export { handler as default };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'function handler() {}', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + ].join('\n'), + ); + }); + + it('binds the default export after the import of an imported local', () => { + const code = ["import handler from './handler';", 'export { handler as default };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + "import handler from './handler';", + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + ].join('\n'), + ); + }); + + it('keeps the default export readable from a circular import during module evaluation', () => { + // `b` reads the entry's default export while the entry is still evaluating, as bundled code can. + const entry = [ + "import { readDefault } from './b.mjs';", + 'var server_default = { fetch() {} };', + 'console.log(readDefault());', + 'export { server_default as default };', + ].join('\n'); + const transformed = transform(entry, ctx)!.code.replace( + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY__ = { withSentry: (_options, handler) => handler };', + ); + + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-cycle-')); + try { + writeFileSync(join(dir, 'a.mjs'), transformed); + writeFileSync( + join(dir, 'b.mjs'), + "import handler from './a.mjs';\nexport function readDefault() { return typeof handler.fetch; }\n", + ); + // Plain Node: Vitest's module runner does not evaluate import cycles like native ESM does. + expect(execFileSync(process.execPath, [join(dir, 'a.mjs')], { encoding: 'utf8' }).trim()).toBe('function'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + /** + * Runs `a.mjs` transformed, under plain Node, with a `withSentry` stub that marks each method it + * finds as instrumented, and returns what `main.mjs` prints. + */ + function runInstrumented(entry: string): string { + const transformed = transform(entry, ctx)!.code.replace( + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY__ = { withSentry: (_options, handler) => { for (const key of Object.keys(handler)) handler[key].instrumented = true; return handler; } };', + ); + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-late-')); + try { + writeFileSync(join(dir, 'a.mjs'), transformed); + writeFileSync( + join(dir, 'main.mjs'), + "import handler from './a.mjs';\nconsole.log(String(handler?.fetch?.instrumented));\n", + ); + return execFileSync(process.execPath, [join(dir, 'main.mjs')], { encoding: 'utf8' }).trim(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it('wraps a default export assigned after its declaration', () => { + const entry = ['let handler;', 'handler = { fetch() {} };', 'export { handler as default };'].join('\n'); + expect(runInstrumented(entry)).toBe('true'); + }); + + it('wraps a method added to the default export after its declaration', () => { + const entry = ['const handler = {};', 'handler.fetch = function () {};', 'export { handler as default };'].join( + '\n', + ); + expect(runInstrumented(entry)).toBe('true'); + }); + + it('leaves sibling specifiers of a default specifier export untouched', () => { + const code = [ + 'const helper = 1;', + 'const handler = { fetch() {} };', + 'export { helper, handler as default };', + ].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const helper = 1;', + 'const handler = { fetch() {} };', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + 'export { helper };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + ].join('\n'), + ); + }); + + it('skips a hand-wrapped `export const` exported as default by a specifier', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export const handler = withSentry((env) => ({}), { fetch() {} });', + 'export { handler as default };', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('skips a hand-wrapped local exported as default by a specifier', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'const handler = withSentry((env) => ({}), { fetch() {} });', + 'export { handler as default };', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + it('uses custom options callback', () => { const custom: TransformContext = { classWrappers: doWrappers(), @@ -500,6 +659,22 @@ describe('WorkerEntrypoint class wrapping (structural)', () => { expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); }); + it.each([ + ['after', ['export class AdminEntry extends WorkerEntrypoint {}', 'export { AdminEntry as default };']], + ['before', ['export { AdminEntry as default };', 'export class AdminEntry extends WorkerEntrypoint {}']], + ])('shares one wrapper between `export class` and a default specifier %s it', (_order, lines) => { + const code = ["import { WorkerEntrypoint } from 'cloudflare:workers';", ...lines].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code.match(/withSentry\(/g)).toHaveLength(1); + // The wrapper follows the class, so it never reads the class before it is set. + expect(result.code.indexOf('const __SENTRY_WRAPPED_AdminEntry__')).toBeGreaterThan( + result.code.indexOf('class AdminEntry extends WorkerEntrypoint {}'), + ); + expect(result.code).toContain('export { __SENTRY_WRAPPED_AdminEntry__ as default };'); + expect(result.code).toContain('export { __SENTRY_WRAPPED_AdminEntry__ as AdminEntry };'); + }); + it('wraps a class extending an aliased WorkerEntrypoint import', () => { const code = [ "import { WorkerEntrypoint as WE } from 'cloudflare:workers';", diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts index 9d8612146b60..87396151e929 100644 --- a/packages/cloudflare/test/vite/wranglerConfig.test.ts +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -41,6 +41,14 @@ describe('resolveWranglerConfig', () => { expect(result!.config.durableObjects).toEqual([{ name: 'MY_DO', className: 'MyDurableObject' }]); }); + it('keeps a package specifier `main` unchanged', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': JSON.stringify({ main: '@astrojs/cloudflare/entrypoints/server' }), + }); + + expect(resolveWranglerConfig(dir)!.config.main).toBe('@astrojs/cloudflare/entrypoints/server'); + }); + it('parses wrangler.json', () => { const dir = writeTempDir({ 'wrangler.json': JSON.stringify({ diff --git a/packages/core/src/integrations/mcp-server/attributeExtraction.ts b/packages/core/src/integrations/mcp-server/attributeExtraction.ts index 75449f43ccc9..fff8d76dc46f 100644 --- a/packages/core/src/integrations/mcp-server/attributeExtraction.ts +++ b/packages/core/src/integrations/mcp-server/attributeExtraction.ts @@ -2,15 +2,25 @@ * Core attribute extraction and building functions for MCP server instrumentation */ -import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url'; import { - MCP_LOGGING_DATA_TYPE_ATTRIBUTE, - MCP_LOGGING_LEVEL_ATTRIBUTE, - MCP_LOGGING_LOGGER_ATTRIBUTE, - MCP_LOGGING_MESSAGE_ATTRIBUTE, - MCP_REQUEST_ID_ATTRIBUTE, - MCP_RESOURCE_URI_ATTRIBUTE, -} from './attributes'; + MCP_CANCELLED_REASON, + MCP_CANCELLED_REQUEST_ID, + MCP_LIFECYCLE_PHASE, + MCP_LOGGING_DATA_TYPE, + MCP_LOGGING_LEVEL, + MCP_LOGGING_LOGGER, + MCP_LOGGING_MESSAGE, + MCP_PROGRESS_CURRENT, + MCP_PROGRESS_MESSAGE, + MCP_PROGRESS_PERCENTAGE, + MCP_PROGRESS_TOKEN, + MCP_PROGRESS_TOTAL, + MCP_PROTOCOL_READY, + MCP_REQUEST_ID, + MCP_RESOURCE_PROTOCOL, + MCP_RESOURCE_URI, +} from '@sentry/conventions/attributes'; +import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url'; import { extractTargetInfo, getRequestArguments } from './methodConfig'; import type { JsonRpcNotification, JsonRpcRequest, McpSpanType } from './types'; @@ -39,59 +49,59 @@ export function getNotificationAttributes( switch (method) { case 'notifications/cancelled': if (params?.requestId) { - attributes['mcp.cancelled.request_id'] = String(params.requestId); + attributes[MCP_CANCELLED_REQUEST_ID] = String(params.requestId); } if (params?.reason) { - attributes['mcp.cancelled.reason'] = String(params.reason); + attributes[MCP_CANCELLED_REASON] = String(params.reason); } break; case 'notifications/message': if (params?.level) { - attributes[MCP_LOGGING_LEVEL_ATTRIBUTE] = String(params.level); + attributes[MCP_LOGGING_LEVEL] = String(params.level); } if (params?.logger) { - attributes[MCP_LOGGING_LOGGER_ATTRIBUTE] = String(params.logger); + attributes[MCP_LOGGING_LOGGER] = String(params.logger); } if (params?.data !== undefined) { - attributes[MCP_LOGGING_DATA_TYPE_ATTRIBUTE] = typeof params.data; + attributes[MCP_LOGGING_DATA_TYPE] = typeof params.data; if (recordInputs) { - attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = formatLoggingData(params.data); + attributes[MCP_LOGGING_MESSAGE] = formatLoggingData(params.data); } } break; case 'notifications/progress': if (params?.progressToken) { - attributes['mcp.progress.token'] = String(params.progressToken); + attributes[MCP_PROGRESS_TOKEN] = String(params.progressToken); } if (typeof params?.progress === 'number') { - attributes['mcp.progress.current'] = params.progress; + attributes[MCP_PROGRESS_CURRENT] = params.progress; } if (typeof params?.total === 'number') { - attributes['mcp.progress.total'] = params.total; + attributes[MCP_PROGRESS_TOTAL] = params.total; if (typeof params?.progress === 'number') { - attributes['mcp.progress.percentage'] = (params.progress / params.total) * 100; + attributes[MCP_PROGRESS_PERCENTAGE] = (params.progress / params.total) * 100; } } if (params?.message) { - attributes['mcp.progress.message'] = String(params.message); + attributes[MCP_PROGRESS_MESSAGE] = String(params.message); } break; case 'notifications/resources/updated': if (params?.uri) { - attributes[MCP_RESOURCE_URI_ATTRIBUTE] = String(params.uri); + attributes[MCP_RESOURCE_URI] = String(params.uri); const urlObject = parseStringToURLObject(String(params.uri)); if (urlObject && !isURLObjectRelative(urlObject)) { - attributes['mcp.resource.protocol'] = urlObject.protocol.replace(':', ''); + attributes[MCP_RESOURCE_PROTOCOL] = urlObject.protocol.replace(':', ''); } } break; case 'notifications/initialized': - attributes['mcp.lifecycle.phase'] = 'initialization_complete'; - attributes['mcp.protocol.ready'] = 1; + attributes[MCP_LIFECYCLE_PHASE] = 'initialization_complete'; + attributes[MCP_PROTOCOL_READY] = 1; break; } @@ -117,7 +127,8 @@ export function buildTypeSpecificAttributes( const targetInfo = extractTargetInfo(request.method, params || {}); return { - ...(request.id !== undefined && { [MCP_REQUEST_ID_ATTRIBUTE]: String(request.id) }), + // oxlint-disable-next-line typescript/no-deprecated -- Preserve the legacy request ID attribute for existing consumers. + ...(request.id !== undefined && { [MCP_REQUEST_ID]: String(request.id) }), ...targetInfo.attributes, ...(recordInputs ? getRequestArguments(request.method, params || {}) : {}), }; diff --git a/packages/core/src/integrations/mcp-server/attributes.ts b/packages/core/src/integrations/mcp-server/attributes.ts index 2baa446418d7..b707336b2b95 100644 --- a/packages/core/src/integrations/mcp-server/attributes.ts +++ b/packages/core/src/integrations/mcp-server/attributes.ts @@ -1,146 +1,3 @@ -/** - * Essential MCP attribute constants for Sentry instrumentation - * - * Based on OpenTelemetry MCP semantic conventions - * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md - */ - -// ============================================================================= -// CORE MCP ATTRIBUTES -// ============================================================================= - -/** The name of the request or notification method */ -export const MCP_METHOD_NAME_ATTRIBUTE = 'mcp.method.name'; - -/** JSON-RPC request identifier for the request. Unique within the MCP session. */ -export const MCP_REQUEST_ID_ATTRIBUTE = 'mcp.request.id'; - -/** Identifies the MCP session */ -export const MCP_SESSION_ID_ATTRIBUTE = 'mcp.session.id'; - -/** Transport method used for MCP communication */ -export const MCP_TRANSPORT_ATTRIBUTE = 'mcp.transport'; - -// ============================================================================= -// CLIENT ATTRIBUTES -// ============================================================================= - -/** Name of the MCP client application */ -export const MCP_CLIENT_NAME_ATTRIBUTE = 'mcp.client.name'; - -/** Display title of the MCP client application */ -export const MCP_CLIENT_TITLE_ATTRIBUTE = 'mcp.client.title'; - -/** Version of the MCP client application */ -export const MCP_CLIENT_VERSION_ATTRIBUTE = 'mcp.client.version'; - -// ============================================================================= -// SERVER ATTRIBUTES -// ============================================================================= - -/** Name of the MCP server application */ -export const MCP_SERVER_NAME_ATTRIBUTE = 'mcp.server.name'; - -/** Display title of the MCP server application */ -export const MCP_SERVER_TITLE_ATTRIBUTE = 'mcp.server.title'; - -/** Version of the MCP server application */ -export const MCP_SERVER_VERSION_ATTRIBUTE = 'mcp.server.version'; - -/** MCP protocol version used in the session */ -export const MCP_PROTOCOL_VERSION_ATTRIBUTE = 'mcp.protocol.version'; - -// ============================================================================= -// METHOD-SPECIFIC ATTRIBUTES -// ============================================================================= - -/** Name of the tool being called */ -export const MCP_TOOL_NAME_ATTRIBUTE = 'mcp.tool.name'; - -/** The resource URI being accessed */ -export const MCP_RESOURCE_URI_ATTRIBUTE = 'mcp.resource.uri'; - -/** Name of the prompt template */ -export const MCP_PROMPT_NAME_ATTRIBUTE = 'mcp.prompt.name'; - -// ============================================================================= -// TOOL RESULT ATTRIBUTES -// ============================================================================= - -/** Whether a tool execution resulted in an error */ -export const MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE = 'mcp.tool.result.is_error'; - -/** Number of content items in the tool result */ -export const MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE = 'mcp.tool.result.content_count'; - -/** Serialized content of the tool result */ -export const MCP_TOOL_RESULT_CONTENT_ATTRIBUTE = 'mcp.tool.result.content'; - -/** Prefix for tool result attributes that contain sensitive content */ -export const MCP_TOOL_RESULT_PREFIX = 'mcp.tool.result'; - -// ============================================================================= -// PROMPT RESULT ATTRIBUTES -// ============================================================================= - -/** Description of the prompt result */ -export const MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE = 'mcp.prompt.result.description'; - -/** Number of messages in the prompt result */ -export const MCP_PROMPT_RESULT_MESSAGE_COUNT_ATTRIBUTE = 'mcp.prompt.result.message_count'; - -/** Role of the message in the prompt result (for single message results) */ -export const MCP_PROMPT_RESULT_MESSAGE_ROLE_ATTRIBUTE = 'mcp.prompt.result.message_role'; - -/** Content of the message in the prompt result (for single message results) */ -export const MCP_PROMPT_RESULT_MESSAGE_CONTENT_ATTRIBUTE = 'mcp.prompt.result.message_content'; - -/** Prefix for prompt result attributes that contain sensitive content */ -export const MCP_PROMPT_RESULT_PREFIX = 'mcp.prompt.result'; - -// ============================================================================= -// REQUEST ARGUMENT ATTRIBUTES -// ============================================================================= - -/** Prefix for MCP request argument prefix for each argument */ -export const MCP_REQUEST_ARGUMENT = 'mcp.request.argument'; - -// ============================================================================= -// LOGGING ATTRIBUTES -// ============================================================================= - -/** Log level for MCP logging operations */ -export const MCP_LOGGING_LEVEL_ATTRIBUTE = 'mcp.logging.level'; - -/** Logger name for MCP logging operations */ -export const MCP_LOGGING_LOGGER_ATTRIBUTE = 'mcp.logging.logger'; - -/** Data type of the logged message */ -export const MCP_LOGGING_DATA_TYPE_ATTRIBUTE = 'mcp.logging.data_type'; - -/** Log message content */ -export const MCP_LOGGING_MESSAGE_ATTRIBUTE = 'mcp.logging.message'; - -// ============================================================================= -// NETWORK ATTRIBUTES (OpenTelemetry Standard) -// ============================================================================= - -/** OSI transport layer protocol */ -export const NETWORK_TRANSPORT_ATTRIBUTE = 'network.transport'; - -/** The version of JSON RPC protocol used */ -export const NETWORK_PROTOCOL_VERSION_ATTRIBUTE = 'network.protocol.version'; - -/** Client address - domain name if available without reverse DNS lookup; otherwise, IP address or Unix domain socket name */ -export const CLIENT_ADDRESS_ATTRIBUTE = 'client.address'; - -/** Client port number */ -export const CLIENT_PORT_ATTRIBUTE = 'client.port'; - -// ============================================================================= -// SENTRY-SPECIFIC MCP ATTRIBUTE VALUES -// ============================================================================= - /** Sentry origin value for MCP function spans */ export const MCP_FUNCTION_ORIGIN_VALUE = 'auto.function.mcp_server'; diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 3b0e8e9a5d0e..4ca42839787d 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -10,10 +10,14 @@ * Falls back to WeakMap by transport instance for stateless transports (no sessionId). */ -import { ERROR_TYPE, RPC_RESPONSE_STATUS_CODE } from '@sentry/conventions/attributes'; +import { + ERROR_TYPE, + MCP_PROTOCOL_VERSION, + MCP_TOOL_RESULT_IS_ERROR, + RPC_RESPONSE_STATUS_CODE, +} from '@sentry/conventions/attributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types/span'; -import { MCP_PROTOCOL_VERSION_ATTRIBUTE, MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE } from './attributes'; import { isJsonRpcServerError } from './errorCapture'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; import { @@ -118,7 +122,7 @@ export function completeSpanWithResults( ...buildServerAttributesFromInfo(responseSessionData.serverInfo), }; if (responseSessionData.protocolVersion) { - responseAttributes[MCP_PROTOCOL_VERSION_ATTRIBUTE] = responseSessionData.protocolVersion; + responseAttributes[MCP_PROTOCOL_VERSION] = responseSessionData.protocolVersion; } if (Object.keys(responseAttributes).length > 0) { span.setAttributes(responseAttributes); @@ -133,7 +137,8 @@ export function completeSpanWithResults( } else if (method === 'tools/call') { const toolAttributes = extractToolResultAttributes(result, spanData.capturePolicy.recordOutputs); span.setAttributes(toolAttributes); - if (toolAttributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] === true) { + // oxlint-disable-next-line typescript/no-deprecated -- Preserve classification based on the existing tool result attribute. + if (toolAttributes[MCP_TOOL_RESULT_IS_ERROR] === true) { span.setAttributes({ [ERROR_TYPE]: 'tool_error' }); span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } diff --git a/packages/core/src/integrations/mcp-server/methodConfig.ts b/packages/core/src/integrations/mcp-server/methodConfig.ts index 0153adf49bbc..1e885187b83e 100644 --- a/packages/core/src/integrations/mcp-server/methodConfig.ts +++ b/packages/core/src/integrations/mcp-server/methodConfig.ts @@ -2,13 +2,15 @@ * Method configuration and request processing for MCP server instrumentation */ -import { isObjectLike } from '../../utils/is'; import { - MCP_PROMPT_NAME_ATTRIBUTE, - MCP_REQUEST_ARGUMENT, - MCP_RESOURCE_URI_ATTRIBUTE, - MCP_TOOL_NAME_ATTRIBUTE, -} from './attributes'; + MCP_PROMPT_NAME, + MCP_REQUEST_ARGUMENT_KEY_BASE, + MCP_REQUEST_ARGUMENT_NAME, + MCP_REQUEST_ARGUMENT_URI, + MCP_RESOURCE_URI, + MCP_TOOL_NAME, +} from '@sentry/conventions/attributes'; +import { isObjectLike } from '../../utils/is'; import type { MethodConfig } from './types'; /** @@ -18,27 +20,29 @@ import type { MethodConfig } from './types'; const METHOD_CONFIGS: Record = { 'tools/call': { targetField: 'name', - targetAttribute: MCP_TOOL_NAME_ATTRIBUTE, + // oxlint-disable-next-line typescript/no-deprecated -- Preserve the legacy tool name attribute for existing consumers. + targetAttribute: MCP_TOOL_NAME, targetIsLowCardinality: true, captureArguments: true, argumentsField: 'arguments', }, 'resources/read': { targetField: 'uri', - targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + targetAttribute: MCP_RESOURCE_URI, captureUri: true, }, 'resources/subscribe': { targetField: 'uri', - targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + targetAttribute: MCP_RESOURCE_URI, }, 'resources/unsubscribe': { targetField: 'uri', - targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + targetAttribute: MCP_RESOURCE_URI, }, 'prompts/get': { targetField: 'name', - targetAttribute: MCP_PROMPT_NAME_ATTRIBUTE, + // oxlint-disable-next-line typescript/no-deprecated -- Preserve the legacy prompt name attribute for existing consumers. + targetAttribute: MCP_PROMPT_NAME, targetIsLowCardinality: true, captureName: true, captureArguments: true, @@ -95,17 +99,17 @@ export function getRequestArguments(method: string, params: Record)) { - args[`${MCP_REQUEST_ARGUMENT}.${key.toLowerCase()}`] = JSON.stringify(value); + args[`${MCP_REQUEST_ARGUMENT_KEY_BASE}.${key.toLowerCase()}`] = JSON.stringify(value); } } } if (config.captureUri && params?.uri) { - args[`${MCP_REQUEST_ARGUMENT}.uri`] = JSON.stringify(params.uri); + args[MCP_REQUEST_ARGUMENT_URI] = JSON.stringify(params.uri); } if (config.captureName && params?.name) { - args[`${MCP_REQUEST_ARGUMENT}.name`] = JSON.stringify(params.name); + args[MCP_REQUEST_ARGUMENT_NAME] = JSON.stringify(params.name); } return args; diff --git a/packages/core/src/integrations/mcp-server/piiFiltering.ts b/packages/core/src/integrations/mcp-server/piiFiltering.ts index 8dc139f2bbf4..fe9b91744cfb 100644 --- a/packages/core/src/integrations/mcp-server/piiFiltering.ts +++ b/packages/core/src/integrations/mcp-server/piiFiltering.ts @@ -5,14 +5,14 @@ * Input/output data (request arguments, tool/prompt results) is controlled * separately via recordInputs/recordOutputs options. */ +import { CLIENT_ADDRESS, CLIENT_PORT, MCP_RESOURCE_URI } from '@sentry/conventions/attributes'; import type { SpanAttributeValue } from '../../types/span'; -import { CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE } from './attributes'; /** * Network PII attributes that should be removed when dataCollection.userInfo is false * @internal */ -const NETWORK_PII_ATTRIBUTES = new Set([CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE]); +const NETWORK_PII_ATTRIBUTES = new Set([CLIENT_ADDRESS, CLIENT_PORT, MCP_RESOURCE_URI]); /** * Checks if an attribute key should be considered network PII. diff --git a/packages/core/src/integrations/mcp-server/resultExtraction.ts b/packages/core/src/integrations/mcp-server/resultExtraction.ts index 58f9ad860083..42fd9d8740c1 100644 --- a/packages/core/src/integrations/mcp-server/resultExtraction.ts +++ b/packages/core/src/integrations/mcp-server/resultExtraction.ts @@ -5,13 +5,34 @@ */ import { - MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE, - MCP_PROMPT_RESULT_MESSAGE_COUNT_ATTRIBUTE, - MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE, - MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE, -} from './attributes'; + MCP_PROMPT_RESULT_DESCRIPTION, + MCP_PROMPT_RESULT_KEY_CONTENT, + MCP_PROMPT_RESULT_KEY_ROLE, + MCP_PROMPT_RESULT_MESSAGE_CONTENT, + MCP_PROMPT_RESULT_MESSAGE_COUNT, + MCP_PROMPT_RESULT_MESSAGE_ROLE, + MCP_TOOL_RESULT_CONTENT, + MCP_TOOL_RESULT_CONTENT_COUNT, + MCP_TOOL_RESULT_CONTENT_TYPE, + MCP_TOOL_RESULT_DATA_SIZE, + MCP_TOOL_RESULT_IS_ERROR, + MCP_TOOL_RESULT_KEY_CONTENT, + MCP_TOOL_RESULT_KEY_CONTENT_TYPE, + MCP_TOOL_RESULT_KEY_DATA_SIZE, + MCP_TOOL_RESULT_KEY_MIME_TYPE, + MCP_TOOL_RESULT_KEY_NAME, + MCP_TOOL_RESULT_KEY_RESOURCE_MIME_TYPE, + MCP_TOOL_RESULT_KEY_RESOURCE_URI, + MCP_TOOL_RESULT_KEY_URI, + MCP_TOOL_RESULT_MIME_TYPE, + MCP_TOOL_RESULT_NAME, + MCP_TOOL_RESULT_RESOURCE_MIME_TYPE, + MCP_TOOL_RESULT_RESOURCE_URI, + MCP_TOOL_RESULT_URI, +} from '@sentry/conventions/attributes'; import { isValidContentItem } from './validation'; +/* oxlint-disable typescript/no-deprecated -- Preserve the legacy result attributes for existing consumers. */ /** * Build attributes for tool result content items * @param content - Array of content items from tool result @@ -23,7 +44,7 @@ function buildAllContentItemAttributes( includeContent: boolean, ): Record { const attributes: Record = { - [MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE]: content.length, + [MCP_TOOL_RESULT_CONTENT_COUNT]: content.length, }; for (const [i, item] of content.entries()) { @@ -31,41 +52,55 @@ function buildAllContentItemAttributes( continue; } - const prefix = content.length === 1 ? 'mcp.tool.result' : `mcp.tool.result.${i}`; + const single = content.length === 1; + const index = String(i); if (typeof item.type === 'string') { - attributes[`${prefix}.content_type`] = item.type; + const key = single ? MCP_TOOL_RESULT_CONTENT_TYPE : MCP_TOOL_RESULT_KEY_CONTENT_TYPE.replace('', index); + attributes[key] = item.type; } if (includeContent) { const safeSet = (key: string, value: unknown): void => { if (typeof value === 'string') { - attributes[`${prefix}.${key}`] = value; + attributes[key] = value; } }; - safeSet('mime_type', item.mimeType); - safeSet('uri', item.uri); - safeSet('name', item.name); + safeSet( + single ? MCP_TOOL_RESULT_MIME_TYPE : MCP_TOOL_RESULT_KEY_MIME_TYPE.replace('', index), + item.mimeType, + ); + safeSet(single ? MCP_TOOL_RESULT_URI : MCP_TOOL_RESULT_KEY_URI.replace('', index), item.uri); + safeSet(single ? MCP_TOOL_RESULT_NAME : MCP_TOOL_RESULT_KEY_NAME.replace('', index), item.name); if (typeof item.text === 'string') { - attributes[`${prefix}.content`] = item.text; + const attrName = single ? MCP_TOOL_RESULT_CONTENT : MCP_TOOL_RESULT_KEY_CONTENT.replace('', index); + attributes[attrName] = item.text; } if (typeof item.data === 'string') { - attributes[`${prefix}.data_size`] = item.data.length; + const key = single ? MCP_TOOL_RESULT_DATA_SIZE : MCP_TOOL_RESULT_KEY_DATA_SIZE.replace('', index); + attributes[key] = item.data.length; } const resource = item.resource; if (isValidContentItem(resource)) { - safeSet('resource_uri', resource.uri); - safeSet('resource_mime_type', resource.mimeType); + safeSet( + single ? MCP_TOOL_RESULT_RESOURCE_URI : MCP_TOOL_RESULT_KEY_RESOURCE_URI.replace('', index), + resource.uri, + ); + safeSet( + single ? MCP_TOOL_RESULT_RESOURCE_MIME_TYPE : MCP_TOOL_RESULT_KEY_RESOURCE_MIME_TYPE.replace('', index), + resource.mimeType, + ); } } } return attributes; } +/* oxlint-enable typescript/no-deprecated */ /** * Extract tool result attributes for span instrumentation @@ -84,7 +119,8 @@ export function extractToolResultAttributes( const attributes = Array.isArray(result.content) ? buildAllContentItemAttributes(result.content, recordOutputs) : {}; if (typeof result.isError === 'boolean') { - attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = result.isError; + // oxlint-disable-next-line typescript/no-deprecated -- Preserve the legacy tool result attribute for existing consumers. + attributes[MCP_TOOL_RESULT_IS_ERROR] = result.isError; } return attributes; @@ -106,11 +142,11 @@ export function extractPromptResultAttributes( } if (recordOutputs && typeof result.description === 'string') { - attributes[MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE] = result.description; + attributes[MCP_PROMPT_RESULT_DESCRIPTION] = result.description; } if (Array.isArray(result.messages)) { - attributes[MCP_PROMPT_RESULT_MESSAGE_COUNT_ATTRIBUTE] = result.messages.length; + attributes[MCP_PROMPT_RESULT_MESSAGE_COUNT] = result.messages.length; if (recordOutputs) { const messages = result.messages; @@ -119,21 +155,21 @@ export function extractPromptResultAttributes( continue; } - const prefix = messages.length === 1 ? 'mcp.prompt.result' : `mcp.prompt.result.${i}`; - - const safeSet = (key: string, value: unknown): void => { - if (typeof value === 'string') { - const attrName = messages.length === 1 ? `${prefix}.message_${key}` : `${prefix}.${key}`; - attributes[attrName] = value; - } - }; - - safeSet('role', message.role); + if (typeof message.role === 'string') { + const attrName = + messages.length === 1 + ? MCP_PROMPT_RESULT_MESSAGE_ROLE + : MCP_PROMPT_RESULT_KEY_ROLE.replace('', String(i)); + attributes[attrName] = message.role; + } if (isValidContentItem(message.content)) { const content = message.content; if (typeof content.text === 'string') { - const attrName = messages.length === 1 ? `${prefix}.message_content` : `${prefix}.content`; + const attrName = + messages.length === 1 + ? MCP_PROMPT_RESULT_MESSAGE_CONTENT + : MCP_PROMPT_RESULT_KEY_CONTENT.replace('', String(i)); attributes[attrName] = content.text; } } diff --git a/packages/core/src/integrations/mcp-server/sessionExtraction.ts b/packages/core/src/integrations/mcp-server/sessionExtraction.ts index a4de982bf1b4..492674ec16c3 100644 --- a/packages/core/src/integrations/mcp-server/sessionExtraction.ts +++ b/packages/core/src/integrations/mcp-server/sessionExtraction.ts @@ -5,17 +5,20 @@ */ import { - CLIENT_ADDRESS_ATTRIBUTE, - CLIENT_PORT_ATTRIBUTE, - MCP_PROTOCOL_VERSION_ATTRIBUTE, - MCP_SERVER_NAME_ATTRIBUTE, - MCP_SERVER_TITLE_ATTRIBUTE, - MCP_SERVER_VERSION_ATTRIBUTE, - MCP_SESSION_ID_ATTRIBUTE, - MCP_TRANSPORT_ATTRIBUTE, - NETWORK_PROTOCOL_VERSION_ATTRIBUTE, - NETWORK_TRANSPORT_ATTRIBUTE, -} from './attributes'; + CLIENT_ADDRESS, + CLIENT_PORT, + MCP_CLIENT_NAME, + MCP_CLIENT_TITLE, + MCP_CLIENT_VERSION, + MCP_PROTOCOL_VERSION, + MCP_SERVER_NAME, + MCP_SERVER_TITLE, + MCP_SERVER_VERSION, + MCP_SESSION_ID, + MCP_TRANSPORT, + NETWORK_PROTOCOL_VERSION, + NETWORK_TRANSPORT, +} from '@sentry/conventions/attributes'; import { getClientInfoForTransport, getProtocolVersionForTransport, @@ -143,13 +146,13 @@ export function getClientAttributes(transport: MCPTransport): Record = {}; if (clientInfo?.name) { - attributes['mcp.client.name'] = clientInfo.name; + attributes[MCP_CLIENT_NAME] = clientInfo.name; } if (clientInfo?.title) { - attributes['mcp.client.title'] = clientInfo.title; + attributes[MCP_CLIENT_TITLE] = clientInfo.title; } if (clientInfo?.version) { - attributes['mcp.client.version'] = clientInfo.version; + attributes[MCP_CLIENT_VERSION] = clientInfo.version; } return attributes; @@ -164,13 +167,13 @@ export function buildClientAttributesFromInfo(clientInfo?: PartyInfo): Record = {}; if (clientInfo?.name) { - attributes['mcp.client.name'] = clientInfo.name; + attributes[MCP_CLIENT_NAME] = clientInfo.name; } if (clientInfo?.title) { - attributes['mcp.client.title'] = clientInfo.title; + attributes[MCP_CLIENT_TITLE] = clientInfo.title; } if (clientInfo?.version) { - attributes['mcp.client.version'] = clientInfo.version; + attributes[MCP_CLIENT_VERSION] = clientInfo.version; } return attributes; @@ -186,13 +189,13 @@ export function getServerAttributes(transport: MCPTransport): Record = {}; if (serverInfo?.name) { - attributes[MCP_SERVER_NAME_ATTRIBUTE] = serverInfo.name; + attributes[MCP_SERVER_NAME] = serverInfo.name; } if (serverInfo?.title) { - attributes[MCP_SERVER_TITLE_ATTRIBUTE] = serverInfo.title; + attributes[MCP_SERVER_TITLE] = serverInfo.title; } if (serverInfo?.version) { - attributes[MCP_SERVER_VERSION_ATTRIBUTE] = serverInfo.version; + attributes[MCP_SERVER_VERSION] = serverInfo.version; } return attributes; @@ -207,13 +210,13 @@ export function buildServerAttributesFromInfo(serverInfo?: PartyInfo): Record = {}; if (serverInfo?.name) { - attributes[MCP_SERVER_NAME_ATTRIBUTE] = serverInfo.name; + attributes[MCP_SERVER_NAME] = serverInfo.name; } if (serverInfo?.title) { - attributes[MCP_SERVER_TITLE_ATTRIBUTE] = serverInfo.title; + attributes[MCP_SERVER_TITLE] = serverInfo.title; } if (serverInfo?.version) { - attributes[MCP_SERVER_VERSION_ATTRIBUTE] = serverInfo.version; + attributes[MCP_SERVER_VERSION] = serverInfo.version; } return attributes; @@ -288,13 +291,13 @@ export function buildTransportAttributes( const protocolVersion = hasRequestMetadata ? messageData?.protocolVersion : getProtocolVersionForTransport(transport); const attributes = { - ...(sessionId && { [MCP_SESSION_ID_ATTRIBUTE]: sessionId }), - ...(clientInfo.address && { [CLIENT_ADDRESS_ATTRIBUTE]: clientInfo.address }), - ...(clientInfo.port && { [CLIENT_PORT_ATTRIBUTE]: clientInfo.port }), - [MCP_TRANSPORT_ATTRIBUTE]: mcpTransport, - [NETWORK_TRANSPORT_ATTRIBUTE]: networkTransport, - [NETWORK_PROTOCOL_VERSION_ATTRIBUTE]: '2.0', - ...(protocolVersion && { [MCP_PROTOCOL_VERSION_ATTRIBUTE]: protocolVersion }), + ...(sessionId && { [MCP_SESSION_ID]: sessionId }), + ...(clientInfo.address && { [CLIENT_ADDRESS]: clientInfo.address }), + ...(clientInfo.port && { [CLIENT_PORT]: clientInfo.port }), + [MCP_TRANSPORT]: mcpTransport, + [NETWORK_TRANSPORT]: networkTransport, + [NETWORK_PROTOCOL_VERSION]: '2.0', + ...(protocolVersion && { [MCP_PROTOCOL_VERSION]: protocolVersion }), ...clientAttributes, ...serverAttributes, }; diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts index b380da55e5a8..9f370a5d1b98 100644 --- a/packages/core/src/integrations/mcp-server/spans.ts +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -5,23 +5,18 @@ * Handles both request and notification spans with attribute extraction. */ -import { getClient } from '../../currentScopes'; -import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { MCP_METHOD_NAME, SENTRY_OP, SENTRY_ORIGIN, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import { MCP_NOTIFICATION_CLIENT_TO_SERVER, MCP_NOTIFICATION_SERVER_TO_CLIENT, MCP_SERVER, } from '@sentry/conventions/op'; +import { getClient } from '../../currentScopes'; import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled'; import { MCP_NOTIFICATION_SPAN_NAME_FALLBACK, MCP_SERVER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames'; import { startSpan } from '../../tracing/trace'; import { buildTransportAttributes, buildTypeSpecificAttributes } from './attributeExtraction'; -import { - MCP_FUNCTION_ORIGIN_VALUE, - MCP_METHOD_NAME_ATTRIBUTE, - MCP_NOTIFICATION_ORIGIN_VALUE, - MCP_ROUTE_SOURCE_VALUE, -} from './attributes'; +import { MCP_FUNCTION_ORIGIN_VALUE, MCP_NOTIFICATION_ORIGIN_VALUE, MCP_ROUTE_SOURCE_VALUE } from './attributes'; import { extractTargetInfo } from './methodConfig'; import { filterMcpPiiFromSpanData } from './piiFiltering'; import type { @@ -103,7 +98,7 @@ function createMcpSpan(config: McpSpanConfig): unknown { const rawAttributes: Record = { ...buildTransportAttributes(transport, extra, message), - [MCP_METHOD_NAME_ATTRIBUTE]: method, + [MCP_METHOD_NAME]: method, ...buildTypeSpecificAttributes(type, message, params, options?.recordInputs), ...buildSentryAttributes(type), }; @@ -201,7 +196,7 @@ export function buildMcpServerSpanConfig( const rawAttributes: Record = { ...buildTransportAttributes(transport, extra, jsonRpcMessage), - [MCP_METHOD_NAME_ATTRIBUTE]: method, + [MCP_METHOD_NAME]: method, ...buildTypeSpecificAttributes('request', jsonRpcMessage, params, options?.recordInputs), ...buildSentryAttributes('request'), }; diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index 599457b01cfd..92788b275f49 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -5,12 +5,12 @@ * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/transports */ +import { MCP_PROTOCOL_VERSION } from '@sentry/conventions/attributes'; import { getClient, getIsolationScope, withIsolationScope } from '../../currentScopes'; import { withActiveSpan } from '../../tracing'; import { startInactiveSpan } from '../../tracing/trace'; import { isObjectLike } from '../../utils/is'; import { fill } from '../../utils/object'; -import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; import { cleanupPendingSpansForTransport, completeSpanWithResults, storeSpanForRequest } from './correlation'; import { captureError, isJsonRpcServerError } from './errorCapture'; import { buildClientAttributesFromInfo, extractSessionDataFromInitializeRequest } from './sessionExtraction'; @@ -73,7 +73,7 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: McpServ span.setAttributes({ ...buildClientAttributesFromInfo(messageSessionData.clientInfo), ...(messageSessionData.protocolVersion && { - [MCP_PROTOCOL_VERSION_ATTRIBUTE]: messageSessionData.protocolVersion, + [MCP_PROTOCOL_VERSION]: messageSessionData.protocolVersion, }), }); } diff --git a/packages/core/src/utils/aggregate-errors.ts b/packages/core/src/utils/aggregate-errors.ts index ef006d12e69d..a481ea7ab28c 100644 --- a/packages/core/src/utils/aggregate-errors.ts +++ b/packages/core/src/utils/aggregate-errors.ts @@ -95,6 +95,32 @@ function aggregateExceptionsFromError( }); } + // SuppressedError (explicit resource management) wraps two errors: `error` (the new one thrown during disposal) + // and `suppressed` (the original one it replaced). + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SuppressedError + + if (isSuppressedError(error)) { + ['error', 'suppressed'].forEach(property => { + const childError = error[property]; + if (isError(childError)) { + applyExceptionGroupFieldsForParentException(exception, exceptionId, error); + const newException = exceptionFromErrorImplementation(parser, childError); + const newExceptionId = newExceptions.length; + applyExceptionGroupFieldsForChildException(newException, property, newExceptionId, exceptionId); + newExceptions = aggregateExceptionsFromError( + exceptionFromErrorImplementation, + parser, + limit, + childError as ExtendedError, + key, + [newException, ...newExceptions], + newException, + newExceptionId, + ); + } + }); + } + return newExceptions; } @@ -102,6 +128,10 @@ function isExceptionGroup(error: ExtendedError): error is ExtendedError & { erro return Array.isArray(error.errors); } +function isSuppressedError(error: ExtendedError): boolean { + return 'error' in error && 'suppressed' in error; +} + function applyExceptionGroupFieldsForParentException( exception: Exception, exceptionId: number, diff --git a/packages/core/test/lib/utils/aggregate-errors.test.ts b/packages/core/test/lib/utils/aggregate-errors.test.ts index 86f796d2d8e0..2ca4891710a9 100644 --- a/packages/core/test/lib/utils/aggregate-errors.test.ts +++ b/packages/core/test/lib/utils/aggregate-errors.test.ts @@ -32,6 +32,18 @@ class CustomAggregateError extends FakeAggregateError { } } +class FakeSuppressedError extends Error { + public error: unknown; + public suppressed: unknown; + + constructor(error: unknown, suppressed: unknown, message = 'An error was suppressed during disposal.') { + super(message); + this.name = 'SuppressedError'; + this.error = error; + this.suppressed = suppressed; + } +} + describe('applyAggregateErrorsToEvent()', () => { test('should not do anything if event does not contain an exception', () => { const event: Event = { exception: undefined }; @@ -482,4 +494,76 @@ describe('applyAggregateErrorsToEvent()', () => { }, }); }); + + test('adds both the `error` and the `suppressed` error of a SuppressedError', () => { + const suppressedError = new FakeSuppressedError(new Error('Error from dispose'), new Error('Original error')); + const event: Event = { exception: { values: [exceptionFromError(stackParser, suppressedError)] } }; + const eventHint: EventHint = { originalException: suppressedError }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, eventHint); + + expect(event).toStrictEqual({ + exception: { + values: [ + { + mechanism: { exception_id: 2, handled: true, parent_id: 0, source: 'suppressed', type: 'chained' }, + type: 'Error', + value: 'Original error', + }, + { + mechanism: { exception_id: 1, handled: true, parent_id: 0, source: 'error', type: 'chained' }, + type: 'Error', + value: 'Error from dispose', + }, + { + mechanism: { exception_id: 0, handled: true, type: 'instrument' }, + type: 'SuppressedError', + value: 'An error was suppressed during disposal.', + }, + ], + }, + }); + }); + + test('walks nested SuppressedErrors (several disposers throwing)', () => { + const inner = new FakeSuppressedError(new Error('dispose 2 failed'), new Error('Original error')); + const outer = new FakeSuppressedError(new Error('dispose 1 failed'), inner); + const event: Event = { exception: { values: [exceptionFromError(stackParser, outer)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, { originalException: outer }); + + expect(event.exception?.values?.map(e => e.value)).toEqual([ + 'Original error', + 'dispose 2 failed', + 'An error was suppressed during disposal.', + 'dispose 1 failed', + 'An error was suppressed during disposal.', + ]); + }); + + test('skips `error` / `suppressed` values that are not Errors', () => { + const suppressedError = new FakeSuppressedError('just a string', new Error('Original error')); + const event: Event = { exception: { values: [exceptionFromError(stackParser, suppressedError)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, { + originalException: suppressedError, + }); + + expect(event.exception?.values?.map(e => e.value)).toEqual([ + 'Original error', + 'An error was suppressed during disposal.', + ]); + }); + + test('does not treat an error that only has an `error` property as a SuppressedError', () => { + const notSuppressed: ExtendedError = new Error('Something else'); + notSuppressed.error = new Error('unrelated'); + const event: Event = { exception: { values: [exceptionFromError(stackParser, notSuppressed)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, { + originalException: notSuppressed, + }); + + expect(event.exception?.values).toHaveLength(1); + }); }); diff --git a/packages/effect/LICENSE b/packages/effect/LICENSE index fea6013e7dbf..6e06c625cd06 100644 --- a/packages/effect/LICENSE +++ b/packages/effect/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/elysia/LICENSE b/packages/elysia/LICENSE index 0ecae617386e..937729b222b1 100644 --- a/packages/elysia/LICENSE +++ b/packages/elysia/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/esbuild-plugin/.gitignore b/packages/esbuild-plugin/.gitignore new file mode 100644 index 000000000000..22cfeae206b4 --- /dev/null +++ b/packages/esbuild-plugin/.gitignore @@ -0,0 +1,2 @@ +build +*.tgz diff --git a/packages/esbuild-plugin/LICENSE b/packages/esbuild-plugin/LICENSE new file mode 100644 index 000000000000..c23a90edc9be --- /dev/null +++ b/packages/esbuild-plugin/LICENSE @@ -0,0 +1,29 @@ +# MIT License + +Copyright (c) 2022-2026, Sentry +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/esbuild-plugin/README.md b/packages/esbuild-plugin/README.md new file mode 100644 index 000000000000..72ba4460548e --- /dev/null +++ b/packages/esbuild-plugin/README.md @@ -0,0 +1,45 @@ +

+ + Sentry + +

+ +# Sentry esbuild Plugin + +A esbuild plugin that uploads source maps to Sentry and injects release and Debug ID information into your bundles. + +This package re-exports `@sentry/bundler-plugins/esbuild`. + +## Installation + +```bash +npm install @sentry/esbuild-plugin --save-dev +``` + +## Usage + +```js +// build.js +import * as esbuild from 'esbuild'; +import { sentryEsbuildPlugin } from '@sentry/esbuild-plugin'; + +await esbuild.build({ + sourcemap: true, + plugins: [ + sentryEsbuildPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}); +``` + +## Documentation + +- [Uploading source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/) + +## 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/esbuild-plugin/package.json b/packages/esbuild-plugin/package.json new file mode 100644 index 000000000000..0f85f5c61ef0 --- /dev/null +++ b/packages/esbuild-plugin/package.json @@ -0,0 +1,56 @@ +{ + "name": "@sentry/esbuild-plugin", + "version": "11.7.0", + "description": "Official Sentry esbuild plugin", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/esbuild-plugin", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "Sentry", + "esbuild", + "bundler", + "plugin" + ], + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-esbuild-plugin-*.tgz", + "lint": "oxlint . --type-aware", + "lint:fix": "oxlint . --fix --type-aware", + "test": "vitest run", + "test:unit": "vitest run" + }, + "dependencies": { + "@sentry/bundler-plugins": "11.7.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/esbuild-plugin/rollup.npm.config.mjs b/packages/esbuild-plugin/rollup.npm.config.mjs new file mode 100644 index 000000000000..b11c2c4f9148 --- /dev/null +++ b/packages/esbuild-plugin/rollup.npm.config.mjs @@ -0,0 +1,12 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + packageSpecificConfig: { + output: { + exports: 'named', + }, + }, + }), +); diff --git a/packages/esbuild-plugin/src/index.ts b/packages/esbuild-plugin/src/index.ts new file mode 100644 index 000000000000..142a16901164 --- /dev/null +++ b/packages/esbuild-plugin/src/index.ts @@ -0,0 +1,2 @@ +export * from '@sentry/bundler-plugins/esbuild'; +export { default } from '@sentry/bundler-plugins/esbuild'; diff --git a/packages/esbuild-plugin/test/index.test.ts b/packages/esbuild-plugin/test/index.test.ts new file mode 100644 index 000000000000..d92084cfd902 --- /dev/null +++ b/packages/esbuild-plugin/test/index.test.ts @@ -0,0 +1,14 @@ +import * as bundlerPlugins from '@sentry/bundler-plugins/esbuild'; +import { describe, expect, it } from 'vitest'; +import * as plugin from '../src/index'; + +describe('@sentry/esbuild-plugin', () => { + it('re-exports @sentry/bundler-plugins/esbuild', () => { + expect(plugin.sentryEsbuildPlugin).toBe(bundlerPlugins.sentryEsbuildPlugin); + expect(Object.keys(plugin).sort()).toEqual(Object.keys(bundlerPlugins).sort()); + }); + + it('re-exports the default export', () => { + expect(plugin.default).toBe(bundlerPlugins.default); + }); +}); diff --git a/packages/esbuild-plugin/test/tsconfig.json b/packages/esbuild-plugin/test/tsconfig.json new file mode 100644 index 000000000000..76d0c9cff6f2 --- /dev/null +++ b/packages/esbuild-plugin/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "include": ["../src/**/*", "./**/*"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/esbuild-plugin/tsconfig.json b/packages/esbuild-plugin/tsconfig.json new file mode 100644 index 000000000000..2044e12308ea --- /dev/null +++ b/packages/esbuild-plugin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./package.json"] +} diff --git a/packages/esbuild-plugin/tsconfig.types.json b/packages/esbuild-plugin/tsconfig.types.json new file mode 100644 index 000000000000..9321489cd9a9 --- /dev/null +++ b/packages/esbuild-plugin/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/esbuild-plugin/vite.config.ts b/packages/esbuild-plugin/vite.config.ts new file mode 100644 index 000000000000..f18ec92095bc --- /dev/null +++ b/packages/esbuild-plugin/vite.config.ts @@ -0,0 +1,8 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + }, +}; diff --git a/packages/gatsby/LICENSE b/packages/gatsby/LICENSE index b956a1944c7b..84c94253c8cf 100644 --- a/packages/gatsby/LICENSE +++ b/packages/gatsby/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/google-cloud-serverless/LICENSE b/packages/google-cloud-serverless/LICENSE index 63e7eb28e19c..48d5f26681c8 100644 --- a/packages/google-cloud-serverless/LICENSE +++ b/packages/google-cloud-serverless/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/hono/src/bun/middleware.ts b/packages/hono/src/bun/middleware.ts index 20c922c32c0c..0ca1a4e92def 100644 --- a/packages/hono/src/bun/middleware.ts +++ b/packages/hono/src/bun/middleware.ts @@ -1,10 +1,11 @@ -import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; +import type { BunOptions } from '@sentry/bun'; +import { debug } from '@sentry/core'; import { init } from './sdk'; import { getConnInfo } from 'hono/bun'; import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -export interface HonoBunOptions extends Options, SentryHonoMiddlewareOptions {} +export interface HonoBunOptions extends BunOptions, SentryHonoMiddlewareOptions {} /** * Sentry middleware for Hono running in a Bun runtime environment. diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index df009ccfb945..ef5349c7d7d6 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -1,5 +1,5 @@ -import { withSentry } from '@sentry/cloudflare'; -import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core'; +import { withSentry, type CloudflareOptions } from '@sentry/cloudflare'; +import { applySdkMetadata, debug } from '@sentry/core'; import { getConnInfo } from 'hono/cloudflare-workers'; import { applyHonoPatches, @@ -10,7 +10,7 @@ import type { Env, Hono, MiddlewareHandler } from 'hono'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactionPatterns'; -export interface HonoCloudflareOptions extends Options, SentryHonoMiddlewareOptions {} +export interface HonoCloudflareOptions extends CloudflareOptions, SentryHonoMiddlewareOptions {} /** * Sentry middleware for Hono on Cloudflare Workers. diff --git a/packages/hono/src/deno/middleware.ts b/packages/hono/src/deno/middleware.ts index 1e63c24d9b65..8a39fc6ecbea 100644 --- a/packages/hono/src/deno/middleware.ts +++ b/packages/hono/src/deno/middleware.ts @@ -1,4 +1,5 @@ -import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; +import { debug } from '@sentry/core'; +import type { DenoOptions } from '@sentry/deno'; import { init } from './sdk'; import { getConnInfo } from 'hono/deno'; import { @@ -8,7 +9,7 @@ import { } from '@sentry/server-utils/no-diagnostic-channels'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -export interface HonoDenoOptions extends Options, SentryHonoMiddlewareOptions {} +export interface HonoDenoOptions extends DenoOptions, SentryHonoMiddlewareOptions {} /** * Sentry middleware for Hono running in a Deno runtime environment. diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index 0b4688ef72f4..0d052c6d72ed 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,9 +1,10 @@ -import { type BaseTransportOptions, consoleSandbox, debug, getClient, type Options } from '@sentry/core'; +import { consoleSandbox, debug, getClient } from '@sentry/core'; +import type { NodeOptions } from '@sentry/node'; import { getConnInfo } from '@hono/node-server/conninfo'; import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -export interface HonoNodeOptions extends Options {} +export interface HonoNodeOptions extends NodeOptions {} /** * Sentry middleware for Hono applications running in a Node.js environment. diff --git a/packages/hono/test/bun/middleware.test.ts b/packages/hono/test/bun/middleware.test.ts index a8ce3baac746..ca8fe21d1b45 100644 --- a/packages/hono/test/bun/middleware.test.ts +++ b/packages/hono/test/bun/middleware.test.ts @@ -2,7 +2,7 @@ import * as SentryCore from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { sentry } from '../../src/bun/middleware'; +import { sentry, type HonoBunOptions } from '../../src/bun/middleware'; import { init } from '../../src/bun/sdk'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../../src/shared/lowQualityTransactionPatterns'; @@ -102,7 +102,8 @@ describe('Hono Bun Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, - }; + enableOpenTelemetrySetup: true, + } satisfies HonoBunOptions; sentry(app, options); @@ -113,6 +114,7 @@ describe('Hono Bun Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: true, }), ); }); diff --git a/packages/hono/test/cloudflare/middleware.test.ts b/packages/hono/test/cloudflare/middleware.test.ts index ea09300f3d96..7c50b2823dfa 100644 --- a/packages/hono/test/cloudflare/middleware.test.ts +++ b/packages/hono/test/cloudflare/middleware.test.ts @@ -3,7 +3,7 @@ import * as SentryCore from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { sentry } from '../../src/cloudflare/middleware'; +import { sentry, type HonoCloudflareOptions } from '../../src/cloudflare/middleware'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../../src/shared/lowQualityTransactionPatterns'; vi.mock('@sentry/cloudflare', { spy: true }); @@ -82,7 +82,9 @@ describe('Hono Cloudflare Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, - }; + enableOpenTelemetrySetup: true, + rpcTracePropagationBindings: ['ORDERS'], + } satisfies HonoCloudflareOptions; sentry(app, options); @@ -95,6 +97,8 @@ describe('Hono Cloudflare Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: true, + rpcTracePropagationBindings: ['ORDERS'], }); }); diff --git a/packages/hono/test/deno/middleware.test.ts b/packages/hono/test/deno/middleware.test.ts index 3da909433d72..bf19b51804b4 100644 --- a/packages/hono/test/deno/middleware.test.ts +++ b/packages/hono/test/deno/middleware.test.ts @@ -2,7 +2,7 @@ import * as SentryCore from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { sentry } from '../../src/deno/middleware'; +import { sentry, type HonoDenoOptions } from '../../src/deno/middleware'; import { init } from '../../src/deno/sdk'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../../src/shared/lowQualityTransactionPatterns'; @@ -102,7 +102,8 @@ describe('Hono Deno Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, - }; + enableOpenTelemetrySetup: false, + } satisfies HonoDenoOptions; sentry(app, options); @@ -113,6 +114,7 @@ describe('Hono Deno Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: false, }), ); }); diff --git a/packages/hono/test/node/sdk.test.ts b/packages/hono/test/node/sdk.test.ts index d5045d7ff343..f9b90c8f6dfd 100644 --- a/packages/hono/test/node/sdk.test.ts +++ b/packages/hono/test/node/sdk.test.ts @@ -82,6 +82,8 @@ describe('Hono Node SDK – init()', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: true, + transportOptions: { proxy: 'http://proxy.example.com:8080' }, }); expect(initNodeMock).toHaveBeenCalledWith( @@ -90,6 +92,8 @@ describe('Hono Node SDK – init()', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: true, + transportOptions: { proxy: 'http://proxy.example.com:8080' }, }), ); }); diff --git a/packages/nestjs/LICENSE b/packages/nestjs/LICENSE index 63e7eb28e19c..48d5f26681c8 100644 --- a/packages/nestjs/LICENSE +++ b/packages/nestjs/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/nextjs/LICENSE b/packages/nextjs/LICENSE index 917e31f85b7a..24624cb5010b 100644 --- a/packages/nextjs/LICENSE +++ b/packages/nextjs/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/nextjs/test/config/conflictingDebugOptions.test.ts b/packages/nextjs/test/config/conflictingDebugOptions.test.ts index 6356fcc9bfdc..0a877af8958a 100644 --- a/packages/nextjs/test/config/conflictingDebugOptions.test.ts +++ b/packages/nextjs/test/config/conflictingDebugOptions.test.ts @@ -1,8 +1,21 @@ import { JSDOM } from 'jsdom'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import * as client from '../../src/client/index.js'; +import * as edge from '../../src/edge/index.js'; +import * as server from '../../src/server/index.js'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +const debugBuild = vi.hoisted(() => ({ value: true })); + +// The entry points read `DEBUG_BUILD` inside `init()`, so a getter lets each test switch it without +// re-importing the SDK module graphs. +vi.mock('../../src/common/debug-build.js', () => ({ + get DEBUG_BUILD() { + return debugBuild.value; + }, +})); + function didWarnAboutDebugRemoved(warnSpy: ReturnType): boolean { return warnSpy.mock.calls.some(call => call.some( @@ -21,15 +34,7 @@ describe('debug: true + removeDebugLogging warning', () => { let originalAddEventListener: unknown; let originalRemoveEventListener: unknown; - beforeAll(async () => { - // Pre-warm V8 compilation cache for the large SDK module graphs. - // Without this, the first dynamic import after vi.resetModules() can hang - // because vitest needs to compile the entire module graph from scratch. - await import('../../src/client/index.js'); - await import('../../src/server/index.js'); - await import('../../src/edge/index.js'); - vi.resetModules(); - + beforeAll(() => { dom = new JSDOM('', { url: 'https://example.com/' }); originalDocument = (globalThis as any).document; @@ -52,41 +57,30 @@ describe('debug: true + removeDebugLogging warning', () => { afterEach(() => { vi.restoreAllMocks(); - vi.resetModules(); - vi.unmock('../../src/common/debug-build.js'); + debugBuild.value = true; delete process.env.NEXT_OTEL_FETCH_DISABLED; delete process.env.NEXT_PHASE; }); - it('warns on client/server/edge when debug is true but DEBUG_BUILD is false', async () => { - vi.doMock('../../src/common/debug-build.js', () => ({ DEBUG_BUILD: false })); + it('warns on client/server/edge when debug is true but DEBUG_BUILD is false', () => { + debugBuild.value = false; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const client = await import('../../src/client/index.js'); client.init({ dsn: TEST_DSN, debug: true } as any); - - const server = await import('../../src/server/index.js'); server.init({ dsn: TEST_DSN, debug: true } as any); - - const edge = await import('../../src/edge/index.js'); edge.init({ dsn: TEST_DSN, debug: true } as any); expect(didWarnAboutDebugRemoved(warnSpy)).toBe(true); }); - it('does not emit that warning when DEBUG_BUILD is true', async () => { - vi.doMock('../../src/common/debug-build.js', () => ({ DEBUG_BUILD: true })); + it('does not emit that warning when DEBUG_BUILD is true', () => { + debugBuild.value = true; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const client = await import('../../src/client/index.js'); client.init({ dsn: TEST_DSN, debug: true } as any); - - const server = await import('../../src/server/index.js'); server.init({ dsn: TEST_DSN, debug: true } as any); - - const edge = await import('../../src/edge/index.js'); edge.init({ dsn: TEST_DSN, debug: true } as any); expect(didWarnAboutDebugRemoved(warnSpy)).toBe(false); diff --git a/packages/nitro/LICENSE b/packages/nitro/LICENSE index 0ecae617386e..937729b222b1 100644 --- a/packages/nitro/LICENSE +++ b/packages/nitro/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/node-native/LICENSE b/packages/node-native/LICENSE index 0da96cd2f885..a05f045c30fa 100644 --- a/packages/node-native/LICENSE +++ b/packages/node-native/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/node/LICENSE b/packages/node/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/node/LICENSE +++ b/packages/node/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/nuxt/LICENSE b/packages/nuxt/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/nuxt/LICENSE +++ b/packages/nuxt/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index 92c9478b7079..423e8c457492 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -36,6 +36,16 @@ interface VueRouter { type VueRouteProviderRouter = ReturnType[0]>; +// 3xx/4xx Nuxt errors are expected control flow (redirects, not-found pages), not bugs. The server skips them too. +function isIgnoredNuxtError(error: unknown): boolean { + if (!isNuxtError(error)) { + return false; + } + // oxlint-disable-next-line typescript/no-deprecated + const statusCode = error?.status || error?.statusCode; + return !!statusCode && statusCode >= 300 && statusCode < 500; +} + // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -87,19 +97,18 @@ export default defineNuxtPlugin({ }); nuxtApp.hook('app:error', error => { - if (isNuxtError(error)) { - // oxlint-disable-next-line typescript/no-deprecated - const statusCode = error?.status || error?.statusCode; - - // Do not report if status code is 3xx or 4xx - if (statusCode && statusCode >= 300 && statusCode < 500) { - return; - } + if (isIgnoredNuxtError(error)) { + return; } reportNuxtError({ error }); }); + // Nuxt calls `vue:error` for every error that reaches the root component, so an error thrown during setup + // (e.g. `createError({ statusCode: 404 })`) arrives here too, not only in `app:error`. nuxtApp.hook('vue:error', (error, instance, info) => { + if (isIgnoredNuxtError(error)) { + return; + } reportNuxtError({ error, instance, info }); }); }, diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts new file mode 100644 index 000000000000..14b75383d1cb --- /dev/null +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { reportNuxtError } from '../../../src/runtime/utils'; + +vi.mock('nuxt/app', () => ({ + defineNuxtPlugin: (plugin: unknown) => plugin, + isNuxtError: (error: unknown) => !!error && typeof error === 'object' && '__nuxt_error' in error, +})); + +vi.mock('../../../src/runtime/utils', () => ({ + reportNuxtError: vi.fn(), +})); + +type Hook = (...args: unknown[]) => void; + +async function setupHook(hookName: string): Promise { + const { default: plugin } = await import('../../../src/runtime/plugins/sentry.client'); + const hooks: Record = {}; + const nuxtApp = { hook: (name: string, hook: Hook) => (hooks[name] = hook) }; + await (plugin as unknown as { setup: (app: unknown) => Promise }).setup(nuxtApp); + expect(hooks).toHaveProperty(hookName); + return hooks[hookName]!; +} + +function nuxtError(statusCode: number): Error { + return Object.assign(new Error(`status ${statusCode}`), { __nuxt_error: true, statusCode }); +} + +describe('sentry client plugin error hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(['app:error', 'vue:error'])('does not report 3xx/4xx Nuxt errors from %s', async hookName => { + const hook = await setupHook(hookName); + + hook(nuxtError(404)); + hook(nuxtError(301)); + + expect(reportNuxtError).not.toHaveBeenCalled(); + }); + + it.each(['app:error', 'vue:error'])('reports 5xx Nuxt errors from %s', async hookName => { + const hook = await setupHook(hookName); + + hook(nuxtError(503)); + + expect(reportNuxtError).toHaveBeenCalledTimes(1); + }); + + it('reports errors that are not Nuxt errors from vue:error', async () => { + const hook = await setupHook('vue:error'); + const error = Object.assign(new Error('boom'), { statusCode: 404 }); + + hook(error, undefined, 'setup function'); + + expect(reportNuxtError).toHaveBeenCalledWith({ error, instance: undefined, info: 'setup function' }); + }); +}); diff --git a/packages/profiling-node/LICENSE b/packages/profiling-node/LICENSE index 293314012679..fde36681396d 100644 --- a/packages/profiling-node/LICENSE +++ b/packages/profiling-node/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/react-router/LICENSE b/packages/react-router/LICENSE index 5251db3eaaca..9a88e71d6cae 100644 --- a/packages/react-router/LICENSE +++ b/packages/react-router/LICENSE @@ -14,3 +14,12 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/remix/LICENSE b/packages/remix/LICENSE index 293314012679..fde36681396d 100644 --- a/packages/remix/LICENSE +++ b/packages/remix/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/rollup-plugin/.gitignore b/packages/rollup-plugin/.gitignore new file mode 100644 index 000000000000..22cfeae206b4 --- /dev/null +++ b/packages/rollup-plugin/.gitignore @@ -0,0 +1,2 @@ +build +*.tgz diff --git a/packages/rollup-plugin/LICENSE b/packages/rollup-plugin/LICENSE new file mode 100644 index 000000000000..c23a90edc9be --- /dev/null +++ b/packages/rollup-plugin/LICENSE @@ -0,0 +1,29 @@ +# MIT License + +Copyright (c) 2022-2026, Sentry +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/rollup-plugin/README.md b/packages/rollup-plugin/README.md new file mode 100644 index 000000000000..17a8d1c7b2c2 --- /dev/null +++ b/packages/rollup-plugin/README.md @@ -0,0 +1,46 @@ +

+ + Sentry + +

+ +# Sentry Rollup Plugin + +A Rollup plugin that uploads source maps to Sentry and injects release and Debug ID information into your bundles. + +This package re-exports `@sentry/bundler-plugins/rollup`. + +## Installation + +```bash +npm install @sentry/rollup-plugin --save-dev +``` + +## Usage + +```js +// rollup.config.js +import { sentryRollupPlugin } from '@sentry/rollup-plugin'; + +export default { + output: { + sourcemap: true, + }, + plugins: [ + sentryRollupPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}; +``` + +## Documentation + +- [Uploading source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/) + +## 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/rollup-plugin/package.json b/packages/rollup-plugin/package.json new file mode 100644 index 000000000000..22252d75e54f --- /dev/null +++ b/packages/rollup-plugin/package.json @@ -0,0 +1,65 @@ +{ + "name": "@sentry/rollup-plugin", + "version": "11.7.0", + "description": "Official Sentry Rollup plugin", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/rollup-plugin", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "Sentry", + "rollup-plugin", + "Rollup", + "bundler", + "plugin" + ], + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-rollup-plugin-*.tgz", + "lint": "oxlint . --type-aware", + "lint:fix": "oxlint . --fix --type-aware", + "test": "vitest run", + "test:unit": "vitest run" + }, + "dependencies": { + "@sentry/bundler-plugins": "11.7.0" + }, + "peerDependencies": { + "rollup": ">=3.2.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/rollup-plugin/rollup.npm.config.mjs b/packages/rollup-plugin/rollup.npm.config.mjs new file mode 100644 index 000000000000..601cc6837ce0 --- /dev/null +++ b/packages/rollup-plugin/rollup.npm.config.mjs @@ -0,0 +1,7 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + }), +); diff --git a/packages/rollup-plugin/src/index.ts b/packages/rollup-plugin/src/index.ts new file mode 100644 index 000000000000..0807365d7f98 --- /dev/null +++ b/packages/rollup-plugin/src/index.ts @@ -0,0 +1 @@ +export * from '@sentry/bundler-plugins/rollup'; diff --git a/packages/rollup-plugin/test/index.test.ts b/packages/rollup-plugin/test/index.test.ts new file mode 100644 index 000000000000..759af8b27bb6 --- /dev/null +++ b/packages/rollup-plugin/test/index.test.ts @@ -0,0 +1,10 @@ +import * as bundlerPlugins from '@sentry/bundler-plugins/rollup'; +import { describe, expect, it } from 'vitest'; +import * as plugin from '../src/index'; + +describe('@sentry/rollup-plugin', () => { + it('re-exports @sentry/bundler-plugins/rollup', () => { + expect(plugin.sentryRollupPlugin).toBe(bundlerPlugins.sentryRollupPlugin); + expect(Object.keys(plugin).sort()).toEqual(Object.keys(bundlerPlugins).sort()); + }); +}); diff --git a/packages/rollup-plugin/test/tsconfig.json b/packages/rollup-plugin/test/tsconfig.json new file mode 100644 index 000000000000..76d0c9cff6f2 --- /dev/null +++ b/packages/rollup-plugin/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "include": ["../src/**/*", "./**/*"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/rollup-plugin/tsconfig.json b/packages/rollup-plugin/tsconfig.json new file mode 100644 index 000000000000..2044e12308ea --- /dev/null +++ b/packages/rollup-plugin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./package.json"] +} diff --git a/packages/rollup-plugin/tsconfig.types.json b/packages/rollup-plugin/tsconfig.types.json new file mode 100644 index 000000000000..9321489cd9a9 --- /dev/null +++ b/packages/rollup-plugin/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/rollup-plugin/vite.config.ts b/packages/rollup-plugin/vite.config.ts new file mode 100644 index 000000000000..f18ec92095bc --- /dev/null +++ b/packages/rollup-plugin/vite.config.ts @@ -0,0 +1,8 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + }, +}; diff --git a/packages/server-utils/src/ai/openai/constants.ts b/packages/server-utils/src/ai/openai/constants.ts index af163af81b6a..60bf73f94bf1 100644 --- a/packages/server-utils/src/ai/openai/constants.ts +++ b/packages/server-utils/src/ai/openai/constants.ts @@ -12,6 +12,8 @@ export const OPENAI_METHOD_REGISTRY = { // Conversations API - for conversation state management // https://platform.openai.com/docs/guides/conversation-state 'conversations.create': { operation: 'chat' }, + // https://developers.openai.com/api/docs/guides/decisions + 'decisions.create': { operation: 'decide' }, } as const satisfies InstrumentedMethodRegistry; export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ 'response.output_item.added', diff --git a/packages/server-utils/src/ai/openai/index.ts b/packages/server-utils/src/ai/openai/index.ts index 981da4281ef5..a832688c8cb3 100644 --- a/packages/server-utils/src/ai/openai/index.ts +++ b/packages/server-utils/src/ai/openai/index.ts @@ -31,6 +31,7 @@ import { import { OPENAI_METHOD_REGISTRY } from './constants'; import { instrumentStream } from './streaming'; import type { ChatCompletionChunk, OpenAiOptions, OpenAIStream, ResponseStreamingEvent } from './types'; +import { getDecisionInputMessages } from './mapping'; import { addResponseAttributes, extractRequestParameters } from './utils'; /** @@ -73,12 +74,15 @@ export function extractRequestAttributes( if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { const params = args[0] as Record; - const availableTools = recordInputs ? extractAvailableTools(params) : undefined; - if (availableTools) { - attributes[GEN_AI_TOOL_DEFINITIONS] = availableTools; + if (recordInputs) { + attributes[GEN_AI_TOOL_DEFINITIONS] = extractAvailableTools(params); } Object.assign(attributes, extractRequestParameters(params)); + + if (recordInputs && operationName === 'decide') { + attributes[GEN_AI_INPUT_MESSAGES] = getDecisionInputMessages(params); + } } else { attributes[GEN_AI_REQUEST_MODEL] = 'unknown'; } @@ -88,6 +92,11 @@ export function extractRequestAttributes( // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. export function addRequestAttributes(span: Span, params: Record, operationName: string): void { + // A decision's input is recorded when its span starts, in `extractRequestAttributes`. + if (operationName === 'decide') { + return; + } + // Store embeddings input on a separate attribute if (operationName === 'embeddings' && 'input' in params) { const input = params.input; @@ -203,7 +212,7 @@ function instrumentMethod( } return originalResult.then(result => { - addResponseAttributes(span, result, options.recordOutputs); + addResponseAttributes(span, result, options.recordOutputs, operationName); return result; }); }); diff --git a/packages/server-utils/src/ai/openai/mapping.ts b/packages/server-utils/src/ai/openai/mapping.ts new file mode 100644 index 000000000000..e43237570d6c --- /dev/null +++ b/packages/server-utils/src/ai/openai/mapping.ts @@ -0,0 +1,102 @@ +/** + * Maps OpenAI decisions requests and responses to the format Jev calls are recorded in, so Sentry renders + * both the same way. + */ +import { isObjectLike } from '@sentry/core'; +import { getEvaluationInputMessages, getEvaluationOutputMessages } from '../core/utils'; + +/** + * Serialize a decisions request. Its evidence is `input` where Jev's is `state`; it is recorded as `state` + * so both render the same. + */ +export function getDecisionInputMessages(params: Record): string | undefined { + const questions = Array.isArray(params.questions) ? params.questions.map(toJevQuestionFormat) : params.questions; + return getEvaluationInputMessages({ state: params.input, questions: keyByName(questions) }); +} + +/** Serialize the answers of a decisions response, laid out and keyed like Jev answers. */ +export function getDecisionOutputMessages(answers: unknown[]): string | undefined { + return getEvaluationOutputMessages(keyByName(answers.map(toJevAnswerFormat))); +} + +/** + * Lay out a decisions question like a Jev question, with its options in `criteria`: choices as + * `{ [value]: description }` (an empty description when there is none), and score levels as their labels, + * indexed by score value, with any level descriptions in `descriptions`, keyed by score value. + */ +function toJevQuestionFormat(question: unknown): unknown { + if (!isObjectLike(question)) { + return question; + } + + if (question.type === 'choice' && Array.isArray(question.choices)) { + const { choices, ...rest } = question; + const criteria = Object.fromEntries( + choices + .filter(isObjectLike) + .map(choice => [String(choice.value), typeof choice.description === 'string' ? choice.description : '']), + ); + return { ...rest, criteria }; + } + + if (question.type === 'score' && Array.isArray(question.levels)) { + const { levels, ...rest } = question; + const criteria = levels.map(level => (isObjectLike(level) ? level.label : level)); + // Sentry renders score criteria as a list of labels only, so level descriptions go in their own field, + // keyed by score value like an answer's `legend`. + const descriptions = Object.fromEntries( + levels.flatMap((level, index) => + isObjectLike(level) && typeof level.description === 'string' ? [[String(index), level.description]] : [], + ), + ); + return Object.keys(descriptions).length ? { ...rest, criteria, descriptions } : { ...rest, criteria }; + } + + return question; +} + +/** + * Lay out choice and score answers like Jev answers: probabilities keyed by option or score value, with + * score labels in a `legend`. Predicate answers are already in the shape Sentry renders. + */ +function toJevAnswerFormat(answer: unknown): unknown { + if ( + !isObjectLike(answer) || + (answer.type !== 'choice' && answer.type !== 'score') || + !Array.isArray(answer.probabilities) + ) { + return answer; + } + + const options = answer.probabilities.filter(isObjectLike); + const probabilities = Object.fromEntries(options.map(option => [String(option.value), option.probability])); + if (answer.type === 'choice') { + // Choice values can be booleans, but Sentry renders a choice as a string, like its `probabilities` keys. + return { ...answer, choice: String(answer.choice), probabilities }; + } + + const legend = Object.fromEntries( + options.filter(option => typeof option.label === 'string').map(option => [String(option.value), option.label]), + ); + return { ...answer, probabilities, legend }; +} + +/** + * The decisions API lists questions and answers as arrays with an optional `name`, where the conventions + * key them by question name. An unnamed item is keyed by its position. + */ +function keyByName(items: unknown): unknown { + if (!Array.isArray(items)) { + return items; + } + + return Object.fromEntries( + items.map((item, index) => { + if (!isObjectLike(item)) { + return [String(index), item]; + } + const { name, ...rest } = item; + return [typeof name === 'string' ? name : String(index), rest]; + }), + ); +} diff --git a/packages/server-utils/src/ai/openai/utils.ts b/packages/server-utils/src/ai/openai/utils.ts index 3f6afeac1c44..7c2190585e9a 100644 --- a/packages/server-utils/src/ai/openai/utils.ts +++ b/packages/server-utils/src/ai/openai/utils.ts @@ -3,6 +3,7 @@ import { isObjectLike } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { GEN_AI_CONVERSATION_ID, + GEN_AI_OUTPUT_MESSAGES, GEN_AI_REQUEST_FREQUENCY_PENALTY, GEN_AI_REQUEST_MODEL, GEN_AI_REQUEST_PRESENCE_PENALTY, @@ -17,6 +18,7 @@ import { GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { @@ -24,6 +26,7 @@ import { GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, } from '../core/gen-ai-attributes'; +import { getDecisionOutputMessages } from './mapping'; import type { ChatCompletionChunk, ResponseStreamingEvent } from './types'; /** @@ -53,9 +56,15 @@ export function isChatCompletionChunk(event: unknown): event is ChatCompletionCh /** * Add response attributes to a span using duck-typing. - * Works for Chat Completions, Responses API, Embeddings, and Conversations API responses. + * Works for Chat Completions, Responses API, Embeddings, and Conversations API responses. Decision + * answers are only recorded when `operationName` is `decide`. */ -export function addResponseAttributes(span: Span, result: unknown, recordOutputs?: boolean): void { +export function addResponseAttributes( + span: Span, + result: unknown, + recordOutputs?: boolean, + operationName?: string, +): void { if (!result || typeof result !== 'object') return; const response = result as Record; @@ -76,32 +85,8 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs attrs[GEN_AI_CONVERSATION_ID] = response.id; } - // Token usage — supports both naming conventions (chat: prompt_tokens/completion_tokens, responses: input_tokens/output_tokens) - if (response.usage && typeof response.usage === 'object') { - const usage = response.usage as Record; - - const inputTokens = usage.prompt_tokens ?? usage.input_tokens; - if (typeof inputTokens === 'number') { - attrs[GEN_AI_USAGE_INPUT_TOKENS] = inputTokens; - } - - const outputTokens = usage.completion_tokens ?? usage.output_tokens; - if (typeof outputTokens === 'number') { - attrs[GEN_AI_USAGE_OUTPUT_TOKENS] = outputTokens; - } - - if (typeof usage.total_tokens === 'number') { - attrs[GEN_AI_USAGE_TOTAL_TOKENS] = usage.total_tokens; - } - const inputDetails = usage.prompt_tokens_details ?? usage.input_tokens_details; - if (isObjectLike(inputDetails)) { - if (typeof inputDetails.cached_tokens === 'number') { - attrs[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS] = inputDetails.cached_tokens; - } - if (typeof inputDetails.cache_write_tokens === 'number') { - attrs[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS] = inputDetails.cache_write_tokens; - } - } + if (isObjectLike(response.usage)) { + addUsageAttributes(attrs, response.usage); } // Finish reasons from choices (chat completions) @@ -151,6 +136,14 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs attrs[GEN_AI_RESPONSE_TEXT] = response.output_text; } + // Typed answers (decisions API) + if (operationName === 'decide' && Array.isArray(response.answers)) { + const outputMessages = getDecisionOutputMessages(response.answers); + if (outputMessages) { + attrs[GEN_AI_OUTPUT_MESSAGES] = outputMessages; + } + } + // Tool calls from output array (responses API) if (Array.isArray(response.output) && response.output.length > 0 && !attrs[GEN_AI_RESPONSE_TOOL_CALLS]) { const functionCalls = (response.output as Array>).filter( @@ -165,6 +158,39 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs span.setAttributes(attrs); } +/** + * Token usage, in both naming conventions (chat: prompt_tokens/completion_tokens, + * responses and decisions: input_tokens/output_tokens). + */ +function addUsageAttributes(attrs: Record, usage: Record): void { + const inputTokens = usage.prompt_tokens ?? usage.input_tokens; + if (typeof inputTokens === 'number') { + attrs[GEN_AI_USAGE_INPUT_TOKENS] = inputTokens; + } + + const outputTokens = usage.completion_tokens ?? usage.output_tokens; + if (typeof outputTokens === 'number') { + attrs[GEN_AI_USAGE_OUTPUT_TOKENS] = outputTokens; + } + + if (typeof usage.total_tokens === 'number') { + attrs[GEN_AI_USAGE_TOTAL_TOKENS] = usage.total_tokens; + } + const inputDetails = usage.prompt_tokens_details ?? usage.input_tokens_details; + if (isObjectLike(inputDetails)) { + if (typeof inputDetails.cached_tokens === 'number') { + attrs[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS] = inputDetails.cached_tokens; + } + if (typeof inputDetails.cache_write_tokens === 'number') { + attrs[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS] = inputDetails.cache_write_tokens; + } + } + const outputDetails = usage.completion_tokens_details ?? usage.output_tokens_details; + if (isObjectLike(outputDetails) && typeof outputDetails.reasoning_tokens === 'number') { + attrs[GEN_AI_USAGE_REASONING_OUTPUT_TOKENS] = outputDetails.reasoning_tokens; + } +} + /** * Extract the Conversations API id (`conversation: "conv_..."`) from request parameters. * `previous_response_id` is deliberately not read: it names the previous response, so it changes on diff --git a/packages/server-utils/src/ai/pi-durable/tools.ts b/packages/server-utils/src/ai/pi-durable/tools.ts index 5d19f583dc14..8ccf21155549 100644 --- a/packages/server-utils/src/ai/pi-durable/tools.ts +++ b/packages/server-utils/src/ai/pi-durable/tools.ts @@ -138,7 +138,7 @@ export function instrumentTool(tool: PiTool, runs: PiRuns, options: GenAiOptions call.endTimestamp = timestampInSeconds(); if (!calls) { // No result entry follows a call outside a tool task phase, nor a nested call. - finishToolCall(call, { content: result?.content, isError: result?.isError }); + finishToolCall(call, { content: result?.output ?? result?.content, isError: result?.isError }); } return result; } catch (error) { diff --git a/packages/server-utils/src/ai/pi-durable/types.ts b/packages/server-utils/src/ai/pi-durable/types.ts index 6244b18af1e5..7c87317eb26a 100644 --- a/packages/server-utils/src/ai/pi-durable/types.ts +++ b/packages/server-utils/src/ai/pi-durable/types.ts @@ -50,6 +50,9 @@ export interface PiModels { } export interface PiToolExecutionResult { + /** The content blocks the tool returns for the model, in pi-durable 1.2.0 and later. */ + output?: unknown[]; + /** The content blocks the tool returns for the model, in pi-durable versions before 1.2.0. */ content?: unknown[]; isError?: boolean; } diff --git a/packages/server-utils/src/integrations/openai.ts b/packages/server-utils/src/integrations/openai.ts index ec7c852e9010..eaa02e867a5d 100644 --- a/packages/server-utils/src/integrations/openai.ts +++ b/packages/server-utils/src/integrations/openai.ts @@ -30,6 +30,7 @@ const ORIGIN = 'auto.ai.openai'; const INSTRUMENTED_CHANNELS = [ { channel: CHANNELS.OPENAI_CHAT, operation: 'chat' }, { channel: CHANNELS.OPENAI_EMBEDDINGS, operation: 'embeddings' }, + { channel: CHANNELS.OPENAI_DECIDE, operation: 'decide' }, ] as const; const _openAIIntegration = ((options: OpenAiOptions = {}) => { @@ -48,7 +49,7 @@ function instrumentOpenai(options: OpenAiOptions): void { data => createGenAiSpan(data, operation, options), { beforeSpanEnd: (span, data) => { - addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); + addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs, operation); }, deferSpanEnd: ({ span, data, end }) => wrapApiPromiseResponse( @@ -134,6 +135,6 @@ function wrapStreamResult(span: Span, data: OrchestrionChannelContext, options: /** * Diagnostics-channel-based OpenAI integration. Subscribes to the `orchestrion:openai:*` * diagnostics_channels injected into `openai`'s `create` methods (chat completions, responses, embeddings, - * conversations), so it requires the Sentry runtime hook or bundler plugin. + * conversations, decisions), so it requires the Sentry runtime hook or bundler plugin. */ export const openAIIntegration = defineIntegration(_openAIIntegration); diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 0b8f66d961f4..d199f7224385 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -9,6 +9,13 @@ import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './re type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown; +// Key of a property on the plugin object. Its value is an object that is unique to each `sentryOrchestrionPlugin()` +// call. An SDK that wraps the plugin with an object spread (`@sentry/remix`) copies the property, so `configResolved` +// can find the first instance in a build also through a wrapper. +const INSTANCE_KEY = '__sentryOrchestrionInstance'; + +type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: object }; + // On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of // client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without // this gate the transform would also run in the CLIENT build — where modules @@ -16,10 +23,10 @@ type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: // snippet's import of the subscriber factories (which import // `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate // on the `ssr` flag, which Vite passes on both major versions. -function ssrOnlyTransform(transform: Plugin['transform']): Plugin['transform'] { +function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boolean): Plugin['transform'] { const gate = (handler: TransformHandler): TransformHandler => function (code, id, opts) { - if (!opts?.ssr) { + if (!opts?.ssr || isDuplicate()) { return null; } return handler.call(this, code, id, opts); @@ -62,9 +69,14 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { '@sentry/server-utils', ]; - return { + const instance = {}; + // `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone. + let isDuplicate = false; + + const plugin: OrchestrionVitePlugin = { ...upstream, - transform: ssrOnlyTransform(upstream.transform), + [INSTANCE_KEY]: instance, + transform: ssrOnlyTransform(upstream.transform, () => isDuplicate), // The module-injected snippet imports `@sentry/server-utils` from INSIDE // transformed `node_modules` files. Under isolated installs (pnpm) that bare // specifier doesn't resolve from an instrumented package's location, so when @@ -119,8 +131,13 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return { resolve: { noExternal: noExternalModules() } }; }, configResolved(config: ResolvedConfig): void { + // Two Sentry Vite plugins can each add this plugin to one build, for example `sentryCloudflareVitePlugin` and + // `sentryReactRouter`. A module transformed twice declares the injected snippet twice and fails the build. + const first = (config.plugins as readonly OrchestrionVitePlugin[]).find(p => p[INSTANCE_KEY]); + isDuplicate = first !== undefined && first[INSTANCE_KEY] !== instance; + // Nothing is force-bundled in `serve`, so an externalized module is expected there. - if (config.command === 'serve') { + if (isDuplicate || config.command === 'serve') { return; } @@ -140,4 +157,6 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { } }, }; + + return plugin; } diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index e68d3a961262..8024cb4b0b67 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -27,6 +27,12 @@ export const openaiConfig = [ module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Sync' as const }, })), + // OpenAI decisions API (`openai` >= 7.30.0), which returns typed answers rather than generated text. + ...['resources/decisions.js', 'resources/decisions.mjs'].map(filePath => ({ + channelName: 'decide', + module: { name: 'openai', versionRange: '>=7.30.0 <8', filePath }, + functionQuery: { className: 'Decisions', methodName: 'create', kind: 'Sync' as const }, + })), ] satisfies InstrumentationConfig[]; export const openaiModuleNames = getModuleNames(openaiConfig); @@ -36,4 +42,5 @@ export const openaiChannels = { // identical span handling, so they share one channel. OPENAI_CHAT: 'orchestrion:openai:chat', OPENAI_EMBEDDINGS: 'orchestrion:openai:embeddings', + OPENAI_DECIDE: 'orchestrion:openai:decide', } as const; diff --git a/packages/server-utils/src/orchestrion/config/prisma.ts b/packages/server-utils/src/orchestrion/config/prisma.ts index 73d6b1a77607..281a97bfba43 100644 --- a/packages/server-utils/src/orchestrion/config/prisma.ts +++ b/packages/server-utils/src/orchestrion/config/prisma.ts @@ -2,12 +2,11 @@ import type { InstrumentationConfig } from '../apmTypes'; import { getModuleNames } from './module-names'; // Prisma 8 has no tracing surface, so its ORM terminals are wrapped directly. Apps install -// `@prisma/orm-postgres`, but `CollectionImpl` lives in the transitive `@prisma/orm-family-sql`. +// `@prisma/orm-postgres`, but `CollectionBase` lives in the transitive `@prisma/orm-family-sql`. const MODULE_NAME = '@prisma/orm-family-sql'; const ORM_CLIENT_FILE = 'dist/orm-client.mjs'; -// The range matcher admits every `8.0.0-rc.*`, not just rc.8 and later; the terminals were verified on rc.8. -const VERSION_RANGE = '>=8.0.0-rc.8 <9'; +const VERSION_RANGE = '>=8.0.0-rc.15 <9'; export const PRISMA_ASYNC_TERMINALS = [ 'aggregate', @@ -31,7 +30,7 @@ function terminalConfig(methodName: PrismaTerminal, kind: 'Async' | 'Sync'): Ins return { channelName: methodName, module: { name: MODULE_NAME, versionRange: VERSION_RANGE, filePath: ORM_CLIENT_FILE }, - functionQuery: { className: 'CollectionImpl', methodName, kind }, + functionQuery: { className: 'CollectionBase', methodName, kind }, }; } diff --git a/packages/server-utils/test/ai/lib/tracing/openai.test.ts b/packages/server-utils/test/ai/lib/tracing/openai.test.ts index 0c6738ce86ac..523d040e8a4c 100644 --- a/packages/server-utils/test/ai/lib/tracing/openai.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/openai.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GEN_AI_OUTPUT_MESSAGES } from '@sentry/conventions/attributes'; +import { GEN_AI_DECIDE } from '@sentry/conventions/op'; import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; import type { Span } from '@sentry/core'; import { instrumentOpenAiClient } from '../../../../src/ai/openai'; @@ -90,4 +92,68 @@ describe('instrumentOpenAiClient span names', () => { expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat'); }); + + it('records `decisions.create` as a decide span', async () => { + const endedSpans = setupClient('stream'); + const instrumented = instrumentOpenAiClient({ + decisions: { + create: vi.fn().mockResolvedValue({ + model: 'gpt-6-luna', + answers: [{ type: 'predicate', name: 'wants_refund', probability: 0.97 }], + }), + }, + }); + + await instrumented.decisions.create({ + model: 'gpt-6-luna', + input: 'I want a refund', + questions: [{ type: 'predicate', name: 'wants_refund', instructions: 'Is a refund requested?' }], + }); + + const json = spanToStaticSpanJSON(endedSpans[0]!); + expect(json.description).toBe('decide gpt-6-luna'); + expect(json.op).toBe(GEN_AI_DECIDE); + expect(JSON.parse(json.data[GEN_AI_OUTPUT_MESSAGES] as string)).toEqual([ + // Keyed by question name, as the conventions define it. + { type: 'evaluation', answers: { wants_refund: { type: 'predicate', probability: 0.97 } } }, + ]); + }); + + it('records a boolean decision choice as a string', async () => { + const endedSpans = setupClient('stream'); + const instrumented = instrumentOpenAiClient({ + decisions: { + create: vi.fn().mockResolvedValue({ + model: 'gpt-6-luna', + answers: [ + { + type: 'choice', + name: 'is_spam', + choice: true, + probabilities: [ + { value: true, probability: 0.91 }, + { value: false, probability: 0.09 }, + ], + confidence: 0.91, + }, + ], + }), + }, + }); + + await instrumented.decisions.create({ + model: 'gpt-6-luna', + input: 'Win a free cruise!', + questions: [{ type: 'choice', name: 'is_spam', choices: [{ value: true }, { value: false }] }], + }); + + expect(JSON.parse(spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_OUTPUT_MESSAGES] as string)).toEqual([ + { + type: 'evaluation', + answers: { + is_spam: { type: 'choice', choice: 'true', probabilities: { true: 0.91, false: 0.09 }, confidence: 0.91 }, + }, + }, + ]); + }); }); diff --git a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts index c2b8f6e9b68e..d23b3310785e 100644 --- a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts @@ -617,6 +617,17 @@ describe('instrumentPiDurableHarnessOptions', () => { }); }); + it('reads the result of a call outside a tool phase from `output`', async () => { + const tool: PiTool = { + name: 'get_weather', + execute: async () => ({ output: [{ type: 'text', text: 'sunny' }] }), + }; + + await instrumentTool(tool, createRuns(), {}).execute({}, { callId: 'call_1' }, undefined); + + expect(spanToJSON(endedSpans[0]!).attributes[GEN_AI_TOOL_CALL_RESULT]).toBe('sunny'); + }); + it.each([ ['no content blocks', []], ['an empty text block', [{ type: 'text', text: '' }]], diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index b04fa7d1f160..59c74f664ef4 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -227,6 +227,7 @@ describe('sentryOrchestrionPlugin (vite)', () => { const plugin = vitePlugin(); (plugin.configResolved as (config: unknown) => void)({ command, + plugins: [plugin], ssr: { external: ssrExternal }, logger: { warn }, } as unknown as ResolvedConfig); @@ -324,6 +325,59 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(transform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); }); + it('transforms and warns only in the first instance when two Sentry plugins add it to one build', () => { + const first = vitePlugin(); + const second = vitePlugin(); + const warn = vi.fn(); + const config = { + command: 'build', + plugins: [{ name: 'other' }, first, second], + ssr: { external: ['mysql'] }, + logger: { warn }, + } as unknown as ResolvedConfig; + + (first.configResolved as (config: unknown) => void)(config); + (second.configResolved as (config: unknown) => void)(config); + + const firstTransform = first.transform as ( + this: unknown, + code: string, + id: string, + opts?: { ssr?: boolean }, + ) => unknown; + const secondTransform = second.transform as ( + this: unknown, + code: string, + id: string, + opts?: { ssr?: boolean }, + ) => unknown; + expect(firstTransform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); + expect(secondTransform.call({}, 'code', 'id', { ssr: true })).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('still transforms when an SDK wraps the plugin with an object spread', () => { + // `@sentry/remix` puts `{ ...orchestrion, configResolved, transform }` into the build and calls the original hooks. + const plugin = vitePlugin(); + const wrapper = { ...plugin, configResolved: vi.fn(), transform: vi.fn() }; + const config = { + command: 'build', + plugins: [wrapper], + ssr: {}, + logger: { warn: vi.fn() }, + } as unknown as ResolvedConfig; + + (plugin.configResolved as (config: unknown) => void)(config); + + const transform = plugin.transform as ( + this: unknown, + code: string, + id: string, + opts?: { ssr?: boolean }, + ) => unknown; + expect(transform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); + }); + it('gates resolveId on the ssr flag and falls back to self-resolution', async () => { const plugin = vitePlugin(); const resolveId = plugin.resolveId as ( diff --git a/packages/solidstart/LICENSE b/packages/solidstart/LICENSE index 63e7eb28e19c..48d5f26681c8 100644 --- a/packages/solidstart/LICENSE +++ b/packages/solidstart/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/sveltekit/LICENSE b/packages/sveltekit/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/sveltekit/LICENSE +++ b/packages/sveltekit/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts index 3d48b7e80ea8..78081dfaacb4 100644 --- a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts +++ b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts @@ -84,6 +84,8 @@ function _getKitSpanOrigin(spanName: string | undefined): SpanOrigin | undefined return 'auto.function.sveltekit.load'; case 'sveltekit.form_action': return 'auto.function.sveltekit.action'; + case 'sveltekit.endpoint': + return 'auto.function.sveltekit.endpoint'; case 'sveltekit.remote.call': return 'auto.rpc.sveltekit.remote'; case 'sveltekit.handle.root': diff --git a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts index 2c53de829a3e..5d8d2d7c0d69 100644 --- a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts +++ b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts @@ -56,6 +56,7 @@ describe('svelteKitSpansIntegration', () => { ['sveltekit.resolve', 'function', 'auto.http.sveltekit'], ['sveltekit.load', 'function', 'auto.function.sveltekit.load'], ['sveltekit.form_action', 'function', 'auto.function.sveltekit.action'], + ['sveltekit.endpoint', 'function', 'auto.function.sveltekit.endpoint'], ['sveltekit.remote.call', 'function', 'auto.rpc.sveltekit.remote'], ['sveltekit.handle.sequenced.0', 'function', 'auto.function.sveltekit.handle'], ['sveltekit.handle.sequenced.myHandler', 'function', 'auto.function.sveltekit.handle'], @@ -194,6 +195,7 @@ describe('svelteKitSpansIntegration', () => { ['sveltekit.resolve', 'function', 'auto.http.sveltekit'], ['sveltekit.load', 'function', 'auto.function.sveltekit.load'], ['sveltekit.form_action', 'function', 'auto.function.sveltekit.action'], + ['sveltekit.endpoint', 'function', 'auto.function.sveltekit.endpoint'], ['sveltekit.remote.call', 'function', 'auto.rpc.sveltekit.remote'], ['sveltekit.handle.sequenced.0', 'function', 'auto.function.sveltekit.handle'], ['sveltekit.handle.sequenced.myHandler', 'function', 'auto.function.sveltekit.handle'], diff --git a/packages/tanstackstart-react/LICENSE b/packages/tanstackstart-react/LICENSE index 0da96cd2f885..a05f045c30fa 100644 --- a/packages/tanstackstart-react/LICENSE +++ b/packages/tanstackstart-react/LICENSE @@ -19,3 +19,12 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this package. This package has +the Sentry CLI (the `sentry` package on npm, https://github.com/getsentry/cli) +as a direct or transitive dependency. The Sentry CLI is not part of this package +and is not covered by the MIT License above. It is licensed under the +Functional Source License, Version 1.1, Apache 2.0 Future License +(FSL-1.1-Apache-2.0): https://github.com/getsentry/cli/blob/main/LICENSE.md diff --git a/packages/tanstackstart-react/src/vite/routePatterns.ts b/packages/tanstackstart-react/src/vite/routePatterns.ts index 5ffbdaa83c52..82a187d08e70 100644 --- a/packages/tanstackstart-react/src/vite/routePatterns.ts +++ b/packages/tanstackstart-react/src/vite/routePatterns.ts @@ -19,6 +19,16 @@ export function makeRoutePatternPlugin(): Plugin { name: 'sentry-tanstackstart-route-patterns', enforce: 'post', + // The transform below must see the SDK's own `wrapFetchWithSentry` file. Vite externalizes dependencies in + // server builds, and Nitro 3 copies external dependencies into its output instead of bundling them. + configEnvironment(name, config, env) { + if (env.command === 'serve' || (config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') { + return null; + } + + return { resolve: { noExternal: ['@sentry/tanstackstart-react'] } }; + }, + configResolved(config) { resolvedRoot = config.root || process.cwd(); }, diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 0dd7f9f12fda..c3359c077d1b 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -1,4 +1,4 @@ -import type { BuildTimeOptionsBase } from '@sentry/core'; +import type { BuildTimeOptionsBase, ReactComponentAnnotationOptions } from '@sentry/core'; import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; @@ -31,17 +31,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components */ - reactComponentAnnotation?: { - /** - * Whether the component name annotate plugin should be enabled or not. - */ - enabled?: boolean; - - /** - * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. - */ - ignoredComponents?: string[]; - }; + reactComponentAnnotation?: ReactComponentAnnotationOptions; /** * Configures a framework-managed same-origin tunnel route for Sentry envelopes. diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 817cad432a22..a0cf3f4e84c7 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,9 +1,35 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; +import * as path from 'node:path'; import type { Plugin, UserConfig } from 'vite'; import type { SentryTanstackStartOptions } from './sentryTanstackStart'; type FilesToDeleteAfterUpload = string | string[] | undefined; +/** TanStack Router and Start split route files into virtual modules, e.g. `index.tsx?tss-serverfn-split`. */ +const TANSTACK_SPLIT_QUERY_REGEX = /\?ts[rs]-[^/]*$/; + +/** Sources such as `virtual:#nitro/runtime` are no file paths. Two letters at least, so drive letters, like `C:`, are not matched by the regex. */ +const SCHEME_REGEX = /^[a-z][a-z\d+.-]+:/i; + +/** + * Makes source paths relative to the project root and removes TanStack's split queries, so that client and server + * frames show the path of the source file, such as `src/routes/index.tsx`. + * + * Paths in a source map are relative to the map's directory, which the default hook ignores. + * + * @internal Only exported for testing. + */ +export function rewriteTanstackStartSource(source: string, _map: unknown, context?: { mapDir: string }): string { + const sourcePath = source.replace(TANSTACK_SPLIT_QUERY_REGEX, ''); + + if (SCHEME_REGEX.test(sourcePath)) { + return sourcePath; + } + + const absolutePath = path.resolve(context?.mapDir ?? process.cwd(), sourcePath); + return path.relative(process.cwd(), absolutePath).split(path.sep).join('/'); +} + /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite to automatically upload source maps to Sentry. */ @@ -70,7 +96,7 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl assets: sourcemaps?.assets, disable: sourcemaps?.disable, ignore: sourcemaps?.ignore, - rewriteSources: sourcemaps?.rewriteSources, + rewriteSources: sourcemaps?.rewriteSources ?? rewriteTanstackStartSource, resolveSourceMap: sourcemaps?.resolveSourceMap, filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, }, diff --git a/packages/tanstackstart-react/test/vite/routePatterns.test.ts b/packages/tanstackstart-react/test/vite/routePatterns.test.ts index 413b29adafe1..abc5c92527d9 100644 --- a/packages/tanstackstart-react/test/vite/routePatterns.test.ts +++ b/packages/tanstackstart-react/test/vite/routePatterns.test.ts @@ -2,7 +2,30 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { extractRoutePatterns, findRouteTreeFile } from '../../src/vite/routePatterns'; +import { extractRoutePatterns, findRouteTreeFile, makeRoutePatternPlugin } from '../../src/vite/routePatterns'; + +describe('makeRoutePatternPlugin', () => { + const configEnvironment = makeRoutePatternPlugin().configEnvironment as ( + name: string, + config: { consumer?: 'client' | 'server' }, + env: { command: 'build' | 'serve'; mode: string }, + ) => { resolve: { noExternal: string[] } } | null; + + it('bundles the SDK into server environments of a build', () => { + const build = { command: 'build', mode: 'production' } as const; + + expect(configEnvironment('ssr', {}, build)).toEqual({ resolve: { noExternal: ['@sentry/tanstackstart-react'] } }); + expect(configEnvironment('nitro', { consumer: 'server' }, build)).toEqual({ + resolve: { noExternal: ['@sentry/tanstackstart-react'] }, + }); + }); + + it('does not change client environments or the dev server', () => { + expect(configEnvironment('client', {}, { command: 'build', mode: 'production' })).toBeNull(); + expect(configEnvironment('ssr', { consumer: 'client' }, { command: 'build', mode: 'production' })).toBeNull(); + expect(configEnvironment('ssr', {}, { command: 'serve', mode: 'development' })).toBeNull(); + }); +}); describe('extractRoutePatterns', () => { it('extracts full path patterns from single-line fullPaths union', () => { diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index e5ea14c95b3e..eb4b3740b658 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -2,11 +2,13 @@ import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; import type { UserConfig } from 'vite'; import { mergeConfig } from 'vite'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'path'; import { getUpdatedSourceMapSettings, makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin, + rewriteTanstackStartSource, } from '../../src/vite/sourceMaps'; const mockedSentryVitePlugin = { @@ -273,6 +275,16 @@ describe('makeAddSentryVitePlugin()', () => { ); }); + it('rewrites sources with the TanStack Start hook by default', () => { + makeAddSentryVitePlugin({}); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ rewriteSources: rewriteTanstackStartSource }), + }), + ); + }); + it('sets the correct metaFramework in telemetry options', () => { makeAddSentryVitePlugin({ org: 'my-org', @@ -353,3 +365,56 @@ describe('getUpdatedSourceMapSettings', () => { expect(result).toBe('hidden'); }); }); + +describe('rewriteTanstackStartSource', () => { + const projectRoot = path.resolve('/projects/shop'); + + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + { + label: 'a server function split from a route', + source: '../../../src/routes/index.tsx?tss-serverfn-split', + mapDir: '.output/server/_ssr', + expected: 'src/routes/index.tsx', + }, + { + label: 'a route component split for the client', + source: '../../../src/routes/users.$userId.tsx?tsr-split=component', + mapDir: '.output/public/assets', + expected: 'src/routes/users.$userId.tsx', + }, + { + label: 'a dependency', + source: '../../node_modules/.pnpm/h3@2.0.2/node_modules/h3/dist/index.mjs', + mapDir: '.output/server', + expected: 'node_modules/.pnpm/h3@2.0.2/node_modules/h3/dist/index.mjs', + }, + { + label: 'a source that climbs fewer directories than the map is deep', + source: '../shared/format-price.ts', + mapDir: '.output/server/chunks/build', + expected: '.output/server/chunks/shared/format-price.ts', + }, + ])('makes the path of $label relative to the project root', ({ source, mapDir, expected }) => { + expect(rewriteTanstackStartSource(source, {}, { mapDir: path.join(projectRoot, mapDir) })).toBe(expected); + }); + + it('keeps sources that are no file paths', () => { + const mapDir = path.join(projectRoot, '.output/server'); + + expect(rewriteTanstackStartSource('virtual:#nitro/virtual/plugins', {}, { mapDir })).toBe( + 'virtual:#nitro/virtual/plugins', + ); + }); + + it('resolves sources from the project root without a map directory', () => { + expect(rewriteTanstackStartSource('src/routes/index.tsx?tss-serverfn-split', {})).toBe('src/routes/index.tsx'); + }); +}); diff --git a/packages/vite-plugin/.gitignore b/packages/vite-plugin/.gitignore new file mode 100644 index 000000000000..22cfeae206b4 --- /dev/null +++ b/packages/vite-plugin/.gitignore @@ -0,0 +1,2 @@ +build +*.tgz diff --git a/packages/vite-plugin/LICENSE b/packages/vite-plugin/LICENSE new file mode 100644 index 000000000000..c23a90edc9be --- /dev/null +++ b/packages/vite-plugin/LICENSE @@ -0,0 +1,29 @@ +# MIT License + +Copyright (c) 2022-2026, Sentry +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md new file mode 100644 index 000000000000..95264132eb3b --- /dev/null +++ b/packages/vite-plugin/README.md @@ -0,0 +1,47 @@ +

+ + Sentry + +

+ +# Sentry Vite Plugin + +A Vite plugin that uploads source maps to Sentry and injects release and Debug ID information into your bundles. + +This package re-exports `@sentry/bundler-plugins/vite`. + +## Installation + +```bash +npm install @sentry/vite-plugin --save-dev +``` + +## Usage + +```js +// vite.config.js +import { defineConfig } from 'vite'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; + +export default defineConfig({ + build: { + sourcemap: true, + }, + plugins: [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}); +``` + +## Documentation + +- [Uploading source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/) + +## 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/vite-plugin/package.json b/packages/vite-plugin/package.json new file mode 100644 index 000000000000..c8fa6bf2f241 --- /dev/null +++ b/packages/vite-plugin/package.json @@ -0,0 +1,56 @@ +{ + "name": "@sentry/vite-plugin", + "version": "11.7.0", + "description": "Official Sentry Vite plugin", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/vite-plugin", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "Sentry", + "Vite", + "bundler", + "plugin" + ], + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-vite-plugin-*.tgz", + "lint": "oxlint . --type-aware", + "lint:fix": "oxlint . --fix --type-aware", + "test": "vitest run", + "test:unit": "vitest run" + }, + "dependencies": { + "@sentry/bundler-plugins": "11.7.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/vite-plugin/rollup.npm.config.mjs b/packages/vite-plugin/rollup.npm.config.mjs new file mode 100644 index 000000000000..601cc6837ce0 --- /dev/null +++ b/packages/vite-plugin/rollup.npm.config.mjs @@ -0,0 +1,7 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + }), +); diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts new file mode 100644 index 000000000000..cc8533273401 --- /dev/null +++ b/packages/vite-plugin/src/index.ts @@ -0,0 +1 @@ +export * from '@sentry/bundler-plugins/vite'; diff --git a/packages/vite-plugin/test/index.test.ts b/packages/vite-plugin/test/index.test.ts new file mode 100644 index 000000000000..5cb1c33948c4 --- /dev/null +++ b/packages/vite-plugin/test/index.test.ts @@ -0,0 +1,10 @@ +import * as bundlerPlugins from '@sentry/bundler-plugins/vite'; +import { describe, expect, it } from 'vitest'; +import * as plugin from '../src/index'; + +describe('@sentry/vite-plugin', () => { + it('re-exports @sentry/bundler-plugins/vite', () => { + expect(plugin.sentryVitePlugin).toBe(bundlerPlugins.sentryVitePlugin); + expect(Object.keys(plugin).sort()).toEqual(Object.keys(bundlerPlugins).sort()); + }); +}); diff --git a/packages/vite-plugin/test/tsconfig.json b/packages/vite-plugin/test/tsconfig.json new file mode 100644 index 000000000000..76d0c9cff6f2 --- /dev/null +++ b/packages/vite-plugin/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "include": ["../src/**/*", "./**/*"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/vite-plugin/tsconfig.json b/packages/vite-plugin/tsconfig.json new file mode 100644 index 000000000000..2044e12308ea --- /dev/null +++ b/packages/vite-plugin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./package.json"] +} diff --git a/packages/vite-plugin/tsconfig.types.json b/packages/vite-plugin/tsconfig.types.json new file mode 100644 index 000000000000..9321489cd9a9 --- /dev/null +++ b/packages/vite-plugin/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/vite-plugin/vite.config.ts b/packages/vite-plugin/vite.config.ts new file mode 100644 index 000000000000..f18ec92095bc --- /dev/null +++ b/packages/vite-plugin/vite.config.ts @@ -0,0 +1,8 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + }, +}; diff --git a/packages/webpack-plugin/.gitignore b/packages/webpack-plugin/.gitignore new file mode 100644 index 000000000000..22cfeae206b4 --- /dev/null +++ b/packages/webpack-plugin/.gitignore @@ -0,0 +1,2 @@ +build +*.tgz diff --git a/packages/webpack-plugin/LICENSE b/packages/webpack-plugin/LICENSE new file mode 100644 index 000000000000..c23a90edc9be --- /dev/null +++ b/packages/webpack-plugin/LICENSE @@ -0,0 +1,29 @@ +# MIT License + +Copyright (c) 2022-2026, Sentry +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md new file mode 100644 index 000000000000..035bc61567ae --- /dev/null +++ b/packages/webpack-plugin/README.md @@ -0,0 +1,44 @@ +

+ + Sentry + +

+ +# Sentry Webpack Plugin + +A Webpack plugin that uploads source maps to Sentry and injects release and Debug ID information into your bundles. + +This package re-exports `@sentry/bundler-plugins/webpack`. + +## Installation + +```bash +npm install @sentry/webpack-plugin --save-dev +``` + +## Usage + +```js +// webpack.config.js +const { sentryWebpackPlugin } = require('@sentry/webpack-plugin'); + +module.exports = { + devtool: 'source-map', + plugins: [ + sentryWebpackPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}; +``` + +## Documentation + +- [Uploading source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/) + +## 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/webpack-plugin/package.json b/packages/webpack-plugin/package.json new file mode 100644 index 000000000000..639451d01134 --- /dev/null +++ b/packages/webpack-plugin/package.json @@ -0,0 +1,64 @@ +{ + "name": "@sentry/webpack-plugin", + "version": "11.7.0", + "description": "Official Sentry Webpack plugin", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/webpack-plugin", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "Sentry", + "Webpack", + "bundler", + "plugin" + ], + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + }, + "./webpack5": { + "types": "./build/types/webpack5.d.ts", + "import": "./build/esm/webpack5.js", + "require": "./build/cjs/webpack5.js" + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-webpack-plugin-*.tgz", + "lint": "oxlint . --type-aware", + "lint:fix": "oxlint . --fix --type-aware", + "test": "vitest run", + "test:unit": "vitest run" + }, + "dependencies": { + "@sentry/bundler-plugins": "11.7.0" + }, + "peerDependencies": { + "webpack": ">=5.1.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/webpack-plugin/rollup.npm.config.mjs b/packages/webpack-plugin/rollup.npm.config.mjs new file mode 100644 index 000000000000..b4522dc9186d --- /dev/null +++ b/packages/webpack-plugin/rollup.npm.config.mjs @@ -0,0 +1,7 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/webpack5.ts'], + }), +); diff --git a/packages/webpack-plugin/src/index.ts b/packages/webpack-plugin/src/index.ts new file mode 100644 index 000000000000..054e9f40a1f3 --- /dev/null +++ b/packages/webpack-plugin/src/index.ts @@ -0,0 +1 @@ +export * from '@sentry/bundler-plugins/webpack'; diff --git a/packages/webpack-plugin/src/webpack5.ts b/packages/webpack-plugin/src/webpack5.ts new file mode 100644 index 000000000000..054e9f40a1f3 --- /dev/null +++ b/packages/webpack-plugin/src/webpack5.ts @@ -0,0 +1 @@ +export * from '@sentry/bundler-plugins/webpack'; diff --git a/packages/webpack-plugin/test/index.test.ts b/packages/webpack-plugin/test/index.test.ts new file mode 100644 index 000000000000..6a542d367dab --- /dev/null +++ b/packages/webpack-plugin/test/index.test.ts @@ -0,0 +1,15 @@ +import * as bundlerPlugins from '@sentry/bundler-plugins/webpack'; +import { describe, expect, it } from 'vitest'; +import * as plugin from '../src/index'; + +describe('@sentry/webpack-plugin', () => { + it('re-exports @sentry/bundler-plugins/webpack', () => { + expect(plugin.sentryWebpackPlugin).toBe(bundlerPlugins.sentryWebpackPlugin); + expect(Object.keys(plugin).sort()).toEqual(Object.keys(bundlerPlugins).sort()); + }); + + it('re-exports the plugin from ./webpack5', async () => { + const webpack5 = await import('../src/webpack5'); + expect(webpack5.sentryWebpackPlugin).toBe(bundlerPlugins.sentryWebpackPlugin); + }); +}); diff --git a/packages/webpack-plugin/test/tsconfig.json b/packages/webpack-plugin/test/tsconfig.json new file mode 100644 index 000000000000..76d0c9cff6f2 --- /dev/null +++ b/packages/webpack-plugin/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "include": ["../src/**/*", "./**/*"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/webpack-plugin/tsconfig.json b/packages/webpack-plugin/tsconfig.json new file mode 100644 index 000000000000..2044e12308ea --- /dev/null +++ b/packages/webpack-plugin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./package.json"] +} diff --git a/packages/webpack-plugin/tsconfig.types.json b/packages/webpack-plugin/tsconfig.types.json new file mode 100644 index 000000000000..9321489cd9a9 --- /dev/null +++ b/packages/webpack-plugin/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/webpack-plugin/vite.config.ts b/packages/webpack-plugin/vite.config.ts new file mode 100644 index 000000000000..f18ec92095bc --- /dev/null +++ b/packages/webpack-plugin/vite.config.ts @@ -0,0 +1,8 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + }, +}; diff --git a/yarn.lock b/yarn.lock index fa2ba2499ab2..a34482b44765 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5334,6 +5334,11 @@ resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.5.1.tgz#a0a3449993b10c7aeb91ecb0d5f1a23692297e51" integrity sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw== +"@ioredis/commands@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-2.0.0.tgz#7207c56e0be51fd9f2ec2505ebdb9372a3ceb76d" + integrity sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -17632,6 +17637,18 @@ invariant@^2.2.1, invariant@^2.2.2: redis-parser "3.0.0" standard-as-callback "2.1.0" +"ioredis-6@npm:ioredis@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-6.0.0.tgz#b757c590547c43a5e18ea9c312d05c5c46c7e6fa" + integrity sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w== + dependencies: + "@ioredis/commands" "2.0.0" + cluster-key-slot "1.1.1" + debug "4.4.3" + denque "2.1.0" + redis-errors "1.2.0" + standard-as-callback "2.1.0" + ioredis@5.10.1, ioredis@^5.10.1: version "5.10.1" resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.10.1.tgz#6082781d8aec8d51ee4936bf81d0610404db1e3d"