diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70256b2a3276..45e298794f10 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1040,10 +1040,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: - matrix.test-application == 'deno' || matrix.test-application == 'deno-static' || matrix.test-application == - 'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql' || - matrix.test-application == 'deno-pg' + if: matrix.test-application == 'deno' || matrix.test-application == 'hono-4' uses: denoland/setup-deno@v2.0.5 with: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} @@ -1150,6 +1147,9 @@ 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 }} + # 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 }} strategy: fail-fast: false matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-optional) }} diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml new file mode 100644 index 000000000000..02fb540f71a3 --- /dev/null +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -0,0 +1,36 @@ +name: 'Automation: Cleanup E2E workers' +on: + pull_request: + types: + - closed + +jobs: + cleanup: + # The optional E2E job deploys only for PRs from this repository, so forks never have a worker to delete. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 5 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + strategy: + matrix: + # Name prefix of every E2E app that deploys a real worker, see the app's global-setup.mjs + worker-prefix: + - e2e-send-to-sentry + steps: + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Delete worker + run: | + WORKER="${{ matrix.worker-prefix }}-pr-${{ github.event.pull_request.number }}" + + if ! output=$(npx --yes wrangler@4 delete --name "$WORKER" --force 2>&1); then + echo "$output" + # 10007 means the worker does not exist, i.e. the PR never ran the optional E2E job. + echo "$output" | grep -q 'code: 10007' || exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c016edff21f..d4975fdd4342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,70 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 11.0.0-rc.1 + +### Important Changes + +- **feat(browser)!: Report `browser.navigation.type` exactly as web-vitals does ([#24479](https://github.com/getsentry/sentry-javascript/pull/24479))** + +The `browser.navigation.type` attribute now reports the same values as the `web-vitals` library. + +- **feat(core): consolidate fetch integrations ([#24346](https://github.com/getsentry/sentry-javascript/pull/24346))** + +The global-`fetch` integrations in `@sentry/bun`, `@sentry/cloudflare`, `@sentry/deno` and `@sentry/vercel-edge` now share one implementation. All four gain a `tracePropagation` option (default `true`) to stop injecting `sentry-trace` and `baggage` without also turning off spans. In `@sentry/deno`, fetch breadcrumbs are now recorded by `fetchIntegration` instead of `breadcrumbsIntegration`, matching the other runtime SDKs. + +- **feat(node): Add Groq and Together AI integrations ([#24454](https://github.com/getsentry/sentry-javascript/pull/24454))** + +Adds instrumentation for the Groq and Together AI SDKs, enabled by default. + +- **ref(bundler-plugins)!: Require webpack 5.1 and remove the `webpack5` entry point ([#24455](https://github.com/getsentry/sentry-javascript/pull/24455))** + +The Sentry webpack plugin now requires webpack `>=5.1` and is only exported from the package root. + +### Other Changes + +- feat(cloudflare): Auto-register Flue instrumentation in bundled workers ([#24476](https://github.com/getsentry/sentry-javascript/pull/24476)) +- feat(deps): bump devalue from 5.9.0 to 5.9.2 ([#24486](https://github.com/getsentry/sentry-javascript/pull/24486)) +- feat(node): Add eveInstrumentation and eveIntegration for eve apps ([#24539](https://github.com/getsentry/sentry-javascript/pull/24539)) +- feat(remix): Upload source maps from the Vite plugin ([#24463](https://github.com/getsentry/sentry-javascript/pull/24463)) +- fix: Remove by accident exported convention ([#24542](https://github.com/getsentry/sentry-javascript/pull/24542)) +- fix(bun): Respect caller-supplied `runtime` option ([#24487](https://github.com/getsentry/sentry-javascript/pull/24487)) +- fix(cloudflare): Capture telemetry from untraced Durable Object RPC calls ([#24447](https://github.com/getsentry/sentry-javascript/pull/24447)) +- fix(cloudflare): Instrument namespaces returned by jurisdiction() ([#24450](https://github.com/getsentry/sentry-javascript/pull/24450)) +- fix(core): Apply the sensitive denylist to cookie headers and configured fetch headers ([#24090](https://github.com/getsentry/sentry-javascript/pull/24090)) +- fix(core): correct MCP error and request attribution ([#24493](https://github.com/getsentry/sentry-javascript/pull/24493)) +- fix(core): instrument MCP transports before start ([#23978](https://github.com/getsentry/sentry-javascript/pull/23978)) +- fix(core): Stop sending client reports for breadcrumbs ([#24537](https://github.com/getsentry/sentry-javascript/pull/24537)) +- fix(ember): Add route IDs to pageload and navigation spans ([#24372](https://github.com/getsentry/sentry-javascript/pull/24372)) +- fix(hono): Allow @cloudflare/workers-types v5 as peer dependency ([#24494](https://github.com/getsentry/sentry-javascript/pull/24494)) +- fix(nextjs): Align tunnel request matching in middleware with tunnel rewrite ([#24499](https://github.com/getsentry/sentry-javascript/pull/24499)) +- fix(nextjs): Prevent sourceMappingURL stripping from truncating minified chunks ([#24022](https://github.com/getsentry/sentry-javascript/pull/24022)) +- fix(nextjs): Resolve Next.js version relative to the SDK when cwd differs ([#24462](https://github.com/getsentry/sentry-javascript/pull/24462)) +- fix(nuxt): Stop importing h3 in the Nitro error hook ([#24283](https://github.com/getsentry/sentry-javascript/pull/24283)) +- fix(server-runtime-injection): Keep `require()` of JSON working on Deno ([#24412](https://github.com/getsentry/sentry-javascript/pull/24412)) +- perf(bundler-plugins): Use fast component annotation for all Rollup and Vite versions ([#24437](https://github.com/getsentry/sentry-javascript/pull/24437)) + +
+ Internal Changes + +- chore(bun): Fix stale comment in `getPerformanceIntegrations` ([#24533](https://github.com/getsentry/sentry-javascript/pull/24533)) +- chore(bundler-plugins): move traces sample rate from 1.0 to 0.3 ([#24488](https://github.com/getsentry/sentry-javascript/pull/24488)) +- chore(test): De-flake slow and racy tests ([#24452](https://github.com/getsentry/sentry-javascript/pull/24452)) +- docs: Update MIGRATION.md ([#24550](https://github.com/getsentry/sentry-javascript/pull/24550)) +- test(cloudflare): Add E2E test that deploys a real Worker and sends to Sentry ([#24280](https://github.com/getsentry/sentry-javascript/pull/24280)) +- test(e2e): Add a node-flue end-to-end application ([#24377](https://github.com/getsentry/sentry-javascript/pull/24377)) +- test(e2e): Add gen-ai-libraries app covering AI libraries via OpenRouter ([#24456](https://github.com/getsentry/sentry-javascript/pull/24456)) +- test(e2e): Avoid SAM runtime port collisions in aws-serverless tests ([#24492](https://github.com/getsentry/sentry-javascript/pull/24492)) +- test(e2e): consolidate the deno e2e test applications ([#24414](https://github.com/getsentry/sentry-javascript/pull/24414)) +- test(node): Run koa integration tests against koa 3 ([#24457](https://github.com/getsentry/sentry-javascript/pull/24457)) +- test(remix): Add source map upload e2e test app ([#24464](https://github.com/getsentry/sentry-javascript/pull/24464)) +- test(server-utils): Cover the Flue instrumentation ([#24266](https://github.com/getsentry/sentry-javascript/pull/24266)) +- test(test-utils): Add Sentry CLI trace helpers as `test-utils/cli` ([#24279](https://github.com/getsentry/sentry-javascript/pull/24279)) + +
+ +Work in this release was contributed by @chiliec, @ihsraham, @msnelling, and @oesnuj. Thank you for your contributions! + ## 11.0.0-rc.0 This is the first release candidate of version 11 of the Sentry JavaScript SDKs. diff --git a/MIGRATION.md b/MIGRATION.md index f2d127a7c465..aadb4dd7793a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -54,6 +54,7 @@ We raised the minimum supported versions of several frameworks and libraries: - **Astro:** dropped Astro 3 (minimum is now 4). - **React Router (framework mode):** minimum is now 7.15. - **Fastify:** dropped Fastify 3.0 through 3.20 (minimum is now 3.21). +- **webpack (bundler plugin):** dropped webpack 5.0.x (minimum is now 5.1). ### AWS Lambda Layer Changes @@ -871,7 +872,7 @@ Attribute availability remains runtime-dependent. For example, browser and Worke Span attributes now use the shared `@sentry/conventions` package under the hood. The deprecated `semanticAttributes` re-export was removed. Import span attribute constants from `@sentry/core` directly. -`SEMANTIC_ATTRIBUTE_SENTRY_SOURCE` (`sentry.source`) was removed. Use `SENTRY_SEGMENT_NAME_SOURCE` (`sentry.segment.name.source`) instead. +`SEMANTIC_ATTRIBUTE_SENTRY_SOURCE` (`sentry.source`) was removed. Use the `sentry.segment.name.source` attribute instead and make sure to only set it on segment/root spans. `sentry.segment.name.source` is only set on the root span. Setting it on a child span is a no-op: `setAttribute` ignores it, and a value passed in a child span's initial attributes is dropped when the span is linked to its parent. ### Span operation (`op`) changes @@ -1428,6 +1429,8 @@ Affected SDKs: `@sentry/remix`. The plugin now also applies the build-time instrumentation transform. If you added `sentryOrchestrionPlugin()` from `@sentry/server-utils/orchestrion/vite` to your Vite config manually, remove it. Opt out with `sentryRemixVitePlugin({ buildTimeInstrumentation: false })`. +It also injects debug IDs and uploads source maps once you pass `org`, `project` and `authToken` — opt out with `sentryRemixVitePlugin({ sourcemaps: { disable: true } })`. + ### React: Simpler React Router setup via `@sentry/react/react-router` Affected SDKs: `@sentry/react`. @@ -1816,6 +1819,18 @@ The deprecated `sourceMapsUploadOptions` and other deprecated Vite/build plugin Deploys that the bundler plugins create automatically on Vercel now use the value of `VERCEL_TARGET_ENV` (`production`, `preview`, or a custom environment name) as their environment instead of `vercel-production` / `vercel-preview`. This matches the new default runtime `environment` of `@sentry/nextjs`, and the `production` default of all other SDKs. If your events use a different environment, set `release.deploy.env` to the same value, or set `release.deploy` to `false` to opt out. +### Bundler plugins: `@sentry/bundler-plugins/webpack5` was removed + +The `@sentry/bundler-plugins/webpack5` entry point was removed. It exported the same `sentryWebpackPlugin` as `@sentry/bundler-plugins/webpack`, minus a fallback that only mattered on webpack 4 and 5.0.x. The webpack plugin now requires webpack 5.1 or newer (the first version that exposes `compiler.webpack`), so there is nothing left to distinguish the two entry points. + +```js +// before +import { sentryWebpackPlugin } from '@sentry/bundler-plugins/webpack5'; + +// after +import { sentryWebpackPlugin } from '@sentry/bundler-plugins/webpack'; +``` + ### Removed `unstable_` bundler plugin options The `unstable_sentry*PluginOptions` escape hatch was removed from every SDK. It existed because the Sentry diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 87372e0c0c97..97c40002e305 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -549,18 +549,61 @@ export async function getFirstSentryEnvelopeRequest( } export async function hidePage(page: Page): Promise { - // web-vitals defers processing an interaction's event entries into - // `requestIdleCallback(..., { timeout: 1000 })`, and Chromium only reaches idle here once that - // timeout elapses. Hiding the page first forces a report while the metric is still unset, so no - // vital is emitted at all. Idle callbacks run in scheduling order, so waiting for one queued now - // means web-vitals' earlier callback has already run. + // web-vitals processes an interaction's event entries in `requestIdleCallback(..., { timeout: + // 1000 })`, and Chromium only reaches idle here once that timeout elapses. Hiding before that + // callback runs loses the interaction: the forced report web-vitals does on `visibilitychange` + // runs ahead of it, while the callback itself reports unforced, so INP is never emitted at all. + // + // Idle callbacks run in scheduling order, but web-vitals only schedules its callback once the + // Event Timing entry reaches its observer, which is after the click. Queuing one right away + // therefore queues it first and hides the page too early. Waiting for the entry and queuing from + // a task after it keeps web-vitals ahead: its observer is registered first, so it is notified + // first, and the `setTimeout` lands after the microtask it defers that work into. await page.evaluate(() => { return new Promise(resolve => { - if (typeof requestIdleCallback !== 'function') { - resolve(); + const scheduleIdle = (): void => { + if (typeof requestIdleCallback !== 'function') { + resolve(); + return; + } + requestIdleCallback(() => resolve(), { timeout: 1000 }); + }; + + // Callers that never interacted have no Event Timing entry coming, so there is nothing to + // order against and nothing to wait for. + const interactionCount = (performance as Performance & { interactionCount?: number }).interactionCount ?? 0; + if (!interactionCount && !performance.getEntriesByType('first-input').length) { + scheduleIdle(); return; } - requestIdleCallback(() => resolve(), { timeout: 1000 }); + + let observer: PerformanceObserver | undefined; + let fallback: ReturnType; + + // An interaction the Event Timing buffer no longer reports would otherwise wait here forever, + // so cap the wait rather than require an entry. + const done = (): void => { + clearTimeout(fallback); + observer?.disconnect(); + setTimeout(scheduleIdle, 0); + }; + + fallback = setTimeout(done, 1000); + + try { + // `durationThreshold` is missing from the DOM types, as it is in the SDK's own observer. + const eventOptions: PerformanceObserverInit & { durationThreshold?: number } = { + type: 'event', + buffered: true, + durationThreshold: 0, + }; + + observer = new PerformanceObserver(done); + observer.observe(eventOptions); + observer.observe({ type: 'first-input', buffered: true }); + } catch { + done(); + } }); }); @@ -572,7 +615,6 @@ export async function hidePage(page: Page): Promise { }, }); - // Dispatch the visibilitychange event to notify listeners document.dispatchEvent(new Event('visibilitychange')); }); } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts index 6395a8728321..972a967bbe09 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts @@ -6,7 +6,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { expect(readOutputFiles()).toMatchInlineSnapshot(` { "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"esbuild","bundler-major-version":"28"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"esbuild","bundler-major-version":"28"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", "telemetry.js": "(() => { diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts index 88a82dc3e008..f8004bc8ee3c 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts @@ -18,7 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { //#endregion ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts index 970ebdaefd1d..648a01c4719b 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts @@ -9,7 +9,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};e.SENTRY_RELEASE={id:"CURRENT_SHA"};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00000000-0000-0000-0000-000000000000",e._sentryDebugIdIdentifier="sentry-dbid-00000000-0000-0000-0000-000000000000");}catch(e){}}();console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"3"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"3"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts index cfb27aee5b5e..94c6834500fd 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts @@ -9,7 +9,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};e.SENTRY_RELEASE={id:"CURRENT_SHA"};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00000000-0000-0000-0000-000000000000",e._sentryDebugIdIdentifier="sentry-dbid-00000000-0000-0000-0000-000000000000");}catch(e){}}();console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts index 6f8d4de0de7a..1f3f77b816df 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts @@ -17,7 +17,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts index fb6a03910565..ac0e8b7b217f 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts @@ -17,7 +17,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"7"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"7"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts index d5e24152625f..608d363246ba 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts @@ -18,7 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { //#endregion ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"8"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"8"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts index b26f095092af..0fa7cf672d64 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts @@ -14,7 +14,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { /******/ })() ;", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"webpack","bundler-major-version":"5"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"webpack","bundler-major-version":"5"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/index.ts new file mode 100644 index 000000000000..3f3fa91bac71 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/index.ts @@ -0,0 +1,64 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; +} + +class TestDurableObjectBase extends DurableObject { + private overlappingCalls = 0; + private releaseOverlappingCalls: () => void = () => {}; + private readonly allOverlappingCallsArrived = new Promise(resolve => { + this.releaseOverlappingCalls = resolve; + }); + + async failingRpcMethod(): Promise { + throw new Error('Test error from Durable Object RPC method'); + } + + // Each call waits until the other one has arrived, so both are in flight at the same time. + async overlappingFailingRpcMethod(label: string): Promise { + this.overlappingCalls++; + if (this.overlappingCalls === 2) { + this.releaseOverlappingCalls(); + } + + await this.allOverlappingCallsArrived; + throw new Error(`Overlapping RPC call ${label}`); + } +} + +export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + TestDurableObjectBase, +); + +// The caller is not instrumented, so its RPC calls carry no trace metadata. +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/overlapping') { + const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName('overlapping')); + const results = await Promise.allSettled([ + stub.overlappingFailingRpcMethod('a'), + stub.overlappingFailingRpcMethod('b'), + ]); + + return new Response(results.map(result => result.status).join(',')); + } + + const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName('test')); + + try { + await stub.failingRpcMethod(); + return new Response('no error'); + } catch (error) { + return new Response(String((error as Error).message)); + } + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/test.ts new file mode 100644 index 000000000000..fee13cdd859f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/test.ts @@ -0,0 +1,49 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('captures errors thrown by a Durable Object RPC method called without trace metadata', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.type).toBe('Error'); + expect(event.exception?.values?.[0]?.value).toBe('Test error from Durable Object RPC method'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }); + }) + .start(signal); + + const response = await runner.makeRequest('get', '/'); + expect(response).toBe('Test error from Durable Object RPC method'); + + await runner.completed(); +}); + +it('gives overlapping Durable Object RPC calls without trace metadata separate traces', async ({ signal }) => { + const traceIds: Record = {}; + + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('Overlapping RPC call a'); + expect(event.contexts?.trace?.trace_id).toMatch(/^[\da-f]{32}$/); + traceIds.a = event.contexts?.trace?.trace_id; + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('Overlapping RPC call b'); + expect(event.contexts?.trace?.trace_id).toMatch(/^[\da-f]{32}$/); + traceIds.b = event.contexts?.trace?.trace_id; + }) + .start(signal); + + const response = await runner.makeRequest('get', '/overlapping'); + expect(response).toBe('rejected,rejected'); + + await runner.completed(); + + expect(traceIds.a).not.toBe(traceIds.b); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/wrangler.jsonc new file mode 100644 index 000000000000..d3bcb34400e6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "durableobject-rpc-without-trace-worker", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "migrations": [ + { + "new_sqlite_classes": ["TestDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "TestDurableObject", + "name": "TEST_DURABLE_OBJECT", + }, + ], + }, + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/index.ts new file mode 100644 index 000000000000..62b9f380305d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/index.ts @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +// Nothing is wrapped manually, the Vite plugin wraps both exports and enables RPC trace +// propagation for `COUNTER`. +export class Counter extends DurableObject { + private calls = 0; + private releaseCalls: () => void = () => {}; + private readonly allCallsArrived = new Promise(resolve => { + this.releaseCalls = resolve; + }); + + // Each call waits until the other one has arrived, so both are in flight at the same time. + async work(label: string): Promise { + this.calls++; + if (this.calls === 2) { + this.releaseCalls(); + } + + await this.allCallsArrived; + Sentry.getActiveSpan()?.setAttribute('test.label', label); + return label; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/overlapping') { + const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e')); + const labels = await Promise.all([stub.work('a'), stub.work('b')]); + return new Response(labels.join(',')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts new file mode 100644 index 000000000000..4355b90010d6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts @@ -0,0 +1,7 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts new file mode 100644 index 000000000000..074a6e258414 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts @@ -0,0 +1,49 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('propagates the worker trace into each of two overlapping Durable Object RPC calls', async ({ signal }) => { + const doTraces: Record = {}; + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.transaction).toBe('work'); + expect(transactionEvent.contexts?.trace?.op).toBe('rpc'); + expect(transactionEvent.contexts?.trace?.data?.['test.label']).toBe('a'); + doTraces.a = transactionEvent.contexts; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.transaction).toBe('work'); + expect(transactionEvent.contexts?.trace?.op).toBe('rpc'); + expect(transactionEvent.contexts?.trace?.data?.['test.label']).toBe('b'); + doTraces.b = transactionEvent.contexts; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.transaction).toBe('GET /overlapping'); + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + workerTraceId = transactionEvent.contexts?.trace?.trace_id; + workerSpanId = transactionEvent.contexts?.trace?.span_id; + }) + .start(signal); + + const response = await runner.makeRequest('get', '/overlapping'); + expect(response).toBe('a,b'); + + await runner.completed(); + + expect(workerTraceId).toMatch(/^[\da-f]{32}$/); + expect(workerSpanId).toMatch(/^[\da-f]{16}$/); + + expect(doTraces.a?.trace?.trace_id).toBe(workerTraceId); + expect(doTraces.a?.trace?.parent_span_id).toBe(workerSpanId); + expect(doTraces.b?.trace?.trace_id).toBe(workerTraceId); + expect(doTraces.b?.trace?.parent_span_id).toBe(workerSpanId); + + expect(doTraces.a?.trace?.span_id).not.toBe(doTraces.b?.trace?.span_id); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/vite.config.mts @@ -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/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/wrangler.jsonc new file mode 100644 index 000000000000..764bd9798731 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "../../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-worker-do-rpc-overlapping", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], +} diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.cjs b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.cjs new file mode 100644 index 000000000000..5a2853d18c4c --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.cjs @@ -0,0 +1,3 @@ +// Deno reports no module format for a `.json` file, so with any load hook +// installed its CJS loader used to compile this JSON as JavaScript. +module.exports = require('./fixture.json'); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.json b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.json new file mode 100644 index 000000000000..6e887fd6abe8 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.json @@ -0,0 +1 @@ +{ "answer": 42 } diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-json-require/scenario.mjs b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/scenario.mjs new file mode 100644 index 000000000000..4fa36618430e --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/scenario.mjs @@ -0,0 +1,14 @@ +// Spawned by test.ts via `deno run`. +// +// Importing `@sentry/deno/import` registers the orchestrion module hook. No +// module here is instrumented — the point is that installing the hook at all +// must not change how `require()` loads JSON. +import '@sentry/deno/import'; + +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { answer } = require('./fixture.cjs'); + +// eslint-disable-next-line no-console +console.log(`SCENARIO answer=${answer}`); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-json-require/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/test.ts new file mode 100644 index 000000000000..80b35fa8c903 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/test.ts @@ -0,0 +1,30 @@ +// + +import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; + +Deno.test('the runtime hook leaves `require()` of JSON working', async () => { + const scenario = new URL('./scenario.mjs', import.meta.url); + + // The package root — where `node_modules` (and thus `@sentry/deno`) resolves + // for the spawned `deno run`. + const cwd = new URL('../../', import.meta.url); + + const command = new Deno.Command('deno', { + args: ['run', '--allow-all', scenario.pathname], + cwd: cwd.pathname, + stdout: 'piped', + stderr: 'piped', + }); + + const { code, stdout, stderr } = await command.output(); + const out = new TextDecoder().decode(stdout); + const err = new TextDecoder().decode(stderr); + + // Without the format fixup in `@sentry/server-runtime-injection` this exits 1 + // with `SyntaxError: Unexpected token ':'` from the JSON compiled as JS. + assertEquals(code, 0, `scenario exited ${code}\nstdout:\n${out}\nstderr:\n${err}`); + + const line = out.split('\n').find(l => l.startsWith('SCENARIO')) ?? ''; + assert(line.includes('answer=42'), `expected the parsed JSON value, got: ${line}\nstderr:\n${err}`); +}); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts index 077e1bd9f5bb..c263bdace328 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts @@ -19,9 +19,8 @@ Deno.test('mysql instrumentation: included in default integrations (Deno 2.8.0+) assert(names.includes('Mysql'), `Mysql should be in defaults, got ${names.join(', ')}`); }); -// The orchestrion runtime hook (`@sentry/deno/import`) only works as a FIRST -// import inside the entry graph in Deno 2.8.0 through 2.8.2. -// TODO: revisit a `--import` or `--preload` approach once Deno 2.8.3 ships. +// Covers the import form of the hook. The `--preload` form the README +// documents is covered by the `deno` E2E app. Deno.test('@sentry/deno/import: transforms mysql so it publishes the orchestrion channel', async () => { const scenario = new URL('./scenario.mjs', import.meta.url); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts index c47715c6a9fc..81012e6fb979 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts @@ -19,9 +19,8 @@ Deno.test('pg instrumentation: included in default integrations (Deno 2.8.0+)', assert(names.includes('Postgres'), `Postgres should be in defaults, got ${names.join(', ')}`); }); -// The orchestrion runtime hook (`@sentry/deno/import`) only works as a FIRST -// import inside the entry graph in Deno 2.8.0 through 2.8.2. -// TODO: revisit a `--import` or `--preload` approach once Deno 2.8.3 ships. +// Covers the import form of the hook. The `--preload` form the README +// documents is covered by the `deno` E2E app. Deno.test('@sentry/deno/import: transforms pg so it publishes the orchestrion channel', async () => { const scenario = new URL('./scenario.mjs', import.meta.url); diff --git a/dev-packages/e2e-tests/.env.example b/dev-packages/e2e-tests/.env.example index 1a709a9b02b9..180fb07078a1 100644 --- a/dev-packages/e2e-tests/.env.example +++ b/dev-packages/e2e-tests/.env.example @@ -15,3 +15,9 @@ E2E_TEST_SENTRY_PROJECT= # An OpenRouter API key to make real model calls. # Only needed to run that test app locally. E2E_OPENROUTER_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. +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ACCOUNT_ID= diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 4145890ed605..4a398a2b9002 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -18,6 +18,10 @@ current state. - Copy `.env.example` to `.env` - OPTIONAL: Fill in auth information in `.env` for an example Sentry project - you only need this to run E2E tests that send data to Sentry. +- OPTIONAL: Fill in the Cloudflare credentials in `.env` - you only need this to run E2E tests that deploy a real + Cloudflare Worker (e.g. `cloudflare-workers-send-to-sentry`). A local run deploys a throwaway worker and deletes it + again afterwards; set `E2E_KEEP_WORKER=1` to keep it for debugging. CI keeps one worker per branch or PR instead, and + PR workers are deleted by the `cleanup-e2e-workers` workflow when the PR closes. - Run `yarn build:tarball` in the root of the repository (needs to be rerun after every update in /packages for the changes to have effect on the tests). 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 ed77302ec088..df5a076ef56d 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 @@ -10,7 +10,8 @@ import { globSync } from 'glob'; const LAMBDA_FUNCTIONS_DIR = './src/lambda-functions-layer'; const LAMBDA_FUNCTION_TIMEOUT = 10; const LAYER_DIR = './node_modules/@sentry/aws-serverless/'; -export const SAM_PORT = Number(process.env.SAM_PORT) || 7120; +// SAM allocates runtime container ports in [5000, 9000) before binding its own endpoint. +export const SAM_PORT = Number(process.env.SAM_PORT) || 17120; /** Match SAM / Docker to this machine so Apple Silicon does not mix arm64 images with an x86_64 template default. */ function samLambdaArchitecture(): 'arm64' | 'x86_64' { @@ -102,13 +103,13 @@ export class LocalLambdaStack extends Stack { try { const response = await fetch(`http://127.0.0.1:${port}/`); - if (response.ok || response.status === 404) { + if (response.status === 404 && response.headers.get('x-amzn-errortype') === 'PathNotFoundLocally') { console.log(`[LocalLambdaStack] SAM stack is ready`); return; } - } catch { - await new Promise(resolve => setTimeout(resolve, 1000)); - } + } catch {} + + await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error(`[LocalLambdaStack] Failed to start SAM stack after ${timeout}ms`); 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 a2a201bd9486..f314643572c2 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 @@ -9,7 +9,8 @@ import { execFileSync } from 'node:child_process'; const LAMBDA_FUNCTIONS_DIR = './src/lambda-functions-npm'; const LAMBDA_FUNCTION_TIMEOUT = 10; -export const SAM_PORT = Number(process.env.SAM_PORT) || 7120; +// SAM allocates runtime container ports in [5000, 9000) before binding its own endpoint. +export const SAM_PORT = Number(process.env.SAM_PORT) || 17120; /** Match SAM / Docker to this machine so Apple Silicon does not mix arm64 images with an x86_64 template default. */ function samLambdaArchitecture(): 'arm64' | 'x86_64' { @@ -143,13 +144,13 @@ export class LocalLambdaStack extends Stack { try { const response = await fetch(`http://127.0.0.1:${port}/`); - if (response.ok || response.status === 404) { + if (response.status === 404 && response.headers.get('x-amzn-errortype') === 'PathNotFoundLocally') { console.log(`[LocalLambdaStack] SAM stack is ready`); return; } - } catch { - await new Promise(resolve => setTimeout(resolve, 1000)); - } + } catch {} + + await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error(`[LocalLambdaStack] Failed to start SAM stack after ${timeout}ms`); diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts index 4d72591e9249..31e6c83eabbe 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -372,7 +372,7 @@ test.describe('the navigation span for a restore', () => { attributes: { 'sentry.op': { type: 'string', value: 'navigation' }, 'sentry.origin': { type: 'string', value: 'auto.navigation.browser.bfcache' }, - 'browser.navigation.type': { type: 'string', value: 'bfcache' }, + 'browser.navigation.type': { type: 'string', value: 'back-forward-cache' }, }, }); }); @@ -467,7 +467,7 @@ test.describe('the navigation span for a restore', () => { const inpSpan = spans.find(span => getSpanOp(span) === 'ui.interaction.click')!; expect(restoreSpan.attributes).toMatchObject({ - 'browser.navigation.type': { type: 'string', value: 'bfcache' }, + 'browser.navigation.type': { type: 'string', value: 'back-forward-cache' }, }); // All three hang off the restore itself. They also carry the `bfcache` navigation type, so the @@ -475,7 +475,7 @@ test.describe('the navigation span for a restore', () => { for (const vital of [lcpSpan, clsSpan, inpSpan]) { expect(vital.parent_span_id).toBe(restoreSpan.span_id); expect(vital.attributes).toMatchObject({ - 'browser.navigation.type': { type: 'string', value: 'bfcache' }, + 'browser.navigation.type': { type: 'string', value: 'back-forward-cache' }, }); } }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore new file mode 100644 index 000000000000..b6c344e957e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +dist +.wrangler +node_modules +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs new file mode 100644 index 000000000000..da689df28552 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs @@ -0,0 +1,74 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function wrangler(args, env = {}) { + execFileSync('pnpm', ['exec', 'wrangler', ...args], { + cwd: __dirname, + env: { ...process.env, ...env }, + stdio: ['ignore', 'inherit', 'inherit'], + }); +} + +/** Deploys the worker under `name` and returns its workers.dev URL. */ +export function deployWorker(name, dsn) { + const outputDir = mkdtempSync(join(tmpdir(), 'wrangler-output-')); + const outputFile = join(outputDir, 'output.ndjson'); + + try { + wrangler(['deploy', '--name', name, '--var', `E2E_TEST_DSN:${dsn}`], { WRANGLER_OUTPUT_FILE_PATH: outputFile }); + + const url = readFileSync(outputFile, 'utf8') + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)) + .find(entry => entry.type === 'deploy') + ?.targets?.find(target => target.endsWith('.workers.dev')); + + if (!url) { + throw new Error(`Could not find the workers.dev URL in the wrangler deploy output for ${name}.`); + } + + return url; + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } +} + +export function deleteWorker(name) { + wrangler(['delete', '--name', name, '--force']); +} + +/** + * CI keeps its Workers: one per ref, overwritten by the next run of the same ref and deleted by the + * cleanup workflow once a PR closes. Local runs delete theirs unless `E2E_KEEP_WORKER` is set. + */ +export function keepsWorker() { + return Boolean(process.env.GITHUB_ACTIONS || process.env.E2E_KEEP_WORKER); +} + +/** A freshly created workers.dev route can take a moment to become reachable. */ +export async function waitForWorker(url) { + const deadline = Date.now() + 60_000; + + while (Date.now() < deadline) { + try { + // The SDK does not trace HEAD requests, so the probe leaves no spans behind in Sentry. + const response = await fetch(url, { method: 'HEAD' }); + + if (response.ok) { + return; + } + } catch { + // DNS for the new subdomain may not have propagated yet. + } + + await new Promise(resolve => setTimeout(resolve, 2_000)); + } + + throw new Error(`Worker at ${url} did not become reachable within 60s.`); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs new file mode 100644 index 000000000000..08e9311cb7ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs @@ -0,0 +1,61 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker.mjs'; + +const WORKER_PREFIX = 'e2e-send-to-sentry'; + +/** + * In CI the name follows the ref, so `develop`, `master` and every PR get a stable Worker that the + * next run of the same ref overwrites. Pull request refs look like `123/merge` and merge queue refs + * like `gh-readonly-queue//pr-123-`; both map to the PR's Worker. + */ +export function getWorkerName() { + if (!process.env.GITHUB_ACTIONS) { + return `${WORKER_PREFIX}-local-${randomBytes(3).toString('hex')}`; + } + + const { GITHUB_EVENT_NAME, GITHUB_REF_NAME = '' } = process.env; + const prNumber = + GITHUB_EVENT_NAME === 'pull_request' ? GITHUB_REF_NAME.split('/')[0] : /\/pr-(\d+)-/.exec(GITHUB_REF_NAME)?.[1]; + const ref = prNumber ? `pr-${prNumber}` : GITHUB_REF_NAME; + // Worker names allow lowercase alphanumerics and dashes only, up to 63 characters. + const slug = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + + return `${WORKER_PREFIX}-${slug}`.slice(0, 63).replace(/-+$/, ''); +} + +export default async function globalSetup() { + if (!existsSync(new URL('.wrangler/deploy/config.json', import.meta.url))) { + throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.'); + } + const { CLOUDFLARE_ACCOUNT_ID, E2E_TEST_DSN } = process.env; + if (!E2E_TEST_DSN) { + throw new Error('E2E_TEST_DSN must be set to deploy the test worker.'); + } + + // Wrangler authenticates with `CLOUDFLARE_API_TOKEN` (CI) or a `wrangler login` session (local), + // but it cannot pick an account on its own outside of a terminal. + if (!CLOUDFLARE_ACCOUNT_ID) { + throw new Error('CLOUDFLARE_ACCOUNT_ID must be set to deploy the test worker.'); + } + + const workerName = getWorkerName(); + const workerUrl = deployWorker(workerName, E2E_TEST_DSN); + process.env.E2E_TEST_WORKER_NAME = workerName; + + try { + await waitForWorker(workerUrl); + } catch (error) { + if (!keepsWorker()) { + try { + deleteWorker(workerName); + } catch (deleteError) { + // The unreachable worker is the failure to report, not the cleanup. + console.error(`Failed to delete worker ${workerName}:`, deleteError); + } + } + throw error; + } + + process.env.E2E_TEST_WORKER_URL = workerUrl; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs new file mode 100644 index 000000000000..0fee903dc5c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs @@ -0,0 +1,24 @@ +import { deleteWorker, keepsWorker } from './deployed-worker.mjs'; + +export default function globalTeardown() { + const workerName = process.env.E2E_TEST_WORKER_NAME; + + if (!workerName) { + return; + } + + if (keepsWorker()) { + console.log(`Keeping worker ${workerName} at ${process.env.E2E_TEST_WORKER_URL}`); + return; + } + + try { + deleteWorker(workerName); + } catch (error) { + // A leaked worker is not an SDK failure, so it must not fail a run whose tests passed. + console.error( + `Failed to delete worker ${workerName}, delete it with \`wrangler delete --name ${workerName}\`:`, + error, + ); + } +} 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 new file mode 100644 index 000000000000..75bbb77d0b7c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json @@ -0,0 +1,35 @@ +{ + "name": "cloudflare-workers-send-to-sentry", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist .wrangler", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.47.0", + "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^26.1.2", + "sentry": "~0.44.1", + "typescript": "~6.0.3", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..0b79fb88052f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + // The worker is deployed once for the whole run and deleted again afterwards. + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', + /* Spans take ~2min to become queryable via the trace endpoint. */ + timeout: 210_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', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts new file mode 100644 index 000000000000..eb80bafb4834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts @@ -0,0 +1,3 @@ +interface Env { + E2E_TEST_DSN: string; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts new file mode 100644 index 000000000000..c48a38137713 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/cloudflare'; + +export default { + async fetch(request) { + const url = new URL(request.url); + // The handler runs inside the request span the Vite plugin's `withSentry` wrapper starts, so + // this is the `http.server` span. + const spanContext = Sentry.getActiveSpan()?.spanContext(); + + switch (url.pathname) { + case '/test-error': { + const eventId = Sentry.captureException(new Error('E2E test error')); + return Response.json({ eventId, traceId: spanContext?.traceId }); + } + case '/test-unhandled-error': + throw new Error('E2E test unhandled error'); + case '/test-span': + return Response.json({ spanId: spanContext?.spanId, traceId: spanContext?.traceId }); + default: + return new Response('Hello World!'); + } + }, +} satisfies ExportedHandler; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts new file mode 100644 index 000000000000..cb0d3bfb44aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts @@ -0,0 +1,9 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +// The Sentry Vite plugin picks this file up by convention, next to the worker entry named in +// wrangler's `main`, and hands its default export to `withSentry`. +export default defineCloudflareOptions((env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..49813f134b10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,45 @@ +import { randomBytes } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { EVENT_POLLING_OPTIONS, findErrorInTrace, findSpanInTrace, traceTarget } from '@sentry-internal/test-utils/cli'; + +// Set by global-setup.mjs once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL; + +test('Sends a captured exception to Sentry', async () => { + const response = await fetch(`${workerUrl}/test-error`); + expect(response.status).toBe(200); + const { eventId, traceId } = await response.json(); + + console.log(`Polling for error eventId ${eventId}: sentry trace view ${traceTarget(traceId)}`); + + await expect.poll(() => findErrorInTrace(traceId, eventId), EVENT_POLLING_OPTIONS).toBeDefined(); +}); + +test('Sends an unhandled exception and its request span to Sentry', async () => { + const traceId = randomBytes(16).toString('hex'); + const publicKey = new URL(process.env.E2E_TEST_DSN!).username; + const response = await fetch(`${workerUrl}/test-unhandled-error`, { + headers: { + 'sentry-trace': `${traceId}-${randomBytes(8).toString('hex')}-1`, + baggage: `sentry-trace_id=${traceId},sentry-public_key=${publicKey},sentry-sampled=true,sentry-sample_rate=1`, + }, + }); + expect(response.status).toBe(500); + + console.log(`Polling for unhandled error: sentry trace view ${traceTarget(traceId)}`); + + await expect.poll(() => findErrorInTrace(traceId), EVENT_POLLING_OPTIONS).toBeDefined(); + await expect.poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS).toBeDefined(); +}); + +test('Sends a request span to Sentry', async () => { + const response = await fetch(`${workerUrl}/test-span`); + expect(response.status).toBe(200); + const { spanId, traceId } = await response.json(); + + console.log(`Polling for request spanId ${spanId}: sentry trace view ${traceTarget(traceId)}`); + + await expect + .poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS) + .toMatchObject({ event_id: spanId }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..94b03468f288 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts new file mode 100644 index 000000000000..75a667ec89d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts @@ -0,0 +1,9 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +// The Sentry plugin wraps the default export of `src/index.ts` with `withSentry` at build time and +// takes the options from `src/instrument.server.ts`, so the entry itself stays uninstrumented. +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc new file mode 100644 index 000000000000..cf5ad9bee22b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // Placeholder only: every test run deploys under a unique name, see global-setup.mjs. + "name": "cloudflare-workers-send-to-sentry", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": true, + // Workers Logs keep the invocations of the last 7 days, so a failed CI run can still be inspected. + "observability": { "enabled": true }, +} diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/deno.json b/dev-packages/e2e-tests/test-applications/deno-mysql/deno.json deleted file mode 100644 index f82f2da8e1e3..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/deno.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "imports": { - "@sentry/deno": "npm:@sentry/deno", - "mysql": "npm:mysql@2.18.1" - }, - "nodeModulesDir": "manual" -} diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno-mysql/docker-compose.yml deleted file mode 100644 index d4a50957c884..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/docker-compose.yml +++ /dev/null @@ -1,18 +0,0 @@ -services: - db: - image: mysql:8.0 - restart: always - container_name: e2e-tests-deno-mysql - # The `mysql` 2.x driver doesn't speak MySQL 8's default - # `caching_sha2_password` auth, so force the legacy plugin. - command: ['--default-authentication-plugin=mysql_native_password'] - ports: - - '3306:3306' - environment: - MYSQL_ROOT_PASSWORD: password - healthcheck: - test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword'] - interval: 2s - timeout: 3s - retries: 30 - start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/global-setup.mjs b/dev-packages/e2e-tests/test-applications/deno-mysql/global-setup.mjs deleted file mode 100644 index 328522280a26..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/global-setup.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default async function globalSetup() { - // Start MySQL via Docker Compose. `--wait` blocks until the healthcheck - // in docker-compose.yml passes, so the Deno app can connect immediately. - execSync('docker compose up -d --wait', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/package.json b/dev-packages/e2e-tests/test-applications/deno-mysql/package.json deleted file mode 100644 index 8fbc85885d33..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "deno-mysql", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "mysql": "2.18.1" - }, - "devDependencies": { - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-mysql/playwright.config.mjs deleted file mode 100644 index d525dd371bc9..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/playwright.config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); - -export default { - ...config, - globalSetup: './global-setup.mjs', - globalTeardown: './global-teardown.mjs', -}; diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-mysql/src/app.ts deleted file mode 100644 index 585c0788b9a0..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/src/app.ts +++ /dev/null @@ -1,66 +0,0 @@ -// `@sentry/deno/import` MUST be the very first import: it registers the -// orchestrion runtime hook, which transforms `mysql` (imported dynamically -// below) to publish the `orchestrion:mysql:query` diagnostics channel. -// In Deno 2.8.0–2.8.2 the hook only works as the first import in the entry graph. -import '@sentry/deno/import'; -import * as Sentry from '@sentry/deno'; - -Sentry.init({ - environment: 'qa', - dsn: Deno.env.get('E2E_TEST_DSN'), - debug: !!Deno.env.get('DEBUG'), - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1, -}); - -// Dynamic import AFTER init so the orchestrion hook (registered above) is in -// place to transform `mysql/lib/Connection.js`'s `query`, and so -// `denoMysqlIntegration` (wired by `init()`) is already subscribed. -const { default: mysql } = await import('mysql'); - -const connection = mysql.createConnection({ - host: Deno.env.get('MYSQL_HOST') ?? '127.0.0.1', - port: Number(Deno.env.get('MYSQL_PORT') ?? 3306), - user: 'root', - password: 'password', -}); - -// Swallow connection errors (e.g. the DB container going away at teardown) so -// they don't become an uncaught exception that crashes the process on shutdown. -connection.on('error', (err: unknown) => { - // eslint-disable-next-line no-console - console.error('mysql connection error', err); -}); - -connection.connect((err: unknown) => { - if (err) { - // eslint-disable-next-line no-console - console.error('mysql connect error', err); - } -}); - -const port = 3030; - -Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { - const url = new URL(req.url); - - // Runs two queries, the second NESTED inside the first's callback. mysql - // dispatches that callback from its socket data handler (a fresh async - // context), so the nested query's span only lands on this request's - // http.server transaction if `denoMysqlIntegration`'s AsyncLocalStorage - // context strategy restored the parent across the async boundary. - if (url.pathname === '/test-mysql') { - await new Promise((resolve, reject) => { - connection.query('SELECT 1 + 1 AS solution', (err: unknown) => { - if (err) return reject(err); - connection.query('SELECT NOW()', (err2: unknown) => { - if (err2) return reject(err2); - resolve(); - }); - }); - }); - return Response.json({ status: 'ok' }); - } - - return new Response('Not found', { status: 404 }); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-mysql/start-event-proxy.mjs deleted file mode 100644 index c01f6f1cca6d..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'deno-mysql', -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/deno.json b/dev-packages/e2e-tests/test-applications/deno-pg/deno.json deleted file mode 100644 index 2bc35855c689..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/deno.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "imports": { - "@sentry/deno": "npm:@sentry/deno", - "pg": "npm:pg@8.16.0" - }, - "nodeModulesDir": "manual" -} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml deleted file mode 100644 index aeee1935341e..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - db: - image: postgres:13 - restart: always - container_name: e2e-tests-deno-pg - ports: - - '5432:5432' - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: postgres - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres'] - interval: 2s - timeout: 3s - retries: 30 - start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs deleted file mode 100644 index 2e9841a6fdbf..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default async function globalSetup() { - // Start PostgreSQL via Docker Compose. `--wait` blocks until the healthcheck - // in docker-compose.yml passes, so the Deno app can connect immediately. - execSync('docker compose up -d --wait', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs deleted file mode 100644 index 2742279431ad..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default async function globalTeardown() { - execSync('docker compose down --volumes', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/package.json b/dev-packages/e2e-tests/test-applications/deno-pg/package.json deleted file mode 100644 index 36b1cf960b65..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "deno-pg", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "pg": "8.16.0" - }, - "devDependencies": { - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs deleted file mode 100644 index d525dd371bc9..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); - -export default { - ...config, - globalSetup: './global-setup.mjs', - globalTeardown: './global-teardown.mjs', -}; diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts deleted file mode 100644 index 2b9e7a432376..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts +++ /dev/null @@ -1,69 +0,0 @@ -// `@sentry/deno/import` MUST be the very first import: it registers the -// orchestrion runtime hook, which transforms `pg` (imported dynamically below) -// to publish the `orchestrion:pg:query` diagnostics channel. -// In Deno 2.8.0–2.8.2 the hook only works as the first import in the entry -// graph. -import '@sentry/deno/import'; -import * as Sentry from '@sentry/deno'; - -Sentry.init({ - environment: 'qa', - dsn: Deno.env.get('E2E_TEST_DSN'), - debug: !!Deno.env.get('DEBUG'), - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1, -}); - -// Dynamic import AFTER init so the orchestrion hook (registered above) is in -// place to transform `pg/lib/client.js`'s `query`, and so -// `denoPostgresIntegration` (wired by `init()`) is already subscribed. -const { default: pg } = await import('pg'); - -const client = new pg.Client({ - host: Deno.env.get('PGHOST') ?? '127.0.0.1', - port: Number(Deno.env.get('PGPORT') ?? 5432), - user: 'postgres', - password: 'password', - database: 'postgres', -}); - -// Swallow connection errors (e.g. the DB container going away at teardown) so -// they don't become an uncaught exception that crashes the process on -// shutdown. -client.on('error', (err: unknown) => { - // eslint-disable-next-line no-console - console.error('pg client error', err); -}); - -client.connect((err: unknown) => { - if (err) { - // eslint-disable-next-line no-console - console.error('pg connect error', err); - } -}); - -const port = 3030; - -Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { - const url = new URL(req.url); - - // Runs two queries, the second NESTED inside the first's callback. pg - // dispatches that callback from its socket data handler (a fresh async - // context), so the nested query's span only lands on this request's - // http.server transaction if `denoPostgresIntegration`'s AsyncLocalStorage - // context strategy restored the parent across the async boundary. - if (url.pathname === '/test-pg') { - await new Promise((resolve, reject) => { - client.query('SELECT 1 + 1 AS solution', (err: unknown) => { - if (err) return reject(err); - client.query('SELECT NOW()', (err2: unknown) => { - if (err2) return reject(err2); - resolve(); - }); - }); - }); - return Response.json({ status: 'ok' }); - } - - return new Response('Not found', { status: 404 }); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/deno.json b/dev-packages/e2e-tests/test-applications/deno-redis/deno.json deleted file mode 100644 index 682591cfa09e..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/deno.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "imports": { - "@sentry/deno": "npm:@sentry/deno", - "ioredis": "npm:ioredis@^5.11.0", - "redis": "npm:redis@^5.12.0" - }, - "nodeModulesDir": "manual" -} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml deleted file mode 100644 index b695ab5a7308..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -services: - redis: - image: redis:8 - restart: always - container_name: e2e-tests-deno-redis - ports: - - '6379:6379' - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 1s - timeout: 3s - retries: 30 diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs deleted file mode 100644 index 2742279431ad..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default async function globalTeardown() { - execSync('docker compose down --volumes', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/package.json b/dev-packages/e2e-tests/test-applications/deno-redis/package.json deleted file mode 100644 index 439845814f99..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "deno-redis", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "ioredis": "^5.11.0", - "redis": "^5.12.0" - }, - "devDependencies": { - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs deleted file mode 100644 index d525dd371bc9..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); - -export default { - ...config, - globalSetup: './global-setup.mjs', - globalTeardown: './global-teardown.mjs', -}; diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts deleted file mode 100644 index 929e913873c6..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts +++ /dev/null @@ -1,113 +0,0 @@ -import * as Sentry from '@sentry/deno'; -import IORedis from 'ioredis'; -import { createClient } from 'redis'; - -Sentry.init({ - environment: 'qa', - dsn: Deno.env.get('E2E_TEST_DSN'), - debug: !!Deno.env.get('DEBUG'), - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1, -}); - -const redisUrl = Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'; - -// One shared client per process. node-redis publishes to the -// `node-redis:command` / `:batch` / `:connect` diagnostics channels for every -// operation on this client; redisIntegration is already subscribed to -// those. -const redis = createClient({ url: redisUrl }); -function onRedisError(err: unknown) { - // eslint-disable-next-line no-console - console.error('redis client error', err); -} -redis.on('error', onRedisError); -await redis.connect(); - -// Separate ioredis client. ioredis >= 5.11 publishes to the `ioredis:command` -// and `ioredis:connect` channels, which redisIntegration also subscribes -// to. lazyConnect so we can yield a microtick before connecting and ensure -// the DC subscriber is registered before ioredis creates its tracing channels. -await Promise.resolve(); -const ioredisUrl = new URL(redisUrl); -const ioredis = new IORedis({ - host: ioredisUrl.hostname, - port: Number(ioredisUrl.port) || 6379, - lazyConnect: true, -}); -function onIoredisError(err: unknown) { - // eslint-disable-next-line no-console - console.error('ioredis client error', err); -} -ioredis.on('error', onIoredisError); -await ioredis.connect(); - -const port = 3030; - -Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { - const url = new URL(req.url); - - // node-redis: GET — exercises the command channel, success path. - if (url.pathname === '/redis-get') { - const key = url.searchParams.get('key') ?? 'cache:key'; - const value = await redis.get(key); - return Response.json({ key, value }); - } - - // node-redis: SET then GET — exercises two commands inside a single - // transaction so we can assert the parent has two db.query children. - if (url.pathname === '/redis-set-get') { - const key = url.searchParams.get('key') ?? 'cache:key'; - const value = url.searchParams.get('value') ?? 'hello'; - await redis.set(key, value); - const echoed = await redis.get(key); - return Response.json({ key, value: echoed }); - } - - // node-redis: MULTI — exercises the batch channel. - if (url.pathname === '/redis-multi') { - const result = await redis.multi().set('multi:a', '1').set('multi:b', '2').get('multi:a').exec(); - return Response.json({ result }); - } - - // ioredis: GET — exercises the ioredis:command channel. - if (url.pathname === '/ioredis-get') { - const key = url.searchParams.get('key') ?? 'iocache:key'; - const value = await ioredis.get(key); - return Response.json({ key, value }); - } - - // ioredis: SET then GET — two commands inside a transaction. - if (url.pathname === '/ioredis-set-get') { - const key = url.searchParams.get('key') ?? 'iocache:key'; - const value = url.searchParams.get('value') ?? 'hello'; - await ioredis.set(key, value); - const echoed = await ioredis.get(key); - return Response.json({ key, value: echoed }); - } - - // ioredis: MULTI — ioredis has no separate batch channel; per-command - // payloads carry `batchMode`/`batchSize` instead, so we still expect one - // db.query span per command. - if (url.pathname === '/ioredis-multi') { - const result = await ioredis.multi().set('iomulti:a', '1').set('iomulti:b', '2').get('iomulti:a').exec(); - return Response.json({ result }); - } - - // ioredis: PIPELINE — same shape as MULTI from the perspective of the - // diagnostics channel. - if (url.pathname === '/ioredis-pipeline') { - const result = await ioredis.pipeline().set('iopipe:a', '1').set('iopipe:b', '2').get('iopipe:a').exec(); - return Response.json({ result }); - } - - if (url.pathname === '/redis-disconnect') { - redis.off('error', onRedisError); - redis.close(); - ioredis.off('error', onIoredisError); - ioredis.disconnect(); - return new Response('ok'); - } - - return new Response('Not found', { status: 404 }); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/deno.json b/dev-packages/e2e-tests/test-applications/deno-static/deno.json deleted file mode 100644 index 35242c740171..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/deno.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "imports": { - "@sentry/deno": "npm:@sentry/deno", - "@sentry/core": "npm:@sentry/core", - "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", - "ai": "npm:ai@^3.0.0", - "ai/test": "npm:ai@^3.0.0/test", - "zod": "npm:zod@^3.22.4" - }, - "nodeModulesDir": "manual" -} diff --git a/dev-packages/e2e-tests/test-applications/deno-static/package.json b/dev-packages/e2e-tests/test-applications/deno-static/package.json deleted file mode 100644 index 0e7b7337ecc4..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "deno-static-app", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "deno run --allow-net --allow-env --allow-read src/app.ts", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "@opentelemetry/api": "^1.9.1", - "ai": "^3.0.0", - "zod": "^3.22.4" - }, - "devDependencies": { - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/deno-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-static/playwright.config.mjs deleted file mode 100644 index 3d3ab7d8df02..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/playwright.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/deno-static/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-static/src/app.ts deleted file mode 100644 index 45da3a4209f8..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/src/app.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { trace } from '@opentelemetry/api'; - -// Simulate a pre-existing OTel provider (like Supabase Edge Runtime registers -// before user code runs). Without trace.disable() in Sentry's setup, this would -// cause setGlobalTracerProvider to be a no-op, silently dropping all OTel spans. -const fakeProvider = { - getTracer: () => ({ - startSpan: () => ({ end: () => {}, setAttributes: () => {} }), - startActiveSpan: (_name: string, fn: Function) => fn({ end: () => {}, setAttributes: () => {} }), - }), -}; -trace.setGlobalTracerProvider(fakeProvider as any); - -// Sentry.init() must call trace.disable() to clear the fake provider above -import * as Sentry from '@sentry/deno'; -import { generateText } from 'ai'; -import { MockLanguageModelV1 } from 'ai/test'; -import { z } from 'zod'; - -Sentry.init({ - traceLifecycle: 'static', - environment: 'qa', - dsn: Deno.env.get('E2E_TEST_DSN'), - debug: !!Deno.env.get('DEBUG'), - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1, -}); - -const port = 3030; - -Deno.serve({ port }, async (req: Request) => { - const url = new URL(req.url); - - if (url.pathname === '/test-success') { - return new Response(JSON.stringify({ version: 'v1' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - if (url.pathname === '/test-error') { - const exceptionId = Sentry.captureException(new Error('This is an error')); - return new Response(JSON.stringify({ exceptionId }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test Sentry.startSpan — uses Sentry's internal pipeline - if (url.pathname === '/test-sentry-span') { - Sentry.startSpan({ name: 'test-sentry-span' }, () => { - // noop - }); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test OTel tracer.startSpan — goes through the global TracerProvider - if (url.pathname === '/test-otel-span') { - const tracer = trace.getTracer('test-tracer'); - const span = tracer.startSpan('test-otel-span'); - span.end(); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test OTel tracer.startActiveSpan — what AI SDK and most instrumentations use - if (url.pathname === '/test-otel-active-span') { - const tracer = trace.getTracer('test-tracer'); - tracer.startActiveSpan('test-otel-active-span', span => { - span.setAttributes({ 'test.active': true }); - span.end(); - }); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test interop: OTel span inside a Sentry span - if (url.pathname === '/test-interop') { - Sentry.startSpan({ name: 'sentry-parent' }, () => { - const tracer = trace.getTracer('test-tracer'); - const span = tracer.startSpan('otel-child'); - span.end(); - }); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test breadcrumbs: add a breadcrumb then capture an error - if (url.pathname === '/test-breadcrumb') { - Sentry.addBreadcrumb({ - message: 'test-breadcrumb', - category: 'custom', - level: 'info', - }); - const exceptionId = Sentry.captureException(new Error('breadcrumb-test')); - return new Response(JSON.stringify({ exceptionId }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test context: set user, tag, extra then capture an error - if (url.pathname === '/test-context') { - Sentry.setUser({ id: '123', email: 'test@sentry.io' }); - Sentry.setTag('deno-runtime', 'true'); - Sentry.setExtra('detail', { key: 'value' }); - const exceptionId = Sentry.captureException(new Error('context-test')); - return new Response(JSON.stringify({ exceptionId }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test scope isolation: tags inside withScope do not leak - if (url.pathname === '/test-scope-isolation') { - let insideId: string | undefined; - let outsideId: string | undefined; - - Sentry.withScope(scope => { - scope.setTag('isolated', 'yes'); - insideId = Sentry.captureException(new Error('inside-scope')); - }); - - outsideId = Sentry.captureException(new Error('outside-scope')); - - return new Response(JSON.stringify({ insideId, outsideId }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test outbound fetch instrumentation - if (url.pathname === '/test-outgoing-fetch') { - const response = await Sentry.startSpan({ name: 'test-outgoing-fetch' }, async () => { - const res = await fetch('http://localhost:3030/test-success'); - return res.json(); - }); - return new Response(JSON.stringify(response), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test AI: Vercel AI SDK generateText with mock model - if (url.pathname === '/test-ai') { - const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => { - // First call - telemetry enabled by default - const result1 = await generateText({ - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 20 }, - text: 'First span here!', - }), - }), - prompt: 'Where is the first span?', - }); - - // Second call - explicitly enabled telemetry - const result2 = await generateText({ - experimental_telemetry: { isEnabled: true }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 20 }, - text: 'Second span here!', - }), - }), - prompt: 'Where is the second span?', - }); - - // Third call - with tool calls - const result3 = await generateText({ - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'tool-calls', - usage: { promptTokens: 15, completionTokens: 25 }, - text: 'Tool call completed!', - toolCalls: [ - { - toolCallType: 'function', - toolCallId: 'call-1', - toolName: 'getWeather', - args: '{ "location": "San Francisco" }', - }, - ], - }), - }), - tools: { - getWeather: { - parameters: z.object({ location: z.string() }), - execute: async (args: { location: string }) => { - return `Weather in ${args.location}: Sunny, 72°F`; - }, - }, - }, - prompt: 'What is the weather in San Francisco?', - }); - - // Fourth call - explicitly disabled telemetry, should not be captured - const result4 = await generateText({ - experimental_telemetry: { isEnabled: false }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 20 }, - text: 'Should not be captured!', - }), - }), - prompt: 'Where is the disabled span?', - }); - - return { - result1: result1.text, - result2: result2.text, - result3: result3.text, - result4: result4.text, - }; - }); - - return new Response(JSON.stringify(results), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test AI error: tool call that throws - if (url.pathname === '/test-ai-error') { - try { - await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { - await generateText({ - experimental_telemetry: { isEnabled: true }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'tool-calls', - usage: { promptTokens: 15, completionTokens: 25 }, - text: 'Tool call completed!', - toolCalls: [ - { - toolCallType: 'function', - toolCallId: 'call-1', - toolName: 'getWeather', - args: '{ "location": "San Francisco" }', - }, - ], - }), - }), - tools: { - getWeather: { - parameters: z.object({ location: z.string() }), - execute: async (_args: { location: string }) => { - throw new Error('Tool call failed'); - }, - }, - }, - prompt: 'What is the weather in San Francisco?', - }); - }); - } catch (e) { - Sentry.captureException(e); - } - - return new Response(JSON.stringify({ status: 'error-handled' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test metrics: emit counter, distribution, and gauge - if (url.pathname === '/test-metrics') { - Sentry.metrics.count('test.deno.count', 1, { - attributes: { - endpoint: '/test-metrics', - 'random.attribute': 'Apples', - }, - }); - Sentry.metrics.distribution('test.deno.distribution', 100, { - attributes: { - endpoint: '/test-metrics', - 'random.attribute': 'Bananas', - }, - }); - Sentry.metrics.gauge('test.deno.gauge', 200, { - attributes: { - endpoint: '/test-metrics', - 'random.attribute': 'Cherries', - }, - }); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test logs: emit a debug log via Sentry.logger - if (url.pathname === '/test-log') { - Sentry.logger.debug('Accessed /test-log route'); - return new Response(JSON.stringify({ message: 'Log sent' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - return new Response('Not found', { status: 404 }); -}); - -console.log(`Deno test app listening on port ${port}`); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs deleted file mode 100644 index a9ac2fbee1cd..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'deno-static', -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts deleted file mode 100644 index b995758f826b..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Sends error event with breadcrumbs', async ({ baseURL }) => { - const errorEventPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'breadcrumb-test'; - }); - - await fetch(`${baseURL}/test-breadcrumb`); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('breadcrumb-test'); - - expect(errorEvent.breadcrumbs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: 'test-breadcrumb', - category: 'custom', - level: 'info', - }), - ]), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts deleted file mode 100644 index 5b7fae12fd4e..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Sends error event with user, tags, and extras', async ({ baseURL }) => { - const errorEventPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'context-test'; - }); - - await fetch(`${baseURL}/test-context`); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('context-test'); - - expect(errorEvent.user).toEqual( - expect.objectContaining({ - id: '123', - email: 'test@sentry.io', - }), - ); - - expect(errorEvent.tags).toEqual( - expect.objectContaining({ - 'deno-runtime': 'true', - }), - ); - - expect(errorEvent.extra).toEqual( - expect.objectContaining({ - detail: { key: 'value' }, - }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts deleted file mode 100644 index 1088141661b2..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Sends error event', async ({ baseURL }) => { - const errorEventPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'This is an error'; - }); - - await fetch(`${baseURL}/test-error`); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an error'); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts deleted file mode 100644 index 04c7e08b2e93..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; -import type { SerializedLogContainer } from '@sentry/core'; - -test('should send logs via Sentry.logger', async ({ baseURL }) => { - const logEnvelopePromise = waitForEnvelopeItem('deno-static', envelope => { - return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; - }); - - await fetch(`${baseURL}/test-log`); - - const logEnvelope = await logEnvelopePromise; - const log = (logEnvelope[1] as SerializedLogContainer).items[0]; - expect(log?.level).toBe('debug'); - expect(log?.body).toBe('Accessed /test-log route'); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts deleted file mode 100644 index c3af9b88a7db..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForMetric } from '@sentry-internal/test-utils'; - -test('Should emit counter, distribution, and gauge metrics', async ({ baseURL }) => { - const countPromise = waitForMetric('deno-static', metric => { - return metric.name === 'test.deno.count'; - }); - - const distributionPromise = waitForMetric('deno-static', metric => { - return metric.name === 'test.deno.distribution'; - }); - - const gaugePromise = waitForMetric('deno-static', metric => { - return metric.name === 'test.deno.gauge'; - }); - - await fetch(`${baseURL}/test-metrics`); - - const count = await countPromise; - const distribution = await distributionPromise; - const gauge = await gaugePromise; - - expect(count).toMatchObject({ - timestamp: expect.any(Number), - trace_id: expect.any(String), - name: 'test.deno.count', - type: 'counter', - value: 1, - attributes: { - endpoint: { value: '/test-metrics', type: 'string' }, - 'random.attribute': { value: 'Apples', type: 'string' }, - 'sentry.environment': { value: 'qa', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, - 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, - }, - }); - - expect(distribution).toMatchObject({ - timestamp: expect.any(Number), - trace_id: expect.any(String), - name: 'test.deno.distribution', - type: 'distribution', - value: 100, - attributes: { - endpoint: { value: '/test-metrics', type: 'string' }, - 'random.attribute': { value: 'Bananas', type: 'string' }, - 'sentry.environment': { value: 'qa', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, - 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, - }, - }); - - expect(gauge).toMatchObject({ - timestamp: expect.any(Number), - trace_id: expect.any(String), - name: 'test.deno.gauge', - type: 'gauge', - value: 200, - attributes: { - endpoint: { value: '/test-metrics', type: 'string' }, - 'random.attribute': { value: 'Cherries', type: 'string' }, - 'sentry.environment': { value: 'qa', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, - 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts deleted file mode 100644 index 5224cdc71ff3..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Scope isolation prevents tag leakage between scopes', async ({ baseURL }) => { - const insideErrorPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'inside-scope'; - }); - - const outsideErrorPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'outside-scope'; - }); - - await fetch(`${baseURL}/test-scope-isolation`); - - const insideError = await insideErrorPromise; - const outsideError = await outsideErrorPromise; - - // The error inside withScope should have the isolated tag - expect(insideError.tags).toEqual( - expect.objectContaining({ - isolated: 'yes', - }), - ); - - // The error outside withScope should NOT have the isolated tag - expect(outsideError.tags?.['isolated']).toBeUndefined(); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno/deno.json b/dev-packages/e2e-tests/test-applications/deno/deno.json index 35242c740171..5a684e3ed7c5 100644 --- a/dev-packages/e2e-tests/test-applications/deno/deno.json +++ b/dev-packages/e2e-tests/test-applications/deno/deno.json @@ -1,10 +1,15 @@ { "imports": { "@sentry/deno": "npm:@sentry/deno", + "@sentry/deno/import": "./node_modules/@sentry/deno/build/import.mjs", "@sentry/core": "npm:@sentry/core", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "ai": "npm:ai@^3.0.0", "ai/test": "npm:ai@^3.0.0/test", + "ioredis": "npm:ioredis@^5.11.0", + "mysql": "npm:mysql@2.18.1", + "pg": "npm:pg@8.16.0", + "redis": "npm:redis@^5.12.0", "zod": "npm:zod@^3.22.4" }, "nodeModulesDir": "manual" diff --git a/dev-packages/e2e-tests/test-applications/deno/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno/docker-compose.yml new file mode 100644 index 000000000000..5bc98ef22c13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/docker-compose.yml @@ -0,0 +1,47 @@ +services: + mysql: + image: mysql:8.0 + restart: always + container_name: e2e-tests-deno-mysql + # The `mysql` 2.x driver doesn't speak MySQL 8's default + # `caching_sha2_password` auth, so force the legacy plugin. + command: ['--default-authentication-plugin=mysql_native_password'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: password + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + + postgres: + image: postgres:13 + restart: always + container_name: e2e-tests-deno-postgres + ports: + - '5432:5432' + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: postgres + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s + + redis: + image: redis:8 + restart: always + container_name: e2e-tests-deno-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 1s + timeout: 3s + retries: 30 diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs b/dev-packages/e2e-tests/test-applications/deno/global-setup.mjs similarity index 64% rename from dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs rename to dev-packages/e2e-tests/test-applications/deno/global-setup.mjs index ba03e561eb6f..bd183b8cd220 100644 --- a/dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/deno/global-setup.mjs @@ -5,8 +5,9 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { - // Start Redis via Docker Compose. `--wait` blocks until the healthcheck - // in docker-compose.yml passes, so the Deno app can connect immediately. + // Start MySQL, Postgres and Redis via Docker Compose. `--wait` blocks + // until the healthchecks in docker-compose.yml pass, so the Deno app can + // connect immediately. execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit', diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/deno/global-teardown.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno-mysql/global-teardown.mjs rename to dev-packages/e2e-tests/test-applications/deno/global-teardown.mjs diff --git a/dev-packages/e2e-tests/test-applications/deno/package.json b/dev-packages/e2e-tests/test-applications/deno/package.json index 68752bf82541..22b3bf4544c3 100644 --- a/dev-packages/e2e-tests/test-applications/deno/package.json +++ b/dev-packages/e2e-tests/test-applications/deno/package.json @@ -3,16 +3,22 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "deno run --allow-net --allow-env --allow-read --allow-sys src/app.ts", + "start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write --preload=@sentry/deno/import src/app.ts", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:static": "E2E_TEST_STATIC=true pnpm install", + "test:assert:static": "E2E_TEST_STATIC=true pnpm test" }, "dependencies": { "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", "@opentelemetry/api": "^1.9.1", "ai": "^3.0.0", + "ioredis": "^5.11.0", + "mysql": "2.18.1", + "pg": "8.16.0", + "redis": "^5.12.0", "zod": "^3.22.4" }, "devDependencies": { @@ -23,6 +29,13 @@ "extends": "../../package.json" }, "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:static", + "assert-command": "pnpm test:assert:static", + "label": "deno (static trace lifecycle)" + } + ], "optionalVariants": [ { "deno-version": "latest", diff --git a/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs index 3d3ab7d8df02..66d063976e96 100644 --- a/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs @@ -1,8 +1,20 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); +// The static trace lifecycle runs as a `sentryTest` variant of this same app, +// so the specs that assert envelope shape are split by directory and only one +// set runs per mode. Everything directly under `tests/` runs in both. +const isStatic = !!process.env.E2E_TEST_STATIC; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + port: 3030, + }, + { + testIgnore: isStatic ? '**/streamed/**' : '**/static/**', + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', + }, +); export default config; diff --git a/dev-packages/e2e-tests/test-applications/deno/src/app.ts b/dev-packages/e2e-tests/test-applications/deno/src/app.ts index 71dba56c3c3b..6e0162ca98c3 100644 --- a/dev-packages/e2e-tests/test-applications/deno/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/deno/src/app.ts @@ -15,6 +15,10 @@ trace.setGlobalTracerProvider(fakeProvider as any); import * as Sentry from '@sentry/deno'; import { generateText } from 'ai'; import { MockLanguageModelV1 } from 'ai/test'; +import IORedis from 'ioredis'; +import mysql from 'mysql'; +import pg from 'pg'; +import { createClient } from 'redis'; import { z } from 'zod'; Sentry.init({ @@ -23,11 +27,96 @@ Sentry.init({ debug: !!Deno.env.get('DEBUG'), tunnel: 'http://localhost:3031/', tracesSampleRate: 1, + // Left unset (so the default span streaming applies) unless the + // `deno (static trace lifecycle)` variant asks for the other lifecycle. + traceLifecycle: Deno.env.get('E2E_TEST_STATIC') ? 'static' : undefined, }); +// `mysql` and `pg` don't emit tracing signals on their own. The +// `--preload=@sentry/deno/import` in this app's start script registers the +// orchestrion runtime hook before the entry graph loads, so both are +// transformed to publish the `orchestrion:mysql:query` / `orchestrion:pg:query` +// diagnostics channels that `init()` above has just subscribed to. `deno.json` +// maps that specifier to the installed file rather than to +// `npm:@sentry/deno`, because this app installs the SDK from a local tarball. +// A tarball has no registry version for Deno to match a preloaded `npm:` +// specifier against. +const connection = mysql.createConnection({ + host: Deno.env.get('MYSQL_HOST') ?? '127.0.0.1', + port: Number(Deno.env.get('MYSQL_PORT') ?? 3306), + user: 'root', + password: 'password', +}); + +// Swallow connection errors (e.g. a DB container going away at teardown) so +// they don't become an uncaught exception that crashes the process on shutdown. +connection.on('error', (err: unknown) => { + // eslint-disable-next-line no-console + console.error('mysql connection error', err); +}); + +connection.connect((err: unknown) => { + if (err) { + // eslint-disable-next-line no-console + console.error('mysql connect error', err); + } +}); + +const pgClient = new pg.Client({ + host: Deno.env.get('PGHOST') ?? '127.0.0.1', + port: Number(Deno.env.get('PGPORT') ?? 5432), + user: 'postgres', + password: 'password', + database: 'postgres', +}); + +pgClient.on('error', (err: unknown) => { + // eslint-disable-next-line no-console + console.error('pg client error', err); +}); + +pgClient.connect((err: unknown) => { + if (err) { + // eslint-disable-next-line no-console + console.error('pg connect error', err); + } +}); + +const redisUrl = Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'; + +// One shared client per process. node-redis publishes to the +// `node-redis:command` / `:batch` / `:connect` diagnostics channels for every +// operation on this client; redisIntegration is already subscribed to +// those. +const redis = createClient({ url: redisUrl }); +function onRedisError(err: unknown) { + // eslint-disable-next-line no-console + console.error('redis client error', err); +} +redis.on('error', onRedisError); +await redis.connect(); + +// Separate ioredis client. ioredis >= 5.11 publishes to the `ioredis:command` +// and `ioredis:connect` channels, which redisIntegration also subscribes +// to. lazyConnect so we can yield a microtick before connecting and ensure +// the DC subscriber is registered before ioredis creates its tracing channels. +await Promise.resolve(); +const ioredisUrl = new URL(redisUrl); +const ioredis = new IORedis({ + host: ioredisUrl.hostname, + port: Number(ioredisUrl.port) || 6379, + lazyConnect: true, +}); +function onIoredisError(err: unknown) { + // eslint-disable-next-line no-console + console.error('ioredis client error', err); +} +ioredis.on('error', onIoredisError); +await ioredis.connect(); + const port = 3030; -Deno.serve({ port }, async (req: Request) => { +Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { const url = new URL(req.url); if (url.pathname === '/test-success') { @@ -300,6 +389,101 @@ Deno.serve({ port }, async (req: Request) => { }); } + // Runs two queries, the second NESTED inside the first's callback. mysql + // dispatches that callback from its socket data handler (a fresh async + // context), so the nested query's span only lands on this request's + // http.server transaction if `denoMysqlIntegration`'s AsyncLocalStorage + // context strategy restored the parent across the async boundary. + if (url.pathname === '/test-mysql') { + await new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', (err: unknown) => { + if (err) return reject(err); + connection.query('SELECT NOW()', (err2: unknown) => { + if (err2) return reject(err2); + resolve(); + }); + }); + }); + return Response.json({ status: 'ok' }); + } + + // Same nested-callback shape as `/test-mysql`, for + // `denoPostgresIntegration`'s context strategy. + if (url.pathname === '/test-pg') { + await new Promise((resolve, reject) => { + pgClient.query('SELECT 1 + 1 AS solution', (err: unknown) => { + if (err) return reject(err); + pgClient.query('SELECT NOW()', (err2: unknown) => { + if (err2) return reject(err2); + resolve(); + }); + }); + }); + return Response.json({ status: 'ok' }); + } + + // node-redis: GET — exercises the command channel, success path. + if (url.pathname === '/redis-get') { + const key = url.searchParams.get('key') ?? 'cache:key'; + const value = await redis.get(key); + return Response.json({ key, value }); + } + + // node-redis: SET then GET — exercises two commands inside a single + // transaction so we can assert the parent has two db.query children. + if (url.pathname === '/redis-set-get') { + const key = url.searchParams.get('key') ?? 'cache:key'; + const value = url.searchParams.get('value') ?? 'hello'; + await redis.set(key, value); + const echoed = await redis.get(key); + return Response.json({ key, value: echoed }); + } + + // node-redis: MULTI — exercises the batch channel. + if (url.pathname === '/redis-multi') { + const result = await redis.multi().set('multi:a', '1').set('multi:b', '2').get('multi:a').exec(); + return Response.json({ result }); + } + + // ioredis: GET — exercises the ioredis:command channel. + if (url.pathname === '/ioredis-get') { + const key = url.searchParams.get('key') ?? 'iocache:key'; + const value = await ioredis.get(key); + return Response.json({ key, value }); + } + + // ioredis: SET then GET — two commands inside a transaction. + if (url.pathname === '/ioredis-set-get') { + const key = url.searchParams.get('key') ?? 'iocache:key'; + const value = url.searchParams.get('value') ?? 'hello'; + await ioredis.set(key, value); + const echoed = await ioredis.get(key); + return Response.json({ key, value: echoed }); + } + + // ioredis: MULTI — ioredis has no separate batch channel; per-command + // payloads carry `batchMode`/`batchSize` instead, so we still expect one + // db.query span per command. + if (url.pathname === '/ioredis-multi') { + const result = await ioredis.multi().set('iomulti:a', '1').set('iomulti:b', '2').get('iomulti:a').exec(); + return Response.json({ result }); + } + + // ioredis: PIPELINE — same shape as MULTI from the perspective of the + // diagnostics channel. + if (url.pathname === '/ioredis-pipeline') { + const result = await ioredis.pipeline().set('iopipe:a', '1').set('iopipe:b', '2').get('iopipe:a').exec(); + return Response.json({ result }); + } + + if (url.pathname === '/redis-disconnect') { + redis.off('error', onRedisError); + redis.close(); + ioredis.off('error', onIoredisError); + ioredis.disconnect(); + return new Response('ok'); + } + return new Response('Not found', { status: 404 }); }); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/static/ai-error.test.ts similarity index 87% rename from dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/static/ai-error.test.ts index 5edaa5000f91..8cf82e56de15 100644 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/static/ai-error.test.ts @@ -2,11 +2,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; test('should link AI errors to the correct trace', async ({ baseURL }) => { - const aiTransactionPromise = waitForTransaction('deno-static', event => { + const aiTransactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'ai-error-test') ?? false; }); - const errorEventPromise = waitForError('deno-static', event => { + const errorEventPromise = waitForError('deno', event => { return event.exception?.values?.[0]?.value?.includes('Tool call failed') ?? false; }); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/static/ai.test.ts similarity index 96% rename from dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/static/ai.test.ts index cf4d77de93bc..0ba5f463d65d 100644 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/static/ai.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) => { - const aiTransactionPromise = waitForTransaction('deno-static', event => { + const aiTransactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'ai-test') ?? false; }); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/fetch.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/static/fetch.test.ts similarity index 88% rename from dev-packages/e2e-tests/test-applications/deno-static/tests/fetch.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/static/fetch.test.ts index 3386c6bcc7a8..7a0dcb30c82e 100644 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/fetch.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/static/fetch.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('Outbound fetch inside Sentry span creates transaction', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'test-outgoing-fetch') ?? false; }); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/static/transactions.test.ts similarity index 90% rename from dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/static/transactions.test.ts index 75f925594005..19077bb76b75 100644 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/static/transactions.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('Sends transaction with Sentry.startSpan', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'test-sentry-span') ?? false; }); @@ -21,7 +21,7 @@ test('Sends transaction with Sentry.startSpan', async ({ baseURL }) => { }); test('Sends transaction with OTel tracer.startSpan despite pre-existing provider', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'test-otel-span') ?? false; }); @@ -45,7 +45,7 @@ test('Sends transaction with OTel tracer.startSpan despite pre-existing provider }); test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'test-otel-active-span') ?? false; }); @@ -68,7 +68,7 @@ test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) = }); test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'sentry-parent') ?? false; }); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/ai-error.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/ai-error.test.ts diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/ai.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/ai.test.ts diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/fetch.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/fetch.test.ts diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/tests/ioredis.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/ioredis.test.ts similarity index 77% rename from dev-packages/e2e-tests/test-applications/deno-redis/tests/ioredis.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/ioredis.test.ts index 2dd1df6d0f8a..39208a581309 100644 --- a/dev-packages/e2e-tests/test-applications/deno-redis/tests/ioredis.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/ioredis.test.ts @@ -1,30 +1,14 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import type { SerializedStreamedSpan } from '@sentry/core'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only; the path lives in `url.path`. -function isSegmentFor(path: string): (span: SerializedStreamedSpan) => boolean { - return span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path; -} - -function isRedisCommand(span: SerializedStreamedSpan): boolean { - return getSpanOp(span) === 'db.query'; -} - -// `db.query.text` carries the key, so with span streaming a redis command span is named -// `{db.operation.name} {server.address}:{server.port}` instead. -function expectedCommandName(span: SerializedStreamedSpan): string { - const { 'db.operation.name': operation, 'server.address': address, 'server.port': port } = span.attributes; - return `${operation?.value} ${address?.value}:${port?.value}`; -} +import { expectedCommandName, isRedisCommand, isSegmentFor } from './utils'; test('ioredis GET emits an http.server segment containing a db.query child span', async ({ baseURL }) => { // Each incoming request gets a Sentry http.server segment span (via the // default denoServeIntegration); the ioredis command runs inside it, so the // child span joins that trace. const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/ioredis-get')) && spans.some(isRedisCommand), ); @@ -48,7 +32,7 @@ test('ioredis GET emits an http.server segment containing a db.query child span' test('ioredis SET then GET emit two db.query child spans on the same trace', async ({ baseURL }) => { const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/ioredis-set-get')) && spans.filter(isRedisCommand).length >= 2, ); @@ -72,7 +56,7 @@ test('ioredis MULTI emits one db.query span per command (no batch channel)', asy // own payload. So the trace should contain multiple command child spans, // but no PIPELINE/MULTI batch span. const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/ioredis-multi')) && spans.filter(isRedisCommand).length >= 3, ); @@ -93,7 +77,7 @@ test('ioredis MULTI emits one db.query span per command (no batch channel)', asy test('ioredis PIPELINE emits one db.query span per command', async ({ baseURL }) => { const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/ioredis-pipeline')) && spans.filter(isRedisCommand).length >= 3, ); diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/tests/mysql.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/mysql.test.ts similarity index 77% rename from dev-packages/e2e-tests/test-applications/deno-mysql/tests/mysql.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/mysql.test.ts index 0ac07ed48870..eb84da633a5a 100644 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/tests/mysql.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/mysql.test.ts @@ -1,20 +1,17 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import type { SerializedStreamedSpan } from '@sentry/core'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only; the path lives in `url.path`. -function isTestMysqlSegment(span: SerializedStreamedSpan): boolean { - return getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === '/test-mysql'; -} +import { isSegmentFor } from './utils'; + +const isRequestSegment = isSegmentFor('/test-mysql'); test('mysql queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => { // Each incoming request gets a Sentry http.server segment span (via the // default denoServeIntegration); the mysql queries run inside it, so their // db spans join that trace. const spansPromise = collectStreamedSpans( - 'deno-mysql', - spans => spans.some(isTestMysqlSegment) && spans.some(span => getSpanOp(span) === 'db'), + 'deno', + spans => spans.some(isRequestSegment) && spans.some(span => getSpanOp(span) === 'db'), ); const res = await fetch(`${baseURL}/test-mysql`); @@ -44,8 +41,8 @@ test('a nested query lands on the same trace (AsyncLocalStorage context restored // restored the parent span across that async boundary (otherwise the nested // query would start its own trace and never join this one). const spansPromise = collectStreamedSpans( - 'deno-mysql', - spans => spans.some(isTestMysqlSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, + 'deno', + spans => spans.some(isRequestSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, ); const res = await fetch(`${baseURL}/test-mysql`); @@ -53,7 +50,7 @@ test('a nested query lands on the same trace (AsyncLocalStorage context restored await res.json(); const spans = await spansPromise; - const segment = spans.find(isTestMysqlSegment)!; + const segment = spans.find(isRequestSegment)!; const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); const queries = dbSpans.map(span => span.attributes['db.query.text']?.value); diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/pg.test.ts similarity index 77% rename from dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/pg.test.ts index 32f24dcb43a4..be7d2aefec8e 100644 --- a/dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/pg.test.ts @@ -1,20 +1,17 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import type { SerializedStreamedSpan } from '@sentry/core'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only; the path lives in `url.path`. -function isTestPgSegment(span: SerializedStreamedSpan): boolean { - return getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === '/test-pg'; -} +import { isSegmentFor } from './utils'; + +const isRequestSegment = isSegmentFor('/test-pg'); test('pg queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => { // Each incoming request gets a Sentry http.server segment span (via the // default denoServeIntegration); the pg queries run inside it, so their // db spans join that trace. const spansPromise = collectStreamedSpans( - 'deno-pg', - spans => spans.some(isTestPgSegment) && spans.some(span => getSpanOp(span) === 'db'), + 'deno', + spans => spans.some(isRequestSegment) && spans.some(span => getSpanOp(span) === 'db'), ); const res = await fetch(`${baseURL}/test-pg`); @@ -45,8 +42,8 @@ test('a nested query lands on the same trace (AsyncLocalStorage context restored // (otherwise the nested query would start its own trace and never join // this one). const spansPromise = collectStreamedSpans( - 'deno-pg', - spans => spans.some(isTestPgSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, + 'deno', + spans => spans.some(isRequestSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, ); const res = await fetch(`${baseURL}/test-pg`); @@ -54,7 +51,7 @@ test('a nested query lands on the same trace (AsyncLocalStorage context restored await res.json(); const spans = await spansPromise; - const segment = spans.find(isTestPgSegment)!; + const segment = spans.find(isRequestSegment)!; const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); const queries = dbSpans.map(span => span.attributes['db.query.text']?.value); diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/redis.test.ts similarity index 76% rename from dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/redis.test.ts index 999444a1d3f6..4660f92ad7a8 100644 --- a/dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/redis.test.ts @@ -2,29 +2,14 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import type { SerializedStreamedSpan } from '@sentry/core'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only; the path lives in `url.path`. -function isSegmentFor(path: string): (span: SerializedStreamedSpan) => boolean { - return span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path; -} - -function isRedisCommand(span: SerializedStreamedSpan): boolean { - return getSpanOp(span) === 'db.query'; -} - -// `db.query.text` carries the key, so with span streaming a redis command span is named -// `{db.operation.name} {server.address}:{server.port}` instead. -function expectedCommandName(span: SerializedStreamedSpan): string { - const { 'db.operation.name': operation, 'server.address': address, 'server.port': port } = span.attributes; - return `${operation?.value} ${address?.value}:${port?.value}`; -} +import { expectedCommandName, isRedisCommand, isSegmentFor } from './utils'; test('GET command emits an http.server segment containing a db.query child span', async ({ baseURL }) => { // Each incoming request gets a Sentry http.server segment span (via the // default denoServeIntegration); the redis command runs inside it, so the // child span joins that trace. const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/redis-get')) && spans.some(isRedisCommand), ); @@ -49,7 +34,7 @@ test('GET command emits an http.server segment containing a db.query child span' test('SET then GET emit two db.query child spans on the same trace', async ({ baseURL }) => { const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/redis-set-get')) && spans.filter(isRedisCommand).length >= 2, ); @@ -71,7 +56,7 @@ test('MULTI batch emits a PIPELINE/MULTI batch span', async ({ baseURL }) => { const isBatchSpan = (span: SerializedStreamedSpan) => span.name === 'MULTI' || span.name === 'PIPELINE'; const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/redis-multi')) && spans.some(isBatchSpan), ); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts similarity index 94% rename from dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts index 41ae5a43d59a..5d46c1daa69c 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts @@ -1,15 +1,7 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { getSpanOp } from '@sentry-internal/test-utils'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only and the path lives in `url.path`. -function collectRequestSpans(path: string) { - return collectStreamedSpans('deno', spans => - spans.some( - span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path, - ), - ); -} +import { collectRequestSpans } from './utils'; const SEGMENT_SPAN = { attributes: { diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/streamed/utils.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/utils.ts new file mode 100644 index 000000000000..b21f72febe6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/utils.ts @@ -0,0 +1,23 @@ +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; + +// `Deno.serve` has no route information, so with span streaming the http.server segment is +// named after the method only; the path lives in `url.path`. +export function isSegmentFor(path: string): (span: SerializedStreamedSpan) => boolean { + return span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path; +} + +export function collectRequestSpans(path: string): Promise { + return collectStreamedSpans('deno', spans => spans.some(isSegmentFor(path))); +} + +export function isRedisCommand(span: SerializedStreamedSpan): boolean { + return getSpanOp(span) === 'db.query'; +} + +// `db.query.text` carries the key, so with span streaming a redis command span is named +// `{db.operation.name} {server.address}:{server.port}` instead. +export function expectedCommandName(span: SerializedStreamedSpan): string { + const { 'db.operation.name': operation, 'server.address': address, 'server.port': port } = span.attributes; + return `${operation?.value} ${address?.value}:${port?.value}`; +} diff --git a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/sentry-performance.test.ts b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/sentry-performance.test.ts index 57d9a6c26972..00a5eefc6d6e 100644 --- a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/sentry-performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/sentry-performance.test.ts @@ -19,6 +19,9 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.ember', + data: { + 'router.navigation.route.id': 'index', + }, }, }, transaction: 'route:index', @@ -47,6 +50,9 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.ember', + data: { + 'router.navigation.route.id': 'tracing', + }, }, }, transaction: 'route:tracing', diff --git a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/streamed-performance.test.ts b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/streamed-performance.test.ts index e66312a19316..a39ae9d880d2 100644 --- a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/streamed-performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/streamed-performance.test.ts @@ -4,6 +4,52 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; // Only the `ember-strict-resolver (streamed)` variant builds the app with `traceLifecycle: 'stream'`. test.skip(process.env.E2E_TEST_TRACE_LIFECYCLE !== 'stream', 'requires the app built with span streaming'); +test('adds the route ID to a streamed pageload', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => { + return ( + span.is_segment && + getSpanOp(span) === 'pageload' && + span.attributes['sentry.origin']?.value === 'auto.pageload.ember' + ); + }); + + await page.goto('/'); + + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.attributes['router.navigation.route.id']).toEqual({ type: 'string', value: 'index' }); + expect(pageloadSpan.name).toBe('route:index'); +}); + +test('adds the route ID to a streamed navigation', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => { + return ( + span.is_segment && + getSpanOp(span) === 'pageload' && + span.attributes['sentry.origin']?.value === 'auto.pageload.ember' + ); + }); + + await page.goto('/'); + await pageloadSpanPromise; + + const navigationSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => { + return ( + span.is_segment && + getSpanOp(span) === 'navigation' && + span.attributes['sentry.origin']?.value === 'auto.navigation.ember' + ); + }); + + await page.getByText('Tracing').click(); + await expect(page).toHaveURL(/\/tracing$/); + + const navigationSpan = await navigationSpanPromise; + + expect(navigationSpan.attributes['router.navigation.route.id']).toEqual({ type: 'string', value: 'tracing' }); + expect(navigationSpan.name).toBe('route:tracing'); +}); + test('names the transition span with the low cardinality fallback', async ({ page }) => { const transitionSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => getSpanOp(span) === 'router'); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/.gitignore b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/.gitignore new file mode 100644 index 000000000000..fdc614d4c5e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/.gitignore @@ -0,0 +1,6 @@ +node_modules +pnpm-lock.yaml +dist +.wrangler +test-results +playwright-report 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 new file mode 100644 index 000000000000..89364cbbe423 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json @@ -0,0 +1,56 @@ +{ + "name": "gen-ai-libraries", + "description": "Real gen_ai spans for every instrumented AI library (OpenAI, Anthropic, Mistral, Together, Vercel AI), each driven through OpenRouter with a chat query and a tool call, on Node and on Cloudflare", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", + "dev:cloudflare": "wrangler dev --config ./dist/gen_ai_libraries/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --var \"E2E_OPENROUTER_API_KEY:$E2E_OPENROUTER_API_KEY\" --port 38787", + "preview": "vite preview --port 38787", + "test": "playwright test", + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install", + "test:build:cloudflare": "pnpm install && vite build", + "test:assert": "pnpm test", + "test:assert:cloudflare": "RUNTIME=cloudflare pnpm test" + }, + "dependencies": { + "@anthropic-ai/sdk": "0.63.0", + "@mistralai/mistralai": "^2.6.4", + "@openrouter/ai-sdk-provider": "~3.0.0", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "ai": "~7.0.97", + "express": "^4.21.2", + "openai": "5.18.1", + "together-ai": "0.54.0", + "zod": "4.5.4" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "1.52.0", + "@cloudflare/workers-types": "^4.20260426.0", + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "tsx": "4.21.0", + "typescript": "^5.5.2", + "vite": "8.3.0", + "wrangler": "^4.86.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build:cloudflare", + "assert-command": "pnpm test:assert:cloudflare", + "label": "gen-ai-libraries (cloudflare)" + } + ] + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/playwright.config.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/playwright.config.ts new file mode 100644 index 000000000000..e3119c8f0ce2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/playwright.config.ts @@ -0,0 +1,21 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { RUNTIME } from './tests/constants'; + +// The same suite runs against both runtimes, selected by the `RUNTIME` env var (see the `sentryTest` +// variants in package.json): the Node entry (runtime channel injection) or the Cloudflare entry (Vite +// build + `@sentry/cloudflare/vite` plugin at build time, the prebuilt bundle served by `wrangler dev`). +const CF_PORT = 38787; +const NODE_PORT = 3030; + +const config = getPlaywrightConfig( + { + startCommand: RUNTIME === 'cloudflare' ? 'pnpm dev:cloudflare' : 'pnpm dev:node', + port: RUNTIME === 'cloudflare' ? CF_PORT : NODE_PORT, + }, + // Every test drives a real OpenRouter model call (a tool-calling turn does two) and then waits for + // the gen_ai spans to flush, which does not fit the default 30s test timeout when the provider is + // slow. + { timeout: 90_000, retries: 0 }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts new file mode 100644 index 000000000000..265b8122f0df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts @@ -0,0 +1,39 @@ +// The Cloudflare variant: the same libraries and routes as the Node entry, but instrumented by the +// `@sentry/cloudflare/vite` bundler plugin (build-time channel injection) and run on workerd. +import * as Sentry from '@sentry/cloudflare'; +import { libraries } from './libraries'; + +const byId = new Map(libraries.map(library => [library.id, library])); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, + }), + { + async fetch(request, env, _ctx) { + const url = new URL(request.url); + const [, id, action] = url.pathname.split('/'); + const library = id ? byId.get(id) : undefined; + + if (!library || (action !== 'chat' && action !== 'tools')) { + return new Response('Not found', { status: 404 }); + } + + const apiKey = env.E2E_OPENROUTER_API_KEY; + if (!apiKey) { + return new Response('E2E_OPENROUTER_API_KEY is not set', { status: 500 }); + } + + try { + const spanName = action === 'tools' ? 'ai-tool-workflow' : 'ai-workflow'; + const result = await Sentry.startSpan({ name: spanName, op: 'function' }, () => library[action](apiKey)); + return Response.json({ result }); + } catch (error) { + return Response.json({ message: (error as Error).message }, { status: 500 }); + } + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts new file mode 100644 index 000000000000..726924dbd69d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts @@ -0,0 +1,48 @@ +// `instrument.node.ts` is preloaded via `node --import`, so Sentry is already initialised here. +import * as Sentry from '@sentry/node'; +import express from 'express'; +import { libraries } from './libraries'; + +const apiKey = process.env.E2E_OPENROUTER_API_KEY; +if (!apiKey) { + throw new Error('E2E_OPENROUTER_API_KEY is not set'); +} + +const app = express(); + +// One `/:lib/chat` and `/:lib/tools` per instrumented library. Each SDK call is wrapped in a manual +// `ai-workflow` span, so the gen_ai span nests inside it, and it inside the auto-instrumented request +// span. +for (const library of libraries) { + app.get(`/${library.id}/chat`, async (_req, res, next) => { + try { + const answer = await Sentry.startSpan({ name: 'ai-workflow', op: 'function' }, () => library.chat(apiKey)); + res.send({ answer }); + } catch (error) { + next(error); + } + }); + + app.get(`/${library.id}/tools`, async (_req, res, next) => { + try { + const toolCalls = await Sentry.startSpan({ name: 'ai-tool-workflow', op: 'function' }, () => + library.tools(apiKey), + ); + res.send({ toolCalls }); + } 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(`gen-ai-libraries (Node) listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/env.d.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/env.d.ts new file mode 100644 index 000000000000..b7f1170dd7e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/env.d.ts @@ -0,0 +1,4 @@ +interface Env { + E2E_TEST_DSN: ''; + E2E_OPENROUTER_API_KEY: ''; +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/instrument.node.ts new file mode 100644 index 000000000000..4d8f7f625877 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/instrument.node.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; + +// Loaded through `node --import`, so the runtime channel-injection hook transforms the AI SDKs and +// express as they load. (The Cloudflare variant covers the build-time bundler-plugin injection path.) +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1, + enableRuntimeChannelInjection: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts new file mode 100644 index 000000000000..1b1490e7e9b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts @@ -0,0 +1,219 @@ +// Each gen-AI *library* (not framework) we instrument, exercised against a real model through +// OpenRouter — the single `E2E_OPENROUTER_API_KEY` the other AI e2e apps already use. Every entry runs +// a plain chat query and a forced tool call, so the same two assertions apply to all of them. These +// handlers are framework- and runtime-agnostic: `entry.node.ts` (express) and `entry.cloudflare.ts` +// (workerd) both call them, passing the key from their respective environments. +// +// OpenRouter serves an OpenAI-compatible `/api/v1/chat/completions` and an Anthropic-compatible +// `/api/v1/messages` ("Anthropic skin"), which is why the OpenAI, Together, Mistral, Anthropic and +// Vercel AI SDKs can all point at it. Every request uses the same `openai/gpt-4o-mini` model — the +// model is incidental; what is under test is each SDK's own request/response code path, the thing +// Sentry instruments. +// +// Two libraries we instrument are intentionally absent because they cannot reach OpenRouter: +// - Google GenAI (`@google/genai`) speaks the native Gemini `generateContent` format, which +// OpenRouter does not serve. +// - Groq (`groq-sdk`) hardcodes a `/openai/v1/...` request path that OpenRouter (served under +// `/api/v1`) does not expose. Its instrumentation is the shared OpenAI-compatible code path that +// Together exercises here, and it is covered by the node-integration-tests. +import Anthropic from '@anthropic-ai/sdk'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { Mistral } from '@mistralai/mistralai'; +import OpenAI from 'openai'; +import Together from 'together-ai'; +import { generateText, tool } from 'ai'; +import { z } from 'zod'; + +const OPENROUTER_V1 = 'https://openrouter.ai/api/v1'; +const OPENROUTER_BASE = 'https://openrouter.ai/api'; +const MODEL = 'openai/gpt-4o-mini'; + +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, and keeping +// "get_weather" out of the prompt means the string only appears in an actual recorded tool call, not +// in `gen_ai.input.messages`. +const WEATHER_PROMPT = `What is the weather in Paris? ${SHORT_ANSWER}`; +const SYSTEM = 'You are a helpful assistant used by an automated test.'; + +// OpenAI-style function tool, shared by the OpenAI-compatible SDKs. +const OPENAI_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'], + }, + }, +}; + +export interface Library { + id: string; + /** The op of the model-call span; asserted by the tests. */ + op: 'gen_ai.chat' | 'gen_ai.generate_content'; + /** `gen_ai.provider.name` for the direct-SDK libraries; unset for the Vercel AI SDK. */ + provider?: string; + /** `sentry.origin` for the direct-SDK libraries; unset for the Vercel AI SDK. */ + origin?: string; + chat: (apiKey: string) => Promise; + tools: (apiKey: string) => Promise; +} + +/** Chat + forced tool call for an OpenAI-compatible chat-completions client (OpenAI, Together). */ +function openAiCompatible( + id: string, + provider: string, + origin: string, + makeClient: (apiKey: string) => { chat: { completions: { create: (body: unknown) => Promise } } }, +): Library { + return { + id, + op: 'gen_ai.chat', + provider, + origin, + chat: async apiKey => { + const completion = await makeClient(apiKey).chat.completions.create({ + model: MODEL, + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: CHAT_PROMPT }, + ], + temperature: 0, + max_tokens: 32, + }); + return completion.choices?.[0]?.message?.content ?? ''; + }, + tools: async apiKey => { + const completion = await makeClient(apiKey).chat.completions.create({ + model: MODEL, + messages: [{ role: 'user', content: WEATHER_PROMPT }], + tools: [OPENAI_TOOL], + tool_choice: 'required', + max_tokens: 64, + }); + return completion.choices?.[0]?.message?.tool_calls ?? []; + }, + }; +} + +export const libraries: Library[] = [ + openAiCompatible('openai', 'openai', 'auto.ai.openai', apiKey => new OpenAI({ apiKey, baseURL: OPENROUTER_V1 })), + openAiCompatible( + 'together', + 'together_ai', + 'auto.ai.together_ai', + apiKey => new Together({ apiKey, baseURL: OPENROUTER_V1 }) as any, + ), + + { + id: 'mistral', + op: 'gen_ai.chat', + provider: 'mistralai', + origin: 'auto.ai.mistralai', + chat: async apiKey => { + const client = new Mistral({ apiKey, serverURL: OPENROUTER_BASE }); + const completion = await client.chat.complete({ + model: MODEL, + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: CHAT_PROMPT }, + ], + temperature: 0, + maxTokens: 32, + }); + return completion.choices?.[0]?.message?.content ?? ''; + }, + tools: async apiKey => { + const client = new Mistral({ apiKey, serverURL: OPENROUTER_BASE }); + const completion = await client.chat.complete({ + model: MODEL, + messages: [{ role: 'user', content: WEATHER_PROMPT }], + tools: [OPENAI_TOOL] as any, + // OpenRouter's OpenAI-compatible endpoint only accepts none/auto/required, not Mistral's `any`. + toolChoice: 'required', + maxTokens: 64, + }); + return completion.choices?.[0]?.message?.toolCalls ?? []; + }, + }, + + { + id: 'anthropic', + op: 'gen_ai.chat', + provider: 'anthropic', + origin: 'auto.ai.anthropic', + chat: async apiKey => { + // OpenRouter's Anthropic skin authenticates with a bearer token, so the key goes in `authToken` + // (Authorization: Bearer) rather than `apiKey` (x-api-key). + const client = new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE }); + const message = await client.messages.create({ + model: MODEL, + max_tokens: 32, + temperature: 0, + system: SYSTEM, + messages: [{ role: 'user', content: CHAT_PROMPT }], + }); + const first = message.content?.[0]; + return first && first.type === 'text' ? first.text : ''; + }, + tools: async apiKey => { + const client = new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE }); + const message = await client.messages.create({ + model: MODEL, + max_tokens: 64, + messages: [{ role: 'user', content: WEATHER_PROMPT }], + tools: [ + { + name: 'get_weather', + description: 'Get the current weather for a city.', + input_schema: { + type: 'object', + properties: { city: { type: 'string', description: 'The city name' } }, + required: ['city'], + }, + }, + ], + tool_choice: { type: 'tool', name: 'get_weather' }, + }); + return (message.content ?? []).filter(block => block.type === 'tool_use'); + }, + }, + + { + id: 'vercel-ai', + // The Vercel AI SDK emits `gen_ai.generate_content` (nested in a `gen_ai.invoke_agent` span), + // reports the provider from the model id, and uses its own span origin. + op: 'gen_ai.generate_content', + chat: async apiKey => { + const openrouter = createOpenRouter({ apiKey }); + const { text } = await generateText({ + model: openrouter(MODEL), + system: SYSTEM, + prompt: CHAT_PROMPT, + temperature: 0, + experimental_telemetry: { isEnabled: true }, + }); + return text; + }, + tools: async apiKey => { + const openrouter = createOpenRouter({ apiKey }); + const result = await generateText({ + model: openrouter(MODEL), + prompt: WEATHER_PROMPT, + toolChoice: 'required', + experimental_telemetry: { isEnabled: true }, + tools: { + get_weather: tool({ + description: 'Get the current weather for a city.', + inputSchema: z.object({ city: z.string().describe('The city name') }), + execute: async ({ city }) => `It is sunny in ${city}.`, + }), + }, + }); + return result.toolCalls ?? []; + }, + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/start-event-proxy.mjs similarity index 74% rename from dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/gen-ai-libraries/start-event-proxy.mjs index 0c77d1f6d4f3..ad68d9340fce 100644 --- a/dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'deno-redis', + proxyServerName: 'gen-ai-libraries', }); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat.test.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat.test.ts new file mode 100644 index 000000000000..59d406841821 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; +import { APP } from './constants'; +import { describeTree, expectCommonChatAttributes, isModelCallSpan, LIBRARIES, traceHasToolEvidence } from './utils'; + +for (const library of LIBRARIES) { + test(`${library.id}: a chat query emits a ${library.op} span`, async ({ baseURL }) => { + // Scope to this chat request's own trace: it carries this library's model-call span and, unlike the + // tools request, no tool-call evidence — so a leftover trace from another request cannot satisfy it. + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => spansOfTrace.some(span => isModelCallSpan(span, library)) && !traceHasToolEvidence(spansOfTrace), + ); + + const response = await fetch(`${baseURL}/${library.id}/chat`); + expect(response.status).toBe(200); + + const spans = await spansPromise; + const modelSpan = spans.find(span => isModelCallSpan(span, library)); + + expect(modelSpan, `expected a ${library.op} span in:\n${describeTree(spans)}`).toBeDefined(); + expectCommonChatAttributes(modelSpan!, library); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/constants.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/constants.ts new file mode 100644 index 000000000000..fb2a2d8999ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/constants.ts @@ -0,0 +1,5 @@ +export type Runtime = 'node' | 'cloudflare'; + +export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime; + +export const APP = 'gen-ai-libraries'; diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/tools.test.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/tools.test.ts new file mode 100644 index 000000000000..d2ff269ca0a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/tools.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { APP } from './constants'; +import { attr, describeTree, hasRecordedToolCalls, isExecuteToolSpan, isModelCallSpan, LIBRARIES } from './utils'; + +// The direct-SDK libraries record the model's tool call on the chat span's `gen_ai.response.tool_calls` +// attribute, which only exists when the model actually returned tool calls. +const DIRECT_SDK_LIBRARIES = LIBRARIES.filter(library => library.provider); + +for (const library of DIRECT_SDK_LIBRARIES) { + test(`${library.id}: the model's tool call is recorded on the ${library.op} span`, async ({ baseURL }) => { + // Scope to this tools request's trace: this library's model-call span, carrying recorded tool calls. + const spansPromise = collectStreamedSpans(APP, spansOfTrace => + spansOfTrace.some(span => isModelCallSpan(span, library) && hasRecordedToolCalls(span)), + ); + + const response = await fetch(`${baseURL}/${library.id}/tools`); + expect(response.status).toBe(200); + + const spans = await spansPromise; + const modelSpan = spans.find(span => isModelCallSpan(span, library) && hasRecordedToolCalls(span)); + + expect( + modelSpan, + `expected a ${library.op} span with recorded tool calls in:\n${describeTree(spans)}`, + ).toBeDefined(); + expect(attr(modelSpan!, 'gen_ai.response.tool_calls')).toContain('get_weather'); + }); +} + +// The Vercel AI SDK executes the tool and emits a dedicated `gen_ai.execute_tool` span instead. +test('vercel-ai: the tool call is captured as a gen_ai.execute_tool span', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + spansOfTrace.some(isExecuteToolSpan) && spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.generate_content'), + ); + + const response = await fetch(`${baseURL}/vercel-ai/tools`); + expect(response.status).toBe(200); + + const spans = await spansPromise; + const toolSpan = spans.find(isExecuteToolSpan); + + expect(toolSpan, `expected a gen_ai.execute_tool span in:\n${describeTree(spans)}`).toBeDefined(); + expect(attr(toolSpan!, 'gen_ai.tool.name')).toBe('get_weather'); +}); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/utils.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/utils.ts new file mode 100644 index 000000000000..4861a01c5334 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/utils.ts @@ -0,0 +1,80 @@ +import { expect } from '@playwright/test'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp } from '@sentry-internal/test-utils'; + +/** Mirrors `src/libraries.ts`, and records how strictly each library's spans can be asserted. */ +export interface LibraryUnderTest { + id: string; + /** The op of the model-call span: `gen_ai.chat` for the direct SDKs, `gen_ai.generate_content` for + * the Vercel AI SDK. */ + op: string; + /** `gen_ai.provider.name` for the direct-SDK libraries; unset for the Vercel AI SDK, whose provider + * name comes from the model id and is not asserted. */ + provider?: string; + /** `sentry.origin` for the direct-SDK libraries; unset for the Vercel AI SDK. */ + origin?: string; +} + +export const LIBRARIES: LibraryUnderTest[] = [ + { id: 'openai', op: 'gen_ai.chat', provider: 'openai', origin: 'auto.ai.openai' }, + { id: 'together', op: 'gen_ai.chat', provider: 'together_ai', origin: 'auto.ai.together_ai' }, + { id: 'mistral', op: 'gen_ai.chat', provider: 'mistralai', origin: 'auto.ai.mistralai' }, + { id: 'anthropic', op: 'gen_ai.chat', provider: 'anthropic', origin: 'auto.ai.anthropic' }, + { id: 'vercel-ai', op: 'gen_ai.generate_content' }, +]; + +export const attr = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; + +/** + * The model-call span for a library. The four direct SDKs all report `gen_ai.chat`, so the provider + * name is what tells them apart — without it, a leftover span from another library's request could be + * mistaken for this one's. + */ +export const isModelCallSpan = (span: SerializedStreamedSpan, library: LibraryUnderTest): boolean => + getSpanOp(span) === library.op && (!library.provider || attr(span, 'gen_ai.provider.name') === library.provider); + +/** A span that recorded the model returning tool calls on a chat-completions request (direct SDKs). */ +export const hasRecordedToolCalls = (span: SerializedStreamedSpan): boolean => + typeof attr(span, 'gen_ai.response.tool_calls') === 'string'; + +/** The dedicated tool-execution span the Vercel AI SDK emits. */ +export const isExecuteToolSpan = (span: SerializedStreamedSpan): boolean => getSpanOp(span) === 'gen_ai.execute_tool'; + +/** Whether a trace shows any evidence of a tool call, used to tell a chat request from a tools one. */ +export const traceHasToolEvidence = (spansOfTrace: SerializedStreamedSpan[]): boolean => + spansOfTrace.some(span => hasRecordedToolCalls(span) || isExecuteToolSpan(span)); + +/** A readable span tree, used as a failure message so a broken assertion is diagnosable. */ +export function describeTree(spans: SerializedStreamedSpan[]): string { + return spans + .map(span => `${span.name} [${getSpanOp(span) ?? '-'}] id=${span.span_id} parent=${span.parent_span_id ?? '-'}`) + .join('\n'); +} + +/** + * The attributes every successful gen_ai model-call span carries, whatever the model happens to + * answer. Model-dependent values (token counts, response text) are checked for shape, not content. + */ +export function expectCommonChatAttributes(span: SerializedStreamedSpan, library: LibraryUnderTest): void { + const operationName = library.op.replace('gen_ai.', ''); + + expect(getSpanOp(span), describeTree([span])).toBe(library.op); + expect(attr(span, 'gen_ai.operation.name')).toBe(operationName); + expect(span.status).toBe('ok'); + + expect(typeof attr(span, 'gen_ai.provider.name')).toBe('string'); + expect(typeof attr(span, 'gen_ai.request.model')).toBe('string'); + expect(typeof attr(span, 'gen_ai.response.model')).toBe('string'); + expect(attr(span, 'gen_ai.usage.input_tokens')).toBeGreaterThan(0); + expect(attr(span, 'gen_ai.usage.output_tokens')).toBeGreaterThan(0); + expect(attr(span, 'gen_ai.usage.total_tokens')).toBeGreaterThan(0); + + // The direct-SDK libraries carry a stable provider name and origin; the Vercel AI SDK does not. + if (library.provider) { + expect(attr(span, 'gen_ai.provider.name')).toBe(library.provider); + expect(span.name).toBe(`${operationName} ${attr(span, 'gen_ai.request.model')}`); + } + if (library.origin) { + expect(attr(span, 'sentry.origin')).toBe(library.origin); + } +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tsconfig.json b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tsconfig.json new file mode 100644 index 000000000000..91fe6c743269 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": ["node", "@cloudflare/workers-types/experimental"] + }, + "exclude": ["tests"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/vite.config.mts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/vite.config.mts new file mode 100644 index 000000000000..1c4c4863046d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/vite.config.mts @@ -0,0 +1,9 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +// Builds the Cloudflare variant (`src/entry.cloudflare.ts`, per `wrangler.toml`). The Node variant runs +// straight from source via tsx and does not use this config. +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/wrangler.jsonc new file mode 100644 index 000000000000..07b7bc9ee832 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "gen-ai-libraries", + "main": "src/entry.cloudflare.ts", + "compatibility_date": "2026-04-20", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts b/dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts deleted file mode 100644 index 3e7b0ad0a5d5..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { defineHook } from 'eve/hooks'; - -// Tags every turn of an eve session with the session id as the Sentry conversation id, so a -// session's AI spans — which land in separate traces (each turn is its own durable workflow) — -// group into one conversation in Sentry. -export default defineHook(Sentry.eveConversationHook()); diff --git a/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts b/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts deleted file mode 100644 index e2d33fd49591..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as Sentry from '@sentry/node'; - -// eve auto-discovers `agent/instrumentation.ts` and runs it at server startup, -// before it loads the agent (and the `ai` SDK). That is early enough for the -// Sentry SDK to install its instrumentation, so no `--import` / `NODE_OPTIONS` -// bootstrap is needed. eve's own OpenTelemetry pipeline is intentionally left -// unused: the gen_ai spans come from Sentry's `ai` instrumentation, not OTel. -Sentry.init({ - environment: 'qa', - dsn: process.env.E2E_TEST_DSN, - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1.0, - // Not a default integration. It only produces spans in the "orchestrion" test - // variant, where the server is started with - // `NODE_OPTIONS=--import=@sentry/node/import` so the orchestrion module - // transform is registered before `dataloader` loads. - integrations: [Sentry.dataloaderIntegration()], -}); diff --git a/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts b/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts new file mode 100644 index 000000000000..850b9983ac20 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { defineInstrumentation } from 'eve/instrumentation'; + +export default defineInstrumentation( + Sentry.eveInstrumentation({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, + }), +); 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 e95a1aaafe41..f51d32a86a5f 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/package.json +++ b/dev-packages/e2e-tests/test-applications/node-eve/package.json @@ -26,7 +26,7 @@ "@sentry/node": "file:../../packed/sentry-node-packed.tgz", "ai": "^7.0.82", "dataloader": "^2.2.3", - "eve": "^0.52.3", + "eve": "^0.63.0", "zod": "4.5.4" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts index 14b458ca82d7..9099824bd7be 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts @@ -76,8 +76,8 @@ test('captures Vercel AI agent spans (invoke_agent, generate_content, execute_to expect(manualSpan?.trace_id).toBe(executeTool?.trace_id); expect(manualSpan?.parent_span_id).toBe(executeTool?.span_id); - // `agent/hooks/sentry.ts` sets the eve session id as the conversation id via - // `Sentry.eveConversationHook()`, so every gen_ai span in the turn is tagged with it — that is + // `Sentry.eveInstrumentation()` (see `agent/instrumentation/sentry.ts`) sets the eve session id as + // the conversation id on each turn, so every gen_ai span in the turn is tagged with it — that is // what links a multi-turn session (each turn is its own trace) into one Sentry conversation. expect(sessionId).toBeTruthy(); for (const span of [invokeAgent, generateContent, executeTool]) { diff --git a/dev-packages/e2e-tests/test-applications/node-flue/.gitignore b/dev-packages/e2e-tests/test-applications/node-flue/.gitignore new file mode 100644 index 000000000000..2685f6ec088a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.flue +*.tsbuildinfo +results.junit.xml +test-results +playwright-report diff --git a/dev-packages/e2e-tests/test-applications/node-flue/flue.config.ts b/dev-packages/e2e-tests/test-applications/node-flue/flue.config.ts new file mode 100644 index 000000000000..a31c8523a9a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/flue.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@flue/runtime/config'; + +export default defineConfig({ + target: 'node', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/package.json b/dev-packages/e2e-tests/test-applications/node-flue/package.json new file mode 100644 index 000000000000..7f6afaeff6b8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/package.json @@ -0,0 +1,50 @@ +{ + "name": "node-flue", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "NODE_OPTIONS='--import=@sentry/node/import' vite dev --port 3030", + "build": "vite build", + "start": "NODE_OPTIONS='--import=@sentry/node/import' PORT=3030 node dist/server.mjs", + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm install && pnpm add @flue/runtime@latest @flue/vite@latest @flue/cli@latest && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:prod": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY TEST_ENV=production playwright test", + "test:dev": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY TEST_ENV=development playwright test" + }, + "dependencies": { + "@flue/runtime": "2.0.5", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "dataloader": "^2.2.3", + "hono": "^4.7.0", + "valibot": "^1.5.0" + }, + "devDependencies": { + "@flue/cli": "2.0.5", + "@flue/vite": "2.0.5", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@types/node": "24.x", + "typescript": "~5.9.0", + "vite": "^8.0.14" + }, + "engines": { + "node": "24.x" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "node-flue (latest)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-flue/playwright.config.mjs new file mode 100644 index 000000000000..1bbde34c9fce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/playwright.config.mjs @@ -0,0 +1,16 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const config = getPlaywrightConfig( + { startCommand: testEnv === 'development' ? 'pnpm dev' : 'pnpm start' }, + // Each test drives a real OpenRouter tool-calling turn and then waits for the spans to flush, + // which does not fit the default 30s timeout when the provider is slow. + { timeout: 90_000 }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-flue/sentry-init.ts b/dev-packages/e2e-tests/test-applications/node-flue/sentry-init.ts new file mode 100644 index 000000000000..199d1bb11fcd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/sentry-init.ts @@ -0,0 +1,13 @@ +import { instrument } from '@flue/runtime'; +import * as Sentry from '@sentry/node'; + +// Imported for its side effects as the first line of `src/app.ts`, which is how a Flue app sets +// Sentry up: there is no framework-owned instrumentation hook to auto-discover. +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, +}); + +instrument(Sentry.createFlueInstrumentation()); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts b/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts new file mode 100644 index 000000000000..baf29050d836 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts @@ -0,0 +1,53 @@ +'use agent'; +import { useModel, useTool } from '@flue/runtime'; +import * as Sentry from '@sentry/node'; +import * as v from 'valibot'; +import DataLoader from 'dataloader'; + +// The `'use agent'` directive is how `@flue/vite` finds this module and binds an identity to it at +// build time. That binding is the part a hand-written scenario cannot reproduce, so it is the main +// reason this app exists alongside the node-integration-test suite. +export function Hello() { + useModel('openrouter/anthropic/claude-haiku-4.5'); + + useTool({ + name: 'get_weather', + description: 'Get the current weather for a city.', + input: v.object({ city: v.string() }), + // Wrapped in a manual span: Flue runs the tool while the SDK's `execute_tool` span is active, + // so this should nest directly under it rather than landing beside it. + run: ({ city }) => + Sentry.startSpan( + { name: 'resolve-weather', attributes: { 'weather.source': 'static-table', 'weather.city': city } }, + () => { + return `It is 21 degrees and sunny in ${city}.`; + }, + ), + }); + + // Called from inside a tool on purpose: the dataloader span then lands under `execute_tool` in + // the agent's trace, which is what "captured alongside the AI spans" has to mean. + useTool({ + name: 'count_items', + description: 'Count items by loading them. Call this when the user asks to count items.', + input: v.object({}), + // Constructed per execution, like node-eve does: a module-level loader caches its keys, so a + // second call would skip the batch function and emit no span. + run: async () => { + const loader = new DataLoader(async keys => keys.map(key => key * 2)); + const doubled = await Promise.all([loader.load(1), loader.load(2), loader.load(3)]); + return `Loaded ${doubled.length} items: ${doubled.join(', ')}.`; + }, + }); + + useTool({ + name: 'fail_now', + description: 'Always throws an error. Call this when the user asks to trigger a failure.', + input: v.object({}), + run: () => { + throw new Error('Intentional flue tool failure'); + }, + }); + + return 'You are a helpful assistant. Use get_weather when asked about weather, count_items when asked to count items, and fail_now when asked to fail.'; +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue/src/app.ts b/dev-packages/e2e-tests/test-applications/node-flue/src/app.ts new file mode 100644 index 000000000000..b54a3357e4ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/src/app.ts @@ -0,0 +1,10 @@ +import '../sentry-init.ts'; +import { createAgentRouter } from '@flue/runtime/routing'; +import { Hono } from 'hono'; +import { Hello } from './agents/hello.ts'; + +const app = new Hono(); + +app.route('/agents/hello', createAgentRouter(Hello)); + +export default app; diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-flue/start-event-proxy.mjs similarity index 77% rename from dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/node-flue/start-event-proxy.mjs index 7f5c950f439e..a452f0dd18d3 100644 --- a/dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/node-flue/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'deno-pg', + proxyServerName: 'node-flue', }); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/dataloader.test.ts new file mode 100644 index 000000000000..a4fed849c3b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/dataloader.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { newConversationId, runAgentTurn } from './utils'; + +const APP = 'node-flue'; + +const isDataloaderSpan = (span: { attributes?: Record }): boolean => + span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader'; + +/** + * `dataloader` is instrumented through orchestrion, a module transform, so it only produces spans + * with the loader registered at process start. A Flue node build needs no externals config for + * that: dependencies stay bare specifiers, so `dataloader` is still a real module to hook. If Flue + * ever switches to a bundled server output, this is what catches it. + * + * The loader is called from inside a tool so its span lands in the agent's trace, beside the AI + * spans, rather than in a trace of its own. + */ +test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items') && + spansOfTrace.some(isDataloaderSpan), + ); + + await runAgentTurn(baseURL!, newConversationId('dataloader'), 'Please call count_items to count the items.'); + + const spans = await spansPromise; + const dataloaderSpan = spans.find(isDataloaderSpan); + const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items'); + + // Sharing the trace is the point: the orchestrion span is captured alongside the AI spans rather + // than in a trace of its own. Not asserting the exact parent — the model may call the tool more + // than once, and the span that ran the loader is not reliably the one found here. + expect(getSpanOp(dataloaderSpan!)).toBe('cache.get'); + expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/errors.test.ts new file mode 100644 index 000000000000..f8b59be4fc1b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/errors.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, waitForError } from '@sentry-internal/test-utils'; +import { newConversationId, runAgentTurn } from './utils'; + +const APP = 'node-flue'; + +test('captures an error thrown inside a Flue tool and marks its span errored', async ({ baseURL }) => { + const errorPromise = waitForError( + APP, + event => event.exception?.values?.[0]?.value === 'Intentional flue tool failure', + ); + const spansPromise = collectStreamedSpans(APP, spansOfTrace => + spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'), + ); + + await runAgentTurn(baseURL!, newConversationId('failure'), 'Please call fail_now to trigger a failure.'); + + const error = await errorPromise; + expect(error.exception?.values?.[0]?.value).toBe('Intentional flue tool failure'); + + const spans = await spansPromise; + const executeTool = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'); + expect(executeTool?.status).toBe('error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts new file mode 100644 index 000000000000..ccf3e552a87d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { newConversationId, runAgentTurn } from './utils'; + +const APP = 'node-flue'; + +type SpanLike = { name?: string; attributes?: Record }; + +const hasOps = (ops: string[]) => (spansOfTrace: SpanLike[]) => + ops.every(op => spansOfTrace.some(span => getSpanOp(span) === op)); + +// Anchored on the tool the test drives, so a leftover trace from another test cannot satisfy it. +const usedTool = (toolName: string) => (spansOfTrace: SpanLike[]) => + spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === toolName); + +test('captures the invoke_agent / chat / execute_tool hierarchy for a Flue turn', async ({ baseURL }) => { + // The trace flushes across several envelopes, so accumulate it rather than asserting on one. + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + hasOps(['gen_ai.invoke_agent', 'gen_ai.chat', 'gen_ai.execute_tool'])(spansOfTrace) && + usedTool('get_weather')(spansOfTrace), + ); + + await runAgentTurn(baseURL!, newConversationId('weather'), 'What is the weather in Paris?'); + + const spans = await spansPromise; + const invokeAgent = spans.find(span => getSpanOp(span) === 'gen_ai.invoke_agent'); + const chat = spans.find(span => getSpanOp(span) === 'gen_ai.chat'); + const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); + + expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.ai.flue'); + expect(invokeAgent?.attributes?.['gen_ai.operation.name']?.value).toBe('invoke_agent'); + expect(invokeAgent?.attributes?.['gen_ai.agent.name']?.value).toBe('Hello'); + + expect(chat?.attributes?.['sentry.origin']?.value).toBe('auto.ai.flue'); + expect(chat?.attributes?.['gen_ai.provider.name']?.value).toBe('openrouter'); + expect(typeof chat?.attributes?.['gen_ai.usage.input_tokens']?.value).toBe('number'); + expect(typeof chat?.attributes?.['gen_ai.usage.output_tokens']?.value).toBe('number'); + // Flue computes cost itself; no provider SDK reports it. + expect(typeof chat?.attributes?.['gen_ai.cost.total_tokens']?.value).toBe('number'); + + expect(executeTool?.attributes?.['gen_ai.tool.name']?.value).toBe('get_weather'); + + // Tool and chat spans are siblings under the agent invocation, matching how Flue's own + // OpenTelemetry adapter projects them. + expect(chat?.parent_span_id).toBe(invokeAgent?.span_id); + expect(executeTool?.parent_span_id).toBe(invokeAgent?.span_id); +}); + +test('nests a manual span raised inside a tool under that tool span', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => usedTool('get_weather')(spansOfTrace) && spansOfTrace.some(span => span.name === 'resolve-weather'), + ); + + await runAgentTurn(baseURL!, newConversationId('manual-span'), 'What is the weather in Berlin?'); + + const spans = await spansPromise; + const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); + const manualSpan = spans.find(span => span.name === 'resolve-weather'); + + expect(manualSpan?.attributes?.['weather.source']?.value).toBe('static-table'); + expect(manualSpan?.trace_id).toBe(executeTool?.trace_id); + expect(manualSpan?.parent_span_id).toBe(executeTool?.span_id); +}); + +// Flue's `model` operation is wrapped so the turn span is active for it, which is what puts the +// provider's HTTP call inside `chat` rather than beside it. +test('nests the provider HTTP call inside the chat span', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.chat') && + spansOfTrace.some(span => getSpanOp(span) === 'http.client'), + ); + + await runAgentTurn(baseURL!, newConversationId('provider-http'), 'Say hello.'); + + const spans = await spansPromise; + const chat = spans.find(span => getSpanOp(span) === 'gen_ai.chat'); + const providerCall = spans.find(span => getSpanOp(span) === 'http.client'); + + expect(providerCall?.trace_id).toBe(chat?.trace_id); + expect(providerCall?.parent_span_id).toBe(chat?.span_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/utils.ts new file mode 100644 index 000000000000..02af2469f47b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/utils.ts @@ -0,0 +1,46 @@ +import { expect } from '@playwright/test'; + +/** + * A conversation id nothing has used yet. + * + * `runAgentTurn` waits for the conversation to report a settlement, so a fixed id that already has + * one — a Playwright retry, or the `test:dev` run hitting the record `test:prod` left behind — + * would return before the new turn finished. + */ +export function newConversationId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Run one agent turn over Flue's agent router and wait for it to settle. + * + * `POST /:id` only admits the work — it returns `202` with a `streamUrl` and the turn runs after. + * Returning there would let one test's turn still be emitting spans while the next one waits for + * spans of its own, so a leftover trace could satisfy the wrong assertion. Reading the conversation + * back until it reports a settlement keeps each test to its own turn. + * + * The conversation id is ours to choose: it is the `:id` path segment. It is not the + * `gen_ai.conversation.id` attribute, which Flue generates. + */ +export async function runAgentTurn(baseURL: string, conversationId: string, message: string): Promise { + const url = `${baseURL}/agents/hello/${conversationId}`; + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'user', body: message }), + }); + expect(res.status).toBe(202); + await res.text(); + + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const conversation = (await (await fetch(url)).json()) as { settlements?: unknown[] }; + if (conversation.settlements?.length) { + return; + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + + throw new Error(`Flue turn for "${conversationId}" did not settle within 60s`); +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-flue/tsconfig.json new file mode 100644 index 000000000000..2cb98b13c9f8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "sentry-init.ts", "flue.config.ts", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue/vite.config.ts b/dev-packages/e2e-tests/test-applications/node-flue/vite.config.ts new file mode 100644 index 000000000000..2f495966e204 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/vite.config.ts @@ -0,0 +1,9 @@ +import { flue } from '@flue/vite'; +import { defineConfig } from 'vite'; + +// Unmodified from what `flue init` scaffolds. No externals config is needed: a Flue node build +// already leaves dependencies as bare specifiers, so orchestrion's module transform still sees them +// as real modules. +export default defineConfig({ + plugins: [flue()], +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/.gitignore b/dev-packages/e2e-tests/test-applications/node-mistral/.gitignore deleted file mode 100644 index 1521c8b7652b..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/build.mjs b/dev-packages/e2e-tests/test-applications/node-mistral/build.mjs deleted file mode 100644 index acb282f9c706..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/build.mjs +++ /dev/null @@ -1,40 +0,0 @@ -// Produces the prod-mode artifact: a single bundle whose `@mistralai/mistralai`, `dataloader` and -// `express` copies were transformed at build time by `sentryEsbuildPlugin`. Nothing is left for a -// runtime hook to do, which is what `enableRuntimeChannelInjection: false` in `instrument.mjs` -// asserts. -// -// `@sentry/node` stays external: the SDK is the subscriber, not a transform target, and inlining it -// would force its CommonJS `require('node:async_hooks')` through esbuild's ESM interop for no gain. -// CJS output for the same reason the `node-esbuild` app uses it. Left unminified so the injected -// snippet keeps its identifiers. -import { rmSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { sentryEsbuildPlugin } from '@sentry/node/esbuild'; -import { build } from 'esbuild'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); - -await build({ - entryPoints: [join(__dirname, 'src', 'app.mjs')], - outfile: join(__dirname, 'dist', 'app.cjs'), - bundle: true, - platform: 'node', - format: 'cjs', - target: 'node18', - external: ['@sentry/node'], - minify: false, - logLevel: 'info', - plugins: [ - sentryEsbuildPlugin({ - telemetry: false, - sourcemaps: { disable: true }, - release: { create: false, finalize: false, inject: false }, - }), - ], -}); - -// eslint-disable-next-line no-console -console.log('built dist/app.cjs with sentryEsbuildPlugin'); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/package.json b/dev-packages/e2e-tests/test-applications/node-mistral/package.json deleted file mode 100644 index 16b53b297d36..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "node-mistral", - "description": "Mistral AI gen_ai spans, errors, span nesting and co-instrumented dataloader spans, exercised through both the runtime loader (dev) and a bundler-instrumented build (prod)", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "start": "node --import ./src/instrument.mjs src/app.mjs", - "start:bundled": "node dist/app.cjs", - "build": "node build.mjs", - "clean": "npx rimraf node_modules dist pnpm-lock.yaml", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev", - "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development playwright test" - }, - "dependencies": { - "@mistralai/mistralai": "^2.6.4", - "@sentry/node": "file:../../packed/sentry-node-packed.tgz", - "dataloader": "^2.2.2", - "express": "^4.21.2" - }, - "devDependencies": { - "@playwright/test": "~1.56.0", - "@sentry-internal/test-utils": "link:../../../test-utils", - "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz", - "@sentry/core": "file:../../packed/sentry-core-packed.tgz", - "esbuild": "0.28.2" - }, - "sentryTest": { - "optional": true - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-mistral/playwright.config.mjs deleted file mode 100644 index 39daff08107f..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/playwright.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -// The suite runs twice, once per instrumentation path, the way `node-mastra` splits dev and prod: -// -// production - `dist/app.cjs`, whose Mistral, dataloader and express copies were transformed at -// build time by `sentryEsbuildPlugin`. `instrument.mjs` turns runtime injection off -// there, so the bundler plugin is the only thing that can have instrumented them. -// development - unbundled ESM behind the runtime `--import` hook. -const isDev = process.env.TEST_ENV === 'development'; - -const config = getPlaywrightConfig({ - startCommand: isDev ? 'pnpm start' : 'pnpm start:bundled', -}); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-mistral/src/app.mjs deleted file mode 100644 index f9e408f057b4..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/src/app.mjs +++ /dev/null @@ -1,180 +0,0 @@ -// `instrument.mjs` is imported for its side effect in the prod bundle; in dev `--import` has already -// run it, and a second import is a no-op because ES modules are evaluated once. -import './instrument.mjs'; - -import { Mistral } from '@mistralai/mistralai'; -import * as Sentry from '@sentry/node'; -import DataLoader from 'dataloader'; -import express from 'express'; - -const apiKey = process.env.E2E_OPENROUTER_API_KEY; -if (!apiKey) { - throw new Error('E2E_OPENROUTER_API_KEY is not set'); -} - -// The Mistral SDK talks to OpenRouter rather than api.mistral.ai, so the suite needs only the one -// OpenRouter key the other AI e2e apps already use. OpenRouter serves an OpenAI-compatible -// `/v1/chat/completions`, which is the endpoint `chat.complete` and `chat.stream` post to, and the -// SDK's response schemas are lenient enough to accept it (`usage` has a `catchall`, `finish_reason` -// is an open enum). What is under test is the SDK's own code path, which is what Sentry instruments. -const client = new Mistral({ apiKey, serverURL: 'https://openrouter.ai/api' }); - -// Same model the eve and mastra apps drive through this key. The model is incidental here; the -// Mistral SDK request/response path is the thing being instrumented. -const MODEL = 'openai/gpt-4o-mini'; - -// Kept short so a live model stays cheap and quick, and so streamed responses still arrive in more -// than one chunk. -const SHORT_ANSWER = 'Answer in at most five words.'; - -const userLoader = new DataLoader(async keys => keys.map(key => ({ id: key, name: `user-${key}` }))); - -async function main() { - const port = Number(process.env.PORT ?? 3030); - const app = express(); - - app.get('/chat', async (req, res) => { - // A manual span wrapping the SDK call: the gen_ai span has to nest inside this one, and this one - // has to nest inside the auto-instrumented request span. - const answer = await Sentry.startSpan({ name: 'ai-workflow', op: 'function' }, async () => { - const completion = await client.chat.complete({ - model: MODEL, - messages: [ - { role: 'system', content: 'You are a helpful assistant used by an automated test.' }, - { role: 'user', content: `What is the capital of France? ${SHORT_ANSWER}` }, - ], - temperature: 0, - maxTokens: 32, - }); - - // A manual sibling of the gen_ai span, so the assertions can tell "child of the manual span" - // apart from "child of whatever ran last". - return Sentry.startSpan( - { name: 'post-process', op: 'function' }, - () => completion.choices?.[0]?.message?.content ?? '', - ); - }); - - res.send({ answer }); - }); - - app.get('/chat-stream', async (req, res) => { - const chunks = []; - - await Sentry.startSpan({ name: 'ai-stream-workflow', op: 'function' }, async () => { - const stream = await client.chat.stream({ - model: MODEL, - messages: [{ role: 'user', content: `Name three colours. ${SHORT_ANSWER}` }], - temperature: 0, - maxTokens: 32, - }); - - for await (const event of stream) { - const content = event.data?.choices?.[0]?.delta?.content; - if (typeof content === 'string') { - chunks.push(content); - } - } - }); - - res.send({ answer: chunks.join('') }); - }); - - // `tee()` acquires its reader through internal slots rather than the public `getReader`, so it is - // the drain path most likely to escape instrumentation. Both branches are drained so the response - // only comes back once the stream is finished. - app.get('/chat-stream-tee', async (req, res) => { - const branches = await Sentry.startSpan({ name: 'ai-tee-workflow', op: 'function' }, async () => { - const stream = await client.chat.stream({ - model: MODEL, - messages: [{ role: 'user', content: `Name three colours. ${SHORT_ANSWER}` }], - temperature: 0, - maxTokens: 32, - }); - - const [left, right] = stream.tee(); - - const drain = async branch => { - const parts = []; - for await (const event of branch) { - const content = event.data?.choices?.[0]?.delta?.content; - if (typeof content === 'string') { - parts.push(content); - } - } - return parts.join(''); - }; - - return Promise.all([drain(left), drain(right)]); - }); - - res.send({ left: branches[0], right: branches[1] }); - }); - - // Relays the stream through a transform, the shape an edge handler would use to forward tokens. - app.get('/chat-stream-pipe', async (req, res) => { - const answer = await Sentry.startSpan({ name: 'ai-pipe-workflow', op: 'function' }, async () => { - const stream = await client.chat.stream({ - model: MODEL, - messages: [{ role: 'user', content: `Name three colours. ${SHORT_ANSWER}` }], - temperature: 0, - maxTokens: 32, - }); - - const relayed = stream.pipeThrough( - new TransformStream({ - transform(event, controller) { - controller.enqueue(event.data?.choices?.[0]?.delta?.content ?? ''); - }, - }), - ); - - const parts = []; - for await (const part of relayed) { - parts.push(part); - } - return parts.join(''); - }); - - res.send({ answer }); - }); - - // A model id the upstream will reject, so the failure is a real API error rather than a simulated - // one. The caller-supplied id makes each request identifiable in the spans it produces. - app.get('/chat-error', async (req, res, next) => { - const model = `no-such-model/${req.query.id ?? 'default'}`; - - try { - await client.chat.complete({ model, messages: [{ role: 'user', content: 'This will fail' }] }); - res.send({ ok: true }); - } catch (error) { - // Rethrown through the express error handler so the SDK captures it the way a real app would. - next(new Error(`Mistral call failed for ${model}: ${error.message}`)); - } - }); - - // A dataloader (orchestrion-instrumented, like Mistral) and a Mistral call in one request, so the - // assertions can prove both sets of spans land in the same trace. - app.get('/dataloader-and-chat', async (req, res) => { - const user = await userLoader.load(`${req.query.id ?? '1'}`); - - const completion = await client.chat.complete({ - model: MODEL, - messages: [{ role: 'user', content: `Say hello to ${user.name}. ${SHORT_ANSWER}` }], - temperature: 0, - maxTokens: 32, - }); - - res.send({ user, answer: completion.choices?.[0]?.message?.content ?? '' }); - }); - - Sentry.setupExpressErrorHandler(app); - - app.use((error, req, res, _next) => { - res.status(500).send({ message: error.message }); - }); - - app.listen(port); -} - -void main(); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-mistral/src/instrument.mjs deleted file mode 100644 index 30abaa918aa1..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/src/instrument.mjs +++ /dev/null @@ -1,24 +0,0 @@ -// Shared Sentry bootstrap for both modes. -// -// dev - loaded through `node --import`, so the runtime channel-injection hook transforms -// `@mistralai/mistralai`, `dataloader` and `express` as they load. -// prod - bundled into `dist/app.cjs` by `build.mjs`, where `sentryEsbuildPlugin` applies the same -// transforms at build time. Runtime injection is switched off there so the bundler plugin is -// the only possible injector and a passing prod test really proves the build-time path. -import * as Sentry from '@sentry/node'; - -// `production` is the bundled build, where `sentryEsbuildPlugin` already injected the channels. -const isDev = process.env.TEST_ENV === 'development'; - -Sentry.init({ - environment: 'qa', - dsn: process.env.E2E_TEST_DSN, - debug: !!process.env.DEBUG, - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1, - traceLifecycle: 'stream', - enableRuntimeChannelInjection: isDev, - integrations: [Sentry.spanStreamingIntegration()], -}); - -Sentry.setTag('e2e.mode', isDev ? 'development' : 'production'); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-mistral/start-event-proxy.mjs deleted file mode 100644 index 2c8fdc947553..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'node-mistral', -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/ai-spans.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/ai-spans.test.ts deleted file mode 100644 index aabe2af66f5c..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/ai-spans.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; -import { APP, attr, expectCommonChatAttributes, isChatSpan } from './utils'; - -test('emits a gen_ai.chat span for a non-streaming call', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat'); - - const response = await request.get(`${baseURL}/chat`); - expect(response.status()).toBe(200); - expect((await response.json()).answer).toBeTruthy(); - - const spans = await spansPromise; - const chatSpan = spans.find(isChatSpan); - - expect(chatSpan).toBeDefined(); - expectCommonChatAttributes(chatSpan!); - expect(attr(chatSpan!, 'gen_ai.request.stream')).toBe(false); - expect(attr(chatSpan!, 'gen_ai.request.temperature')).toBe(0); - expect(attr(chatSpan!, 'gen_ai.request.max_tokens')).toBe(32); -}); - -test('emits a gen_ai.chat span for a streaming call', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat-stream'); - - const response = await request.get(`${baseURL}/chat-stream`); - expect(response.status()).toBe(200); - expect((await response.json()).answer).toBeTruthy(); - - const spans = await spansPromise; - const streamSpan = spans.find(isChatSpan); - - expect(streamSpan).toBeDefined(); - expectCommonChatAttributes(streamSpan!); - // Set from the called method: v2's `stream` request field is optional and the app never passes it. - expect(attr(streamSpan!, 'gen_ai.request.stream')).toBe(true); - expect(attr(streamSpan!, 'gen_ai.response.streaming')).toBe(true); -}); - -test('records inputs and outputs in the shape the gen_ai conventions specify', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat'); - - await request.get(`${baseURL}/chat`); - - const spans = await spansPromise; - const chatSpan = spans.find(isChatSpan)!; - - // The system message is split out from the rest of the prompt. - expect(attr(chatSpan, 'gen_ai.system_instructions')).toContain('automated test'); - expect(attr(chatSpan, 'gen_ai.input.messages')).toContain('capital of France'); - - // A stringified array of messages, not one concatenated string. - const responseText = JSON.parse(attr(chatSpan, 'gen_ai.response.text') as string); - expect(Array.isArray(responseText)).toBe(true); - expect(responseText).toHaveLength(1); - expect(typeof responseText[0]).toBe('string'); - - const outputMessages = JSON.parse(attr(chatSpan, 'gen_ai.output.messages') as string); - expect(outputMessages).toEqual([ - { - role: 'assistant', - parts: [{ type: 'text', content: expect.any(String) }], - finish_reason: expect.any(String), - }, - ]); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/co-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/co-instrumentation.test.ts deleted file mode 100644 index 9888e154f7cf..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/co-instrumentation.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; -import { APP, attr, isChatSpan } from './utils'; - -// Mistral and dataloader are both instrumented through orchestrion, so one request that touches -// both proves the Mistral channels coexist with the rest of the injected set rather than displacing -// them. dataloader is also CommonJS where Mistral is ESM-only, so this covers both module formats -// going through the same transform in one process. -test('emits dataloader spans alongside gen_ai spans in one trace', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /dataloader-and-chat'); - - const response = await request.get(`${baseURL}/dataloader-and-chat?id=7`); - expect(response.status()).toBe(200); - expect((await response.json()).user).toEqual({ id: '7', name: 'user-7' }); - - const spans = await spansPromise; - const segment = spans.find(span => span.is_segment && span.name === 'GET /dataloader-and-chat')!; - - const chatSpan = spans.find(isChatSpan); - const dataloaderSpans = spans.filter(span => attr(span, 'sentry.origin') === 'auto.db.dataloader'); - - expect(chatSpan).toBeDefined(); - expect(dataloaderSpans.length).toBeGreaterThan(0); - - // `load` is recorded as a cache read. - expect(dataloaderSpans.some(span => getSpanOp(span) === 'cache.get')).toBe(true); - - // Both instrumentations contribute to the same trace, under the same request. - expect(chatSpan!.trace_id).toBe(segment.trace_id); - for (const span of dataloaderSpans) { - expect(span.trace_id).toBe(segment.trace_id); - } -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/drain-paths.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/drain-paths.test.ts deleted file mode 100644 index 40563d376a4d..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/drain-paths.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; -import { APP, attr, byName, expectCommonChatAttributes, isChatSpan } from './utils'; - -// `tee`, `pipeTo` and `pipeThrough` take their reader from internal slots rather than the public -// `getReader`, so they bypass a stream instrumented only through `getReader` and the async iterator. -// These cover the two an app is realistically built on: teeing to relay and persist at once, and -// piping through a transform to forward tokens to a client. - -test('records a gen_ai span for a teed stream, once', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat-stream-tee'); - - const response = await request.get(`${baseURL}/chat-stream-tee`); - expect(response.status()).toBe(200); - - // Both branches receive the same stream. - const { left, right } = await response.json(); - expect(left).toBeTruthy(); - expect(left).toBe(right); - - const spans = await spansPromise; - const chatSpans = spans.filter(isChatSpan); - - // One span, not one per tee branch. - expect(chatSpans).toHaveLength(1); - const chatSpan = chatSpans[0]!; - - expectCommonChatAttributes(chatSpan); - expect(attr(chatSpan, 'gen_ai.request.stream')).toBe(true); - expect(attr(chatSpan, 'gen_ai.response.streaming')).toBe(true); - - // Nesting still holds on this drain path. - const segment = spans.find(span => span.is_segment && span.name === 'GET /chat-stream-tee')!; - const workflow = byName(spans, 'ai-tee-workflow'); - expect(chatSpan.parent_span_id).toBe(workflow.span_id); - expect(chatSpan.trace_id).toBe(segment.trace_id); -}); - -test('records a gen_ai span for a stream relayed through a transform', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat-stream-pipe'); - - const response = await request.get(`${baseURL}/chat-stream-pipe`); - expect(response.status()).toBe(200); - expect((await response.json()).answer).toBeTruthy(); - - const spans = await spansPromise; - const chatSpan = spans.find(isChatSpan); - - expect(chatSpan).toBeDefined(); - expectCommonChatAttributes(chatSpan!); - expect(attr(chatSpan!, 'gen_ai.response.streaming')).toBe(true); - - const segment = spans.find(span => span.is_segment && span.name === 'GET /chat-stream-pipe')!; - const workflow = byName(spans, 'ai-pipe-workflow'); - expect(chatSpan!.parent_span_id).toBe(workflow.span_id); - expect(chatSpan!.trace_id).toBe(segment.trace_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/errors.test.ts deleted file mode 100644 index c2dc4067d9d5..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/errors.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, waitForError } from '@sentry-internal/test-utils'; -import { APP, attr, isChatSpan } from './utils'; - -test('captures an error thrown by a failed Mistral call', async ({ baseURL, request }) => { - const model = 'no-such-model/capture'; - const errorPromise = waitForError( - APP, - event => !event.type && !!event.exception?.values?.[0]?.value?.includes(model), - ); - - const response = await request.get(`${baseURL}/chat-error?id=capture`); - expect(response.status()).toBe(500); - - const errorEvent = await errorPromise; - - expect(errorEvent.exception?.values?.[0]?.value).toContain('Mistral call failed'); - expect(errorEvent.transaction).toBe('GET /chat-error'); - expect(errorEvent.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); -}); - -test('marks the gen_ai span errored and ties it to the captured error', async ({ baseURL, request }) => { - const id = 'linked'; - const model = `no-such-model/${id}`; - - const errorPromise = waitForError( - APP, - event => !event.type && !!event.exception?.values?.[0]?.value?.includes(model), - ); - // Every request to this route produces an equivalent-looking trace, so the predicate names the - // per-request model rather than the route. - const spansPromise = collectStreamedSpans( - APP, - spansOfTrace => - spansOfTrace.some(span => span.is_segment && span.name === 'GET /chat-error') && - spansOfTrace.some(span => attr(span, 'gen_ai.request.model') === model), - ); - - await request.get(`${baseURL}/chat-error?id=${id}`); - - const [errorEvent, spans] = await Promise.all([errorPromise, spansPromise]); - const chatSpan = spans.find(isChatSpan)!; - - expect(chatSpan).toBeDefined(); - expect(chatSpan.status).not.toBe('ok'); - // No response was produced, so nothing should have been recorded from one. - expect(attr(chatSpan, 'gen_ai.response.text')).toBeUndefined(); - expect(attr(chatSpan, 'gen_ai.output.messages')).toBeUndefined(); - - expect(chatSpan.trace_id).toBe(errorEvent.contexts?.trace?.trace_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/instrumentation-path.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/instrumentation-path.test.ts deleted file mode 100644 index 1c53b9d332ae..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/instrumentation-path.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { expect, test } from '@playwright/test'; - -// Guards the premise the prod run rests on. `Sentry.init` registers the runtime injection hook -// unless `enableRuntimeChannelInjection` is false, which `instrument.mjs` sets outside dev. With -// that off and no `--import` on the bundled start command, the bundler plugin is the only thing -// that can have injected these channels, so finding them in the built file is what makes a passing -// production run mean build-time instrumentation rather than a silent fallback. -test('the bundle carries build-time injected channels', () => { - test.skip(process.env.TEST_ENV === 'development', 'the dev run is instrumented by the runtime hook'); - - const bundle = readFileSync('dist/app.cjs', 'utf8'); - - expect(bundle).toContain('orchestrion:@mistralai/mistralai:chat'); - expect(bundle).toContain('orchestrion:@mistralai/mistralai:chat-stream'); - expect(bundle).toContain('orchestrion:dataloader:load'); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/nesting.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/nesting.test.ts deleted file mode 100644 index 8cb72ffa1728..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/nesting.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; -import { ancestorIds, APP, byName, describeTree, isChatSpan } from './utils'; - -test('nests the manual span under the request span and the gen_ai span under the manual span', async ({ - baseURL, - request, -}) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat'); - - await request.get(`${baseURL}/chat`); - - const spans = await spansPromise; - const tree = describeTree(spans); - - const segment = spans.find(span => span.is_segment && span.name === 'GET /chat')!; - const workflow = byName(spans, 'ai-workflow'); - const postProcess = byName(spans, 'post-process'); - const chatSpan = spans.find(isChatSpan)!; - - // Manual span inside the generated request span. Express contributes its own middleware and - // request-handler spans in between, so this is an ancestry check, not a direct-parent one. - expect(ancestorIds(spans, workflow), `ai-workflow is not under the request span:\n${tree}`).toContain( - segment.span_id, - ); - - // Generated span directly inside the manual one: nothing should slip between them. - expect(chatSpan.parent_span_id, `gen_ai span is not a child of ai-workflow:\n${tree}`).toBe(workflow.span_id); - - // A second manual span, sibling of the gen_ai span rather than its child. - expect(postProcess.parent_span_id, `post-process is not a child of ai-workflow:\n${tree}`).toBe(workflow.span_id); - - for (const span of [workflow, postProcess, chatSpan]) { - expect(span.trace_id).toBe(segment.trace_id); - } -}); - -test('nests the streaming gen_ai span under its manual parent', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat-stream'); - - await request.get(`${baseURL}/chat-stream`); - - const spans = await spansPromise; - const tree = describeTree(spans); - - const segment = spans.find(span => span.is_segment && span.name === 'GET /chat-stream')!; - const workflow = byName(spans, 'ai-stream-workflow'); - const streamSpan = spans.find(isChatSpan)!; - - expect(ancestorIds(spans, workflow), `ai-stream-workflow is not under the request span:\n${tree}`).toContain( - segment.span_id, - ); - - // The stream is drained inside the manual span, so the gen_ai span has to close under it rather - // than escaping to the request root. - expect(streamSpan.parent_span_id, `streamed gen_ai span is not a child of ai-stream-workflow:\n${tree}`).toBe( - workflow.span_id, - ); - expect(streamSpan.trace_id).toBe(segment.trace_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/utils.ts deleted file mode 100644 index 5172a74beb54..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect } from '@playwright/test'; -import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; -import { getSpanOp } from '@sentry-internal/test-utils'; - -export const APP = 'node-mistral'; - -export const attr = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; - -export const isChatSpan = (span: SerializedStreamedSpan): boolean => getSpanOp(span) === 'gen_ai.chat'; - -/** A readable span tree, used as a failure message so a broken assertion is diagnosable. */ -export function describeTree(spans: SerializedStreamedSpan[]): string { - return spans - .map(span => `${span.name} [${getSpanOp(span) ?? '-'}] id=${span.span_id} parent=${span.parent_span_id ?? '-'}`) - .join('\n'); -} - -/** Walk to the trace root, so assertions can allow auto-instrumented spans in between. */ -export function ancestorIds(spans: SerializedStreamedSpan[], span: SerializedStreamedSpan): string[] { - const byId = new Map(spans.map(candidate => [candidate.span_id, candidate])); - const ids: string[] = []; - - let current: SerializedStreamedSpan | undefined = span; - while (current?.parent_span_id) { - ids.push(current.parent_span_id); - current = byId.get(current.parent_span_id); - } - - return ids; -} - -export function byName(spans: SerializedStreamedSpan[], name: string): SerializedStreamedSpan { - const span = spans.find(candidate => candidate.name === name); - expect(span, `expected a span named "${name}" in:\n${describeTree(spans)}`).toBeDefined(); - return span!; -} - -/** - * Attributes every successful gen_ai span carries, whatever the model happens to answer. Values that - * depend on the model (token counts, response text) are checked for shape and not for content. - */ -export function expectCommonChatAttributes(span: SerializedStreamedSpan): void { - expect(attr(span, 'sentry.origin')).toBe('auto.ai.mistralai'); - expect(attr(span, 'gen_ai.provider.name')).toBe('mistralai'); - expect(attr(span, 'gen_ai.operation.name')).toBe('chat'); - expect(span.name).toBe(`chat ${attr(span, 'gen_ai.request.model')}`); - expect(span.status).toBe('ok'); - - expect(typeof attr(span, 'gen_ai.response.model')).toBe('string'); - expect(attr(span, 'gen_ai.usage.input_tokens')).toBeGreaterThan(0); - expect(attr(span, 'gen_ai.usage.output_tokens')).toBeGreaterThan(0); - expect(attr(span, 'gen_ai.usage.total_tokens')).toBeGreaterThan(0); -} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue index 089d77a2eee9..3547773a1af9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue @@ -2,6 +2,7 @@
+
@@ -15,4 +16,8 @@ const fetchError = async () => { const fetchNitroFetch = async () => { await useFetch('/api/nitro-fetch'); }; + +const fetchThirdPartyHttpError = async () => { + await useFetch('/api/third-party-http-error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts new file mode 100644 index 000000000000..1f2d3c2ee90e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts @@ -0,0 +1,16 @@ +import { defineEventHandler } from '#imports'; + +// Mimics ky's and got's `HTTPError`: it shares its `name` with h3's error class, but keeps the +// status on `response` instead of on the error itself. +class ThirdPartyHTTPError extends Error { + public readonly response = { status: 404 }; + + public constructor(message: string) { + super(message); + this.name = 'HTTPError'; + } +} + +export default defineEventHandler(() => { + throw new ThirdPartyHTTPError('Nuxt 4 third-party HTTPError'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index 8f7bf451a1f6..ea9c78b2d60e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -69,4 +69,26 @@ test.describe('server-side errors', async () => { exception_id: 0, }); }); + + // ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the + // hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike + // is covered by the unit tests. + test('captures a thrown third-party `HTTPError`', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 4 third-party HTTPError'); + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/third-party-http-error'); + expect(error.exception.values).toContainEqual( + expect.objectContaining({ + value: 'Nuxt 4 third-party HTTPError', + mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }), + }), + ); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue index 089d77a2eee9..3547773a1af9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue @@ -2,6 +2,7 @@
+
@@ -15,4 +16,8 @@ const fetchError = async () => { const fetchNitroFetch = async () => { await useFetch('/api/nitro-fetch'); }; + +const fetchThirdPartyHttpError = async () => { + await useFetch('/api/third-party-http-error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts new file mode 100644 index 000000000000..b6b6d2aff38e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts @@ -0,0 +1,16 @@ +import { defineHandler } from 'nitro'; + +// Mimics ky's and got's `HTTPError`: it shares its `name` with h3's error class, but keeps the +// status on `response` instead of on the error itself. +class ThirdPartyHTTPError extends Error { + public readonly response = { status: 404 }; + + public constructor(message: string) { + super(message); + this.name = 'HTTPError'; + } +} + +export default defineHandler(() => { + throw new ThirdPartyHTTPError('Nuxt 5 third-party HTTPError'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts index fe17f262b0ae..ebe8b5097d5d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts @@ -69,4 +69,27 @@ test.describe('server-side errors', async () => { exception_id: 0, }); }); + + // ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the + // hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike + // is covered by the unit tests. + test('captures a thrown third-party `HTTPError`', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 5 third-party HTTPError'); + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/third-party-http-error'); + expect(error.exception.values).toContainEqual( + expect.objectContaining({ + type: 'HTTPError', + value: 'Nuxt 5 third-party HTTPError', + mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }), + }), + ); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/.gitignore new file mode 100644 index 000000000000..a7bced322ecd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/build +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx new file mode 100644 index 000000000000..9a290b88c9a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx @@ -0,0 +1,12 @@ +import { RemixBrowser } from '@remix-run/react'; +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/root.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/root.tsx new file mode 100644 index 000000000000..9081dbd5c5c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/root.tsx @@ -0,0 +1,19 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'; + +export default function App() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx new file mode 100644 index 000000000000..ea184ec80a18 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx @@ -0,0 +1,3 @@ +export default function Index() { + return

Sourcemaps test app

; +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..7f4ae319867b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts @@ -0,0 +1,128 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getArtifactBundles, getDebugIdPairs, getSourcemaps, loadMockServerResults } from '@sentry-internal/test-utils'; + +const BUILD_DIR = 'build'; +const CLIENT_ASSETS_DIR = 'build/client/assets'; + +// Both injectors write this assignment, so counting it per file counts injections regardless of +// which one ran. Matching only the bundler plugin's trailing `_sentryDebugIdIdentifier` would miss +// the `sentry-cli` snippet, which omits it. +const DEBUG_ID_ASSIGNMENT = + /_sentryDebugIds\[[^\]]+\]\s*=\s*"([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})"/gi; + +function getClientChunks(): string[] { + assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`); + + return fs + .readdirSync(CLIENT_ASSETS_DIR) + .filter(file => file.endsWith('.js')) + .map(file => path.join(CLIENT_ASSETS_DIR, file)); +} + +const chunks = getClientChunks(); +assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASSETS_DIR}`); + +// 1. No chunk carries more than one debug ID. +// +// Two injections per chunk is the failure mode of +// https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at runtime, +// `applyDebugIds` flattens them to a single filename, and the last one wins. For Remix that happens +// when an app keeps `npx @sentry/remix --upload-sourcemaps` in its build script alongside the Vite +// plugin, so the CLI injects a second ID over the plugin's. +const injectedDebugIds = new Map(); + +for (const chunk of chunks) { + const code = fs.readFileSync(chunk, 'utf-8'); + const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); + + assert.ok( + ids.length <= 1, + `Expected at most one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}.`, + ); + + if (ids.length === 1) { + injectedDebugIds.set(chunk, ids[0] as string); + } +} + +console.log(`no client chunk carries more than one debug ID (${injectedDebugIds.size}/${chunks.length} carry one)\n`); + +const requests = loadMockServerResults(); +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one uploaded artifact bundle'); + +// 2. Source maps with real content reached Sentry. +// +// Asserting on the upload rather than on disk, because deleting the maps after a successful upload +// is the intended behaviour - the plugin defaults `filesToDeleteAfterUpload` when the app does not +// configure source maps itself. +const uploadedSourcemaps = getSourcemaps(bundles); +assert.ok(uploadedSourcemaps.length > 0, 'Expected at least one source map in the uploaded artifact bundles'); +assert.ok( + uploadedSourcemaps.some(entry => (entry.sourcemap.mappings?.length ?? 0) > 0), + 'Expected at least one uploaded source map with non-empty mappings', +); +console.log(`${uploadedSourcemaps.length} source map(s) uploaded with content`); + +// 3. The debug IDs that shipped are the ones that were uploaded. +// +// This is what actually breaks un-minification: a chunk can carry a perfectly valid debug ID that +// has no artifact bundle behind it. +const debugIdPairs = getDebugIdPairs(bundles); +const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source map pair with a debug ID'); + +// The uploaded artifacts are named after the debug ID (`~/-.js`), not after the chunk +// they came from, so the two file name sets never line up. Cross-check the IDs themselves: every +// debug ID that shipped has to have an artifact bundle behind it. +let crossCheckedChunks = 0; + +for (const [chunk, injectedDebugId] of injectedDebugIds) { + assert.ok( + uploadedDebugIds.has(injectedDebugId.toLowerCase()), + `Debug ID ${injectedDebugId} in ${chunk} was never uploaded.\n` + + `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}`, + ); + crossCheckedChunks++; +} + +assert.ok( + crossCheckedChunks > 0, + 'Expected at least one chunk carrying a debug ID to cross-check against the upload.\n' + + `Client chunks: ${JSON.stringify(chunks.map(chunk => path.basename(chunk)))}\n` + + `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}\n` + + `Uploaded JS urls: ${JSON.stringify(debugIdPairs.map(pair => pair.jsUrl))}`, +); +console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`); + +// 4. No source map survived the build. +// +// The plugin defaults `filesToDeleteAfterUpload` when the app configures no source map setting, so +// a leftover `.map` means one of the builds was never cleaned up. Remix runs a client and an SSR +// pass with different `outDir`s, while the deletion glob is held in a promise that settles once - +// this is what catches the second pass being left behind. +function findSourceMaps(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + return findSourceMaps(entryPath); + } + + return entry.name.endsWith('.map') ? [entryPath] : []; + }); +} + +const leftoverSourceMaps = findSourceMaps(BUILD_DIR); +assert.deepEqual( + leftoverSourceMaps, + [], + `Expected every source map to be deleted after upload, found ${leftoverSourceMaps.length}:\n${leftoverSourceMaps.join( + '\n', + )}`, +); +console.log('no source maps left in the build output\n'); + +console.log('All remix source map assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json new file mode 100644 index 000000000000..d1eb5cdce759 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json @@ -0,0 +1,33 @@ +{ + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & remix vite:build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; cat .tmp_build_stdout .tmp_build_stderr; exit $BUILD_EXIT", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm tsx assert-build.ts" + }, + "dependencies": { + "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", + "@remix-run/node": "2.17.4", + "@remix-run/react": "2.17.4", + "@remix-run/serve": "2.17.4", + "isbot": "^3.6.8", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@remix-run/dev": "2.17.4", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.34", + "tsx": "^4.23.0", + "typescript": "^5.1.6", + "vite": "^5.4.11" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..69a346a1e440 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ port: 3033 }); diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..144b70c821b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["./app/**/*.ts", "./app/**/*.tsx", "./*.ts"], + "exclude": ["node_modules", "build"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "bundler", + "module": "ES2022", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/vite.config.ts new file mode 100644 index 000000000000..0229581eda4f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/vite.config.ts @@ -0,0 +1,21 @@ +import { vitePlugin as remix } from '@remix-run/dev'; +import { sentryRemixVitePlugin } from '@sentry/remix/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + remix({ + ignoredRouteFiles: ['**/.*'], + }), + sentryRemixVitePlugin({ + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + sentryUrl: 'http://localhost:3033', + release: { + name: 'test-release', + }, + debug: true, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 2e46a20631e8..1436b6a9734d 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -71,6 +71,7 @@ "fastify": "^5.12.1", "generic-pool": "^3.9.0", "graphql": "^16.11.0", + "groq-sdk": "1.6.0", "graphql-tag": "^2.12.7", "hono": "^4.13.5", "http-terminator": "^3.2.0", @@ -99,6 +100,7 @@ "redis-5": "npm:redis@~5.11.0", "redis-5-tracing": "npm:redis@^5.12.0", "reflect-metadata": "0.2.1", + "together-ai": "0.54.0", "rxjs": "^7.8.2", "tedious": "^19.2.1", "winston": "^3.17.0", diff --git a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts index fa0e7bb4c601..f1e1ba0c3b8c 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts @@ -24,6 +24,19 @@ const cron = new CronJobWithCheckIn('* * * * * *', () => { cron.start(); +// `cron` sizes the delay to the next tick from two clock reads, clamps a negative result to -1 and +// then treats that -1 as "stop". A pause between those two reads that straddles a second boundary +// is enough to hit it, and the job is left scheduled for nothing: no ticks, no check-ins, and the +// scenario exits on the timeout below having sent nothing at all. Starting again re-reads the clock +// away from the boundary. +for (let attempt = 0; attempt < 5 && !cron.running; attempt++) { + cron.start(); +} + +if (!cron.running) { + throw new Error('`cron` refused to schedule the job'); +} + setTimeout(() => { process.exit(); }, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/test.ts b/dev-packages/node-integration-tests/suites/cron/cron/test.ts index d539f03eba44..68f0beda37d3 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/test.ts @@ -8,6 +8,9 @@ afterAll(() => { test('cron instrumentation', { timeout: 60_000 }, async () => { await createRunner(__dirname, 'scenario.ts') .withMockSentryServer() + // Each check-in is its own HTTP request, so the order they reach the mock server is not the + // order the SDK sent them in. Under load the `ok` check-in has overtaken the `in_progress` one. + .unordered() .expect({ check_in: { check_in_id: expect.any(String), diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js index 04c3e3e2142e..6e400cce7eba 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js @@ -1,5 +1,6 @@ const Sentry = require('@sentry/node'); const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { waitForLocalVariables } = require('./wait-for-local-variables'); const externalFunctionFile = require.resolve('./node_modules/out-of-app-function.js'); @@ -17,11 +18,13 @@ Sentry.init({ includeLocalVariables: true, }); -setTimeout(async () => { +void (async () => { + await waitForLocalVariables(); + try { in_app_function(); } catch (e) { Sentry.captureException(e); await Sentry.flush(); } -}, 500); +})(); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js index 66fd4d80e2d6..76fc88a8a905 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js @@ -1,5 +1,6 @@ const Sentry = require('@sentry/node'); const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { waitForLocalVariables } = require('./wait-for-local-variables'); const externalFunctionFile = require.resolve('./node_modules/out-of-app-function.js'); @@ -22,11 +23,13 @@ function in_app_function() { out_of_app_function(`${inAppVar} modified value`); } -setTimeout(async () => { +void (async () => { + await waitForLocalVariables(); + try { in_app_function(); } catch (e) { Sentry.captureException(e); await Sentry.flush(); } -}, 500); +})(); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts index fcb1c9dcfe72..63683b9ab7fd 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts @@ -3,10 +3,6 @@ import * as path from 'path'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; -// This test takes some time because it connects the debugger etc. -// So we increase the timeout here -// vi.setTimeout(45_000); - const EXPECTED_LOCAL_VARIABLES_EVENT = { exception: { values: [ diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/wait-for-local-variables.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/wait-for-local-variables.js new file mode 100644 index 000000000000..680b15c0fe73 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/wait-for-local-variables.js @@ -0,0 +1,34 @@ +// The async local variables integration attaches its debugger from a worker thread, and nothing +// reports when that is done. Until it has attached, a caught exception is not paused on and the +// frames arrive with no `vars` at all, so a scenario that throws too early sees the integration do +// nothing. Attaching takes around 100ms on an idle machine and over 300ms under load, which the +// fixed 500ms wait these scenarios used was not a safe margin for on a loaded CI runner. +// +// The integration tags the thrown error once it has captured its scope, so throwing a probe and +// looking for that tag is the signal. It is an internal key, so if it ever changes this stops +// resolving early and falls back to the bounded wait below, which is the behavior it replaces. +const LOCAL_VARIABLES_KEY = '__SENTRY_ERROR_LOCAL_VARIABLES__'; + +// Slow enough to stay well under the integration's 50 exceptions/second rate limit, which would +// otherwise switch it back to capturing uncaught exceptions only. +const POLL_INTERVAL_MS = 25; + +async function waitForLocalVariables(timeoutMs = 10_000) { + const giveUpAt = Date.now() + timeoutMs; + + while (Date.now() < giveUpAt) { + try { + throw new Error('local variables readiness probe'); + } catch (e) { + if (e[LOCAL_VARIABLES_KEY]) { + return true; + } + } + + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + return false; +} + +module.exports = { waitForLocalVariables }; diff --git a/dev-packages/node-integration-tests/suites/tracing/flue/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/flue/instrument.mjs new file mode 100644 index 000000000000..31609dbe3960 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flue/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'stream', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/flue/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/flue/scenario.mjs new file mode 100644 index 000000000000..b71b000bf18f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flue/scenario.mjs @@ -0,0 +1,42 @@ +import * as Sentry from '@sentry/node'; +import { __flueBindAgentModule, init, instrument, useModel, useTool } from '@flue/runtime'; +import { start } from '@flue/runtime/node'; +import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux'; +import * as v from 'valibot'; + +// `pi-ai`'s faux provider scripts model responses in-process, so the run is deterministic and needs +// no provider key or mock server. Two steps: a tool call, then the final answer. +instrument(Sentry.createFlueInstrumentation()); + +const faux = fauxProvider({ + provider: 'faux', + models: [{ id: 'faux-model', cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }], +}); +faux.setResponses([ + fauxAssistantMessage(fauxToolCall('get_weather', { city: 'Berlin' }, { id: 'call_1' }), { stopReason: 'toolUse' }), + fauxAssistantMessage('It is 21 degrees and sunny in Berlin.'), +]); + +function Hello() { + useModel('faux/faux-model'); + useTool({ + name: 'get_weather', + description: 'Get the current weather for a city.', + // Without an `input` schema Flue validates the call against an empty one and rejects the + // model's arguments, so the tool never runs and its span settles as an error. + input: v.object({ city: v.string() }), + run: ({ data }) => `It is 21 degrees and sunny in ${data.city}.`, + }); + return 'You are a helpful assistant.'; +} +__flueBindAgentModule(Hello, { identity: 'Hello' }); + +await Sentry.startSpan({ name: 'flue-test', op: 'function' }, async () => { + const flue = await start({ agents: [Hello], providers: [faux.provider] }); + const agent = init(Hello, { id: 'e2e' }); + const receipt = await agent.dispatch('What is the weather in Berlin?'); + await agent.read(receipt); + await flue[Symbol.asyncDispose]?.(); +}); + +await Sentry.flush(2000); diff --git a/dev-packages/node-integration-tests/suites/tracing/flue/test.ts b/dev-packages/node-integration-tests/suites/tracing/flue/test.ts new file mode 100644 index 000000000000..98ad59dcc4df --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flue/test.ts @@ -0,0 +1,114 @@ +import { + GEN_AI_AGENT_NAME, + GEN_AI_CONVERSATION_ID, + GEN_AI_COST_TOTAL_TOKENS, + GEN_AI_OPERATION_NAME, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_TOOL_NAME, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { afterAll, expect } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// `@flue/runtime` declares `engines.node >= 22.19`, so it can't live in the package's root +// `devDependencies` (that would break `yarn install` on the 20.19 CI matrix). Install it per-suite +// instead, guarded by the `min: 22` skip below. +const FLUE_DEPENDENCIES = { + additionalDependencies: { + '@flue/runtime': '2.0.3', + '@earendil-works/pi-ai': '0.85.1', + valibot: '1.1.0', + }, +}; + +conditionalTest({ min: 22 })('Flue integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test, mode) => { + // `@flue/runtime` is ESM-only — its `exports` map has no `require` condition, so there is no + // CJS variant of this scenario to run. + if (mode === 'cjs') { + return; + } + + test('creates the invoke_agent / chat / execute_tool hierarchy', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const root = spans.find(span => span.name === 'flue-test')!; + expect(root.is_segment).toBe(true); + + // Counted rather than looked up: the interceptor skips the submission wrapper + // operation, so one dispatch opens exactly one agent span, and each turn and tool call + // is spanned once. `find` passes just as happily on a duplicate. + expect(spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ai.flue')).toHaveLength(4); + + const agents = spans.filter(span => span.name === 'invoke_agent Hello'); + const chats = spans.filter(span => span.name === 'chat faux-model'); + const tools = spans.filter(span => span.name === 'execute_tool get_weather'); + expect(agents).toHaveLength(1); + expect(tools).toHaveLength(1); + // One turn asks for the tool, the second answers with its result. + expect(chats).toHaveLength(2); + + const agent = agents[0]!; + expect(agent.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent'); + expect(agent.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent'); + expect(agent.attributes[GEN_AI_AGENT_NAME]?.value).toBe('Hello'); + expect(agent.parent_span_id).toBe(root.span_id); + + const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value; + expect(conversationId).toEqual(expect.any(String)); + + // Both turns, not just the first: they leave the provider by different paths (a tool + // call, then a final answer) and resolve their parent through separate tracker lookups. + for (const chat of chats) { + expect(chat.attributes['sentry.op']?.value).toBe('gen_ai.chat'); + expect(chat.attributes['sentry.origin']?.value).toBe('auto.ai.flue'); + expect(chat.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId); + expect(chat.parent_span_id).toBe(agent.span_id); + expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0); + expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0); + expect(chat.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBeGreaterThan(0); + // Flue computes cost itself; no provider SDK reports it. The faux provider prices + // every model at zero, so this only proves the attribute is mapped. + expect(chat.attributes[GEN_AI_COST_TOTAL_TOKENS]?.value).toEqual(expect.any(Number)); + } + + expect(chats.map(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).sort()).toEqual([ + '["stop"]', + '["toolUse"]', + ]); + + const tool = tools[0]!; + // The tool has to actually run: a schema mismatch still produces a correctly named and + // parented span, so only the status separates a real call from a rejected one. + expect(tool.status).toBe('ok'); + expect(tool.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool'); + expect(tool.attributes['sentry.origin']?.value).toBe('auto.ai.flue'); + expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather'); + + // Tool spans are siblings of `chat` under the agent invocation, matching how Flue's + // own OpenTelemetry adapter projects them. + expect(tool.parent_span_id).toBe(agent.span_id); + }, + }) + .start() + .completed(); + }); + }, + FLUE_DEPENDENCIES, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/groq/instrument-with-pii.mjs new file mode 100644 index 000000000000..1c507ba84b9c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/groq/instrument-with-pii.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: true, outputs: true } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/groq/instrument.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/groq/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs new file mode 100644 index 000000000000..03f656eb1ee4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs @@ -0,0 +1,139 @@ +import Groq from 'groq-sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/openai/v1/chat/completions', (req, res) => { + const { model, stream } = req.body; + + // error-model returns 404 (not retried by the SDK) so the span records an error + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const chunks = [ + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { content: 'Hello from Groq streaming!' }, finish_reason: null }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 }, + }, + ]; + + chunks.forEach((chunk, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + if (index === chunks.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + } else { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { index: 0, message: { role: 'assistant', content: 'Hello from Groq mock!' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + } + }); + + app.post('/openai/v1/embeddings', (req, res) => { + const { model } = req.body; + res.send({ + id: 'embd-mock123', + object: 'list', + model, + data: [{ object: 'embedding', index: 0, embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Groq({ + apiKey: 'mock-api-key', + // `groq-sdk` appends `/openai/v1/...` to the baseURL, so it stays bare here. + baseURL: `http://localhost:${server.address().port}`, + }); + + await client.chat.completions.create({ + model: 'llama-3.3-70b-versatile', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + try { + await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // expected + } + + const stream = await client.chat.completions.create({ + model: 'llama-3.1-8b-instant', + messages: [{ role: 'user', content: 'Tell me about streaming' }], + temperature: 0.8, + stream: true, + }); + + for await (const chunk of stream) { + void chunk; + } + + await client.embeddings.create({ + model: 'nomic-embed-text-v1_5', + input: 'Embedding test!', + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/test.ts b/dev-packages/node-integration-tests/suites/tracing/groq/test.ts new file mode 100644 index 000000000000..c494401817b0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/groq/test.ts @@ -0,0 +1,109 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_MODEL, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_RESPONSE_TEXT, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +const PROVIDER = 'groq'; +const ORIGIN = 'auto.ai.groq'; + +describe('Groq integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates chat and embeddings spans with genAI recording disabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.name).toBe('chat llama-3.3-70b-versatile'); + expect(chatSpan!.status).toBe('ok'); + expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('llama-3.3-70b-versatile'); + expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]?.value).toBe(0.7); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('llama-3.3-70b-versatile'); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["stop"]'); + expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10); + expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(15); + expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25); + // recording disabled → no prompt/response content + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + + const streamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123', + ); + expect(streamSpan).toBeDefined(); + expect(streamSpan!.name).toBe('chat llama-3.1-8b-instant'); + expect(streamSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30); + + const errorSpan = container.items.find(s => s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-model'); + expect(errorSpan).toBeDefined(); + expect(errorSpan!.status).toBe('error'); + + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.name).toBe('embeddings nomic-embed-text-v1_5'); + expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('embeddings'); + expect(embeddingsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.embeddings'); + expect(embeddingsSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(embeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(8); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records chat and embeddings inputs/outputs with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + // The system message is split out into gen_ai.system_instructions. + expect(chatSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.'); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( + '[{"role":"user","content":"What is the capital of France?"}]', + ); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from Groq mock!"]'); + + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Embedding test!'); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/instrument.mjs index bd934b7a9c2b..11ff9c8eb451 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/instrument.mjs @@ -7,11 +7,17 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, + dataCollection: { + httpHeaders: { + request: { deny: ['x-tenant-id'] }, + response: { deny: ['content-length'] }, + }, + }, integrations: [ Sentry.nativeNodeFetchIntegration({ headersToSpanAttributes: { - requestHeaders: ['x-test-header'], - responseHeaders: ['x-powered-by'], + requestHeaders: ['x-test-header', 'authorization', 'x-tenant-id'], + responseHeaders: ['x-powered-by', 'content-length'], }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/scenario.mjs index 0edf81a9a50a..9f5650834bd4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/scenario.mjs @@ -2,5 +2,7 @@ import * as Sentry from '@sentry/node'; // eslint-disable-next-line @typescript-eslint/no-floating-promises Sentry.startSpan({ name: 'test_transaction' }, async () => { - await fetch(`${process.env.SERVER_URL}/api/v0`, { headers: { 'x-test-header': 'test-value' } }); + await fetch(`${process.env.SERVER_URL}/api/v0`, { + headers: { 'x-test-header': 'test-value', authorization: 'Bearer super-secret', 'x-tenant-id': 'acme-corp' }, + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts index d17d0a4132fe..6bfb18909525 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts @@ -29,7 +29,12 @@ describe('outgoing fetch spans - headers to span attributes', () => { origin: 'auto.http.node_fetch', data: expect.objectContaining({ 'http.request.header.x-test-header': ['test-value'], + // Listed in `headersToSpanAttributes`, but the built-in denylist still wins. + 'http.request.header.authorization': ['[Filtered]'], + // Listed in `headersToSpanAttributes`, but denied via `dataCollection.httpHeaders`. + 'http.request.header.x-tenant-id': ['[Filtered]'], 'http.response.header.x-powered-by': ['Express'], + 'http.response.header.content-length': ['[Filtered]'], }), }), ]), diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/test.ts b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts index b1d8ded48e5b..4a9f99364a3a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/koa/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts @@ -29,81 +29,92 @@ describe('koa auto-instrumentation', () => { }, }; - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('should auto-instrument `koa` router and middleware layers.', async () => { - const runner = createRunner() - .expect({ - span: container => { - expect(container.items.find(item => item.is_segment)?.name).toBe('GET /'); + describe.each([ + ['v2', {}], + ['v3', { koa: '^3.0.0' }], + ])('%s', (_version, additionalDependencies) => { + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('should auto-instrument `koa` router and middleware layers.', async () => { + const runner = createRunner() + .expect({ + span: container => { + expect(container.items.find(item => item.is_segment)?.name).toBe('GET /'); - // Router layer span (from `@koa/router`), carrying the matched route. - expect(container.items).toContainEqual( - expect.objectContaining({ - name: '/', - attributes: expect.objectContaining({ - 'http.route': { type: 'string', value: '/' }, - 'koa.type': { type: 'string', value: 'router' }, - 'sentry.op': { type: 'string', value: 'router' }, - 'sentry.origin': { type: 'string', value: origin }, - }), - }), - ); + // Router layer span (from `@koa/router`), carrying the matched route. + expect(container.items).toContainEqual( + expect.objectContaining({ + name: '/', + attributes: expect.objectContaining({ + 'http.route': { type: 'string', value: '/' }, + 'koa.type': { type: 'string', value: 'router' }, + 'sentry.op': { type: 'string', value: 'router' }, + 'sentry.origin': { type: 'string', value: origin }, + }), + }), + ); - // Plain middleware span. - expect(container.items).toContainEqual( - expect.objectContaining({ - name: 'simpleMiddleware', - attributes: expect.objectContaining({ - 'koa.type': { type: 'string', value: 'middleware' }, - 'code.function.name': { type: 'string', value: 'simpleMiddleware' }, - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: origin }, - }), - }), - ); - }, - }) - .start(); - runner.makeRequest('get', '/'); - await runner.completed(); - }); + // Plain middleware span. + expect(container.items).toContainEqual( + expect.objectContaining({ + name: 'simpleMiddleware', + attributes: expect.objectContaining({ + 'koa.type': { type: 'string', value: 'middleware' }, + 'code.function.name': { type: 'string', value: 'simpleMiddleware' }, + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: origin }, + }), + }), + ); + }, + }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); - test('should assign a parameterized segment name.', async () => { - const runner = createRunner() - .expect({ - span: container => { - expect(container.items.find(item => item.is_segment)?.name).toBe('GET /test-param/:id'); + test('should assign a parameterized segment name.', async () => { + const runner = createRunner() + .expect({ + span: container => { + expect(container.items.find(item => item.is_segment)?.name).toBe('GET /test-param/:id'); - expect(container.items).toContainEqual( - expect.objectContaining({ - name: '/test-param/:id', - attributes: expect.objectContaining({ - 'http.route': { type: 'string', value: '/test-param/:id' }, - 'koa.type': { type: 'string', value: 'router' }, - 'sentry.op': { type: 'string', value: 'router' }, - 'sentry.origin': { type: 'string', value: origin }, - }), - }), - ); - }, - }) - .start(); - runner.makeRequest('get', '/test-param/123'); - await runner.completed(); - }); + expect(container.items).toContainEqual( + expect.objectContaining({ + name: '/test-param/:id', + attributes: expect.objectContaining({ + 'http.route': { type: 'string', value: '/test-param/:id' }, + 'koa.type': { type: 'string', value: 'router' }, + 'sentry.op': { type: 'string', value: 'router' }, + 'sentry.origin': { type: 'string', value: origin }, + }), + }), + ); + }, + }) + .start(); + runner.makeRequest('get', '/test-param/123'); + await runner.completed(); + }); - test('should capture errors thrown in routes via the koa error handler.', async () => { - const runner = createRunner() - .unordered() - .expect({ - span: container => { - expect(container.items.find(item => item.is_segment)?.name).toBe('GET /error'); - }, - }) - .expect({ event: EXPECTED_ERROR_EVENT }) - .start(); - runner.makeRequest('get', '/error', { expectError: true }); - await runner.completed(); - }); + test('should capture errors thrown in routes via the koa error handler.', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + expect(container.items.find(item => item.is_segment)?.name).toBe('GET /error'); + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); + }); + }, + { additionalDependencies }, + ); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs index 46a27dd03b74..984d2b7d2ef0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs @@ -6,4 +6,14 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, + enableOpenTelemetrySetup: process.env.ENABLE_OTEL === 'true', +}); + +let initializeSpansStarted = 0; +Sentry.getClient()?.on('spanStart', span => { + const attributes = Sentry.spanToJSON(span).attributes; + if (attributes['sentry.op'] === 'mcp.server' && attributes['mcp.method.name'] === 'initialize') { + initializeSpansStarted += 1; + span.setAttribute('test.mcp.initialize_spans_started', initializeSpansStarted); + } }); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs new file mode 100644 index 000000000000..1f51dbd77342 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs @@ -0,0 +1,30 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; +import { wrapMcpServerWithSentry } from '@sentry/node'; + +const server = wrapMcpServerWithSentry(new McpServer({ name: 'Echo', version: '1.0.0' })); + +async function run() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }, { versionNegotiation: { mode: 'legacy' } }); + const originalSend = clientTransport.send.bind(clientTransport); + const requestQueued = new Promise(resolve => { + clientTransport.send = async (...args) => { + const result = await originalSend(...args); + if (args[0]?.method === 'initialize') { + resolve(); + } + return result; + }; + }); + + const clientConnection = client.connect(clientTransport); + await requestQueued; + await server.connect(serverTransport); + await clientConnection; + + await client.close(); + await server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs new file mode 100644 index 000000000000..1bc775aef562 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs @@ -0,0 +1,31 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { wrapMcpServerWithSentry } from '@sentry/node'; + +const server = wrapMcpServerWithSentry(new McpServer({ name: 'Echo', version: '1.0.0' })); + +async function run() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const originalSend = clientTransport.send.bind(clientTransport); + const requestQueued = new Promise(resolve => { + clientTransport.send = async (...args) => { + const result = await originalSend(...args); + if (args[0]?.method === 'initialize') { + resolve(); + } + return result; + }; + }); + + const clientConnection = client.connect(clientTransport); + await requestQueued; + await server.connect(serverTransport); + await clientConnection; + + await client.close(); + await server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts index eb146ca33fbd..d53f5756a4b6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts @@ -6,6 +6,20 @@ function mcpSpans(container: SerializedStreamedSpanContainer): SerializedStreame return container.items.filter(item => item.attributes['sentry.op']?.value === 'mcp.server'); } +function assertInitializeSpan(container: SerializedStreamedSpanContainer): void { + const initializeSpans = mcpSpans(container).filter( + span => span.attributes['mcp.method.name']?.value === 'initialize', + ); + + expect(initializeSpans).toHaveLength(1); + const initializeSpan = initializeSpans[0]!; + expect(initializeSpan.name).toBe('initialize'); + expect(initializeSpan.status).toBe('ok'); + expect(initializeSpan.attributes['sentry.op']).toEqual({ type: 'string', value: 'mcp.server' }); + expect(initializeSpan.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.function.mcp_server' }); + expect(initializeSpan.attributes['test.mcp.initialize_spans_started']).toEqual({ type: 'integer', value: 1 }); +} + describe('MCP server spans (streamed)', () => { afterAll(() => { cleanupChildProcesses(); @@ -43,4 +57,31 @@ describe('MCP server spans (streamed)', () => { .completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-start-v2.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('captures an MCP v2 initialize request queued before transport start once', async () => { + await createTestRunner().unordered().expect({ span: assertInitializeSpan }).start().completed(); + }); + + test('captures the queued request with Sentry OpenTelemetry setup enabled', async () => { + await createTestRunner() + .withEnv({ ENABLE_OTEL: 'true' }) + .unordered() + .expect({ span: assertInitializeSpan }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-v1.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('captures an MCP v1 initialize request queued before transport start once', async () => { + await createTestRunner().unordered().expect({ span: assertInitializeSpan }).start().completed(); + }); + }, + { additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument-with-pii.mjs new file mode 100644 index 000000000000..1c507ba84b9c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument-with-pii.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: true, outputs: true } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs new file mode 100644 index 000000000000..6ef405ed2abf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs @@ -0,0 +1,138 @@ +import Together from 'together-ai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/chat/completions', (req, res) => { + const { model, stream } = req.body; + + // error-model returns 404 (not retried by the SDK) so the span records an error + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const chunks = [ + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { content: 'Hello from Together streaming!' }, finish_reason: null }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 }, + }, + ]; + + chunks.forEach((chunk, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + if (index === chunks.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + } else { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { index: 0, message: { role: 'assistant', content: 'Hello from Together mock!' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + } + }); + + app.post('/embeddings', (req, res) => { + const { model } = req.body; + res.send({ + id: 'embd-mock123', + object: 'list', + model, + data: [{ object: 'embedding', index: 0, embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Together({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}`, + }); + + await client.chat.completions.create({ + model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + try { + await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // expected + } + + const stream = await client.chat.completions.create({ + model: 'meta-llama/Llama-3.1-8B-Instruct-Turbo', + messages: [{ role: 'user', content: 'Tell me about streaming' }], + temperature: 0.8, + stream: true, + }); + + for await (const chunk of stream) { + void chunk; + } + + await client.embeddings.create({ + model: 'togethercomputer/m2-bert-80M-8k-retrieval', + input: 'Embedding test!', + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts b/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts new file mode 100644 index 000000000000..c33de6ebfb53 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts @@ -0,0 +1,109 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_MODEL, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_RESPONSE_TEXT, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +const PROVIDER = 'together_ai'; +const ORIGIN = 'auto.ai.together_ai'; + +describe('Together integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates chat and embeddings spans with genAI recording disabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.name).toBe('chat meta-llama/Llama-3.3-70B-Instruct-Turbo'); + expect(chatSpan!.status).toBe('ok'); + expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('meta-llama/Llama-3.3-70B-Instruct-Turbo'); + expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]?.value).toBe(0.7); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('meta-llama/Llama-3.3-70B-Instruct-Turbo'); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["stop"]'); + expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10); + expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(15); + expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25); + // recording disabled → no prompt/response content + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + + const streamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123', + ); + expect(streamSpan).toBeDefined(); + expect(streamSpan!.name).toBe('chat meta-llama/Llama-3.1-8B-Instruct-Turbo'); + expect(streamSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30); + + const errorSpan = container.items.find(s => s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-model'); + expect(errorSpan).toBeDefined(); + expect(errorSpan!.status).toBe('error'); + + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.name).toBe('embeddings togethercomputer/m2-bert-80M-8k-retrieval'); + expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('embeddings'); + expect(embeddingsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.embeddings'); + expect(embeddingsSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(embeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(8); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records chat and embeddings inputs/outputs with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + // The system message is split out into gen_ai.system_instructions. + expect(chatSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.'); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( + '[{"role":"user","content":"What is the capital of France?"}]', + ); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from Together mock!"]'); + + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Embedding test!'); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 18cab043a8ae..5aa53b9db183 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -23,6 +23,16 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./cli": { + "import": { + "types": "./build/types/cli.d.ts", + "default": "./build/esm/cli.js" + }, + "require": { + "types": "./build/types/cli.d.ts", + "default": "./build/cjs/cli.js" + } } }, "sideEffects": false, diff --git a/dev-packages/test-utils/rollup.npm.config.mjs b/dev-packages/test-utils/rollup.npm.config.mjs index b684e2efe16b..3c774d19e5a6 100644 --- a/dev-packages/test-utils/rollup.npm.config.mjs +++ b/dev-packages/test-utils/rollup.npm.config.mjs @@ -2,6 +2,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/cli.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/dev-packages/test-utils/src/cli.ts b/dev-packages/test-utils/src/cli.ts new file mode 100644 index 000000000000..b35e37bed6c9 --- /dev/null +++ b/dev-packages/test-utils/src/cli.ts @@ -0,0 +1,131 @@ +import { spawnSync } from 'node:child_process'; + +/** + * Spans only become queryable once they have made it through to EAP, which takes + * noticeably longer than the error pipeline (~2min vs ~20s when this was measured). + */ +export const EVENT_POLLING_OPTIONS = { timeout: 180_000, intervals: [5_000] }; + +/** + * A node of the span tree returned by `sentry trace view`. Spans, errors and occurrences all + * share this shape and are discriminated by `event_type`. + */ +export interface TraceItem { + /** On a span this is the span id. */ + event_id?: string; + event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check'; + op?: string | null; + children?: TraceItem[] | null; + errors?: TraceItem[] | null; + occurrences?: TraceItem[] | null; +} + +/** + * The `sentry trace view` target of a trace in the E2E test project, so a log line can be pasted + * into a terminal as-is. + */ +export function traceTarget(traceId: string): string { + return `${process.env['E2E_TEST_SENTRY_ORG_SLUG']}/${process.env['E2E_TEST_SENTRY_PROJECT']}/${traceId}`; +} + +/** + * Fetch a trace of the E2E test project through the `sentry` CLI, which the calling test app has to + * list as a dev dependency. Returns an empty list while the trace has not landed yet. + */ +export function fetchTrace(traceId: string): TraceItem[] { + const target = traceTarget(traceId); + const result = spawnSync('pnpm', ['exec', 'sentry', 'trace', 'view', target, '--json', '--fresh'], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + env: { + ...process.env, + // The E2E token is the only credential CI has. Locally the CLI would prefer a stored login + // over an env token, so force the env token for identical behaviour everywhere. + SENTRY_AUTH_TOKEN: process.env['E2E_TEST_AUTH_TOKEN'], + SENTRY_FORCE_ENV_TOKEN: '1', + }, + }); + + if (result.error) { + throw new Error( + `Could not run \`pnpm exec sentry trace view\`: ${result.error.message}. ` + + 'The test app needs `sentry` as a dev dependency.', + ); + } + + if (result.status === 0) { + return (JSON.parse(result.stdout) as { spans?: TraceItem[] }).spans ?? []; + } + + // Exit codes 10-19 are auth errors, and a rejected token also surfaces as an API error (exit 30) + // with a 401 in the message. Neither resolves by waiting, so fail loudly instead of polling until + // the timeout and reporting it as a missing event. The trace endpoint is org scoped, so the token + // needs `org:read` on top of the project scopes. + const isAuthError = result.status !== null && result.status >= 10 && result.status < 20; + if (isAuthError || /\b40[13]\b/.test(result.stderr)) { + throw new Error( + `sentry trace view ${target} failed with exit code ${result.status}: ${result.stderr}` + + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', + ); + } + + const traceMissing = result.status === 23 && result.stderr.includes(`Trace '${traceId}' not found`); + + if (traceMissing) { + return []; + } + + throw new Error(`sentry trace view ${target} exited with ${result.status}: ${result.stderr}`); +} + +/** + * Errors attach to whichever span was active when they were captured, and relocate from the + * top level into that span once it lands, so a given event can surface at any depth. + */ +export function flattenTrace(items: TraceItem[]): TraceItem[] { + return items.flatMap(item => [ + item, + ...flattenTrace(item.children ?? []), + ...flattenTrace(item.errors ?? []), + ...flattenTrace(item.occurrences ?? []), + ]); +} + +/** + * Without an `eventId` any error in the trace matches. That is what a request the server failed + * needs, because the client never learns the event id of an unhandled exception. + */ +export function findErrorInTrace(traceId: string, eventId?: string): TraceItem | undefined { + return flattenTrace(fetchTrace(traceId)).find( + item => item.event_type === 'error' && (eventId === undefined || item.event_id === eventId), + ); +} + +let loggedTraceShape = false; + +/** + * Streamed spans never become transaction events, so the segment is matched by its op rather than by + * the event id of an enclosing transaction. The trace is already unique to the request under test, + * so the op identifies the segment within it. + */ +export function findSpanInTrace(traceId: string, op: string): TraceItem | undefined { + const items = flattenTrace(fetchTrace(traceId)); + const match = items.find(item => item.op === op); + + // The trace endpoint's exact span shape is what this lookup depends on, so report it once when a + // non-empty trace does not contain the op we are waiting for. + if (!match && items.length && !loggedTraceShape) { + loggedTraceShape = true; + // eslint-disable-next-line no-console + console.log( + `Trace ${traceId} has no "${op}" item yet. Items so far:`, + JSON.stringify( + items.map(item => ({ event_type: item.event_type, op: item.op, event_id: item.event_id })), + null, + 2, + ), + ); + } + + return match; +} diff --git a/dev-packages/test-utils/src/page.ts b/dev-packages/test-utils/src/page.ts index 11a444e572eb..83344967bcd4 100644 --- a/dev-packages/test-utils/src/page.ts +++ b/dev-packages/test-utils/src/page.ts @@ -4,23 +4,66 @@ import type { Page } from '@playwright/test'; * Hides the page so the SDK reports the web vitals that are only finalized on pagehide. */ export async function hidePage(page: Page): Promise { - // web-vitals defers processing an interaction's event entries into - // `requestIdleCallback(..., { timeout: 1000 })`, and Chromium only reaches idle here once that - // timeout elapses. Hiding the page first forces a report while the metric is still unset, so no - // vital is emitted at all. Idle callbacks run in scheduling order, so waiting for one queued now - // means web-vitals' earlier callback has already run. + // web-vitals processes an interaction's event entries in `requestIdleCallback(..., { timeout: + // 1000 })`, and Chromium only reaches idle here once that timeout elapses. Hiding before that + // callback runs loses the interaction: the forced report web-vitals does on `visibilitychange` + // runs ahead of it, while the callback itself reports unforced, so INP is never emitted at all. + // + // Idle callbacks run in scheduling order, but web-vitals only schedules its callback once the + // Event Timing entry reaches its observer, which is after the click. Queuing one right away + // therefore queues it first and hides the page too early. Waiting for the entry and queuing from + // a task after it keeps web-vitals ahead: its observer is registered first, so it is notified + // first, and the `setTimeout` lands after the microtask it defers that work into. + /* oxlint-disable no-restricted-globals */ await page.evaluate(() => { return new Promise(resolve => { - if (typeof requestIdleCallback !== 'function') { - resolve(); + const scheduleIdle = (): void => { + if (typeof requestIdleCallback !== 'function') { + resolve(); + return; + } + requestIdleCallback(() => resolve(), { timeout: 1000 }); + }; + + // Callers that never interacted have no Event Timing entry coming, so there is nothing to + // order against and nothing to wait for. + const interactionCount = (performance as Performance & { interactionCount?: number }).interactionCount ?? 0; + if (!interactionCount && !performance.getEntriesByType('first-input').length) { + scheduleIdle(); return; } - requestIdleCallback(() => resolve(), { timeout: 1000 }); + + let observer: PerformanceObserver | undefined; + let fallback: ReturnType; + + // An interaction the Event Timing buffer no longer reports would otherwise wait here forever, + // so cap the wait rather than require an entry. + const done = (): void => { + clearTimeout(fallback); + observer?.disconnect(); + setTimeout(scheduleIdle, 0); + }; + + fallback = setTimeout(done, 1000); + + try { + // `durationThreshold` is missing from the DOM types, as it is in the SDK's own observer. + const eventOptions: PerformanceObserverInit & { durationThreshold?: number } = { + type: 'event', + buffered: true, + durationThreshold: 0, + }; + + observer = new PerformanceObserver(done); + observer.observe(eventOptions); + observer.observe({ type: 'first-input', buffered: true }); + } catch { + done(); + } }); }); // The callback below runs in the page, so `document` is the browser's, not Node's. - /* oxlint-disable no-restricted-globals */ await page.evaluate(() => { Object.defineProperty(document, 'visibilityState', { configurable: true, diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 66faa541489e..8f765fc367f8 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -93,6 +93,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, createFlueInstrumentation, @@ -117,7 +119,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setConversationId, setCurrentClient, @@ -188,6 +189,8 @@ export { withStreamedSpan, metrics, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index d231a660a9f9..c467d1b2266f 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -61,6 +61,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, mastraIntegration, @@ -93,7 +95,6 @@ export { parameterize, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, @@ -174,6 +175,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/browser-utils/src/instrumentation/performanceObserver.ts b/packages/browser-utils/src/instrumentation/performanceObserver.ts index 7bdefc299c20..4424a5f3ef87 100644 --- a/packages/browser-utils/src/instrumentation/performanceObserver.ts +++ b/packages/browser-utils/src/instrumentation/performanceObserver.ts @@ -59,8 +59,7 @@ export interface PerformanceLongAnimationFrameTiming extends PerformanceEntry { // TypeScript lib version (e.g. `NavigationType`), which leaks into and breaks consumers on older // TS. Keeping this local keeps web-vitals' global augmentations out of our published types. /** - * The navigation types web-vitals reports a metric for. Wider than the set the - * `browser.navigation.type` attribute uses - see `toBrowserNavigationType`. + * The navigation types web-vitals reports a metric for, reported as-is on `browser.navigation.type`. */ export type MetricNavigationType = | 'navigate' diff --git a/packages/browser-utils/src/web-vitals/emitSpan.ts b/packages/browser-utils/src/web-vitals/emitSpan.ts index 3a32e1eddd3c..890de057b6a6 100644 --- a/packages/browser-utils/src/web-vitals/emitSpan.ts +++ b/packages/browser-utils/src/web-vitals/emitSpan.ts @@ -21,20 +21,6 @@ import { WINDOW } from '../types'; import type { MetricNavigationType } from '../instrumentation/performanceObserver'; import type { WebVitalReportEvent } from './reportEvents'; -// web-vitals reports a wider set of navigation types than the attribute defines. Only the states -// Navigation Timing cannot express keep their own value; every ordinary document navigation folds -// into `navigate`, including a back/forward that missed the bfcache and a discarded-tab restore. -const BROWSER_NAVIGATION_TYPES: Partial> = { - reload: 'reload', - prerender: 'prerender', - 'back-forward-cache': 'bfcache', - 'soft-navigation': 'soft-navigation', -}; - -function toBrowserNavigationType(navigationType: MetricNavigationType): string { - return BROWSER_NAVIGATION_TYPES[navigationType] ?? 'navigate'; -} - // Locally-defined interfaces to avoid leaking bare global type references into the // generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these // available during this package's compilation but are NOT carried to consumers. @@ -134,7 +120,7 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { } if (navigationType) { - attributes[BROWSER_NAVIGATION_TYPE] = toBrowserNavigationType(navigationType); + attributes[BROWSER_NAVIGATION_TYPE] = navigationType; } // A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index fd5d6ebbd6ac..1996715d51cb 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -73,12 +73,12 @@ function trackWebVitalPerNavigation( let bfcacheNavigationSpan: Span | undefined; client.on('spanStart', span => { // The op has to be checked too: the web vital spans emitted for a restore carry the same - // `bfcache` navigation type, so matching on that alone lets the first of them replace the + // `back-forward-cache` navigation type, so matching on that alone lets the first of them replace the // navigation span, and every later vital then hangs off a sibling vital instead. const attributes = spanToJSON(span).attributes; if ( attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === NAVIGATION && - attributes[BROWSER_NAVIGATION_TYPE] === 'bfcache' + attributes[BROWSER_NAVIGATION_TYPE] === 'back-forward-cache' ) { bfcacheNavigationSpan = span; } diff --git a/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts b/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts index abaf22003625..f7b51f71c473 100644 --- a/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts +++ b/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts @@ -1,4 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +// Loaded for its side effect: `performanceObserver` reaches `@sentry/core`, and transforming that +// graph is charged to whichever test imports it first, which timed out on slower CI runners. +// Importing it here pays that once at collect time instead. +import '@sentry/core'; // The web vital observers are shared: whoever registers the first handler used to create them, which // froze web-vitals' options for every other consumer. Replay registers its handlers from its own diff --git a/packages/browser-utils/test/web-vitals/inp.test.ts b/packages/browser-utils/test/web-vitals/inp.test.ts index ad21d8c86b42..a7aa2db57f54 100644 --- a/packages/browser-utils/test/web-vitals/inp.test.ts +++ b/packages/browser-utils/test/web-vitals/inp.test.ts @@ -3,6 +3,10 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Loaded for its side effect: importing the mocked specifier here runs the factory below at collect +// time, so the one-time transform of the `@sentry/core` graph is not charged to whichever test +// imports it first. That timed out on slower CI runners. +import '@sentry/core'; const windowListeners = vi.hoisted(() => new Map void>()); const performanceHandlers = vi.hoisted(() => new Map void>()); diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index b148faa2758a..daad65d7b5cb 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -79,7 +79,7 @@ describe('_emitWebVitalSpan', () => { vi.mocked(SentryCore.spanToJSON).mockImplementation( (span: any) => (span === bfcacheNavigationSpan - ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'back-forward-cache' } } : { attributes: {} }) as any, ); // A root span is its own root, which is what the web vital spans are parented to. @@ -338,15 +338,14 @@ describe('_emitWebVitalSpan', () => { }); it.each([ - ['navigate', 'navigate'], - ['reload', 'reload'], - ['prerender', 'prerender'], - ['soft-navigation', 'soft-navigation'], - ['back-forward-cache', 'bfcache'], - // Ordinary document navigations the attribute has no separate value for. - ['back-forward', 'navigate'], - ['restore', 'navigate'], - ] as const)('reports navigationType %s as browser.navigation.type %s', (navigationType, expected) => { + 'navigate', + 'reload', + 'prerender', + 'soft-navigation', + 'back-forward-cache', + 'back-forward', + 'restore', + ] as const)('reports navigationType %s as browser.navigation.type unchanged', navigationType => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', @@ -359,7 +358,7 @@ describe('_emitWebVitalSpan', () => { expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ - attributes: expect.objectContaining({ 'browser.navigation.type': expected }), + attributes: expect.objectContaining({ 'browser.navigation.type': navigationType }), }), ); }); @@ -593,7 +592,7 @@ describe('_sendInpSpan', () => { vi.mocked(SentryCore.spanToJSON).mockImplementation( (span: any) => (span === bfcacheNavigationSpan - ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'back-forward-cache' } } : { attributes: {} }) as any, ); // A root span is its own root, which is what the web vital spans are parented to. @@ -717,7 +716,7 @@ describe('trackInpAsSpan', () => { vi.mocked(SentryCore.spanToJSON).mockImplementation( (span: any) => (span === bfcacheNavigationSpan - ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'back-forward-cache' } } : { attributes: {} }) as any, ); // A root span is its own root, which is what the web vital spans are parented to. @@ -810,9 +809,9 @@ describe('soft navigation web vitals', () => { vi.mocked(SentryCore.spanToJSON).mockImplementation( (span: any) => (span === bfcacheNavigationSpan - ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'back-forward-cache' } } : span === bfcacheVitalSpan - ? { attributes: { 'sentry.op': 'ui.webvital.lcp', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'ui.webvital.lcp', 'browser.navigation.type': 'back-forward-cache' } } : { attributes: {} }) as any, ); vi.mocked(htmlTreeAsString).mockReturnValue('
'); @@ -930,7 +929,7 @@ describe('soft navigation web vitals', () => { expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ parentSpan: bfcacheNavigationSpan, - attributes: expect.objectContaining({ 'browser.navigation.type': 'bfcache' }), + attributes: expect.objectContaining({ 'browser.navigation.type': 'back-forward-cache' }), }), ); expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalledWith( @@ -961,7 +960,7 @@ describe('soft navigation web vitals', () => { }); it("does not let a restore's own vital span become the parent of the next one", () => { - // Web vital spans for a restore carry the same `bfcache` navigation type as the navigation span + // Web vital spans for a restore carry the same `back-forward-cache` navigation type as the navigation span // they hang off, so the second vital would otherwise be parented to the first. vi.mocked(SentryCore.getActiveSpan).mockReturnValue(undefined); diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index a6d9d57766ee..6708e12549ba 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -81,7 +81,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, } from '@sentry/core'; export { WINDOW } from './helpers'; diff --git a/packages/browser/src/integrations/httpclient.ts b/packages/browser/src/integrations/httpclient.ts index f9d01c5719b5..a47725b3d37d 100644 --- a/packages/browser/src/integrations/httpclient.ts +++ b/packages/browser/src/integrations/httpclient.ts @@ -93,16 +93,12 @@ function _fetchResponseHandler( const reqCookieStr = request.headers.get('Cookie') || undefined; if (reqCookieStr) { const filtered = _INTERNAL_filterCookies(reqCookieStr, dc.cookies); - if (typeof filtered === 'object') { - requestCookies = filtered; - } + requestCookies = typeof filtered === 'string' ? { cookie: filtered } : filtered; } const resCookieStr = response.headers.get('Set-Cookie') || undefined; if (resCookieStr) { const filtered = _INTERNAL_filterCookies(resCookieStr, dc.cookies); - if (typeof filtered === 'object') { - responseCookies = filtered; - } + responseCookies = typeof filtered === 'string' ? { 'set-cookie': filtered } : filtered; } } @@ -146,9 +142,7 @@ function _xhrResponseHandler( const cookieString = xhr.getResponseHeader('Set-Cookie') || xhr.getResponseHeader('set-cookie') || undefined; if (cookieString) { const filtered = _INTERNAL_filterCookies(cookieString, dc.cookies); - if (typeof filtered === 'object') { - responseCookies = filtered; - } + responseCookies = typeof filtered === 'string' ? { 'set-cookie': filtered } : filtered; } } catch { // ignore it if parsing fails diff --git a/packages/browser/src/integrations/webVitals.ts b/packages/browser/src/integrations/webVitals.ts index 7c1b3d14a9fe..3d62d9146a53 100644 --- a/packages/browser/src/integrations/webVitals.ts +++ b/packages/browser/src/integrations/webVitals.ts @@ -49,7 +49,7 @@ export interface WebVitalsOptions { * * A restore is a new page view measured against a document that was never reloaded, so its vitals * are reported against the navigation span `browserTracingIntegration` starts for the restore, - * and tagged `browser.navigation.type: bfcache`. A restore is near-instant by construction, so + * and tagged `browser.navigation.type: back-forward-cache`. A restore is near-instant by construction, so * these are a distinct population from page load vitals and are meant to be read through that * attribute rather than pooled with them. Set this to `false` to leave restores unmeasured. * diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 080d4154f609..b791dddecefc 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -723,7 +723,7 @@ export const browserTracingIntegration = ((options: Partial { expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache', - 'browser.navigation.type': 'bfcache', + 'browser.navigation.type': 'back-forward-cache', }), ); }); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c9f7981a3122..10aa2d422844 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -83,6 +83,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, mastraIntegration, @@ -114,7 +116,6 @@ export { parameterize, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, @@ -191,6 +192,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; @@ -215,5 +218,6 @@ export { bunServerIntegration } from './integrations/bunserver'; export type { BunServerIntegrationOptions } from './integrations/bunserver'; export { bunHttpServerIntegration } from './integrations/bunHttpServer'; export { fetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics'; export { makeFetchTransport } from './transports'; diff --git a/packages/bun/src/integrations/fetch.ts b/packages/bun/src/integrations/fetch.ts index b908ccaf2e25..21e01ccb2307 100644 --- a/packages/bun/src/integrations/fetch.ts +++ b/packages/bun/src/integrations/fetch.ts @@ -1,166 +1,10 @@ -import type { - Client, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, - Span, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'Fetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -interface FetchOptions { - /** - * Whether breadcrumbs should be recorded for requests. - * Defaults to true. - */ - breadcrumbs?: boolean; - - /** - * Function determining whether or not to create spans to track outgoing requests to the given URL. - * By default, spans will be created for all outgoing requests. - */ - shouldCreateSpanForRequest?: (url: string) => boolean; -} - -const _fetchIntegration = ((options: FetchOptions = {}) => { - const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs; - const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - - const _createSpanUrlMap = new LRUMap(100); - const _headersUrlMap = new LRUMap(100); - - const spans: Record = {}; - - /** Decides whether to attach trace data to the outgoing fetch request */ - function _shouldAttachTraceData(url: string): boolean { - const client = getClient(); - - if (!client) { - return false; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - /** Helper that wraps shouldCreateSpanForRequest option */ - function _shouldCreateSpan(url: string): boolean { - if (shouldCreateSpanForRequest === undefined) { - return true; - } - - const cachedDecision = _createSpanUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = shouldCreateSpanForRequest(url); - _createSpanUrlMap.set(url, decision); - return decision; - } - - return { - name: INTEGRATION_NAME, - setupOnce() { - addFetchInstrumentationHandler(handlerData => { - const client = getClient(); - if (!client || !HAS_CLIENT_MAP.get(client)) { - return; - } - const { propagateTraceparent } = client.getOptions(); - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.fetch', - propagateTraceparent, - }); - - if (breadcrumbs) { - createBreadcrumb(handlerData); - } - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** * Instruments outgoing `fetch` requests in Bun: creates spans, records breadcrumbs and * attaches trace propagation headers. */ -export const fetchIntegration = defineIntegration(_fetchIntegration); - -function createBreadcrumb(handlerData: HandlerDataFetch): void { - const { startTimestamp, endTimestamp } = handlerData; - - // We only capture complete fetch requests - if (!endTimestamp) { - return; - } - - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - - if (handlerData.error) { - const hint: FetchBreadcrumbHint = { - data: handlerData.error, - input: handlerData.args, - startTimestamp, - endTimestamp, - }; - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); - } else { - const response = handlerData.response as Response | undefined; - - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - - const hint: FetchBreadcrumbHint = { - input: handlerData.args, - response, - startTimestamp, - endTimestamp, - }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); - } -} +export const fetchIntegration = createFetchIntegration({ + name: 'Fetch', + spanOrigin: 'auto.http.fetch', +}); diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index ba77f87ef9a9..983d45cb3938 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -28,12 +28,9 @@ import { bunHttpServerIntegration } from './integrations/bunHttpServer'; import { getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils'; /** - * The performance integrations for bun: the OTel auto-performance set, but with - * the orchestrion diagnostics-channel subscribers swapped in for their OTel - * equivalents *only* when the orchestrion channels were actually injected (i.e. - * the app was built with `@sentry/bun/plugin`). Without that, the channels - * never fire — and the OTel versions rely on a runtime require-hook bun doesn't - * support — so leave the auto-performance set alone. + * The tracing integrations for bun, added whenever spans are enabled. Most of them listen on + * the orchestrion diagnostics channels, which only exist when the app is built with + * `@sentry/bun/plugin`. Without the plugin, those integrations stay installed but create no spans. */ function getPerformanceIntegrations(options: Options): Integration[] { if (!hasSpansEnabled(options)) { @@ -145,7 +142,7 @@ function _init( const options = { ...userOptions, platform: 'javascript', - runtime: { name: 'bun', version: typeof Bun !== 'undefined' ? Bun.version : 'unknown' }, + runtime: userOptions.runtime || { name: 'bun', version: typeof Bun !== 'undefined' ? Bun.version : 'unknown' }, serverName: userOptions.serverName || global.process.env.SENTRY_NAME || os.hostname(), }; diff --git a/packages/bun/src/types.ts b/packages/bun/src/types.ts index 34643a995ab1..ade64f83137f 100644 --- a/packages/bun/src/types.ts +++ b/packages/bun/src/types.ts @@ -21,6 +21,14 @@ export interface BaseBunOptions extends ServerRuntimeOptions { * @default false */ enableOpenTelemetrySetup?: boolean; + + /** + * Override the runtime name reported in events. + * Defaults to 'bun' with the current Bun version if not specified. + * + * @hidden This is primarily used internally to support SDKs wrapping the Bun SDK, like Elysia. + */ + runtime?: { name: string; version?: string }; } /** diff --git a/packages/bun/test/init.test.ts b/packages/bun/test/init.test.ts index abf3aabf060e..2fced80fd67a 100644 --- a/packages/bun/test/init.test.ts +++ b/packages/bun/test/init.test.ts @@ -129,6 +129,20 @@ describe('init()', () => { }); }); + describe('runtime', () => { + it('defaults to bun', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'static' }); + + expect(getClient()?.getOptions().runtime).toEqual({ name: 'bun', version: Bun.version }); + }); + + it('respects a runtime provided through options', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'static', runtime: { name: 'node', version: '20.0.0' } }); + + expect(getClient()?.getOptions().runtime).toEqual({ name: 'node', version: '20.0.0' }); + }); + }); + describe('initWithoutDefaultIntegrations()', () => { it('installs no default integrations', () => { initWithoutDefaultIntegrations({ dsn: PUBLIC_DSN, traceLifecycle: 'static' }); diff --git a/packages/bun/test/integrations/fetch.test.ts b/packages/bun/test/integrations/fetch.test.ts new file mode 100644 index 000000000000..a201d7462c89 --- /dev/null +++ b/packages/bun/test/integrations/fetch.test.ts @@ -0,0 +1,100 @@ +import http from 'node:http'; +import type { TransactionEvent } from '@sentry/core'; +import { getCurrentScope, getIsolationScope, startSpan } from '@sentry/core'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { init } from '../../src'; + +async function startServer( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, +): Promise<{ port: number; close: () => Promise }> { + const server = http.createServer(handler); + const port = await new Promise(resolve => { + server.listen(0, () => resolve((server.address() as { port: number }).port)); + }); + return { + port, + close: () => new Promise(resolve => server.close(() => resolve())), + }; +} + +const transactions: TransactionEvent[] = []; + +/** Bind on the real completion signal so a "never arrives" regression fails instead of hanging. */ +function waitForTransaction(name: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for the "${name}" transaction`)), 5000); + const poll = setInterval(() => { + const found = transactions.find(event => event.transaction === name); + if (found) { + clearTimeout(timer); + clearInterval(poll); + resolve(found); + } + }, 10); + }); +} + +function header(headers: http.IncomingHttpHeaders | undefined, name: string): string | undefined { + const value = headers?.[name]; + return Array.isArray(value) ? value[0] : value; +} + +describe('fetchIntegration', () => { + beforeAll(() => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + traceLifecycle: 'static', + beforeSendTransaction(event) { + transactions.push(event); + return null; + }, + transport: () => ({ send: async () => ({}), flush: async () => true }), + }); + }); + + afterAll(() => { + getCurrentScope().setClient(undefined); + }); + + test('creates an http.client span and propagates trace headers', async () => { + let received: http.IncomingHttpHeaders | undefined; + const { port, close } = await startServer((req, res) => { + received = req.headers; + res.end('ok'); + }); + + await startSpan({ name: 'parent', op: 'test' }, async () => { + await fetch(`http://localhost:${port}/downstream`).then(res => res.text()); + }); + + const parent = await waitForTransaction('parent'); + await close(); + + const clientSpan = parent.spans?.find(span => span.op === 'http.client'); + expect(clientSpan).toBeDefined(); + expect(clientSpan?.origin).toBe('auto.http.fetch'); + + const traceId = parent.contexts?.trace?.trace_id; + const sentryTrace = header(received, 'sentry-trace'); + expect(sentryTrace).toBeDefined(); + expect(sentryTrace!.split('-')[0]).toBe(traceId!); + expect(sentryTrace!.split('-')[1]).toBe(clientSpan!.span_id!); + expect(header(received, 'baggage')).toContain(`sentry-trace_id=${traceId}`); + }); + + test('records exactly one fetch breadcrumb', async () => { + const { port, close } = await startServer((_req, res) => res.end('ok')); + const url = `http://localhost:${port}/crumb`; + + getIsolationScope().clearBreadcrumbs(); + await fetch(url).then(res => res.text()); + await close(); + + const crumbs = getIsolationScope() + .getScopeData() + .breadcrumbs.filter(crumb => crumb.category === 'fetch' && crumb.data?.url === url); + + expect(crumbs).toHaveLength(1); + }); +}); diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index f9a7a9e6ca62..11a34f9e063a 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -24,11 +24,6 @@ "import": "./build/esm/webpack/index.js", "require": "./build/cjs/webpack/index.js" }, - "./webpack5": { - "types": "./build/types/webpack/webpack5.d.ts", - "import": "./build/esm/webpack/webpack5.js", - "require": "./build/cjs/webpack/webpack5.js" - }, "./rollup": { "types": "./build/types/rollup/index.d.ts", "import": "./build/esm/rollup/index.js", @@ -115,12 +110,13 @@ "dotenv": "^17.4.2", "glob": "^13.0.6", "magic-string": "~0.30.8", + "oxc-parser": "^0.143.0", "sentry": "^0.44.0", "supports-color": "^8.1.1" }, "peerDependencies": { "rollup": ">=3.2.0", - "webpack": ">=5.0.0" + "webpack": ">=5.1.0" }, "peerDependenciesMeta": { "rollup": { @@ -134,7 +130,6 @@ "@babel/preset-react": "^7.23.3", "@types/babel__core": "^7.20.5", "@types/node": "^18.6.3", - "@types/webpack": "npm:@types/webpack@^4", "premove": "^4.0.0", "rolldown": "^1.0.0", "vitest": "^3.2.7", diff --git a/packages/bundler-plugins/rollup.npm.config.mjs b/packages/bundler-plugins/rollup.npm.config.mjs index bd0f18d8a23d..fe1cb44cbe76 100644 --- a/packages/bundler-plugins/rollup.npm.config.mjs +++ b/packages/bundler-plugins/rollup.npm.config.mjs @@ -8,7 +8,6 @@ export default makeNPMConfigVariants( 'src/vite/index.ts', 'src/esbuild/index.ts', 'src/webpack/index.ts', - 'src/webpack/webpack5.ts', 'src/webpack/component-annotation-transform.ts', 'src/babel-plugin/index.ts', ], diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-ast.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-ast.ts similarity index 100% rename from packages/bundler-plugins/src/core/component-annotation-vite-ast.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc-ast.ts diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-fragments.ts similarity index 97% rename from packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc-fragments.ts index 5ce47fd58491..8baa7d027855 100644 --- a/packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-fragments.ts @@ -1,6 +1,6 @@ -import type { AstNode, FragmentContext } from './component-annotation-vite-ast'; -import { isAstNode, isObjectLike, walkAst } from './component-annotation-vite-ast'; -import { getStringName } from './component-annotation-vite-jsx'; +import type { AstNode, FragmentContext } from './component-annotation-oxc-ast'; +import { isAstNode, isObjectLike, walkAst } from './component-annotation-oxc-ast'; +import { getStringName } from './component-annotation-oxc-jsx'; export function collectFragmentContext(ast: AstNode): FragmentContext { const context: FragmentContext = { diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts similarity index 99% rename from packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts index 7c5ecd6a2571..78fd40d99ae6 100644 --- a/packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts @@ -12,8 +12,8 @@ import type { JSXFragmentNode, JSXOpeningElementNode, JSXRootNode, -} from './component-annotation-vite-ast'; -import { isAstNode, isObjectLike } from './component-annotation-vite-ast'; +} from './component-annotation-oxc-ast'; +import { isAstNode, isObjectLike } from './component-annotation-oxc-ast'; const UNKNOWN_ELEMENT_NAME = 'unknown'; const WEB_ATTRIBUTE_NAMES = [WEB_ELEMENT_NAME, WEB_COMPONENT_NAME, WEB_SOURCE_FILE_NAME] as const; diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-walk.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts similarity index 94% rename from packages/bundler-plugins/src/core/component-annotation-vite-walk.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts index 420b6ac8033a..7425d145dd4e 100644 --- a/packages/bundler-plugins/src/core/component-annotation-vite-walk.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts @@ -1,17 +1,17 @@ -import type { AstNode, AttributeInsertion, FragmentContext, JSXRootNode } from './component-annotation-vite-ast'; +import type { AstNode, AttributeInsertion, FragmentContext, JSXRootNode } from './component-annotation-oxc-ast'; import { addPendingAttributes, getStringName, isJSXElement, isJSXRoot, toAttributeInsertions, -} from './component-annotation-vite-jsx'; -import { isAstNode, isObjectLike, walkAst } from './component-annotation-vite-ast'; -import { collectFragmentContext } from './component-annotation-vite-fragments'; +} from './component-annotation-oxc-jsx'; +import { isAstNode, isObjectLike, walkAst } from './component-annotation-oxc-ast'; +import { collectFragmentContext } from './component-annotation-oxc-fragments'; type ComponentJSXRoots = { name: string; roots: JSXRootNode[] }; -export function collectViteComponentAnnotationInsertions( +export function collectOxcComponentAnnotationInsertions( code: string, ast: AstNode, ignoredComponents: string[], diff --git a/packages/bundler-plugins/src/core/component-annotation-vite.ts b/packages/bundler-plugins/src/core/component-annotation-oxc.ts similarity index 74% rename from packages/bundler-plugins/src/core/component-annotation-vite.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc.ts index a0ce0907268e..8bb27f8a7963 100644 --- a/packages/bundler-plugins/src/core/component-annotation-vite.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc.ts @@ -4,26 +4,50 @@ import MagicString from 'magic-string'; import { KNOWN_INCOMPATIBLE_PLUGINS } from '../babel-plugin/constants'; import { stripQueryAndHashFromPath } from './utils'; -import { isAstNode } from './component-annotation-vite-ast'; -import { collectViteComponentAnnotationInsertions } from './component-annotation-vite-walk'; +import { isAstNode } from './component-annotation-oxc-ast'; +import { collectOxcComponentAnnotationInsertions } from './component-annotation-oxc-walk'; import type { AttributeInsertion, ComponentAnnotationTransformMeta, ComponentAnnotationTransformResult, MagicStringLike, ParseAstAsync, -} from './component-annotation-vite-ast'; +} from './component-annotation-oxc-ast'; export type { ComponentAnnotationTransformMeta, ComponentAnnotationTransformResult, -} from './component-annotation-vite-ast'; +} from './component-annotation-oxc-ast'; + +let oxcParseAstAsyncPromise: Promise | undefined; + +export function getOxcParseAstAsync(): Promise { + if (!oxcParseAstAsyncPromise) { + oxcParseAstAsyncPromise = import('oxc-parser') + .then(({ parse }): ParseAstAsync => { + return async (code, { lang }) => { + // preserveParens: false matches the AST Vite 8 produces. The walker + // does not look through ParenthesizedExpression nodes. + const { program, errors } = await parse(`component.${lang}`, code, { lang, preserveParens: false }); + + if (errors.length > 0) { + throw new Error(errors[0]?.message); + } + + return program; + }; + }) + .catch(() => null); + } + + return oxcParseAstAsyncPromise; +} // Keep this as a superset of JSX tag starts Babel can annotate, because a miss suppresses Babel fallback. const JSX_TAG_START_REGEXP = /<[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}.:-]*|<>/u; const JSX_FILE_REGEXP = /\.[jt]sx$/; -function isViteAnnotationFile(idWithoutQueryAndHash: string): boolean { +function isAnnotationFile(idWithoutQueryAndHash: string): boolean { if (idWithoutQueryAndHash.match(/\\node_modules\\|\/node_modules\//)) { return false; } @@ -72,7 +96,7 @@ function getMagicString( return { magicString: new MagicString(code), isNative: false }; } -async function annotateWithViteParser( +async function annotateWithOxcParser( code: string, id: string, ignoredComponents: string[], @@ -83,7 +107,7 @@ async function annotateWithViteParser( if ( !idWithoutQueryAndHash || - !isViteAnnotationFile(idWithoutQueryAndHash) || + !isAnnotationFile(idWithoutQueryAndHash) || !shouldTryParse(code) || shouldSkipIncompatibleFile(idWithoutQueryAndHash) ) { @@ -100,7 +124,7 @@ async function annotateWithViteParser( return undefined; } - const insertions = collectViteComponentAnnotationInsertions( + const insertions = collectOxcComponentAnnotationInsertions( code, ast, ignoredComponents, @@ -132,7 +156,7 @@ async function annotateWithViteParser( }; } -export function createViteComponentNameAnnotateHooks( +export function createOxcComponentNameAnnotateHooks( ignoredComponents: string[], getParseAstAsync: () => Promise, ): { @@ -151,7 +175,7 @@ export function createViteComponentNameAnnotateHooks( return undefined; } - return await annotateWithViteParser(code, id, ignoredComponents, parseAstAsync, meta); + return await annotateWithOxcParser(code, id, ignoredComponents, parseAstAsync, meta); } catch { return undefined; } diff --git a/packages/bundler-plugins/src/core/sentry/telemetry.ts b/packages/bundler-plugins/src/core/sentry/telemetry.ts index a68f106e07b9..72ea587bfef8 100644 --- a/packages/bundler-plugins/src/core/sentry/telemetry.ts +++ b/packages/bundler-plugins/src/core/sentry/telemetry.ts @@ -27,7 +27,7 @@ export function createSentryInstance( dsn: 'https://4c2bae7d9fbc413e8f7385f55c515d51@o1.ingest.sentry.io/6690737', - tracesSampleRate: 1, + tracesSampleRate: 0.3, traceLifecycle: 'static', sampleRate: 1, @@ -64,6 +64,11 @@ export function createSentryInstance( const scope = new Scope(); scope.setClient(client); + // Integration tests snapshot the emitted transaction, so the sampling decision must not depend on chance. + if (process.env['SENTRY_TEST_OUT_DIR']) { + scope.setPropagationContext({ ...scope.getPropagationContext(), sampleRand: 0 }); + } + setTelemetryDataOnScope(options, scope, buildTool, buildToolMajorVersion); return { sentryScope: scope, sentryClient: client }; diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index 22fe5a295046..1bb1def0b80d 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -18,7 +18,7 @@ import { import type { ComponentAnnotationTransformMeta, ComponentAnnotationTransformResult, -} from '../core/component-annotation-vite'; +} from '../core/component-annotation-oxc'; import type { SourceMap } from 'magic-string'; import MagicString from 'magic-string'; import * as path from 'node:path'; @@ -41,7 +41,7 @@ type ViteModule = { }; type ViteParseAstAsync = NonNullable; -type ViteAnnotationHooks = { +type FastAnnotationHooks = { transform( code: string, id: string, @@ -168,27 +168,27 @@ export function _rollupPluginInternal( !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, ) : undefined; - const transformViteAnnotations = - options.reactComponentAnnotation?.enabled && - buildTool === 'vite' && - buildToolMajorVersion === '8' && - !options.reactComponentAnnotation?._experimentalInjectIntoHtml + const transformFastAnnotations = + options.reactComponentAnnotation?.enabled && !options.reactComponentAnnotation?._experimentalInjectIntoHtml ? (() => { - let viteAnnotationHooksPromise: Promise | undefined; + let fastAnnotationHooksPromise: Promise | undefined; return { transform(code: string, id: string, meta?: ComponentAnnotationTransformMeta) { - if (!viteAnnotationHooksPromise) { - viteAnnotationHooksPromise = import('../core/component-annotation-vite').then( - ({ createViteComponentNameAnnotateHooks }) => - createViteComponentNameAnnotateHooks( + if (!fastAnnotationHooksPromise) { + fastAnnotationHooksPromise = import('../core/component-annotation-oxc').then( + ({ createOxcComponentNameAnnotateHooks, getOxcParseAstAsync }) => + createOxcComponentNameAnnotateHooks( options.reactComponentAnnotation?.ignoredComponents || [], - getViteParseAstAsync, + // Vite 8 already loads an oxc-based parser, so reuse it. + buildTool === 'vite' && buildToolMajorVersion === '8' + ? getViteParseAstAsync + : getOxcParseAstAsync, ), ); } - return viteAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta)); + return fastAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta)); }, }; })() @@ -212,8 +212,8 @@ export function _rollupPluginInternal( // only in Sentry code. If we successfully add annotations, we can return early. let shouldRunBabelAnnotations = true; - if (transformViteAnnotations?.transform) { - const result = await transformViteAnnotations.transform(code, id, meta); + if (transformFastAnnotations?.transform) { + const result = await transformFastAnnotations.transform(code, id, meta); if (result) { return result; } diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index 634f2c1e958f..0597b5794c08 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -1,37 +1,353 @@ -import type { SentryWebpackPluginOptions } from './webpack4and5'; -import { sentryWebpackPluginFactory } from './webpack4and5'; +import type { Options } from '../core/index'; +import { + createSentryBuildPluginManager, + generateReleaseInjectorCode, + generateModuleMetadataInjectorCode, + stringToUUID, + createComponentNameAnnotateHooks, + CodeInjection, + getDebugIdSnippet, + createDebugIdUploadFunction, + isJsFile, + stampDebugId, +} from '../core/index'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; +import { randomUUID } from 'node:crypto'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type PluginClass = new (options: any) => unknown; +const _req = createRequire(import.meta.url); + +// Resolve the loader path via the package's own exports. +// This module may end up in a shared chunk (_chunks/) whose import.meta.url +// does not point to the webpack/ directory where the transform file lives, so +// a path-relative lookup would fail. Using require.resolve on the package export +// always finds the correct installed file regardless of chunk placement. +let COMPONENT_ANNOTATION_LOADER: string; +try { + COMPONENT_ANNOTATION_LOADER = _req.resolve('@sentry/bundler-plugins/webpack-loader'); +} catch { + // Fallback for non-packaged environments (e.g., monorepo source runs without dist) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Rollup transpiles import.meta for us for CJS + const dirname = path.dirname(fileURLToPath(import.meta.url)); + // The Rollup build emits `.js` for both CJS and ESM, so the extension is the same in both. + COMPONENT_ANNOTATION_LOADER = path.resolve(dirname, 'component-annotation-transform.js'); +} + +interface BannerPluginCallbackArg { + chunk?: { + hash?: string; + contentHash?: { + javascript?: string; + }; + }; +} + +type PluginClass = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (options: any): unknown; +}; + +type WebpackSource = { + source: () => string | Buffer; +}; + +type WebpackRawSource = { + new (source: string): WebpackSource; +}; + +type WebpackAsset = { + name: string; + source: WebpackSource; + info: { + related?: { + sourceMap?: string | string[]; + }; + }; +}; + +type WebpackCompiler = { + options: { + plugins?: unknown[]; + mode?: string; + module?: { + rules?: unknown[]; + }; + }; + hooks: { + thisCompilation: { + tap: (name: string, callback: (compilation: WebpackCompilation) => void) => void; + }; + afterEmit: { + tapAsync: (name: string, callback: (compilation: WebpackCompilation, cb: () => void) => void) => void; + }; + done: { + tap: (name: string, callback: () => void) => void; + }; + }; + webpack?: { + BannerPlugin?: PluginClass; + DefinePlugin?: PluginClass; + Compilation?: { + PROCESS_ASSETS_STAGE_DEV_TOOLING?: number; + }; + sources?: { + RawSource?: WebpackRawSource; + }; + }; +}; + +type WebpackCompilation = { + outputOptions: { + path?: string; + }; + assets: Record; + getAssets: () => WebpackAsset[]; + getAsset: (name: string) => WebpackAsset | undefined; + updateAsset: (name: string, source: WebpackSource) => void; + hooks: { + processAssets: { + tap: (options: { name: string; stage: number }, callback: () => void) => void; + }; + }; +}; type WebpackModule = { - BannerPlugin?: PluginClass; - DefinePlugin?: PluginClass; - default?: WebpackModule; + version?: string; + default?: { version?: string }; }; -// `webpack` is an optional peer dependency. We require it lazily so the plugin doesn't -// crash on load in bundlers that don't ship `webpack` (e.g. rspack) — those provide -// the plugin classes via `compiler.webpack` at runtime instead. -function loadWebpack(): WebpackModule { +// Only used for telemetry; `webpack` is an optional peer dependency and may be absent (e.g. rspack). +function getWebpackMajorVersion(): string | undefined { try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Rollup transpiles import.meta for CJS - return createRequire(import.meta.url)('webpack') as WebpackModule; + const webpack = _req('webpack') as WebpackModule; + const version = webpack.version ?? webpack.default?.version; + return version?.split('.')[0]; } catch { - return {}; + return undefined; + } +} + +/** + * Stamps each JS asset's debug ID into the asset itself and its source map asset. + * + * Runs after source maps have been generated, so the JS asset no longer needs to carry + * source map information and can be replaced with a plain `RawSource`. + */ +function addDebugIdsToAssets(compilation: WebpackCompilation, RawSource: WebpackRawSource): void { + for (const asset of compilation.getAssets()) { + if (!isJsFile(asset.name)) { + continue; + } + + const bundleSource = asset.source.source().toString(); + const relatedSourceMap = asset.info.related?.sourceMap; + const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; + const sourceMapAsset = compilation.getAsset(sourceMapName); + + const stamped = stampDebugId(bundleSource, sourceMapAsset?.source.source().toString()); + if (!stamped) { + continue; + } + + compilation.updateAsset(asset.name, new RawSource(stamped.bundleSource)); + if (stamped.sourceMapSource !== undefined) { + compilation.updateAsset(sourceMapName, new RawSource(stamped.sourceMapSource)); + } } } -const webpack = loadWebpack(); -const BannerPlugin = webpack.BannerPlugin ?? webpack.default?.BannerPlugin; -const DefinePlugin = webpack.DefinePlugin ?? webpack.default?.DefinePlugin; +function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) { + const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { + loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]', + buildTool: 'webpack', + buildToolMajorVersion: getWebpackMajorVersion(), + }); + + const { + logger, + normalizedOptions: options, + bundleSizeOptimizationReplacementValues: replacementValues, + bundleMetadata, + createDependencyOnBuildArtifacts, + } = sentryBuildPluginManager; + + if (options.disable) { + return { + apply() { + // noop plugin + }, + }; + } + + if (process.cwd().match(/\\node_modules\\|\/node_modules\//)) { + logger.warn('Running Sentry plugin from within a `node_modules` folder. Some features may not work.'); + } + + const sourcemapsEnabled = options.sourcemaps?.disable !== true; + const staticInjectionCode = new CodeInjection(); + + if (!options.release.inject) { + logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); + } else if (!options.release.name) { + logger.debug( + 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', + ); + } else { + staticInjectionCode.append( + generateReleaseInjectorCode({ + release: options.release.name, + injectBuildInformation: options._experiments.injectBuildInformation || false, + }), + ); + } + + if (Object.keys(bundleMetadata).length > 0) { + staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + } + + const transformAnnotations = options.reactComponentAnnotation?.enabled + ? createComponentNameAnnotateHooks( + options.reactComponentAnnotation?.ignoredComponents || [], + !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, + ) + : undefined; + + const transformReplace = Object.keys(replacementValues).length > 0; + + return { + apply(compiler: WebpackCompiler) { + void sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal().catch(() => { + // Telemetry failures are acceptable + }); + + const { BannerPlugin, DefinePlugin } = compiler.webpack ?? {}; + + // Add BannerPlugin for code injection (release, metadata, debug IDs) + if (!staticInjectionCode.isEmpty() || sourcemapsEnabled) { + if (!BannerPlugin) { + logger.warn( + 'BannerPlugin is not available. Skipping code injection. This usually means webpack is not properly configured.', + ); + } else { + compiler.options.plugins = compiler.options.plugins || []; + compiler.options.plugins.push( + new BannerPlugin({ + raw: true, + include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/, + banner: (arg?: BannerPluginCallbackArg) => { + const codeToInject = staticInjectionCode.clone(); + if (sourcemapsEnabled) { + const hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; + const debugId = hash ? stringToUUID(hash) : randomUUID(); + codeToInject.append(getDebugIdSnippet(debugId)); + } + return codeToInject.code(); + }, + }), + ); + } + } + + // The upload routine (which stamps debug IDs into temp copies of the artifacts) is skipped + // with `disable-upload`, so the emitted artifacts get stamped in the asset pipeline instead. + if (sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload') { + const RawSource = compiler.webpack?.sources?.RawSource; + // Right after source map generation (and thus after minification, which would strip the comment), + // so later stages (real content hashing, subresource integrity) see the final assets. + const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_DEV_TOOLING ?? 500) + 1; + + if (!RawSource) { + logger.warn( + 'Webpack sources are not available. Skipping debug ID injection into emitted source maps. This usually means webpack is not properly configured.', + ); + } else { + compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { + compilation.hooks.processAssets.tap({ name: 'sentry-webpack-plugin', stage }, () => { + addDebugIdsToAssets(compilation, RawSource); + }); + }); + } + } + + // Add DefinePlugin for bundle size optimizations + if (transformReplace && DefinePlugin) { + compiler.options.plugins = compiler.options.plugins || []; + compiler.options.plugins.push(new DefinePlugin(replacementValues)); + } + + // Add component name annotation transform + if (transformAnnotations?.transform) { + compiler.options.module = compiler.options.module || {}; + compiler.options.module.rules = compiler.options.module.rules || []; + compiler.options.module.rules.unshift({ + test: /\.[jt]sx$/, + exclude: /node_modules/, + enforce: 'pre', + use: [ + { + loader: COMPONENT_ANNOTATION_LOADER, + options: { + transform: transformAnnotations.transform, + }, + }, + ], + }); + } + + compiler.hooks.afterEmit.tapAsync( + 'sentry-webpack-plugin', + (compilation: WebpackCompilation, callback: (err?: Error) => void) => { + const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); + const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + + const run = async (): Promise => { + try { + await sentryBuildPluginManager.createRelease(); + if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { + const outputPath = compilation.outputOptions.path ?? path.resolve(); + const buildArtifacts = Object.keys(compilation.assets).map(asset => path.join(outputPath, asset)); + await upload(buildArtifacts); + } + } finally { + freeGlobalDependencyOnBuildArtifacts(); + await sentryBuildPluginManager.deleteArtifacts(); + } + }; + + run().then( + () => callback(), + (err: Error) => callback(err), + ); + }, + ); + + if (userOptions._experiments?.forceExitOnBuildCompletion && compiler.options.mode === 'production') { + compiler.hooks.done.tap('sentry-webpack-plugin', () => { + setTimeout(() => { + logger.debug('Exiting process after debug file upload'); + process.exit(0); + }); + }); + } + }, + }; +} // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = sentryWebpackPluginFactory({ - BannerPlugin, - DefinePlugin, -}); +export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = createSentryWebpackPlugin; -export type { SentryWebpackPluginOptions }; +export type SentryWebpackPluginOptions = Options & { + _experiments?: Options['_experiments'] & { + /** + * If enabled, the webpack plugin will exit the build process after the build completes. + * Use this with caution, as it will terminate the process. + * + * More information: https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/345 + * + * @default false + */ + forceExitOnBuildCompletion?: boolean; + }; +}; diff --git a/packages/bundler-plugins/src/webpack/webpack4and5.ts b/packages/bundler-plugins/src/webpack/webpack4and5.ts deleted file mode 100644 index 42c635e37bb2..000000000000 --- a/packages/bundler-plugins/src/webpack/webpack4and5.ts +++ /dev/null @@ -1,375 +0,0 @@ -import type { Options } from '../core/index'; -import { - createSentryBuildPluginManager, - generateReleaseInjectorCode, - generateModuleMetadataInjectorCode, - stringToUUID, - createComponentNameAnnotateHooks, - CodeInjection, - getDebugIdSnippet, - createDebugIdUploadFunction, - isJsFile, - stampDebugId, -} from '../core/index'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; -import { randomUUID } from 'node:crypto'; - -const _req = createRequire(import.meta.url); - -// Resolve the loader path via the package's own exports. -// webpack4and5.ts may end up in a shared chunk (_chunks/) whose import.meta.url -// does not point to the webpack/ directory where the transform file lives, so -// a path-relative lookup would fail. Using require.resolve on the package export -// always finds the correct installed file regardless of chunk placement. -let COMPONENT_ANNOTATION_LOADER: string; -try { - COMPONENT_ANNOTATION_LOADER = _req.resolve('@sentry/bundler-plugins/webpack-loader'); -} catch { - // Fallback for non-packaged environments (e.g., monorepo source runs without dist) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Rollup transpiles import.meta for us for CJS - const dirname = path.dirname(fileURLToPath(import.meta.url)); - // The Rollup build emits `.js` for both CJS and ESM, so the extension is the same in both. - COMPONENT_ANNOTATION_LOADER = path.resolve(dirname, 'component-annotation-transform.js'); -} - -// since webpack 5.1 compiler contains webpack module so plugins always use correct webpack version -// https://github.com/webpack/webpack/commit/65eca2e529ce1d79b79200d4bdb1ce1b81141459 - -interface BannerPluginCallbackArg { - chunk?: { - hash?: string; - contentHash?: { - javascript?: string; - }; - }; -} - -type UnsafeBannerPlugin = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (options: any): unknown; -}; - -type UnsafeDefinePlugin = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (options: any): unknown; -}; - -type WebpackSource = { - source: () => string | Buffer; -}; - -type WebpackRawSource = { - new (source: string): WebpackSource; -}; - -type WebpackAsset = { - name: string; - source: WebpackSource; - info: { - related?: { - sourceMap?: string | string[]; - }; - }; -}; - -type WebpackCompiler = { - options: { - plugins?: unknown[]; - mode?: string; - module?: { - rules?: unknown[]; - }; - }; - hooks: { - thisCompilation: { - tap: (name: string, callback: (compilation: WebpackCompilation) => void) => void; - }; - afterEmit: { - tapAsync: (name: string, callback: (compilation: WebpackCompilation, cb: () => void) => void) => void; - }; - done: { - tap: (name: string, callback: () => void) => void; - }; - }; - webpack?: { - BannerPlugin?: UnsafeBannerPlugin; - DefinePlugin?: UnsafeDefinePlugin; - Compilation?: { - PROCESS_ASSETS_STAGE_DEV_TOOLING?: number; - }; - sources?: { - RawSource?: WebpackRawSource; - }; - }; -}; - -type WebpackCompilation = { - outputOptions: { - path?: string; - }; - assets: Record; - getAssets: () => WebpackAsset[]; - getAsset: (name: string) => WebpackAsset | undefined; - updateAsset: (name: string, source: WebpackSource) => void; - hooks: { - processAssets: { - tap: (options: { name: string; stage: number }, callback: () => void) => void; - }; - }; -}; - -// Detect webpack major version for telemetry (helps differentiate webpack 4 vs 5 usage) -function getWebpackMajorVersion(): string | undefined { - try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Rollup already transpiles this for us - const req = createRequire(import.meta.url); - const webpack = req('webpack') as { version?: string; default?: { version?: string } }; - const version = webpack?.version ?? webpack?.default?.version; - const webpackMajorVersion = version?.split('.')[0]; // "4" or "5" - return webpackMajorVersion; - } catch { - return undefined; - } -} - -/** - * Stamps each JS asset's debug ID into the asset itself and its source map asset. - * - * Runs after source maps have been generated, so the JS asset no longer needs to carry - * source map information and can be replaced with a plain `RawSource`. - */ -function addDebugIdsToAssets(compilation: WebpackCompilation, RawSource: WebpackRawSource): void { - for (const asset of compilation.getAssets()) { - if (!isJsFile(asset.name)) { - continue; - } - - const bundleSource = asset.source.source().toString(); - const relatedSourceMap = asset.info.related?.sourceMap; - const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; - const sourceMapAsset = compilation.getAsset(sourceMapName); - - const stamped = stampDebugId(bundleSource, sourceMapAsset?.source.source().toString()); - if (!stamped) { - continue; - } - - compilation.updateAsset(asset.name, new RawSource(stamped.bundleSource)); - if (stamped.sourceMapSource !== undefined) { - compilation.updateAsset(sourceMapName, new RawSource(stamped.sourceMapSource)); - } - } -} - -/** - * The factory function accepts BannerPlugin and DefinePlugin classes in - * order to avoid direct dependencies on webpack. - * - * This allow us to export version of the plugin for webpack 5.1+ and compatible environments. - * - * Since webpack 5.1 compiler contains webpack module so plugins always use correct webpack version. - */ -export function sentryWebpackPluginFactory({ - BannerPlugin: UnsafeBannerPlugin, - DefinePlugin: UnsafeDefinePlugin, -}: { - BannerPlugin?: UnsafeBannerPlugin; - DefinePlugin?: UnsafeDefinePlugin; -} = {}) { - return function sentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) { - const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { - loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]', - buildTool: 'webpack', - buildToolMajorVersion: getWebpackMajorVersion(), - }); - - const { - logger, - normalizedOptions: options, - bundleSizeOptimizationReplacementValues: replacementValues, - bundleMetadata, - createDependencyOnBuildArtifacts, - } = sentryBuildPluginManager; - - if (options.disable) { - return { - apply() { - // noop plugin - }, - }; - } - - if (process.cwd().match(/\\node_modules\\|\/node_modules\//)) { - logger.warn('Running Sentry plugin from within a `node_modules` folder. Some features may not work.'); - } - - const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); - - if (!options.release.inject) { - logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); - } else if (!options.release.name) { - logger.debug( - 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', - ); - } else { - staticInjectionCode.append( - generateReleaseInjectorCode({ - release: options.release.name, - injectBuildInformation: options._experiments.injectBuildInformation || false, - }), - ); - } - - if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); - } - - const transformAnnotations = options.reactComponentAnnotation?.enabled - ? createComponentNameAnnotateHooks( - options.reactComponentAnnotation?.ignoredComponents || [], - !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, - ) - : undefined; - - const transformReplace = Object.keys(replacementValues).length > 0; - - return { - apply(compiler: WebpackCompiler) { - void sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal().catch(() => { - // Telemetry failures are acceptable - }); - - // Get the correct plugin classes (webpack 5.1+ vs older versions) - const BannerPlugin = compiler?.webpack?.BannerPlugin || UnsafeBannerPlugin; - const DefinePlugin = compiler?.webpack?.DefinePlugin || UnsafeDefinePlugin; - - // Add BannerPlugin for code injection (release, metadata, debug IDs) - if (!staticInjectionCode.isEmpty() || sourcemapsEnabled) { - if (!BannerPlugin) { - logger.warn( - 'BannerPlugin is not available. Skipping code injection. This usually means webpack is not properly configured.', - ); - } else { - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push( - new BannerPlugin({ - raw: true, - include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/, - banner: (arg?: BannerPluginCallbackArg) => { - const codeToInject = staticInjectionCode.clone(); - if (sourcemapsEnabled) { - const hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; - const debugId = hash ? stringToUUID(hash) : randomUUID(); - codeToInject.append(getDebugIdSnippet(debugId)); - } - return codeToInject.code(); - }, - }), - ); - } - } - - // The upload routine (which stamps debug IDs into temp copies of the artifacts) is skipped - // with `disable-upload`, so the emitted artifacts get stamped in the asset pipeline instead. - if (sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload') { - const RawSource = compiler.webpack?.sources?.RawSource; - // Right after source map generation (and thus after minification, which would strip the comment), - // so later stages (real content hashing, subresource integrity) see the final assets. - const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_DEV_TOOLING ?? 500) + 1; - - if (!RawSource) { - logger.warn( - 'Webpack sources are not available. Skipping debug ID injection into emitted source maps. This usually means webpack is not properly configured.', - ); - } else { - compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { - compilation.hooks.processAssets.tap({ name: 'sentry-webpack-plugin', stage }, () => { - addDebugIdsToAssets(compilation, RawSource); - }); - }); - } - } - - // Add DefinePlugin for bundle size optimizations - if (transformReplace && DefinePlugin) { - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push(new DefinePlugin(replacementValues)); - } - - // Add component name annotation transform - if (transformAnnotations?.transform) { - compiler.options.module = compiler.options.module || {}; - compiler.options.module.rules = compiler.options.module.rules || []; - compiler.options.module.rules.unshift({ - test: /\.[jt]sx$/, - exclude: /node_modules/, - enforce: 'pre', - use: [ - { - loader: COMPONENT_ANNOTATION_LOADER, - options: { - transform: transformAnnotations.transform, - }, - }, - ], - }); - } - - compiler.hooks.afterEmit.tapAsync( - 'sentry-webpack-plugin', - (compilation: WebpackCompilation, callback: (err?: Error) => void) => { - const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); - const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); - - const run = async (): Promise => { - try { - await sentryBuildPluginManager.createRelease(); - if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { - const outputPath = compilation.outputOptions.path ?? path.resolve(); - const buildArtifacts = Object.keys(compilation.assets).map(asset => path.join(outputPath, asset)); - await upload(buildArtifacts); - } - } finally { - freeGlobalDependencyOnBuildArtifacts(); - await sentryBuildPluginManager.deleteArtifacts(); - } - }; - - run().then( - () => callback(), - (err: Error) => callback(err), - ); - }, - ); - - if (userOptions._experiments?.forceExitOnBuildCompletion && compiler.options.mode === 'production') { - compiler.hooks.done.tap('sentry-webpack-plugin', () => { - setTimeout(() => { - logger.debug('Exiting process after debug file upload'); - process.exit(0); - }); - }); - } - }, - }; - }; -} - -export type SentryWebpackPluginOptions = Options & { - _experiments?: Options['_experiments'] & { - /** - * If enabled, the webpack plugin will exit the build process after the build completes. - * Use this with caution, as it will terminate the process. - * - * More information: https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/345 - * - * @default false - */ - forceExitOnBuildCompletion?: boolean; - }; -}; diff --git a/packages/bundler-plugins/src/webpack/webpack5.ts b/packages/bundler-plugins/src/webpack/webpack5.ts deleted file mode 100644 index 063aee71da02..000000000000 --- a/packages/bundler-plugins/src/webpack/webpack5.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SentryWebpackPluginOptions } from './webpack4and5'; -import { sentryWebpackPluginFactory } from './webpack4and5'; - -const createSentryWebpackPlugin = sentryWebpackPluginFactory(); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = createSentryWebpackPlugin; - -export type { SentryWebpackPluginOptions }; diff --git a/packages/bundler-plugins/test/core/component-annotation-vite.test.ts b/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts similarity index 78% rename from packages/bundler-plugins/test/core/component-annotation-vite.test.ts rename to packages/bundler-plugins/test/core/component-annotation-oxc.test.ts index 789437dd7903..2529460dfa3f 100644 --- a/packages/bundler-plugins/test/core/component-annotation-vite.test.ts +++ b/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts @@ -5,9 +5,11 @@ import { describe, expect, it, vi } from 'vitest'; import componentNameAnnotatePlugin from '../../src/babel-plugin'; import { - createViteComponentNameAnnotateHooks, + createOxcComponentNameAnnotateHooks, + getOxcParseAstAsync, type ComponentAnnotationTransformResult, -} from '../../src/core/component-annotation-vite'; +} from '../../src/core/component-annotation-oxc'; +import type { ParseAstAsync } from '../../src/core/component-annotation-oxc-ast'; type Annotation = { elementName: string; @@ -80,17 +82,21 @@ async function annotateWithBabel(code: string, id: string, ignoredComponents: st return collectAnnotations(result?.code ?? '', id); } -async function annotateWithVite( +async function annotateWithOxc( code: string, id: string, ignoredComponents: string[] = [], + getParseAstAsync: () => Promise = async () => parseAstAsync, ): Promise { - const hooks = createViteComponentNameAnnotateHooks(ignoredComponents, async () => parseAstAsync); + const hooks = createOxcComponentNameAnnotateHooks(ignoredComponents, getParseAstAsync); return hooks.transform(code, id); } -describe('createViteComponentNameAnnotateHooks', () => { +describe.each<[string, () => Promise]>([ + ['@babel/parser', async () => parseAstAsync], + ['oxc-parser', getOxcParseAstAsync], +])('createOxcComponentNameAnnotateHooks with %s', (_parserName, getParseAstAsync) => { it.each([ [ 'function declarations and nested children', @@ -254,11 +260,28 @@ export function TypedComponent(props: Props) { }`, [], ], + [ + 'tsx files with parenthesized returns and TypeScript expressions', + '/src/typed-parenthesized.tsx', + `import React from "react"; + +type Props = { items?: T[] }; + +export const List = (props: Props) => { + const items = props.items!; + return ( + + item={items[0]} /> +
+ ); +};`, + [], + ], ])('matches Babel annotations for %s', async (_name, id, code, ignoredComponents) => { - const viteResult = await annotateWithVite(code, id, ignoredComponents); + const oxcResult = await annotateWithOxc(code, id, ignoredComponents, getParseAstAsync); - expect(viteResult).toBeTruthy(); - expect(collectAnnotations(viteResult?.code.toString() ?? '', id)).toEqual( + expect(oxcResult).toBeTruthy(); + expect(collectAnnotations(oxcResult?.code.toString() ?? '', id)).toEqual( await annotateWithBabel(code, id, ignoredComponents), ); }); @@ -267,10 +290,10 @@ export function TypedComponent(props: Props) { const code = `export const App = () => <${elementName} />;`; const id = '/src/app.jsx'; - const viteResult = await annotateWithVite(code, id); + const oxcResult = await annotateWithOxc(code, id, [], getParseAstAsync); - expect(viteResult).toBeTruthy(); - expect(collectAnnotations(viteResult?.code.toString() ?? '', id)).toEqual([ + expect(oxcResult).toBeTruthy(); + expect(collectAnnotations(oxcResult?.code.toString() ?? '', id)).toEqual([ { elementName, attributes: { @@ -281,14 +304,16 @@ export function TypedComponent(props: Props) { }, ]); }); +}); +describe('createOxcComponentNameAnnotateHooks', () => { it('uses the native magicString object from transform metadata when it is available', async () => { const code = `export function App() { return ; }`; const id = '/src/app.jsx'; const magicString = new MagicString(code); - const hooks = createViteComponentNameAnnotateHooks([], async () => parseAstAsync); + const hooks = createOxcComponentNameAnnotateHooks([], async () => parseAstAsync); const result = await hooks.transform(code, id, { magicString }); @@ -296,19 +321,25 @@ export function TypedComponent(props: Props) { expect(result?.code.toString()).toContain(`data-sentry-component="App"`); }); - it('returns null without parsing when the file cannot contain public Vite annotations', async () => { + it('returns null without parsing when the file cannot contain annotations', async () => { const parse = vi.fn(parseAstAsync); - const hooks = createViteComponentNameAnnotateHooks([], async () => parse); + const hooks = createOxcComponentNameAnnotateHooks([], async () => parse); await expect(hooks.transform('const value = 1;', '/src/app.js')).resolves.toBeNull(); expect(parse).not.toHaveBeenCalled(); }); it('returns undefined when parsing fails so callers can fall back to Babel', async () => { - const hooks = createViteComponentNameAnnotateHooks([], async () => { + const hooks = createOxcComponentNameAnnotateHooks([], async () => { throw new Error('parser unavailable'); }); await expect(hooks.transform('export const App = () => ;', '/src/app.jsx')).resolves.toBeUndefined(); }); + + it('returns undefined when oxc-parser reports a syntax error so callers can fall back to Babel', async () => { + const hooks = createOxcComponentNameAnnotateHooks([], getOxcParseAstAsync); + + await expect(hooks.transform('export const App = () => ;', '/src/app.tsx')).resolves.toBeUndefined(); + }); }); diff --git a/packages/bundler-plugins/test/rollup/public-api.test.ts b/packages/bundler-plugins/test/rollup/public-api.test.ts index b54077fce2bc..06b8220dee2c 100644 --- a/packages/bundler-plugins/test/rollup/public-api.test.ts +++ b/packages/bundler-plugins/test/rollup/public-api.test.ts @@ -3,15 +3,25 @@ import { createComponentNameAnnotateHooks } from '../../src/core'; import type { Plugin, SourceMap } from 'rollup'; import { describe, it, expect, test, beforeEach, vi } from 'vitest'; -const { babelCoreImportMock, transformAsyncMock, viteAnnotationModuleImportMock, viteAnnotationTransformMock } = - vi.hoisted(() => { - return { - babelCoreImportMock: vi.fn(), - transformAsyncMock: vi.fn(async (code: string) => ({ code, map: null })), - viteAnnotationModuleImportMock: vi.fn(), - viteAnnotationTransformMock: vi.fn(async () => ({ code: 'fast-path', map: null })), - }; - }); +const { + babelCoreImportMock, + transformAsyncMock, + annotationTransformMock, + createOxcComponentNameAnnotateHooksMock, + getOxcParseAstAsyncMock, +} = vi.hoisted(() => { + const annotationTransformMock = vi.fn(async () => ({ code: 'fast-path', map: null })); + + return { + babelCoreImportMock: vi.fn(), + transformAsyncMock: vi.fn(async (code: string) => ({ code, map: null })), + annotationTransformMock, + createOxcComponentNameAnnotateHooksMock: vi.fn(() => ({ + transform: annotationTransformMock, + })), + getOxcParseAstAsyncMock: vi.fn(), + }; +}); vi.mock('@babel/core', () => { babelCoreImportMock(); @@ -20,12 +30,10 @@ vi.mock('@babel/core', () => { }; }); -vi.mock('../../src/core/component-annotation-vite', () => { - viteAnnotationModuleImportMock(); +vi.mock('../../src/core/component-annotation-oxc', () => { return { - createViteComponentNameAnnotateHooks: vi.fn(() => ({ - transform: viteAnnotationTransformMock, - })), + createOxcComponentNameAnnotateHooks: createOxcComponentNameAnnotateHooksMock, + getOxcParseAstAsync: getOxcParseAstAsyncMock, }; }); @@ -59,29 +67,57 @@ test('component annotations only load Babel when the Babel transform runs', asyn expect(transformAsyncMock).toHaveBeenCalledTimes(1); }); -test('Vite annotation fast path only loads for Vite 8 annotation transforms', async () => { - expect(viteAnnotationModuleImportMock).not.toHaveBeenCalled(); +describe('annotation fast path', () => { + const code = 'export function App() { return
; }'; - const vite7Plugin = _rollupPluginInternal( - { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, - 'vite', - '7', - ) as Plugin; + beforeEach(() => { + vi.clearAllMocks(); + }); - await runTransform(vite7Plugin, 'export function App() { return
; }', '/src/app.jsx'); + it.each<[string, 'rollup' | 'vite', string | undefined]>([ + ['Rollup', 'rollup', undefined], + ['Vite 7', 'vite', '7'], + ])('uses the fast path with oxc-parser for %s', async (_name, buildTool, majorVersion) => { + const plugin = _rollupPluginInternal( + { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, + buildTool, + majorVersion, + ) as Plugin; + + await expect(runTransform(plugin, code, '/src/app.jsx')).resolves.toEqual({ code: 'fast-path', map: null }); + + expect(createOxcComponentNameAnnotateHooksMock).toHaveBeenCalledWith([], getOxcParseAstAsyncMock); + expect(annotationTransformMock).toHaveBeenCalledTimes(1); + expect(transformAsyncMock).not.toHaveBeenCalled(); + }); - expect(viteAnnotationModuleImportMock).not.toHaveBeenCalled(); + it("uses the fast path with Vite's parser for Vite 8", async () => { + const plugin = _rollupPluginInternal( + { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, + 'vite', + '8', + ) as Plugin; - const vite8Plugin = _rollupPluginInternal( - { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, - 'vite', - '8', - ) as Plugin; + await expect(runTransform(plugin, code, '/src/app.jsx')).resolves.toEqual({ code: 'fast-path', map: null }); + + expect(createOxcComponentNameAnnotateHooksMock).toHaveBeenCalledWith([], expect.any(Function)); + expect(createOxcComponentNameAnnotateHooksMock).not.toHaveBeenCalledWith([], getOxcParseAstAsyncMock); + expect(annotationTransformMock).toHaveBeenCalledTimes(1); + expect(transformAsyncMock).not.toHaveBeenCalled(); + }); - await runTransform(vite8Plugin, 'export function App() { return
; }', '/src/app.jsx'); + it('does not use the fast path when injecting into HTML', async () => { + const plugin = _rollupPluginInternal( + { release: { inject: false }, reactComponentAnnotation: { enabled: true, _experimentalInjectIntoHtml: true } }, + 'vite', + '8', + ) as Plugin; - expect(viteAnnotationModuleImportMock).toHaveBeenCalledTimes(1); - expect(viteAnnotationTransformMock).toHaveBeenCalledTimes(1); + await runTransform(plugin, code, '/src/app.jsx'); + + expect(annotationTransformMock).not.toHaveBeenCalled(); + expect(transformAsyncMock).toHaveBeenCalledTimes(1); + }); }); test('uses a Rollup 3-compatible function transform hook for Rollup builds', () => { diff --git a/packages/bundler-plugins/test/webpack/public-api.test.ts b/packages/bundler-plugins/test/webpack/public-api.test.ts index daa1f3e0d87b..97854c6352bf 100644 --- a/packages/bundler-plugins/test/webpack/public-api.test.ts +++ b/packages/bundler-plugins/test/webpack/public-api.test.ts @@ -1,12 +1,40 @@ import type { WebpackPluginInstance } from 'webpack'; import { sentryWebpackPlugin } from '../../src/webpack'; -import { describe, it, expect, test } from 'vitest'; +import { describe, it, expect, test, vi } from 'vitest'; test('Webpack plugin should exist', () => { expect(sentryWebpackPlugin).toBeDefined(); expect(typeof sentryWebpackPlugin).toBe('function'); }); +type PluginClass = new (options: unknown) => unknown; + +type Compiler = { + options: { plugins: unknown[] }; + hooks: Record>>; + webpack?: { BannerPlugin: PluginClass; DefinePlugin: PluginClass }; +}; + +class BannerPlugin { + public constructor(public options: unknown) {} +} + +class DefinePlugin { + public constructor(public options: unknown) {} +} + +function createCompiler(webpack?: Compiler['webpack']): Compiler { + return { + options: { plugins: [] }, + hooks: { + thisCompilation: { tap: vi.fn() }, + afterEmit: { tapAsync: vi.fn() }, + done: { tap: vi.fn() }, + }, + webpack, + }; +} + describe('sentryWebpackPlugin', () => { it('returns a webpack plugin', () => { const plugin = sentryWebpackPlugin({ @@ -18,4 +46,25 @@ describe('sentryWebpackPlugin', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expect(plugin).toEqual({ apply: expect.any(Function) }); }); + + it('registers the plugin classes provided by `compiler.webpack`', () => { + const compiler = createCompiler({ BannerPlugin, DefinePlugin }); + + sentryWebpackPlugin({ telemetry: false, release: { name: 'my-release' } }).apply(compiler); + + expect(compiler.options.plugins).toEqual([expect.any(BannerPlugin)]); + }); + + it('warns instead of throwing when `compiler.webpack` is unavailable', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const compiler = createCompiler(undefined); + + expect(() => + sentryWebpackPlugin({ telemetry: false, release: { name: 'my-release' } }).apply(compiler), + ).not.toThrow(); + + expect(compiler.options.plugins).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('BannerPlugin is not available')); + warn.mockRestore(); + }); }); diff --git a/packages/bundler-plugins/test/webpack/webpack5.test.ts b/packages/bundler-plugins/test/webpack/webpack5.test.ts deleted file mode 100644 index b4d7b0b26f66..000000000000 --- a/packages/bundler-plugins/test/webpack/webpack5.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { WebpackPluginInstance } from 'webpack'; -import { sentryWebpackPlugin } from '../../src/webpack/index'; -import { describe, it, expect, test } from 'vitest'; - -test('Webpack plugin should exist', () => { - expect(sentryWebpackPlugin).toBeDefined(); - expect(typeof sentryWebpackPlugin).toBe('function'); -}); - -describe('sentryWebpackPlugin', () => { - it('returns a webpack plugin', () => { - const plugin = sentryWebpackPlugin({ - authToken: 'test-token', - org: 'test-org', - project: 'test-project', - }) as WebpackPluginInstance; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect(plugin).toEqual({ apply: expect.any(Function) }); - }); -}); diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index b2157e709241..9adc9e5115f0 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ /* eslint-disable @typescript-eslint/unbound-method */ import { RPC } from '@sentry/conventions/op'; -import { isObjectLike } from '@sentry/core'; +import { getDefaultIsolationScope, getIsolationScope, isObjectLike, startNewTrace } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; @@ -144,8 +144,8 @@ function resolveFrameworkManagedMethods( type RpcInstanceState = { options: CloudflareOptions; context: InstrumentedDurableObjectContext; - /** Per-instance cache of the traced method wrappers, keyed by method name. Created on first use. */ - tracedMethods?: Map; + /** Per-instance cache of the instrumented method wrappers, keyed by method name. Created on first use. */ + instrumentedMethods?: Map; }; /** @@ -170,7 +170,7 @@ const RESERVED_RPC_METHOD_NAMES: ReadonlySet = new Set([ const rpcInstanceStates = new WeakMap(); /** - * Adds trace propagation to a constructed Durable Object's RPC methods. + * Instruments a constructed Durable Object's RPC methods. * * RPC methods are wrapped on the prototype because Cloudflare dispatches them with the Durable * Object instance as the receiver. This preserves native private-field access and keeps the methods @@ -256,17 +256,52 @@ function instrumentPrototypeRpcMethods(obj: object, excludedMethods?: ReadonlySe } /** - * Creates a prototype wrapper that traces RPC calls carrying Sentry metadata. + * Returns the instance's instrumented wrapper for an RPC method, creating it on first use. + */ +function getInstrumentedRpcMethod( + state: RpcInstanceState, + methodName: string, + originalMethod: UncheckedMethod, +): UncheckedMethod { + const instrumentedMethods = (state.instrumentedMethods ??= new Map()); + let instrumented = instrumentedMethods.get(methodName); + + if (!instrumented) { + instrumented = wrapMethodWithSentry( + { + options: state.options, + context: state.context, + spanName: rpcMeta => (rpcMeta ? methodName : undefined), + spanOp: RPC, + origin: 'auto.faas.cloudflare.durable_object', + }, + originalMethod, + undefined, + true, + ); + instrumentedMethods.set(methodName, instrumented); + } + + return instrumented; +} + +/** + * Creates a prototype wrapper that instruments external RPC calls. * * The wrapper looks up SDK state from its receiver, allowing one prototype function to serve every - * instance. Calls without RPC metadata or instance state use the original method directly. The - * original function name and arity are preserved because frameworks may inspect them for dispatch. + * instance. A call carrying Sentry metadata continues that trace in an `rpc` span. A call without it + * still gets a client, so its errors, logs and metrics are captured, but no span. Calls the instance + * makes to its own methods run the original method directly. The original function name and arity + * are preserved because frameworks may inspect them for dispatch. */ function createRpcPrototypeWrapper(methodName: string, originalMethod: UncheckedMethod): UncheckedMethod { const wrapper = function (this: unknown, ...args: unknown[]): unknown { - // Untraced calls are the common case — every internal call the instance makes to one of its - // own methods lands here too — so check the arguments before touching per-instance state. - if (!hasRpcMeta(args)) { + const traced = hasRpcMeta(args); + + // workerd dispatches an incoming RPC call outside any async context, so a call made while an + // invocation is already in flight comes from the instance itself (`this.helper()` inside + // `fetch`, `alarm` or another RPC method). Check that before touching per-instance state. + if (!traced && getIsolationScope() !== getDefaultIsolationScope()) { return Reflect.apply(originalMethod, this, args); } @@ -276,26 +311,15 @@ function createRpcPrototypeWrapper(methodName: string, originalMethod: Unchecked return Reflect.apply(originalMethod, this, args); } - const tracedMethods = (state.tracedMethods ??= new Map()); - let traced = tracedMethods.get(methodName); - - if (!traced) { - traced = wrapMethodWithSentry( - { - options: state.options, - context: state.context, - spanName: methodName, - spanOp: RPC, - origin: 'auto.faas.cloudflare.durable_object', - }, - originalMethod, - undefined, - true, - ); - tracedMethods.set(methodName, traced); + const instrumented = getInstrumentedRpcMethod(state, methodName, originalMethod); + + if (traced) { + return Reflect.apply(instrumented, this, args); } - return Reflect.apply(traced, this, args); + // Unlike a WorkerEntrypoint, a Durable Object instance is long-lived and serves overlapping + // calls. Without a new trace, every untraced call would share the trace of the default scope. + return startNewTrace(() => Reflect.apply(instrumented, this, args)); }; Object.defineProperties(wrapper, { @@ -316,7 +340,8 @@ function createRpcPrototypeWrapper(methodName: string, originalMethod: Unchecked * - webSocketClose * - webSocketError * - * RPC methods (prototype methods) are instrumented too, so an incoming trace continues into them. + * RPC methods (prototype methods) are instrumented too: an incoming trace continues into them, and + * errors, logs and metrics are captured whether or not the caller propagates a trace. * * @param optionsCallback Function that returns the options for the SDK initialization. * @param DurableObjectClass The Durable Object class to instrument. diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 8645514540d1..acd492c06c15 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -88,7 +88,6 @@ export { consoleIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, spanToStaticSpanJSON, spanToJSON, @@ -108,6 +107,7 @@ export { } from '@sentry/core'; export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; export { createFlueInstrumentation, instrumentPostgresJsSql } from '@sentry/server-utils'; +export type { FlueOptions } from '@sentry/server-utils'; export { withSentry } from './withSentry'; export { defineCloudflareOptions } from './defineCloudflareOptions'; @@ -120,6 +120,7 @@ export { _INTERNAL_wrapRequestHandler, getDefaultIntegrations } from './sdk'; export { httpServerIntegration } from './integrations/httpServer'; export { fetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; export { spotlightIntegration } from './integrations/spotlight'; export { openTelemetryIntegration, @@ -136,6 +137,7 @@ export { instrumentCreateReactAgent, vercelAIIntegration, eveConversationHook, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/server-utils'; diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts index 321a90dd3c4e..cb3db118125a 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -10,6 +10,7 @@ export const STUB_NON_RPC_METHODS = new Set(['fetch', 'connect', 'dup']); * * Wraps: * - `namespace.get(id)` / `namespace.getByName(name)` with a span + instruments returned stub + * - `namespace.jurisdiction(name)` by instrumenting the returned namespace the same way * - `namespace.idFromName(name)` / `namespace.idFromString(id)` / `namespace.newUniqueId()` with breadcrumbs * * @param namespace - The DurableObjectNamespace to instrument @@ -35,6 +36,14 @@ export function instrumentDurableObjectNamespace( }; } + if (prop === 'jurisdiction') { + return function (this: unknown, ...args: unknown[]) { + const subnamespace = Reflect.apply(value, target, args); + + return instrumentDurableObjectNamespace(subnamespace, propagateRpcTrace); + }; + } + return value.bind(target); }, }); diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts index 585377e9d76d..68d0be8466b0 100644 --- a/packages/cloudflare/src/integrations/fetch.ts +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -1,165 +1,9 @@ -import type { - Client, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, - Span, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'Fetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -export interface Options { - /** - * Whether breadcrumbs should be recorded for requests - * Defaults to true - */ - breadcrumbs: boolean; - - /** - * Function determining whether or not to create spans to track outgoing requests to the given URL. - * By default, spans will be created for all outgoing requests. - */ - shouldCreateSpanForRequest?: (url: string) => boolean; -} - -const _fetchIntegration = ((options: Partial = {}) => { - const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs; - const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - - const _createSpanUrlMap = new LRUMap(100); - const _headersUrlMap = new LRUMap(100); - - const spans: Record = {}; - - /** Decides whether to attach trace data to the outgoing fetch request */ - function _shouldAttachTraceData(url: string): boolean { - const client = getClient(); - - if (!client) { - return false; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - /** Helper that wraps shouldCreateSpanForRequest option */ - function _shouldCreateSpan(url: string): boolean { - if (shouldCreateSpanForRequest === undefined) { - return true; - } - - const cachedDecision = _createSpanUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = shouldCreateSpanForRequest(url); - _createSpanUrlMap.set(url, decision); - return decision; - } - - return { - name: INTEGRATION_NAME, - setupOnce() { - addFetchInstrumentationHandler(handlerData => { - const client = getClient(); - const { propagateTraceparent } = client?.getOptions() || {}; - if (!client || !HAS_CLIENT_MAP.get(client)) { - return; - } - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.fetch', - propagateTraceparent, - }); - - if (breadcrumbs) { - createBreadcrumb(handlerData); - } - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** * Creates spans and attaches tracing headers to fetch requests. */ -export const fetchIntegration = defineIntegration(_fetchIntegration); - -function createBreadcrumb(handlerData: HandlerDataFetch): void { - const { startTimestamp, endTimestamp } = handlerData; - - // We only capture complete fetch requests - if (!endTimestamp) { - return; - } - - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - - if (handlerData.error) { - const hint: FetchBreadcrumbHint = { - data: handlerData.error, - input: handlerData.args, - startTimestamp, - endTimestamp, - }; - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); - } else { - const response = handlerData.response as Response | undefined; - - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - - const hint: FetchBreadcrumbHint = { - input: handlerData.args, - response, - startTimestamp, - endTimestamp, - }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); - } -} +export const fetchIntegration = createFetchIntegration({ + name: 'Fetch', + spanOrigin: 'auto.http.fetch', +}); diff --git a/packages/cloudflare/src/vite/flueRuntime.ts b/packages/cloudflare/src/vite/flueRuntime.ts new file mode 100644 index 000000000000..1ac2d09aed77 --- /dev/null +++ b/packages/cloudflare/src/vite/flueRuntime.ts @@ -0,0 +1,75 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import MagicString from 'magic-string'; + +// Namespace binding the injected provider import uses; read back by the integration +// off the global marker. +const PROVIDER_IDENTIFIER = '__SENTRY_FLUE_RUNTIME__'; + +const FLUE_MODULE = '@flue/runtime'; + +// The bundled `@sentry/server-utils` Flue integration module (ESM build — the only one a +// worker loads). It reads `@flue/runtime` off the global marker this provider populates, +// because `instrument()` registers into module-scope state no channel payload can carry. +const FLUE_INTEGRATION_ID = /@sentry\/server-utils\/build\/esm\/integrations\/flue\.js$/; + +/** Whether `id` is the Sentry Flue integration module the provider injects into. */ +export function isFlueIntegrationModuleId(id: string): boolean { + const normalizedId = id.replace(/\\/g, '/').replace(/[?#].*$/, ''); + return FLUE_INTEGRATION_ID.test(normalizedId); +} + +/** + * Splices a static `import * as … from '@flue/runtime'` into Sentry's own Flue integration module + * and exposes the namespace on the global orchestrion marker. + * + * Flue is registered rather than patched — `instrument()` writes into module-scope state — so + * instrumenting it needs that module's own binding, and no channel payload carries one. On Node the + * user passes it by calling `instrument()` themselves; a bundled worker has no `node_modules` to + * resolve from, so it is supplied at build time instead. + */ +export function sentryFlueRuntimeProviderPlugin(): { + name: string; + configResolved(config: { root: string }): void; + transform(code: string, id: string): { code: string; map: ReturnType } | undefined; +} { + let providerSnippet: string | undefined; + + return { + name: 'sentry-cloudflare-flue-runtime-provider', + + configResolved(config: { root: string }): void { + // Build-time only; never ships to the worker. Probed with CJS resolution, which an ESM-only + // `@flue/runtime` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED` — so only a module-not-found + // counts as absent, and any other failure still injects and lets Vite report it. Not + // `import.meta.resolve`: `parentURL` is ignored without a flag, and it is absent from the + // CJS build. + try { + createRequire(resolve(config.root, 'noop.js')).resolve(FLUE_MODULE); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') { + return; + } + } + // A getter where Mastra assigns: the bundler may evaluate Sentry's module before + // `@flue/runtime` is initialized, and assigning there would store `undefined`. + providerSnippet = + `import * as ${PROVIDER_IDENTIFIER} from '${FLUE_MODULE}';\n` + + '(globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {});\n' + + '(globalThis.__SENTRY_ORCHESTRION__.providedModules = globalThis.__SENTRY_ORCHESTRION__.providedModules || {});\n' + + `Object.defineProperty(globalThis.__SENTRY_ORCHESTRION__.providedModules, '${FLUE_MODULE}', ` + + `{ configurable: true, enumerable: true, get() { return ${PROVIDER_IDENTIFIER}; } });\n`; + }, + + transform(code: string, id: string): { code: string; map: ReturnType } | undefined { + // `code.includes` keeps this idempotent: a second pass over already-injected output would + // otherwise emit a duplicate `import * as` binding, which is a syntax error. + if (!providerSnippet || !isFlueIntegrationModuleId(id) || code.includes(PROVIDER_IDENTIFIER)) return undefined; + + const ms = new MagicString(code); + ms.prepend(providerSnippet); + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; +} diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index be679c4823f5..d6005250d9ee 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -5,6 +5,7 @@ // expose it — same setup as `@sentry/server-utils/orchestrion/vite` itself. import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import { sentryCloudflareAutoInstrumentPlugin } from './autoInstrument'; +import { sentryFlueRuntimeProviderPlugin } from './flueRuntime'; import { sentryMastraObservabilityProviderPlugin } from './mastraObservability'; /** @@ -91,6 +92,7 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp dcModule: '@sentry/cloudflare/orchestrion-diagnostics-channel', }), sentryMastraObservabilityProviderPlugin(), + sentryFlueRuntimeProviderPlugin(), ...(options.autoInstrumentation !== false ? [sentryCloudflareAutoInstrumentPlugin({ wranglerConfigPath: options.wranglerConfigPath })] : []), diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index ce1efdca3fe1..3450111286c3 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -640,6 +640,158 @@ describe('instrumentDurableObjectWithSentry', () => { }); }); + // Callers only attach RPC metadata when they are instrumented, have an active trace and got the + // stub from an instrumented binding, so a Durable Object regularly serves calls without it. + describe('RPC calls without trace metadata', () => { + const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + const TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + function setup any>(testClass: C) { + const events: Event[] = []; + const transactions: Event[] = []; + const metricNames: string[] = []; + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => { + waits.push(promise); + }); + + const instrumented = instrumentDurableObjectWithSentry( + () => ({ + dsn: DSN, + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSend(event: Event) { + events.push(event); + return null; + }, + beforeSendTransaction(event: Event) { + transactions.push(event); + return null; + }, + beforeSendMetric(metric: { name: string }) { + metricNames.push(metric.name); + return null; + }, + }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, [{ waitUntil }, {}]) as InstanceType; + const settle = async (): Promise => { + while (waits.length) { + await Promise.all(waits.splice(0)); + } + }; + + return { obj, events, transactions, metricNames, waitUntil, settle }; + } + + it('captures errors and metrics', async () => { + const { obj, events, metricNames, settle } = setup( + class { + async rpcMethod(): Promise { + SentryCore.metrics.count('do.rpc_called', 1); + throw new Error('RPC failed'); + } + }, + ); + + await expect(obj.rpcMethod()).rejects.toThrow('RPC failed'); + await settle(); + + expect(metricNames).toEqual(['do.rpc_called']); + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.value).toBe('RPC failed'); + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }); + }); + + it('does not create a span', async () => { + const { obj, transactions, settle } = setup( + class { + rpcMethod(): string { + return 'result'; + } + }, + ); + + expect(obj.rpcMethod()).toBe('result'); + await settle(); + + expect(transactions).toHaveLength(0); + }); + + // One Durable Object instance serves overlapping calls, so each call needs its own scope. + it('gives overlapping calls separate traces and keeps the events of both', async () => { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const { obj, events, settle } = setup( + class { + async rpcMethod(label: string): Promise { + await gate; + SentryCore.captureMessage(label); + } + }, + ); + + const first = obj.rpcMethod('first'); + const second = obj.rpcMethod('second'); + release(); + await Promise.all([first, second]); + await settle(); + + expect(events.map(event => event.message).sort()).toEqual(['first', 'second']); + expect(events[0]?.contexts?.trace?.trace_id).toEqual(expect.any(String)); + expect(events[0]?.contexts?.trace?.trace_id).not.toBe(events[1]?.contexts?.trace?.trace_id); + }); + + it('does not instrument calls the instance makes to its own methods', async () => { + const { obj, events, waitUntil, settle } = setup( + class { + async rpcMethod(): Promise { + return this.helper(); + } + + async helper(): Promise { + SentryCore.captureMessage('from helper'); + return 'helper-result'; + } + }, + ); + + await expect(obj.rpcMethod()).resolves.toBe('helper-result'); + await settle(); + + expect(events.map(event => event.message)).toEqual(['from helper']); + expect(waitUntil).toHaveBeenCalledOnce(); + }); + + it('continues the caller trace when the call carries trace metadata', async () => { + const { obj, events, transactions, settle } = setup( + class { + async rpcMethod(arg: string): Promise { + SentryCore.captureMessage('traced'); + return arg; + } + }, + ); + + const result = await obj.rpcMethod('arg', { + __sentry_rpc_meta__: { 'sentry-trace': `${TRACE_ID}-bbbbbbbbbbbbbbbb-1`, baggage: '' }, + }); + await settle(); + + expect(result).toBe('arg'); + expect(events[0]?.contexts?.trace?.trace_id).toBe(TRACE_ID); + expect(transactions).toHaveLength(1); + expect(transactions[0]?.transaction).toBe('rpcMethod'); + expect(transactions[0]?.contexts?.trace?.trace_id).toBe(TRACE_ID); + }); + }); + it('flush performs after all waitUntil promises are finished', async () => { // Spy on Client.prototype.flush and mock it to resolve immediately to avoid timeout issues with fake timers const flush = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts index 05c78ae40089..d86c8081d0c2 100644 --- a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -112,6 +112,57 @@ describe('instrumentDurableObjectNamespace', () => { }); }); + describe('jurisdiction', () => { + function createJurisdictionNamespace() { + const rpcMethod = vi.fn().mockReturnValue('rpc-result'); + const { namespace: subnamespace } = createMockNamespace(); + subnamespace.get.mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }); + const { namespace } = createMockNamespace(); + namespace.jurisdiction.mockReturnValue(subnamespace); + + return { namespace, subnamespace, rpcMethod }; + } + + it('propagates RPC trace context from stubs of the jurisdiction-restricted namespace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + const { namespace, subnamespace, rpcMethod } = createJurisdictionNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace, true); + + const eu = instrumented.jurisdiction('eu'); + const stub = eu.get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1'); + + expect(namespace.jurisdiction).toHaveBeenCalledWith('eu'); + expect(subnamespace.get).toHaveBeenCalledOnce(); + expect(rpcMethod).toHaveBeenCalledWith('arg1', { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not propagate RPC trace context when propagation is off for the binding', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + }); + const { namespace, rpcMethod } = createJurisdictionNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.jurisdiction('eu').get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1'); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + }); + describe('stub instrumentation', () => { it('calls stub.fetch with URL object', async () => { const { namespace, mockStub } = createMockNamespace(); diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index c2cdda44d182..a57361234e2b 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -1,210 +1,49 @@ -import type { HandlerDataFetch, Integration } from '@sentry/core'; -import * as sentryCore from '@sentry/core'; -import { createStackParser } from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TransactionEvent } from '@sentry/core'; +import { createStackParser, setCurrentClient, startSpan } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { CloudflareClient } from '../../src/client'; import { fetchIntegration } from '../../src/integrations/fetch'; +import { getDefaultIntegrations } from '../../src/sdk'; -class FakeClient extends CloudflareClient { - public getIntegrationByName(name: string): T | undefined { - return name === 'Fetch' ? (fetchIntegration() as T) : undefined; - } -} - -const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryCore, 'addFetchInstrumentationHandler'); -const instrumentFetchRequestSpy = vi.spyOn(sentryCore, 'instrumentFetchRequest'); -const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); +// The behavior lives in `createFetchIntegration` and is covered by +// `packages/core/test/lib/integrations/fetch.test.ts`. This only pins the wiring. +describe('fetchIntegration', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); -describe('WinterCGFetch instrumentation', () => { - let client: FakeClient; + it('is named `Fetch` and is enabled by default', () => { + expect(fetchIntegration().name).toBe('Fetch'); + expect(getDefaultIntegrations({}).map(integration => integration.name)).toContain('Fetch'); + }); - beforeEach(() => { - vi.clearAllMocks(); + it('creates `http.client` spans with the `auto.http.fetch` origin', async () => { + vi.stubGlobal('fetch', () => Promise.resolve(new Response('ok'))); - client = new FakeClient({ + const transactions: TransactionEvent[] = []; + const client = new CloudflareClient({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, - integrations: [], - transport: () => ({ - send: () => Promise.resolve({}), - flush: () => Promise.resolve(true), - }), - tracePropagationTargets: ['http://my-website.com/'], + traceLifecycle: 'static', + integrations: [fetchIntegration()], stackParser: createStackParser(), - }); - - vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( - startHandlerData, - expect.any(Function), - expect.any(Function), - expect.any(Object), - { spanOrigin: 'auto.http.fetch' }, - ); - - const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); - expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); - - // tracePropagationTargets match regardless of casing - expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); - expect(shouldAttachTraceData('https://WWW.3RD-PARTY-WEBSITE.at/')).toBe(false); - - expect(shouldCreateSpan('http://my-website.com/')).toBe(true); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); - }); - - it('should not instrument if client is not setup', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration(); - integration.setupOnce!(); - // integration.setup!(client) is not called! - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should properly apply the `shouldCreateSpanForRequest` option', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration({ - shouldCreateSpanForRequest(url) { - return url === 'http://only-acceptable-url.com/'; + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + beforeSendTransaction(event) { + transactions.push(event); + return null; }, }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldCreateSpan('http://only-acceptable-url.com/')).toBe(true); - expect(shouldCreateSpan('http://my-website.com/')).toBe(false); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(false); - }); - - it('should create a breadcrumb for an outgoing request', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + setCurrentClient(client); + client.init(); - const integration = fetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(addBreadcrumbSpy).toBeCalledWith( - { - category: 'fetch', - data: { - method: 'POST', - status_code: 201, - url: 'http://my-website.com/', - }, - type: 'http', - }, - { - endTimestamp, - input: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' }, - startTimestamp, - }, - ); - }); - - it('should not create a breadcrumb for an outgoing request if `breadcrumbs: false` is set', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration({ breadcrumbs: false }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; + await startSpan({ name: 'parent', op: 'test' }, async () => { + await fetch('http://my-website.com/').then(response => response.text()); + }); - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); + const parent = transactions.find(event => event.transaction === 'parent'); + const clientSpan = parent?.spans?.find(span => span.op === 'http.client'); - expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + expect(clientSpan).toBeDefined(); + expect(clientSpan?.origin).toBe('auto.http.fetch'); }); }); diff --git a/packages/cloudflare/test/vite/flueRuntime.test.ts b/packages/cloudflare/test/vite/flueRuntime.test.ts new file mode 100644 index 000000000000..8ebc1ae595f0 --- /dev/null +++ b/packages/cloudflare/test/vite/flueRuntime.test.ts @@ -0,0 +1,162 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { sentryCloudflareVitePlugin } from '../../src/vite/index'; +import { isFlueIntegrationModuleId, sentryFlueRuntimeProviderPlugin } from '../../src/vite/flueRuntime'; + +const PROVIDER_PLUGIN = 'sentry-cloudflare-flue-runtime-provider'; +const FLUE_INTEGRATION_MODULE = '/app/node_modules/@sentry/server-utils/build/esm/integrations/flue.js'; + +/** An app root whose `node_modules` holds an ESM-only `@flue/runtime`, as published. */ +function createRootWithFlue(): string { + const root = mkdtempSync(join(tmpdir(), 'sentry-flue-root-')); + const pkgDir = join(root, 'node_modules', '@flue', 'runtime'); + mkdirSync(join(pkgDir, 'dist'), { recursive: true }); + writeFileSync( + join(pkgDir, 'package.json'), + // No `require` condition — the reason `resolve()` reports ERR_PACKAGE_PATH_NOT_EXPORTED. + JSON.stringify({ + name: '@flue/runtime', + version: '2.0.8', + type: 'module', + exports: { '.': { import: './dist/index.mjs' } }, + }), + ); + writeFileSync(join(pkgDir, 'dist', 'index.mjs'), 'export const instrument = () => {};\n'); + return root; +} + +function createEmptyRoot(): string { + return mkdtempSync(join(tmpdir(), 'sentry-flue-empty-')); +} + +/** An app root holding an installed but unreadable `@flue/runtime`. */ +function createRootWithBrokenFlue(): string { + const root = mkdtempSync(join(tmpdir(), 'sentry-flue-broken-')); + const pkgDir = join(root, 'node_modules', '@flue', 'runtime'); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync(join(pkgDir, 'package.json'), '{ not json'); + return root; +} + +describe('isFlueIntegrationModuleId', () => { + it('matches the ESM Flue integration module', () => { + expect(isFlueIntegrationModuleId(FLUE_INTEGRATION_MODULE)).toBe(true); + }); + + it('ignores a trailing query/hash Vite may append', () => { + expect(isFlueIntegrationModuleId(`${FLUE_INTEGRATION_MODULE}?v=abc`)).toBe(true); + }); + + it('normalizes Windows separators', () => { + expect( + isFlueIntegrationModuleId('C:\\app\\node_modules\\@sentry\\server-utils\\build\\esm\\integrations\\flue.js'), + ).toBe(true); + }); + + it('does not match the CJS build (workers load ESM)', () => { + expect(isFlueIntegrationModuleId('/app/node_modules/@sentry/server-utils/build/cjs/integrations/flue.js')).toBe( + false, + ); + }); + + it('does not match another integration module', () => { + expect(isFlueIntegrationModuleId('/app/node_modules/@sentry/server-utils/build/esm/integrations/mastra.js')).toBe( + false, + ); + }); + + it('does not match Flue itself', () => { + expect(isFlueIntegrationModuleId('/app/node_modules/@flue/runtime/dist/index.mjs')).toBe(false); + }); +}); + +describe('sentryFlueRuntimeProviderPlugin', () => { + describe('when the app has @flue/runtime installed', () => { + let root: string; + + beforeAll(() => { + root = createRootWithFlue(); + }); + + it('injects the provider even though the package is ESM-only', () => { + // Regression guard: treating that error as "absent" silently disabled auto-instrumentation. + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + const result = plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE); + + expect(result?.code).toContain("import * as __SENTRY_FLUE_RUNTIME__ from '@flue/runtime';"); + expect(result?.code).toContain('__SENTRY_ORCHESTRION__.providedModules'); + expect(result?.code).toContain('export const x = 1;'); + }); + + it('exposes the namespace through a getter rather than a snapshot', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + expect(plugin.transform('', FLUE_INTEGRATION_MODULE)?.code).toContain( + 'get() { return __SENTRY_FLUE_RUNTIME__; }', + ); + }); + + it('leaves every other module untouched', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + expect(plugin.transform('export const x = 1;', '/app/src/index.ts')).toBeUndefined(); + }); + + it('injects once, so a second pass cannot emit a duplicate binding', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + const once = plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE)?.code ?? ''; + + expect(plugin.transform(once, FLUE_INTEGRATION_MODULE)).toBeUndefined(); + }); + }); + + describe('when @flue/runtime is installed but unresolvable', () => { + it('still injects, so the failure surfaces from Vite instead of silently disabling tracing', () => { + // Only a module-not-found means absent. Skipping on every other resolve failure is how an + // installed package silently loses instrumentation, which is the bug this plugin fixes. + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root: createRootWithBrokenFlue() }); + + expect(plugin.transform('', FLUE_INTEGRATION_MODULE)).toBeDefined(); + }); + }); + + describe('when the app does not have @flue/runtime installed', () => { + it('injects nothing', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root: createEmptyRoot() }); + + expect(plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE)).toBeUndefined(); + }); + + it("resolves from the app root, not from Sentry's own install", () => { + // This repo has no `@flue/runtime`, so only an app root that does can pass the check. + const withFlue = sentryFlueRuntimeProviderPlugin(); + withFlue.configResolved({ root: createRootWithFlue() }); + + const withoutFlue = sentryFlueRuntimeProviderPlugin(); + withoutFlue.configResolved({ root: createEmptyRoot() }); + + expect(withFlue.transform('', FLUE_INTEGRATION_MODULE)).toBeDefined(); + expect(withoutFlue.transform('', FLUE_INTEGRATION_MODULE)).toBeUndefined(); + }); + }); +}); + +describe('sentryCloudflareVitePlugin', () => { + it('always includes the Flue runtime provider plugin', () => { + expect(sentryCloudflareVitePlugin().map(plugin => plugin.name)).toContain(PROVIDER_PLUGIN); + // Not gated by auto-instrumentation: it injects into Sentry's own module, not the entry. + expect(sentryCloudflareVitePlugin({ autoInstrumentation: false }).map(plugin => plugin.name)).toContain( + PROVIDER_PLUGIN, + ); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index efe39a7e1cf3..20b52be30603 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,7 +10,6 @@ export type { OfflineStore, OfflineTransportOptions } from './transports/offline export type { IntegrationIndex } from './integration'; export * from './tracing'; export * from './semanticAttributes'; -export { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; export * from './tracing/spans/spanNames'; export type { RawAttributes } from './attributes'; export { createEventEnvelope, createSessionEnvelope } from './envelope'; @@ -164,6 +163,8 @@ export { featureFlagsIntegration } from './integrations/featureFlags'; export { growthbookIntegration } from './integrations/featureFlags'; export { conversationIdIntegration } from './integrations/conversationId'; export { spanStreamingIntegration } from './integrations/spanStreaming'; +export { createFetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from './integrations/fetch'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) // Therefore: diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts new file mode 100644 index 000000000000..5cf7a0489719 --- /dev/null +++ b/packages/core/src/integrations/fetch.ts @@ -0,0 +1,212 @@ +import { URL_FRAGMENT, URL_QUERY } from '@sentry/conventions/attributes'; +import { addBreadcrumb } from '../breadcrumbs'; +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import { instrumentFetchRequest } from '../fetch'; +import { defineIntegration } from '../integration'; +import { addFetchInstrumentationHandler } from '../instrument/fetch'; +import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '../types/breadcrumb'; +import type { HandlerDataFetch } from '../types/instrument'; +import type { Integration, IntegrationFn } from '../types/integration'; +import type { Span, SpanOrigin } from '../types/span'; +import { getBreadcrumbLogLevelFromHttpStatusCode } from '../utils/breadcrumb-log-level'; +import { filterCollectedUrlQuery } from '../utils/data-collection/filterCollectedUrl'; +import { isSentryRequestUrl } from '../utils/isSentryRequestUrl'; +import { LRUMap } from '../utils/lru'; +import { shouldPropagateTraceForUrl } from '../utils/tracePropagationTargets'; +import { getSanitizedUrlString, getUrlFragment, getUrlQuery, parseUrl } from '../utils/url'; + +export interface FetchIntegrationOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * + * @default `true` + */ + breadcrumbs?: boolean; + + /** + * Function determining whether or not to create spans to track outgoing requests to the given URL. + * By default, spans will be created for all outgoing requests. + */ + shouldCreateSpanForRequest?: (url: string) => boolean; + + /** + * Whether to inject trace propagation headers (`sentry-trace`, `baggage`) into outgoing requests. + * + * To scope propagation to specific URLs, configure `tracePropagationTargets` in the client options + * instead. Turn this off only to suppress propagation entirely, for example alongside + * `shouldCreateSpanForRequest`, which suppresses the span but not the headers. + * + * Covers the global `fetch` only. A runtime that also instruments another HTTP client switches + * that one separately, for example `denoHttpIntegration({ tracePropagation: false })`. + * + * @default `true` + */ + tracePropagation?: boolean; +} + +interface CreateFetchIntegrationOptions { + /** Integration name, e.g. `'Fetch'`. */ + name: string; + + /** Span origin for the `http.client` spans this integration creates. */ + spanOrigin: SpanOrigin; +} + +interface ClientConfig { + breadcrumbs: boolean; + shouldCreateSpan: (url: string) => boolean; + shouldAttachTraceData: (url: string) => boolean; +} + +/** + * Builds an integration that instruments the global `fetch` function: creates `http.client` spans, + * records breadcrumbs, and attaches trace propagation headers. + * + * Runtimes that patch the global `fetch` (Bun, Cloudflare Workers, Deno, Vercel Edge) differ only in + * the integration name and span origin, so they all share this implementation. Node is not one of + * them: it instruments undici through diagnostics channels instead. Neither is the browser, whose + * fetch tracing is driven by `browserTracingIntegration` and shares its span map with XHR. + */ +export function createFetchIntegration({ + name, + spanOrigin, +}: CreateFetchIntegrationOptions): (options?: FetchIntegrationOptions) => Integration { + // Shared by every instance of this integration, because `setupOnce` runs once per process: the + // handler it registers must be able to end a span that a different instance started. + const spans: Record = {}; + + // Keyed by client rather than captured in the instance closure, so that a second `init()` uses its + // own options instead of silently inheriting the first one's. + const configs = new WeakMap(); + + const integration = ((options: FetchIntegrationOptions = {}) => { + return { + name, + setupOnce() { + addFetchInstrumentationHandler(handlerData => { + const client = getClient(); + const config = client && configs.get(client); + + if (!client || !config) { + return; + } + + if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + return; + } + + const { propagateTraceparent } = client.getOptions(); + instrumentFetchRequest(handlerData, config.shouldCreateSpan, config.shouldAttachTraceData, spans, { + spanOrigin, + propagateTraceparent, + }); + + if (config.breadcrumbs) { + createBreadcrumb(handlerData, client); + } + }); + }, + setup(client) { + configs.set(client, resolveConfig(client, options)); + }, + }; + }) satisfies IntegrationFn; + + return defineIntegration(integration); +} + +function resolveConfig(client: Client, options: FetchIntegrationOptions): ClientConfig { + const { breadcrumbs = true, shouldCreateSpanForRequest, tracePropagation = true } = options; + + const createSpanUrlMap = new LRUMap(100); + const headersUrlMap = new LRUMap(100); + + return { + breadcrumbs, + + shouldCreateSpan(url) { + if (shouldCreateSpanForRequest === undefined) { + return true; + } + + const cachedDecision = createSpanUrlMap.get(url); + if (cachedDecision !== undefined) { + return cachedDecision; + } + + const decision = shouldCreateSpanForRequest(url); + createSpanUrlMap.set(url, decision); + return decision; + }, + + shouldAttachTraceData(url) { + if (!tracePropagation) { + return false; + } + + return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, headersUrlMap); + }, + }; +} + +function createBreadcrumb(handlerData: HandlerDataFetch, client: Client): void { + const { startTimestamp, endTimestamp } = handlerData; + + // We only capture complete fetch requests + if (!endTimestamp) { + return; + } + + const parsedUrl = parseUrl(handlerData.fetchData.url); + + const breadcrumbData: FetchBreadcrumbData = { + method: handlerData.fetchData.method, + url: getSanitizedUrlString(parsedUrl), + [URL_QUERY]: filterCollectedUrlQuery(getUrlQuery(parsedUrl.search), client), + [URL_FRAGMENT]: getUrlFragment(parsedUrl.hash), + }; + + if (handlerData.error) { + const hint: FetchBreadcrumbHint = { + data: handlerData.error, + input: handlerData.args, + startTimestamp, + endTimestamp, + }; + + addBreadcrumb( + { + category: 'fetch', + data: breadcrumbData, + level: 'error', + type: 'http', + }, + hint, + ); + } else { + const response = handlerData.response as Response | undefined; + + breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; + breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; + breadcrumbData.status_code = response?.status; + + const hint: FetchBreadcrumbHint = { + input: handlerData.args, + response, + startTimestamp, + endTimestamp, + }; + const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); + + addBreadcrumb( + { + category: 'fetch', + data: breadcrumbData, + type: 'http', + level, + }, + hint, + ); + } +} diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index c9a7ba4b5c94..3b0e8e9a5d0e 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -10,9 +10,11 @@ * Falls back to WeakMap by transport instance for stateless transports (no sessionId). */ +import { ERROR_TYPE, 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 } from './attributes'; +import { MCP_PROTOCOL_VERSION_ATTRIBUTE, MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE } from './attributes'; +import { isJsonRpcServerError } from './errorCapture'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; import { buildServerAttributesFromInfo, @@ -20,7 +22,7 @@ import { extractSessionDataFromResponse, } from './sessionExtraction'; import { updateSessionDataForTransport } from './sessionManagement'; -import type { MCPTransport, RequestId, RequestSpanMapValue, ResolvedMcpOptions } from './types'; +import type { JsonRpcError, MCPTransport, RequestId, RequestSpanMapValue, ResolvedMcpOptions } from './types'; /** * Session-scoped correlation for stateful transports (with sessionId) @@ -93,13 +95,13 @@ export function storeSpanForRequest( * @param transport - MCP transport instance * @param requestId - Request identifier * @param result - Execution result for attribute extraction - * @param hasError - Whether the JSON-RPC response contained an error + * @param error - The JSON-RPC error response, if any */ export function completeSpanWithResults( transport: MCPTransport, requestId: RequestId, result: unknown, - hasError = false, + error?: JsonRpcError, ): void { const spanMap = getOrCreateSpanMap(transport); const spanData = spanMap.get(requestId); @@ -109,7 +111,7 @@ export function completeSpanWithResults( method === 'initialize' ? extractSessionDataFromInitializeResponse(result) : extractSessionDataFromResponse(result); - if (responseSessionData.protocolVersion || responseSessionData.serverInfo) { + if (method === 'initialize' && (responseSessionData.protocolVersion || responseSessionData.serverInfo)) { updateSessionDataForTransport(transport, responseSessionData); } const responseAttributes: Record = { @@ -122,11 +124,19 @@ export function completeSpanWithResults( span.setAttributes(responseAttributes); } - if (hasError) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + if (error) { + span.setAttributes({ [RPC_RESPONSE_STATUS_CODE]: String(error.code) }); + if (isJsonRpcServerError(error.code)) { + span.setAttributes({ [ERROR_TYPE]: String(error.code) }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: error.message }); + } } else if (method === 'tools/call') { const toolAttributes = extractToolResultAttributes(result, spanData.capturePolicy.recordOutputs); span.setAttributes(toolAttributes); + if (toolAttributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] === true) { + span.setAttributes({ [ERROR_TYPE]: 'tool_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } } else if (method === 'prompts/get') { const promptAttributes = extractPromptResultAttributes(result, spanData.capturePolicy.recordOutputs); span.setAttributes(promptAttributes); diff --git a/packages/core/src/integrations/mcp-server/errorCapture.ts b/packages/core/src/integrations/mcp-server/errorCapture.ts index 2ba401c93d08..18ff6c054ecb 100644 --- a/packages/core/src/integrations/mcp-server/errorCapture.ts +++ b/packages/core/src/integrations/mcp-server/errorCapture.ts @@ -11,6 +11,15 @@ import { SPAN_STATUS_ERROR } from '../../tracing'; import { getActiveSpan } from '../../utils/spanUtils'; import type { McpErrorType } from './types'; +/** + * Classifies JSON-RPC errors using MCP server semantic conventions. + * @param code - JSON-RPC error code + * @see https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md#server + */ +export function isJsonRpcServerError(code: number): boolean { + return ![-32700, -32600, -32601, -32602, -32002].includes(code); +} + /** * Captures an error without affecting MCP server operation. * diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index 62521def4d01..192372462a9e 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -10,6 +10,79 @@ import { validateMcpServerInstance } from './validation'; */ const wrappedMcpServerInstances = new WeakSet(); +function instrumentTransport(transport: MCPTransport, options: McpServerWrapperOptions): void { + wrapTransportOnMessage(transport, options); + wrapTransportSend(transport, options); + wrapTransportOnClose(transport); + wrapTransportError(transport); +} + +function interceptTransportStart(transport: MCPTransport, beforeStart: () => void): () => void { + let transportStart: MCPTransport['start']; + let originalDescriptor: PropertyDescriptor | undefined; + + try { + transportStart = transport.start; + originalDescriptor = Object.getOwnPropertyDescriptor(transport, 'start'); + } catch { + return () => undefined; + } + + if (typeof transportStart !== 'function') { + return () => undefined; + } + + const originalStart = transportStart; + let isInstalled = false; + + const restoreStart = (): void => { + if (!isInstalled) { + return; + } + + try { + const currentDescriptor = Object.getOwnPropertyDescriptor(transport, 'start'); + if (currentDescriptor?.value !== interceptedStart) { + isInstalled = false; + return; + } + + if (originalDescriptor) { + Object.defineProperty(transport, 'start', originalDescriptor); + isInstalled = false; + } else if (Reflect.deleteProperty(transport, 'start')) { + isInstalled = false; + } + } catch {} + }; + + function interceptedStart(this: MCPTransport): Promise { + // Restoring first keeps recursive calls and user-observed method identity identical to the original transport. + restoreStart(); + beforeStart(); + return originalStart.call(this); + } + + const replacementDescriptor: PropertyDescriptor = + originalDescriptor && 'value' in originalDescriptor + ? { ...originalDescriptor, value: interceptedStart } + : { + configurable: originalDescriptor?.configurable ?? true, + enumerable: originalDescriptor?.enumerable ?? false, + writable: true, + value: interceptedStart, + }; + + try { + Object.defineProperty(transport, 'start', replacementDescriptor); + isInstalled = true; + } catch { + // The post-connect fallback preserves the previous behavior for transports which cannot be patched. + } + + return restoreStart; +} + /** * Wraps an MCP Server instance with Sentry instrumentation. * @@ -63,18 +136,30 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S, fill(serverInstance, 'connect', originalConnect => { return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) { - const result = await (originalConnect as (...args: unknown[]) => Promise).call( - this, - transport, - ...restArgs, - ); - - wrapTransportOnMessage(transport, captureOptions); - wrapTransportSend(transport, captureOptions); - wrapTransportOnClose(transport); - wrapTransportError(transport); - - return result; + let isTransportInstrumented = false; + const instrumentTransportOnce = (): void => { + if (isTransportInstrumented) { + return; + } + + isTransportInstrumented = true; + instrumentTransport(transport, captureOptions); + }; + const restoreStart = interceptTransportStart(transport, instrumentTransportOnce); + + try { + const result = await (originalConnect as (...args: unknown[]) => Promise).call( + this, + transport, + ...restArgs, + ); + + instrumentTransportOnce(); + + return result; + } finally { + restoreStart(); + } }; }); diff --git a/packages/core/src/integrations/mcp-server/sessionExtraction.ts b/packages/core/src/integrations/mcp-server/sessionExtraction.ts index e487dcad1873..a4de982bf1b4 100644 --- a/packages/core/src/integrations/mcp-server/sessionExtraction.ts +++ b/packages/core/src/integrations/mcp-server/sessionExtraction.ts @@ -267,19 +267,25 @@ export function getTransportTypes(transport: MCPTransport): { mcpTransport: stri * Build transport and network attributes * @param transport - MCP transport instance * @param extra - Optional extra handler data + * @param message - Current message carrying request-scoped protocol metadata * @returns Transport attributes for span instrumentation * @note sessionId may be undefined during initial setup - session should be established by client during initialize flow */ export function buildTransportAttributes( transport: MCPTransport, extra?: ExtraHandlerData, + message?: JsonRpcRequest | JsonRpcNotification, ): Record { - const sessionId = transport && 'sessionId' in transport ? transport.sessionId : undefined; + const messageData = message && extractSessionDataFromMessage(message); + const hasRequestMetadata = messageData?.protocolVersion !== undefined || messageData?.clientInfo !== undefined; + const sessionId = !hasRequestMetadata && transport && 'sessionId' in transport ? transport.sessionId : undefined; const clientInfo = extra ? extractClientInfo(extra) : {}; const { mcpTransport, networkTransport } = getTransportTypes(transport); - const clientAttributes = getClientAttributes(transport); - const serverAttributes = getServerAttributes(transport); - const protocolVersion = getProtocolVersionForTransport(transport); + const clientAttributes = hasRequestMetadata + ? buildClientAttributesFromInfo(messageData?.clientInfo) + : getClientAttributes(transport); + const serverAttributes = hasRequestMetadata ? {} : getServerAttributes(transport); + const protocolVersion = hasRequestMetadata ? messageData?.protocolVersion : getProtocolVersionForTransport(transport); const attributes = { ...(sessionId && { [MCP_SESSION_ID_ATTRIBUTE]: sessionId }), diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts index 7277d199a06e..9159ce497122 100644 --- a/packages/core/src/integrations/mcp-server/spans.ts +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -103,7 +103,7 @@ function createMcpSpan(config: McpSpanConfig): unknown { } const rawAttributes: Record = { - ...buildTransportAttributes(transport, extra), + ...buildTransportAttributes(transport, extra, message), [MCP_METHOD_NAME_ATTRIBUTE]: method, ...buildTypeSpecificAttributes(type, message, params, options?.recordInputs), ...buildSentryAttributes(type), @@ -201,7 +201,7 @@ export function buildMcpServerSpanConfig( const spanName = method ? createSpanName(method, target) : MCP_SERVER_SPAN_NAME_FALLBACK; const rawAttributes: Record = { - ...buildTransportAttributes(transport, extra), + ...buildTransportAttributes(transport, extra, jsonRpcMessage), [MCP_METHOD_NAME_ATTRIBUTE]: 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 e235a754741f..599457b01cfd 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -12,12 +12,8 @@ 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 } from './errorCapture'; -import { - buildClientAttributesFromInfo, - extractSessionDataFromInitializeRequest, - extractSessionDataFromMessage, -} from './sessionExtraction'; +import { captureError, isJsonRpcServerError } from './errorCapture'; +import { buildClientAttributesFromInfo, extractSessionDataFromInitializeRequest } from './sessionExtraction'; import { cleanupSessionDataForTransport, updateSessionDataForTransport } from './sessionManagement'; import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; import type { ExtraHandlerData, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; @@ -41,8 +37,8 @@ function resolveMcpOptions(options: McpServerWrapperOptions): ResolvedMcpOptions /** * Wraps transport.onmessage to create spans for incoming messages. - * Extracts and stores client info and protocol version from legacy initialize - * requests and modern message envelopes. + * Stores client info and protocol version only for legacy initialize requests. + * Modern request metadata is read directly when building each message's span. * @param transport - MCP transport instance to wrap * @param options - MCP capture overrides */ @@ -52,15 +48,11 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: McpServ return function (this: MCPTransport, message: unknown, extra?: unknown) { const request = isJsonRpcRequest(message) ? message : undefined; const notification = isJsonRpcNotification(message) ? message : undefined; - const jsonRpcMessage = request || notification; let messageSessionData: SessionData | undefined; - if (jsonRpcMessage) { + if (request?.method === 'initialize') { try { - messageSessionData = - request?.method === 'initialize' - ? extractSessionDataFromInitializeRequest(request) - : extractSessionDataFromMessage(jsonRpcMessage); + messageSessionData = extractSessionDataFromInitializeRequest(request); if (messageSessionData.protocolVersion || messageSessionData.clientInfo) { updateSessionDataForTransport(transport, messageSessionData); } @@ -109,8 +101,8 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: McpServ /** * Wraps transport.send to handle outgoing messages and response correlation. - * Extracts and stores protocol version and server info from legacy initialize - * responses and modern result metadata. + * Caches legacy initialize metadata and applies modern result metadata only to + * the corresponding request span. * @param transport - MCP transport instance to wrap * @param options - MCP capture overrides */ @@ -133,7 +125,7 @@ export function wrapTransportSend(transport: MCPTransport, options: McpServerWra captureJsonRpcErrorResponse(message.error); } - completeSpanWithResults(transport, message.id, message.result, !!message.error); + completeSpanWithResults(transport, message.id, message.result, message.error); } } @@ -185,10 +177,7 @@ function captureJsonRpcErrorResponse(errorResponse: unknown): void { if (isObjectLike(errorResponse) && 'code' in errorResponse && 'message' in errorResponse) { const jsonRpcError = errorResponse as { code: number; message: string; data?: unknown }; - const isServerError = - jsonRpcError.code === -32603 || (jsonRpcError.code >= -32099 && jsonRpcError.code <= -32000); - - if (isServerError) { + if (typeof jsonRpcError.code === 'number' && isJsonRpcServerError(jsonRpcError.code)) { const error = new Error(jsonRpcError.message); error.name = `JsonRpcError_${jsonRpcError.code}`; diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 7cca7051b785..dc8cceb7d896 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -65,6 +65,9 @@ export interface JsonRpcNotification { * @description Abstraction for MCP communication transport layer */ export interface MCPTransport { + /** Starts the transport lifecycle. */ + start?: () => Promise; + /** * Message handler for incoming JSON-RPC messages * The first argument is a JSON RPC message diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 028e8aef5c8a..d2fba52a8fc0 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -574,7 +574,6 @@ export class Scope { this._breadcrumbs.push(mergedBreadcrumb); if (this._breadcrumbs.length > maxCrumbs) { this._breadcrumbs = this._breadcrumbs.slice(-maxCrumbs); - this._client?.recordDroppedEvent('buffer_overflow', 'log_item'); } this._notifyScopeListeners(); diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 25dac9eeb7a4..e48e0ea1de5f 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -38,3 +38,5 @@ export type { HttpServerResponse, HttpModuleExport, } from './integrations/http/types'; +export { createFetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from './integrations/fetch'; diff --git a/packages/core/src/types/breadcrumb.ts b/packages/core/src/types/breadcrumb.ts index 391100a5a377..3eb70ac87f7d 100644 --- a/packages/core/src/types/breadcrumb.ts +++ b/packages/core/src/types/breadcrumb.ts @@ -77,10 +77,13 @@ export interface BreadcrumbHint { export interface FetchBreadcrumbData { method: string; + /** Sanitized URL: the query string and fragment live in their own fields below. */ url: string; status_code?: number; request_body_size?: number; response_body_size?: number; + 'url.query'?: string; + 'url.fragment'?: string; } export interface XhrBreadcrumbData { diff --git a/packages/core/src/utils/data-collection/filterCookies.ts b/packages/core/src/utils/data-collection/filterCookies.ts index ad18d67fe14a..0fc373f4bfce 100644 --- a/packages/core/src/utils/data-collection/filterCookies.ts +++ b/packages/core/src/utils/data-collection/filterCookies.ts @@ -8,6 +8,8 @@ import { filterKeyValueData } from './filterKeyValueData'; * * When individual cookies can be parsed, each key-value pair is filtered * independently. When parsing fails, the entire string is replaced with `[Filtered]`. + * A nameless segment inside an otherwise parseable string (`"opaque-blob; theme=dark"`) is + * dropped, since a record key cannot carry a `[Filtered]` marker without leaking the token. */ export function filterCookies(cookieString: string, behavior: CollectBehavior): Record | string { if (behavior === false) { @@ -17,8 +19,9 @@ export function filterCookies(cookieString: string, behavior: CollectBehavior): try { const parsed = parseCookie(cookieString); + // A non-empty string we cannot parse may still hold a session token, so it counts as sensitive. if (Object.keys(parsed).length === 0) { - return {}; + return cookieString ? FILTERED : {}; } return filterKeyValueData(parsed, behavior, SENSITIVE_COOKIE_NAME_SNIPPETS); diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 932a10f652b8..7c6ae29a3304 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -305,11 +305,16 @@ export function httpHeadersToSpanAttributes( const cookies = parseCookieHeader(value, lowerKey === 'set-cookie'); spanAttributes[`${prefix}${lowerKey}`] = cookies.length - ? cookies.map(([cookieKey, cookieValue]) => - shouldFilterDataKey(cookieKey, cookieBehavior, SENSITIVE_COOKIE_NAME_SNIPPETS) + ? cookies.map(([cookieKey, cookieValue]) => { + // A nameless cookie's bare token is its value; no denylist could match it, so it is + // always filtered. + if (cookieKey === '') { + return FILTERED_VALUE; + } + return shouldFilterDataKey(cookieKey, cookieBehavior, SENSITIVE_COOKIE_NAME_SNIPPETS) ? `${cookieKey}=${FILTERED_VALUE}` - : `${cookieKey}=${cookieValue}`, - ) + : `${cookieKey}=${cookieValue}`; + }) : [FILTERED_VALUE]; } else { if (headerBehavior === false) { @@ -338,22 +343,31 @@ export function httpHeadersToSpanAttributes( return spanAttributes; } +/** + * Splits a `Cookie` / `Set-Cookie` header into its name-value pairs. + * + * A segment without an `=` is a nameless cookie, so the bare token is its value (RFC 6265bis): + * it is returned as a pair with an empty name. + */ function parseCookieHeader(value: string | string[], isSetCookie: boolean): [string, string][] { // Set-Cookie: one cookie per value, with attributes ("name=value; HttpOnly; Secure") - // Cookie: multiple cookies separated by "; " ("cookie1=value1; cookie2=value2") + // Cookie: multiple cookies separated by ";" (the space after ";" is not guaranteed on the wire) const cookies = (Array.isArray(value) ? value : [value]).flatMap(headerValue => { if (typeof headerValue !== 'string' || headerValue === '') { return []; } - return isSetCookie ? [headerValue.split(';')[0]!] : headerValue.split('; '); + return isSetCookie ? [headerValue.split(';')[0]!] : headerValue.split(';'); }); - return cookies.map(cookie => { - const equalSignIndex = cookie.indexOf('='); - return equalSignIndex !== -1 - ? [cookie.substring(0, equalSignIndex), cookie.substring(equalSignIndex + 1)] - : [cookie, '']; - }); + return cookies + .map(cookie => cookie.trim()) + .filter(cookie => cookie !== '') + .map(cookie => { + const equalSignIndex = cookie.indexOf('='); + return equalSignIndex !== -1 + ? [cookie.substring(0, equalSignIndex), cookie.substring(equalSignIndex + 1)] + : ['', cookie]; + }); } /** Extract the query params from an URL. */ diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index 45be6373bf29..63ecad6dc4d8 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -319,7 +319,9 @@ export function getSanitizedUrlString(url: PartialURL): string { .replace(/(:80)$/, '') .replace(/(:443)$/, '') || ''; - return `${protocol ? `${protocol}://` : ''}${filteredHost}${path}`; + // `parseUrl` returns `{}` for an empty or unparseable URL, and interpolating a missing path + // would render the string 'undefined'. + return `${protocol ? `${protocol}://` : ''}${filteredHost}${path || ''}`; } /** diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index d820b599e686..e2c3269c4929 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -357,22 +357,6 @@ describe('Client', () => { expect(isolationScopeBreadcrumbs).toEqual([{ message: 'hello3', timestamp: expect.any(Number) }]); }); - test('it records `buffer_overflow` client discard reason when buffer overflows', () => { - const options = getDefaultTestClientOptions({ maxBreadcrumbs: 1 }); - const client = new TestClient(options); - const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); - setCurrentClient(client); - getIsolationScope().setClient(client); - client.init(); - - addBreadcrumb({ message: 'hello1' }); - addBreadcrumb({ message: 'hello2' }); - addBreadcrumb({ message: 'hello3' }); - - expect(recordLostEventSpy).toHaveBeenCalledTimes(2); - expect(recordLostEventSpy).toHaveBeenLastCalledWith('buffer_overflow', 'log_item'); - }); - test('calls `beforeBreadcrumb` and adds the breadcrumb without any changes', () => { const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); const options = getDefaultTestClientOptions({ beforeBreadcrumb }); diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts new file mode 100644 index 000000000000..4c332b479fa0 --- /dev/null +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as breadcrumbsModule from '../../../src/breadcrumbs'; +import * as currentScopesModule from '../../../src/currentScopes'; +import * as fetchModule from '../../../src/fetch'; +import { createFetchIntegration } from '../../../src/integrations/fetch'; +import * as instrumentFetchModule from '../../../src/instrument/fetch'; +import type { HandlerDataFetch } from '../../../src/types/instrument'; +import type { Integration } from '../../../src/types/integration'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +const fetchIntegration = createFetchIntegration({ name: 'Fetch', spanOrigin: 'auto.http.fetch' }); + +class FakeClient extends TestClient { + public getIntegrationByName(name: string): T | undefined { + return name === 'Fetch' ? (fetchIntegration() as T) : undefined; + } +} + +const addFetchInstrumentationHandlerSpy = vi.spyOn(instrumentFetchModule, 'addFetchInstrumentationHandler'); +const instrumentFetchRequestSpy = vi.spyOn(fetchModule, 'instrumentFetchRequest'); +const addBreadcrumbSpy = vi.spyOn(breadcrumbsModule, 'addBreadcrumb'); + +function makeClient(options: Partial[0]> = {}): FakeClient { + return new FakeClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + tracePropagationTargets: ['http://my-website.com/'], + ...options, + }), + ); +} + +/** Registers the integration against `client` and returns the handler it installed. */ +function setupIntegration( + integration: ReturnType, + client: FakeClient, +): (handlerData: HandlerDataFetch) => void { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => () => undefined); + integration.setupOnce!(); + integration.setup!(client); + + const [handler] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + expect(handler).toBeDefined(); + return handler; +} + +const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), +}; + +describe('createFetchIntegration', () => { + let client: FakeClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = makeClient(); + vi.spyOn(currentScopesModule, 'getClient').mockImplementation(() => client); + }); + + it('calls `instrumentFetchRequest` for outgoing fetch requests', () => { + const handler = setupIntegration(fetchIntegration(), client); + handler(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + { spanOrigin: 'auto.http.fetch', propagateTraceparent: undefined }, + ); + + const [, , shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + + expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); + expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); + // tracePropagationTargets match regardless of casing + expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); + }); + + it('uses the span origin it was created with', () => { + const winterCGFetchIntegration = createFetchIntegration({ + name: 'WinterCGFetch', + spanOrigin: 'auto.http.wintercg_fetch', + }); + + const handler = setupIntegration(winterCGFetchIntegration(), client); + handler(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + expect.objectContaining({ spanOrigin: 'auto.http.wintercg_fetch' }), + ); + }); + + it('forwards the client `propagateTraceparent` option', () => { + client = makeClient({ propagateTraceparent: true }); + const handler = setupIntegration(fetchIntegration(), client); + handler(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + expect.objectContaining({ propagateTraceparent: true }), + ); + }); + + it('does not instrument if the client is not set up', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => () => undefined); + const integration = fetchIntegration(); + integration.setupOnce!(); + // no `setup(client)` call + + const [handler] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + handler!(startHandlerData); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + }); + + it('does not instrument outgoing requests to Sentry', () => { + const handler = setupIntegration(fetchIntegration(), client); + handler({ + fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=public', method: 'POST' }, + args: ['https://dsn.ingest.sentry.io/1337?sentry_key=public'], + startTimestamp: Date.now(), + }); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + it('applies the `shouldCreateSpanForRequest` option', () => { + const handler = setupIntegration( + fetchIntegration({ shouldCreateSpanForRequest: url => url === 'http://only-this-one.com/' }), + client, + ); + handler(startHandlerData); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + + expect(shouldCreateSpan('http://only-this-one.com/')).toBe(true); + expect(shouldCreateSpan('http://my-website.com/')).toBe(false); + }); + + it('attaches trace data by default', () => { + const handler = setupIntegration(fetchIntegration(), client); + handler(startHandlerData); + + const [, , shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); + }); + + it('attaches no trace data when `tracePropagation: false` is set', () => { + const handler = setupIntegration(fetchIntegration({ tracePropagation: false }), client); + handler(startHandlerData); + + const [, , shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldAttachTraceData('http://my-website.com/')).toBe(false); + }); + + it('still creates spans when `tracePropagation: false` is set', () => { + const handler = setupIntegration(fetchIntegration({ tracePropagation: false }), client); + handler(startHandlerData); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldCreateSpan('http://my-website.com/')).toBe(true); + }); + + it('creates a breadcrumb for an outgoing request', () => { + const handler = setupIntegration(fetchIntegration(), client); + + const startTimestamp = Date.now(); + const endTimestamp = startTimestamp + 100; + const response = { status: 200 } as Response; + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST', request_body_size: 10, response_body_size: 20 }, + args: ['http://my-website.com/'], + startTimestamp, + endTimestamp, + response, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + { + category: 'fetch', + data: { + method: 'POST', + url: 'http://my-website.com/', + request_body_size: 10, + response_body_size: 20, + status_code: 200, + }, + type: 'http', + }, + { + input: ['http://my-website.com/'], + response, + startTimestamp, + endTimestamp, + }, + ); + }); + + it('sanitizes the breadcrumb URL and reports the query separately', () => { + const handler = setupIntegration(fetchIntegration(), client); + + handler({ + fetchData: { url: 'http://user:pw@my-website.com/path?q=hello&token=secret#frag', method: 'GET' }, + args: ['http://my-website.com/path?q=hello&token=secret#frag'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + url: 'http://[filtered]:[filtered]@my-website.com/path', + 'url.query': 'q=hello&token=[Filtered]', + 'url.fragment': 'frag', + }), + }), + expect.anything(), + ); + }); + + it('redacts the breadcrumb query when `dataCollection.urlQueryParams` is off', () => { + client = makeClient({ dataCollection: { urlQueryParams: false } }); + const handler = setupIntegration(fetchIntegration(), client); + + handler({ + fetchData: { url: 'http://my-website.com/path?token=secret', method: 'GET' }, + args: ['http://my-website.com/path?token=secret'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ url: 'http://my-website.com/path' }), + }), + expect.anything(), + ); + expect(addBreadcrumbSpy.mock.lastCall?.[0].data?.['url.query']).toBeUndefined(); + }); + + it('records an empty breadcrumb URL rather than the string "undefined"', () => { + const handler = setupIntegration(fetchIntegration(), client); + + handler({ + fetchData: { url: '', method: 'GET' }, + args: [''], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + error: new Error('Invalid URL'), + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ data: { method: 'GET', url: '' } }), + expect.anything(), + ); + }); + + it('creates an error-level breadcrumb for a failed request', () => { + const handler = setupIntegration(fetchIntegration(), client); + + const error = new Error('kaboom'); + const startTimestamp = Date.now(); + const endTimestamp = startTimestamp + 100; + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp, + endTimestamp, + error, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + { + category: 'fetch', + data: { method: 'POST', url: 'http://my-website.com/' }, + level: 'error', + type: 'http', + }, + { + data: error, + input: ['http://my-website.com/'], + startTimestamp, + endTimestamp, + }, + ); + }); + + it('creates no breadcrumb when `breadcrumbs: false` is set', () => { + const handler = setupIntegration(fetchIntegration({ breadcrumbs: false }), client); + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + it('uses each client own options when a second client is set up', () => { + // `setupOnce` runs once per process, so the handler must read the options of whichever client + // is current rather than the ones captured by the first instance. + const handler = setupIntegration(fetchIntegration({ breadcrumbs: false }), client); + + const secondClient = makeClient(); + fetchIntegration({ breadcrumbs: true, shouldCreateSpanForRequest: () => false }).setup!(secondClient); + vi.spyOn(currentScopesModule, 'getClient').mockImplementation(() => secondClient); + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldCreateSpan('http://my-website.com/')).toBe(false); + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/errorClassification.test.ts b/packages/core/test/lib/integrations/mcp-server/errorClassification.test.ts new file mode 100644 index 000000000000..2dab513ff24f --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/errorClassification.test.ts @@ -0,0 +1,160 @@ +import { ERROR_TYPE, RPC_RESPONSE_STATUS_CODE } from '@sentry/conventions/attributes'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import * as exports from '../../../../src/exports'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import type { JsonRpcResponse } from '../../../../src/integrations/mcp-server/types'; +import { SentrySpan } from '../../../../src/tracing/sentrySpan'; +import { SPAN_STATUS_ERROR } from '../../../../src/tracing/spanstatus'; +import * as tracing from '../../../../src/tracing/trace'; +import { spanToJSON } from '../../../../src/utils/spanUtils'; +import { createMockClient, createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP server response error classification', () => { + beforeEach(() => { + vi.spyOn(currentScopes, 'getClient').mockReturnValue(createMockClient()); + vi.spyOn(exports, 'captureException').mockReturnValue('event-id'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function createRequest(method: string) { + const span = new SentrySpan({ name: method }); + vi.spyOn(tracing, 'startInactiveSpan').mockReturnValue(span); + const server = wrapMcpServerWithSentry(createMockMcpServer(), { recordOutputs: false }); + const transport = createMockTransport(); + const send = transport.send; + + await server.connect(transport); + transport.onmessage({ jsonrpc: '2.0', id: 'request-1', method }, {}); + + return { span, transport, send }; + } + + it.each([ + { code: -32700, message: 'Parse error' }, + { code: -32600, message: 'Invalid Request' }, + { code: -32601, message: 'Method not found' }, + { code: -32602, message: 'Invalid params' }, + { code: -32002, message: 'Resource not found' }, + ])('records $message ($code) without reporting a server failure', async ({ code, message }) => { + const { span, transport, send } = await createRequest('resources/list'); + const response: JsonRpcResponse = Object.freeze({ + jsonrpc: '2.0', + id: 'request-1', + error: Object.freeze({ code, message, data: { reason: 'unsupported request' } }), + }); + const sendOptions = { relatedRequestId: 'request-1' }; + + await transport.send(response, sendOptions); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(response, sendOptions); + const result = spanToJSON(span); + expect(result.status).toBe('ok'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBe(String(code)); + expect(result.attributes[ERROR_TYPE]).toBeUndefined(); + expect(result.attributes['sentry.status.message']).toBeUndefined(); + expect(result.end_timestamp).toBeDefined(); + expect(exports.captureException).not.toHaveBeenCalled(); + }); + + it.each([-32603, -32099, -32000, -32020, -32021, -32022, -32042, 1000])( + 'reports JSON-RPC error %s as a server failure', + async code => { + const { span, transport, send } = await createRequest('tools/call'); + const response: JsonRpcResponse = { + jsonrpc: '2.0', + id: 'request-1', + error: { code, message: 'Request could not be completed' }, + }; + + await transport.send(response); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(response); + const result = spanToJSON(span); + expect(result.status).toBe('error'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBe(String(code)); + expect(result.attributes[ERROR_TYPE]).toBe(String(code)); + expect(result.attributes['sentry.status.message']).toBe('Request could not be completed'); + expect(exports.captureException).toHaveBeenCalledTimes(1); + expect(exports.captureException).toHaveBeenCalledWith( + Object.assign(new Error('Request could not be completed'), { name: `JsonRpcError_${code}` }), + { mechanism: { type: 'auto.ai.mcp_server', handled: false, data: { error_type: 'protocol' } } }, + ); + }, + ); + + it('preserves a previously recorded failure when the response is a caller error', async () => { + const { span, transport } = await createRequest('resources/list'); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'A separate failure' }); + + await transport.send({ + jsonrpc: '2.0', + id: 'request-1', + error: { code: -32601, message: 'Method not found' }, + }); + + const result = spanToJSON(span); + expect(result.status).toBe('error'); + expect(result.attributes['sentry.status.message']).toBe('A separate failure'); + }); + + it('reports a tool error independently of output capture', async () => { + const { span, transport, send } = await createRequest('tools/call'); + const response: JsonRpcResponse = { + jsonrpc: '2.0', + id: 'request-1', + result: { isError: true, content: [{ type: 'text', text: 'Tool failed' }] }, + }; + + await transport.send(response); + + expect(send).toHaveBeenCalledWith(response); + const result = spanToJSON(span); + expect(result.status).toBe('error'); + expect(result.attributes[ERROR_TYPE]).toBe('tool_error'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBeUndefined(); + expect(result.attributes['mcp.tool.result.is_error']).toBe(true); + expect(result.attributes['mcp.tool.result.content']).toBeUndefined(); + expect(exports.captureException).not.toHaveBeenCalled(); + }); + + it('does not classify an input_required tool response as a server failure', async () => { + const { span, transport, send } = await createRequest('tools/call'); + const response: JsonRpcResponse = { + jsonrpc: '2.0', + id: 'request-1', + result: { resultType: 'input_required', requestState: 'opaque-state' }, + }; + + await transport.send(response); + + expect(send).toHaveBeenCalledWith(response); + const result = spanToJSON(span); + expect(result.status).toBe('ok'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBeUndefined(); + expect(result.attributes[ERROR_TYPE]).toBeUndefined(); + expect(result.end_timestamp).toBeDefined(); + expect(exports.captureException).not.toHaveBeenCalled(); + }); + + it('does not leak error classification into a subsequent successful response', async () => { + const { transport } = await createRequest('resources/list'); + await transport.send({ jsonrpc: '2.0', id: 'request-1', error: { code: -32601, message: 'Method not found' } }); + const span = new SentrySpan({ name: 'tools/call' }); + vi.mocked(tracing.startInactiveSpan).mockReturnValue(span); + transport.onmessage({ jsonrpc: '2.0', id: 'request-2', method: 'tools/call' }, {}); + + await transport.send({ jsonrpc: '2.0', id: 'request-2', result: { isError: false, content: [] } }); + + const result = spanToJSON(span); + expect(result.status).toBe('ok'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBeUndefined(); + expect(result.attributes[ERROR_TYPE]).toBeUndefined(); + expect(result.attributes['mcp.tool.result.is_error']).toBe(false); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/modernMetadata.test.ts b/packages/core/test/lib/integrations/mcp-server/modernMetadata.test.ts new file mode 100644 index 000000000000..31eab524d194 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/modernMetadata.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { SentrySpan } from '../../../../src/tracing/sentrySpan'; +import * as tracing from '../../../../src/tracing/trace'; +import { spanToJSON } from '../../../../src/utils/spanUtils'; +import { createMockClient, createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP request metadata isolation', () => { + const spans: SentrySpan[] = []; + const modernMeta = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {}, + }; + const resourcesResult = { resultType: 'complete', resources: [], ttlMs: 0, cacheScope: 'private' }; + + beforeEach(() => { + spans.length = 0; + vi.spyOn(currentScopes, 'getClient').mockReturnValue(createMockClient()); + vi.spyOn(tracing, 'startInactiveSpan').mockImplementation(options => { + const span = new SentrySpan(options); + spans.push(span); + return span; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function connect() { + const transport = createMockTransport(); + transport.sessionId = ''; + await wrapMcpServerWithSentry(createMockMcpServer()).connect(transport); + return transport; + } + + it('does not inherit optional client or server identity from another modern request', async () => { + const transport = await connect(); + transport.onmessage({ + jsonrpc: '2.0', + id: 1, + method: 'resources/list', + params: { + _meta: { + ...modernMeta, + 'io.modelcontextprotocol/clientInfo': { name: 'first-client', version: '1.0' }, + }, + }, + }); + await transport.send({ + jsonrpc: '2.0', + id: 1, + result: { + ...resourcesResult, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'first-server', version: '1.0' } }, + }, + }); + transport.onmessage({ + jsonrpc: '2.0', + id: 2, + method: 'resources/list', + params: { _meta: modernMeta }, + }); + await transport.send({ jsonrpc: '2.0', id: 2, result: resourcesResult }); + + expect(spans.map(span => spanToJSON(span).attributes)).toEqual([ + expect.objectContaining({ + 'mcp.client.name': 'first-client', + 'mcp.client.version': '1.0', + 'mcp.server.name': 'first-server', + 'mcp.server.version': '1.0', + 'mcp.protocol.version': '2026-07-28', + }), + expect.objectContaining({ 'mcp.protocol.version': '2026-07-28' }), + ]); + const secondAttributes = spanToJSON(spans[1]!).attributes; + for (const attribute of ['mcp.client.name', 'mcp.client.version', 'mcp.server.name', 'mcp.server.version']) { + expect(secondAttributes).not.toHaveProperty(attribute); + } + }); + + it('does not attribute a request missing protocol metadata to the preceding modern request', async () => { + const transport = await connect(); + transport.onmessage({ jsonrpc: '2.0', id: 1, method: 'resources/list', params: { _meta: modernMeta } }); + await transport.send({ jsonrpc: '2.0', id: 1, result: resourcesResult }); + transport.onmessage({ jsonrpc: '2.0', id: 2, method: 'resources/list' }); + await transport.send({ jsonrpc: '2.0', id: 2, error: { code: -32602, message: 'Missing protocol metadata' } }); + + expect(spanToJSON(spans[0]!).attributes['mcp.protocol.version']).toBe('2026-07-28'); + expect(spanToJSON(spans[1]!).attributes).not.toHaveProperty('mcp.protocol.version'); + }); + + it('preserves established legacy session metadata but does not apply it to a modern request', async () => { + const transport = await connect(); + transport.sessionId = 'legacy-metadata-session'; + transport.onmessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy-client', version: '1.0' } }, + }); + await transport.send({ + jsonrpc: '2.0', + id: 1, + result: { protocolVersion: '2025-06-18', serverInfo: { name: 'legacy-server', version: '1.0' } }, + }); + transport.onmessage({ jsonrpc: '2.0', id: 2, method: 'resources/list' }); + await transport.send({ jsonrpc: '2.0', id: 2, result: { resources: [] } }); + transport.onmessage({ jsonrpc: '2.0', id: 3, method: 'resources/list', params: { _meta: modernMeta } }); + await transport.send({ jsonrpc: '2.0', id: 3, result: resourcesResult }); + + expect(spanToJSON(spans[1]!).attributes).toEqual( + expect.objectContaining({ + 'mcp.protocol.version': '2025-06-18', + 'mcp.client.name': 'legacy-client', + 'mcp.server.name': 'legacy-server', + 'mcp.session.id': 'legacy-metadata-session', + }), + ); + const modernAttributes = spanToJSON(spans[2]!).attributes; + expect(modernAttributes['mcp.protocol.version']).toBe('2026-07-28'); + for (const attribute of ['mcp.client.name', 'mcp.server.name', 'mcp.session.id']) { + expect(modernAttributes).not.toHaveProperty(attribute); + } + transport.onclose(); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts index e97745c57253..6428b777bef3 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -34,6 +34,33 @@ import { createMockWrapperTransport, } from './testUtils'; +type StartImplementation = (transport: InMemoryTransport) => Promise; + +class InMemoryTransport { + public onmessage?: (...args: unknown[]) => void; + public onclose?: (...args: unknown[]) => void; + public onerror?: (error: Error) => void; + public send = vi.fn().mockResolvedValue(undefined); + + public constructor(private readonly startImplementation: StartImplementation = () => Promise.resolve()) {} + + public start(): Promise { + return this.startImplementation(this); + } +} + +function createStartingMcpServer() { + return { + ...createMockMcpServer(), + connect: vi.fn(async (transport: InMemoryTransport) => { + transport.onmessage = vi.fn(); + transport.onclose = vi.fn(); + transport.onerror = vi.fn(); + await transport.start(); + }), + }; +} + describe('MCP Server Transport Instrumentation', () => { const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); @@ -101,6 +128,139 @@ describe('MCP Server Transport Instrumentation', () => { expect(originalConnect).toHaveBeenCalledWith(mockTransport); }); + it('instruments requests once during and after transport startup', async () => { + const transport = new InMemoryTransport(connectedTransport => { + connectedTransport.onmessage?.({ + jsonrpc: '2.0', + method: 'tools/call', + id: 'queued-request', + params: { name: 'get-weather' }, + }); + return Promise.resolve(); + }); + + await wrapMcpServerWithSentry(createStartingMcpServer()).connect(transport); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'tools/call get-weather', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'get-weather', + 'mcp.request.id': 'queued-request', + 'mcp.transport': 'InMemoryTransport', + 'network.transport': 'unknown', + 'network.protocol.version': '2.0', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.segment.name.source': 'route', + }, + }); + + startInactiveSpanSpy.mockClear(); + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'tools/call', + id: 'post-start-request', + params: { name: 'get-weather' }, + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + }); + + it('preserves the start receiver and Promise and restores the inherited method before calling it', async () => { + const startPromise = Promise.resolve(); + let startResult: Promise | undefined; + let receivedExpectedThis = false; + let wasRestoredBeforeStart = false; + let originalStart: InMemoryTransport['start']; + const transport = new InMemoryTransport(connectedTransport => { + receivedExpectedThis = connectedTransport === transport; + wasRestoredBeforeStart = connectedTransport.start === originalStart; + return startPromise; + }); + originalStart = transport.start; + const server = createStartingMcpServer(); + server.connect.mockImplementation(async connectedTransport => { + startResult = connectedTransport.start(); + await startResult; + }); + + await wrapMcpServerWithSentry(server).connect(transport); + + expect(startResult).toBe(startPromise); + expect(receivedExpectedThis).toBe(true); + expect(wasRestoredBeforeStart).toBe(true); + expect(transport.start).toBe(originalStart); + expect(Object.prototype.hasOwnProperty.call(transport, 'start')).toBe(false); + }); + + it('restores start when connect rejects before starting the transport', async () => { + const connectionError = new Error('connection failed'); + const transport = new InMemoryTransport(); + const server = { + ...createMockMcpServer(), + connect: vi.fn().mockRejectedValue(connectionError), + }; + + const connection = wrapMcpServerWithSentry(server).connect(transport); + + await expect(connection).rejects.toBe(connectionError); + expect(Object.prototype.hasOwnProperty.call(transport, 'start')).toBe(false); + }); + + it('restores start and preserves a synchronous start error', async () => { + const startError = new Error('start failed'); + const originalStart = vi.fn(() => { + throw startError; + }); + const transport = new InMemoryTransport(); + Object.defineProperty(transport, 'start', { + configurable: true, + enumerable: false, + value: originalStart, + writable: false, + }); + const originalDescriptor = Object.getOwnPropertyDescriptor(transport, 'start'); + const callStart = vi.fn((connectedTransport: InMemoryTransport) => connectedTransport.start()); + const server = createStartingMcpServer(); + server.connect.mockImplementation(async connectedTransport => { + await callStart(connectedTransport); + }); + + const connection = wrapMcpServerWithSentry(server).connect(transport); + + await expect(connection).rejects.toBe(startError); + expect(callStart).toHaveBeenCalledOnce(); + expect(callStart).not.toHaveReturned(); + expect(Object.getOwnPropertyDescriptor(transport, 'start')).toEqual(originalDescriptor); + expect(originalStart).toHaveBeenCalledOnce(); + }); + + it('falls back to post-connect instrumentation when start cannot be replaced', async () => { + const originalStart = vi.fn().mockResolvedValue(undefined); + const transport = new InMemoryTransport(); + Object.defineProperty(transport, 'start', { + configurable: false, + enumerable: false, + value: originalStart, + writable: false, + }); + + await wrapMcpServerWithSentry(createStartingMcpServer()).connect(transport); + + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'tools/call', + id: 'post-start-request', + params: { name: 'get-weather' }, + }); + + expect(originalStart).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + }); + it('should create spans for incoming JSON-RPC requests', async () => { await wrappedMcpServer.connect(mockTransport); @@ -209,7 +369,7 @@ describe('MCP Server Transport Instrumentation', () => { }; await mockTransport.send?.(jsonRpcErrorResponse as any); - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: 2, message: jsonRpcErrorResponse.error.message }); expect(mockSpan.end).toHaveBeenCalled(); }); diff --git a/packages/core/test/lib/utils/data-collection/filterCookies.test.ts b/packages/core/test/lib/utils/data-collection/filterCookies.test.ts index 11e5a660c1e6..4f8ed3d57fba 100644 --- a/packages/core/test/lib/utils/data-collection/filterCookies.test.ts +++ b/packages/core/test/lib/utils/data-collection/filterCookies.test.ts @@ -78,8 +78,24 @@ describe('filterCookies', () => { expect(filterCookies('', true)).toEqual({}); }); - it('returns empty record for string with no key-value pairs', () => { - expect(filterCookies(';;;', true)).toEqual({}); + it('filters the whole string when no key-value pairs can be extracted', () => { + expect(filterCookies(';;;', true)).toBe('[Filtered]'); + expect(filterCookies('opaque-session-blob', true)).toBe('[Filtered]'); + }); + }); + + // Intended behavior for the cookie parsing consolidation follow-up: `Set-Cookie` attributes are + // metadata, not cookies, so they must not show up as key-value pairs. Marked `fails` until the + // shared parser handles them. + describe('Set-Cookie attribute handling (known gaps)', () => { + it.fails('does not report Set-Cookie attributes as cookie pairs', () => { + expect(filterCookies('sid=1; Max-Age=3600; Path=/', true)).toEqual({ sid: '[Filtered]' }); + }); + + it.fails('does not report Expires/Domain attributes as cookie pairs', () => { + expect(filterCookies('theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Domain=example.com', true)).toEqual({ + theme: 'dark', + }); }); }); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 2041e06ec3da..58e29ad12ddd 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -650,8 +650,7 @@ describe('request utils', () => { it('attaches and filters sensitive cookie headers', () => { const headers = { - Cookie: - 'session=abc123; tracking=enabled; cookie-authentication-key-without-value; theme=dark; lang=en; user_session=xyz789; pref=1', + Cookie: 'session=abc123; tracking=enabled; theme=dark; lang=en; user_session=xyz789; pref=1', }; const result = httpHeadersToSpanAttributes(headers, resolveDataCollectionOptions({})); @@ -660,7 +659,6 @@ describe('request utils', () => { 'http.request.header.cookie': [ 'session=[Filtered]', 'tracking=enabled', - 'cookie-authentication-key-without-value=[Filtered]', 'theme=dark', 'lang=en', 'user_session=[Filtered]', @@ -669,6 +667,35 @@ describe('request utils', () => { }); }); + it('filters cookie segments that are not a name=value pair', () => { + // The bare token is a nameless cookie's value, so it must be filtered. + const headers = { Cookie: 'session=abc123; theme=dark; y7Uu0Rk2QpLmXv3' }; + + const result = httpHeadersToSpanAttributes(headers, resolveDataCollectionOptions({})); + + expect(result).toEqual({ + 'http.request.header.cookie': ['session=[Filtered]', 'theme=dark', '[Filtered]'], + }); + }); + + it('filters a cookie header that holds no name=value pair', () => { + const headers = { Cookie: 'y7Uu0Rk2QpLmXv3' }; + + const result = httpHeadersToSpanAttributes(headers, resolveDataCollectionOptions({})); + + expect(result).toEqual({ 'http.request.header.cookie': ['[Filtered]'] }); + }); + + it('splits cookies on ";" without a following space', () => { + const headers = { Cookie: 'theme=dark;__Secure-session=abc123' }; + + const result = httpHeadersToSpanAttributes(headers, resolveDataCollectionOptions({})); + + expect(result).toEqual({ + 'http.request.header.cookie': ['theme=dark', '__Secure-session=[Filtered]'], + }); + }); + it('filters common framework and provider session-style cookie names', () => { const headers = { Cookie: @@ -728,7 +755,8 @@ describe('request utils', () => { ['pref=1; Max-Age=3600', { 'http.request.header.set-cookie': ['pref=1'] }], ['color=blue; Path=/dashboard', { 'http.request.header.set-cookie': ['color=blue'] }], ['token=eyJhbGc=.eyJzdWI=.SflKxw; Secure', { 'http.request.header.set-cookie': ['token=[Filtered]'] }], - ['auth_required; HttpOnly', { 'http.request.header.set-cookie': ['auth_required=[Filtered]'] }], + // A set-cookie string without "=" is a nameless cookie: the bare token is its value. + ['auth_required; HttpOnly', { 'http.request.header.set-cookie': ['[Filtered]'] }], ['empty=; Secure', { 'http.request.header.set-cookie': ['empty='] }], ])('should parse and filter Set-Cookie header: %s', (setCookieValue, expected) => { const headers = { 'Set-Cookie': setCookieValue }; diff --git a/packages/core/test/lib/utils/url.test.ts b/packages/core/test/lib/utils/url.test.ts index e045366955fe..ad491ea983a6 100644 --- a/packages/core/test/lib/utils/url.test.ts +++ b/packages/core/test/lib/utils/url.test.ts @@ -78,6 +78,8 @@ describe('getSanitizedUrlString', () => { ['url with port 4433', 'http://172.31.12.144:4433/test', 'http://172.31.12.144:4433/test'], ['url with port 443', 'http://172.31.12.144:443/test', 'http://172.31.12.144/test'], ['url with IP and port 80', 'http://172.31.12.144:80/test', 'http://172.31.12.144/test'], + ['empty url', '', ''], + ['unparseable url', '???', ''], ])('returns a sanitized URL for a %s', (_, rawUrl: string, sanitizedURL: string) => { const urlObject = parseUrl(rawUrl); expect(getSanitizedUrlString(urlObject)).toEqual(sanitizedURL); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index c3d5cf4e95ad..3b44d8b6d875 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -83,7 +83,6 @@ export { zodErrorsIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, startSession, captureSession, @@ -111,7 +110,7 @@ export { getDefaultIntegrations, init } from './sdk'; export { denoServeIntegration } from './integrations/deno-serve'; export type { DenoServeIntegrationOptions } from './integrations/deno-serve'; export { fetchIntegration } from './integrations/fetch'; -export type { FetchOptions } from './integrations/fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; export { denoHttpIntegration } from './integrations/http'; export type { DenoHttpIntegrationOptions } from './integrations/http'; @@ -146,10 +145,13 @@ export { mysql2Integration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, postgresIntegration, postgresJsIntegration, tediousIntegration, eveConversationHook, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/server-utils'; export { openTelemetryIntegration, getOtlpTracesEndpoint } from '@sentry/server-utils/no-diagnostic-channels'; diff --git a/packages/deno/src/integrations/breadcrumbs.ts b/packages/deno/src/integrations/breadcrumbs.ts index a260a9d6fd91..8cb07f1eaf58 100644 --- a/packages/deno/src/integrations/breadcrumbs.ts +++ b/packages/deno/src/integrations/breadcrumbs.ts @@ -1,23 +1,15 @@ -import type { - Client, - Event as SentryEvent, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - getEventDescription, -} from '@sentry/core'; +import type { Client, Event as SentryEvent, IntegrationFn } from '@sentry/core'; +import { addBreadcrumb, consoleSandbox, defineIntegration, getClient, getEventDescription } from '@sentry/core'; interface BreadcrumbsOptions { - fetch: boolean; sentry: boolean; + + /** + * @deprecated Fetch breadcrumbs are recorded by `fetchIntegration`. Disable them with + * `fetchIntegration({ breadcrumbs: false })` instead. This option no longer has any effect and + * will be removed in a future major version. + */ + fetch: boolean; } const INTEGRATION_NAME = 'Breadcrumbs' as const; @@ -27,8 +19,16 @@ const INTEGRATION_NAME = 'Breadcrumbs' as const; * The Deno-version does not support browser-specific APIs like dom, xhr and history. */ const _breadcrumbsIntegration = ((options: Partial = {}) => { + if ('fetch' in options) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + '[Sentry] `breadcrumbsIntegration({ fetch })` is deprecated and no longer has any effect. Fetch breadcrumbs are recorded by `fetchIntegration`; disable them with `fetchIntegration({ breadcrumbs: false })`.', + ); + }); + } + const _options = { - fetch: true, sentry: true, ...options, }; @@ -36,9 +36,6 @@ const _breadcrumbsIntegration = ((options: Partial = {}) => return { name: INTEGRATION_NAME, setup(client) { - if (_options.fetch) { - addFetchInstrumentationHandler(_getFetchBreadcrumbHandler(client)); - } if (_options.sentry) { client.on('beforeSendEvent', _getSentryBreadcrumbHandler(client)); } @@ -47,7 +44,9 @@ const _breadcrumbsIntegration = ((options: Partial = {}) => }) satisfies IntegrationFn; /** - * Adds a breadcrumbs for fetch and sentry events. + * Adds breadcrumbs for sentry events. + * + * Fetch breadcrumbs come from `fetchIntegration`. * * Enabled by default in the Deno SDK. * @@ -84,74 +83,3 @@ function _getSentryBreadcrumbHandler(client: Client): (event: SentryEvent) => vo ); }; } - -/** - * Creates breadcrumbs from fetch API calls - */ -function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFetch) => void { - return function _fetchBreadcrumb(handlerData: HandlerDataFetch): void { - if (getClient() !== client) { - return; - } - - const { startTimestamp, endTimestamp } = handlerData; - - // We only capture complete fetch requests - if (!endTimestamp) { - return; - } - - if (handlerData.fetchData.url.match(/sentry_key/) && handlerData.fetchData.method === 'POST') { - // We will not create breadcrumbs for fetch requests that contain `sentry_key` (internal sentry requests) - return; - } - - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - - if (handlerData.error) { - const hint: FetchBreadcrumbHint = { - data: handlerData.error, - input: handlerData.args, - startTimestamp, - endTimestamp, - }; - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); - } else { - const response = handlerData.response as Response | undefined; - - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - - const hint: FetchBreadcrumbHint = { - input: handlerData.args, - response, - startTimestamp, - endTimestamp, - }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); - } - }; -} diff --git a/packages/deno/src/integrations/fetch.ts b/packages/deno/src/integrations/fetch.ts index 17d82072ec00..c873aab979da 100644 --- a/packages/deno/src/integrations/fetch.ts +++ b/packages/deno/src/integrations/fetch.ts @@ -1,87 +1,10 @@ -import type { Client, IntegrationFn, Span } from '@sentry/core'; -import { - addFetchInstrumentationHandler, - defineIntegration, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'Fetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -export interface FetchOptions { - /** - * Function determining whether or not to create spans to track outgoing requests to the given URL. - * By default, spans will be created for all outgoing requests. - */ - shouldCreateSpanForRequest?: (url: string) => boolean; -} - -const _fetchIntegration = ((options: FetchOptions = {}) => { - const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - - const _createSpanUrlMap = new LRUMap(100); - const _headersUrlMap = new LRUMap(100); - - const spans: Record = {}; - - function _shouldAttachTraceData(url: string): boolean { - const client = getClient(); - - if (!client) { - return false; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - function _shouldCreateSpan(url: string): boolean { - if (shouldCreateSpanForRequest === undefined) { - return true; - } - - const cachedDecision = _createSpanUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = shouldCreateSpanForRequest(url); - _createSpanUrlMap.set(url, decision); - return decision; - } - - return { - name: INTEGRATION_NAME, - setupOnce() { - addFetchInstrumentationHandler(handlerData => { - const client = getClient(); - if (!client || !HAS_CLIENT_MAP.get(client)) { - return; - } - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - const { propagateTraceparent } = client.getOptions(); - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.fetch', - propagateTraceparent, - }); - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** - * Instruments outgoing `fetch` requests in Deno by creating spans and attaching trace propagation headers. - * The separate breadcrumbs integration records fetch breadcrumbs. + * Instruments outgoing `fetch` requests in Deno: creates spans, records breadcrumbs and + * attaches trace propagation headers. */ -export const fetchIntegration = defineIntegration(_fetchIntegration); +export const fetchIntegration = createFetchIntegration({ + name: 'Fetch', + spanOrigin: 'auto.http.fetch', +}); diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts index f4cfeee81d17..15a5270a6bb7 100644 --- a/packages/deno/src/integrations/http.ts +++ b/packages/deno/src/integrations/http.ts @@ -51,6 +51,9 @@ export interface DenoHttpIntegrationOptions { * When set to `false`, Sentry will not inject any trace propagation headers, but will still create breadcrumbs * (if `breadcrumbs` is enabled). * + * Covers `node:http` requests only. Outgoing `fetch` has its own switch, + * `fetchIntegration({ tracePropagation: false })`. + * * @default `true` */ tracePropagation?: boolean; diff --git a/packages/deno/test/deno-fetch.test.ts b/packages/deno/test/deno-fetch.test.ts index f744c751fc2f..8f13905183a0 100644 --- a/packages/deno/test/deno-fetch.test.ts +++ b/packages/deno/test/deno-fetch.test.ts @@ -6,7 +6,8 @@ import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import type { DenoClient } from '../build/esm/index.js'; -import { captureMessage, init, startSpan } from '../build/esm/index.js'; +import { breadcrumbsIntegration, captureMessage, init, startSpan } from '../build/esm/index.js'; +import { makeTestTransport } from './transport.ts'; function resetGlobals(): void { getMainCarrier().__SENTRY__ = undefined; @@ -105,3 +106,82 @@ Deno.test({ } }, }); + +Deno.test({ + name: 'fetchIntegration: emits an http.client span under the default (streaming) trace lifecycle', + async fn() { + resetGlobals(); + + const server = Deno.serve({ port: 0, hostname: '127.0.0.1', onListen() {} }, () => new Response('ok')); + const url = `http://127.0.0.1:${server.addr.port}/streamed`; + + try { + let resolveSpan: ((name: string) => void) | undefined; + const clientSpan = new Promise(resolve => (resolveSpan = resolve)); + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + // No `traceLifecycle`: this is the default ('stream') path the other tests do not cover. + tracePropagationTargets: [url], + transport: makeTestTransport(envelope => { + for (const [header, body] of envelope[1] as [{ type: string }, Record][]) { + if (header.type !== 'span') continue; + for (const span of (body.items ?? [body]) as Record[]) { + if (span.attributes?.['sentry.op']?.value === 'http.client') { + resolveSpan?.(span.attributes['sentry.origin']?.value); + } + } + } + }), + }); + + await startSpan({ name: 'parent', op: 'test' }, async () => { + const response = await fetch(url); + assertEquals(await response.text(), 'ok'); + }); + + assertEquals(await withTimeout(clientSpan, 5_000, 'streamed http.client span'), 'auto.http.fetch'); + } finally { + await server.shutdown(); + } + }, +}); + +Deno.test({ + name: 'breadcrumbsIntegration: the deprecated `fetch` option no longer suppresses fetch breadcrumbs', + async fn() { + resetGlobals(); + + const server = Deno.serve({ port: 0, hostname: '127.0.0.1', onListen() {} }, () => new Response('ok')); + const url = `http://127.0.0.1:${server.addr.port}/still-recorded`; + + try { + let resolveEvent: ((event: Event) => void) | undefined; + const capturedEvent = new Promise(resolve => (resolveEvent = resolve)); + + init({ + dsn: 'https://username@domain/123', + // oxlint-disable-next-line typescript/no-deprecated + integrations: [breadcrumbsIntegration({ fetch: false })], + beforeSend(event) { + resolveEvent?.(event); + return null; + }, + }); + + await fetch(url).then(response => response.text()); + + captureMessage('capture fetch breadcrumb'); + const event = await withTimeout(capturedEvent, 5_000, 'event containing fetch breadcrumb'); + const fetchBreadcrumbs = event.breadcrumbs?.filter( + breadcrumb => breadcrumb.category === 'fetch' && breadcrumb.data?.url === url, + ); + + // `fetchIntegration` owns fetch breadcrumbs now, so the old switch has no effect. + assertEquals(fetchBreadcrumbs?.length, 1); + } finally { + await server.shutdown(); + } + }, +}); diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 28e6fc1ee2f3..80e3c9e303ba 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -62,6 +62,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, createFlueInstrumentation, @@ -93,7 +95,6 @@ export { parameterize, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, diff --git a/packages/elysia/test/sdk.test.ts b/packages/elysia/test/sdk.test.ts index d27011f0e416..142c3f4ff456 100644 --- a/packages/elysia/test/sdk.test.ts +++ b/packages/elysia/test/sdk.test.ts @@ -29,6 +29,7 @@ const { init, getDefaultIntegrations } = await import('../src/sdk'); describe('init', () => { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); it('sets SDK metadata to elysia', () => { @@ -108,6 +109,16 @@ describe('init', () => { expect(calledOptions.runtime.name).toBe('node'); expect(calledOptions.runtime.version).toBe(process.version); }); + + it('detects bun runtime when Bun is defined', () => { + vi.stubGlobal('Bun', { version: '1.2.3' }); + + init({ dsn: 'https://***@o0.ingest.sentry.io/0' }); + + const calledOptions = mockInitNode.mock.calls[0]![0]; + expect(calledOptions.runtime).toEqual({ name: 'bun', version: '1.2.3' }); + expect(mockApplySdkMetadata).toHaveBeenCalledWith(expect.anything(), 'elysia', ['elysia', 'bun']); + }); }); describe('getDefaultIntegrations', () => { diff --git a/packages/ember/src/utils/instrumentEmberAppInstanceForPerformance.ts b/packages/ember/src/utils/instrumentEmberAppInstanceForPerformance.ts index 3abd359bad21..d598c41fa834 100644 --- a/packages/ember/src/utils/instrumentEmberAppInstanceForPerformance.ts +++ b/packages/ember/src/utils/instrumentEmberAppInstanceForPerformance.ts @@ -7,6 +7,7 @@ import type { } from '@sentry/browser'; import { getAbsoluteUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, WINDOW } from '@sentry/browser'; import { + ROUTER_NAVIGATION_ROUTE_ID, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP, URL_FULL, @@ -68,13 +69,16 @@ export function instrumentEmberAppInstanceForPerformance( activeRootSpan = startBrowserTracingPageLoadSpan(client, { // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. name: routeInfo - ? `route:${routeInfo.name}` + ? routeInfo.name + ? `route:${routeInfo.name}` + : 'route' : hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : url || WINDOW.location.pathname, attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: routeInfo ? 'route' : 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.ember', + ...(routeInfo?.name && { [ROUTER_NAVIGATION_ROUTE_ID]: routeInfo.name }), ...(url ? _getRouteUrlAttributes(client, url, routeInfo?.params) : {}), toRoute: routeInfo?.name, }, @@ -91,9 +95,10 @@ export function instrumentEmberAppInstanceForPerformance( routerService.on('routeWillChange', (transition: Transition) => { const { fromRoute, toRoute } = getTransitionInformation(transition, routerService); + const transactionName = toRoute ? `route:${toRoute}` : 'route'; // Store this here to be used, even if the active span has ended - getCurrentScope().setTransactionName(`route:${toRoute}`); + getCurrentScope().setTransactionName(transactionName); // We want to ignore loading && error routes if (transitionIsIntermediate(transition)) { @@ -113,10 +118,11 @@ export function instrumentEmberAppInstanceForPerformance( const urlAttributes = targetUrl ? _getRouteUrlAttributes(client, targetUrl, transition.to?.params) : {}; activeRootSpan = startBrowserTracingNavigationSpan(client, { - name: `route:${toRoute}`, + name: transactionName, attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.ember', + ...(toRoute && { [ROUTER_NAVIGATION_ROUTE_ID]: toRoute }), ...urlAttributes, fromRoute, toRoute, @@ -130,9 +136,10 @@ export function instrumentEmberAppInstanceForPerformance( const url = _getLocationURL(location); if (url) { const routeInfo = _recognizeURL(routerService, url); - activeRootSpan.updateName(`route:${toRoute}`); + activeRootSpan.updateName(transactionName); activeRootSpan.setAttributes({ [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + ...(toRoute && { [ROUTER_NAVIGATION_ROUTE_ID]: toRoute }), ..._getRouteUrlAttributes(client, url, routeInfo?.params), toRoute: toRoute, }); diff --git a/packages/ember/tests/instrument-ember-app-instance-performance.test.ts b/packages/ember/tests/instrument-ember-app-instance-performance.test.ts new file mode 100644 index 000000000000..a790ac8fc74e --- /dev/null +++ b/packages/ember/tests/instrument-ember-app-instance-performance.test.ts @@ -0,0 +1,236 @@ +import type ApplicationInstance from '@ember/application/instance'; +import type Transition from '@ember/routing/transition'; +import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { getCurrentScope, SentrySpan, spanToJSON, type Client, type StartSpanOptions } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentEmberAppInstanceForPerformance } from '../src/utils/instrumentEmberAppInstanceForPerformance.ts'; + +function createRouterFixture(traceLifecycle: 'stream' | 'static' = 'stream') { + const handlers = new Map void>(); + const router = { + recognize: vi.fn<() => { name: string; params: Record } | undefined>().mockReturnValue({ + name: 'index', + params: {}, + }), + currentRouteName: undefined as string | undefined, + currentURL: '/', + on: (event: string, callback: (transition: Transition) => void) => handlers.set(event, callback), + }; + const location = { + rootURL: '/', + getURL: () => '/', + formatURL: (url: string) => url, + }; + const appInstance = { + lookup: (name: string) => (name === 'service:router' ? router : { location }), + } as unknown as ApplicationInstance; + const client = { + getOptions: () => ({ traceLifecycle }), + getDataCollectionOptions: () => ({ urlQueryParams: true }), + } as unknown as Client; + const pageloadSpan = new SentrySpan({ name: 'Pageload' }); + const navigationSpan = new SentrySpan({ name: 'Navigation' }); + const startPageloadSpan = vi.fn((_client: Client, options: StartSpanOptions) => { + pageloadSpan.updateName(options.name); + pageloadSpan.setAttributes(options.attributes ?? {}); + return pageloadSpan; + }); + const startNavigationSpan = vi.fn((_client: Client, options: StartSpanOptions) => { + navigationSpan.updateName(options.name); + navigationSpan.setAttributes(options.attributes ?? {}); + return navigationSpan; + }); + + return { + client, + router, + pageloadSpan, + navigationSpan, + startPageloadSpan, + startNavigationSpan, + instrument: (config: Parameters[2] = {}) => + instrumentEmberAppInstanceForPerformance(client, appInstance, config, startPageloadSpan, startNavigationSpan), + routeWillChange: (transition: { from?: { name: string }; to?: { name?: string; localName?: string } }) => { + const handler = handlers.get('routeWillChange'); + if (!handler) { + throw new Error('routeWillChange was not registered'); + } + handler(transition as Transition); + }, + }; +} + +describe('instrumentEmberAppInstanceForPerformance', () => { + let previousTransactionName: string | undefined; + + beforeEach(() => { + previousTransactionName = getCurrentScope().getScopeData().transactionName; + getCurrentScope().setTransactionName(undefined); + vi.stubGlobal('location', { origin: 'https://ember.example.com', pathname: '/' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + getCurrentScope().setTransactionName(previousTransactionName); + }); + + it('adds the recognized route ID to the pageload', () => { + const fixture = createRouterFixture(); + + fixture.instrument(); + + expect(fixture.startPageloadSpan).toHaveBeenCalledExactlyOnceWith(fixture.client, { + name: 'route:index', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'sentry.origin': 'auto.pageload.ember', + 'router.navigation.route.id': 'index', + 'url.path': '/', + 'url.full': 'https://ember.example.com/', + 'url.template': '/', + toRoute: 'index', + }, + }); + }); + + it.each(['stream', 'static'] as const)('falls back for an empty %s pageload name', traceLifecycle => { + const fixture = createRouterFixture(traceLifecycle); + fixture.router.recognize.mockReturnValue({ name: '', params: {} }); + + fixture.instrument(); + + expect(fixture.startPageloadSpan).toHaveBeenCalledTimes(1); + expect(spanToJSON(fixture.pageloadSpan).name).toBe('route'); + expect(spanToJSON(fixture.pageloadSpan).attributes).not.toHaveProperty('router.navigation.route.id'); + }); + + it.each([true, false])('updates the initial pageload when navigation instrumentation is %s', instrumentNavigation => { + const fixture = createRouterFixture(); + fixture.router.recognize.mockReturnValue(undefined); + fixture.instrument({ instrumentNavigation }); + expect(spanToJSON(fixture.pageloadSpan).name).toBe('Pageload'); + expect(spanToJSON(fixture.pageloadSpan).attributes).not.toHaveProperty('router.navigation.route.id'); + + fixture.routeWillChange({ to: { name: 'index' } }); + + expect(fixture.startPageloadSpan).toHaveBeenCalledTimes(1); + expect(fixture.startNavigationSpan).not.toHaveBeenCalled(); + expect(spanToJSON(fixture.pageloadSpan).name).toBe('route:index'); + expect(getCurrentScope().getScopeData().transactionName).toBe('route:index'); + expect(spanToJSON(fixture.pageloadSpan).attributes['router.navigation.route.id']).toBe('index'); + expect(spanToJSON(fixture.pageloadSpan).attributes[SENTRY_SEGMENT_NAME_SOURCE]).toBe('route'); + }); + + it('adds the destination route ID without a destination URL', () => { + const fixture = createRouterFixture(); + fixture.instrument(); + + fixture.routeWillChange({ from: { name: 'index' }, to: { name: 'tracing' } }); + + expect(getCurrentScope().getScopeData().transactionName).toBe('route:tracing'); + expect(fixture.startNavigationSpan).toHaveBeenCalledExactlyOnceWith(fixture.client, { + name: 'route:tracing', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'sentry.origin': 'auto.navigation.ember', + 'router.navigation.route.id': 'tracing', + fromRoute: 'index', + toRoute: 'tracing', + }, + }); + }); + + it('uses the current route fallback when the transition has no destination', () => { + const fixture = createRouterFixture(); + fixture.router.currentRouteName = 'tracing'; + fixture.instrument(); + + fixture.routeWillChange({ from: { name: 'index' } }); + + expect(spanToJSON(fixture.navigationSpan).attributes['router.navigation.route.id']).toBe('tracing'); + }); + + it.each([ + ['stream', undefined], + ['stream', ''], + ['static', undefined], + ['static', ''], + ] as const)('uses a navigation fallback in %s mode when the route name is %j', (traceLifecycle, currentRouteName) => { + const fixture = createRouterFixture(traceLifecycle); + fixture.router.currentRouteName = currentRouteName; + fixture.instrument(); + + fixture.routeWillChange({ from: { name: 'index' } }); + + expect(fixture.startNavigationSpan).toHaveBeenCalledExactlyOnceWith(fixture.client, { + name: 'route', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'sentry.origin': 'auto.navigation.ember', + fromRoute: 'index', + toRoute: currentRouteName, + }, + }); + expect(getCurrentScope().getScopeData().transactionName).toBe('route'); + }); + + it.each([ + ['stream', undefined], + ['stream', ''], + ['static', undefined], + ['static', ''], + ] as const)('preserves caller route ID in %s mode for %j', (traceLifecycle, currentRouteName) => { + const fixture = createRouterFixture(traceLifecycle); + fixture.router.currentRouteName = currentRouteName; + fixture.router.recognize.mockReturnValue(undefined); + fixture.instrument(); + fixture.pageloadSpan.setAttribute('router.navigation.route.id', 'caller-route'); + const updateName = vi.spyOn(fixture.pageloadSpan, 'updateName'); + const setAttributes = vi.spyOn(fixture.pageloadSpan, 'setAttributes'); + + fixture.routeWillChange({}); + + expect(updateName).toHaveBeenCalledExactlyOnceWith('route'); + expect(spanToJSON(fixture.pageloadSpan).name).toBe('route'); + expect(getCurrentScope().getScopeData().transactionName).toBe('route'); + expect(setAttributes).toHaveBeenCalledExactlyOnceWith({ + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'url.path': '/', + 'url.full': 'https://ember.example.com/', + 'url.template': '/', + toRoute: currentRouteName, + }); + expect(spanToJSON(fixture.pageloadSpan).attributes['router.navigation.route.id']).toBe('caller-route'); + }); + + it('does not create a pageload when pageload instrumentation is disabled', () => { + const fixture = createRouterFixture(); + + fixture.instrument({ instrumentPageLoad: false }); + fixture.routeWillChange({ to: { name: 'index' } }); + + expect(fixture.startPageloadSpan).not.toHaveBeenCalled(); + expect(fixture.startNavigationSpan).not.toHaveBeenCalled(); + }); + + it('does not create a navigation when navigation instrumentation is disabled', () => { + const fixture = createRouterFixture(); + fixture.instrument({ instrumentNavigation: false }); + + fixture.routeWillChange({ from: { name: 'index' }, to: { name: 'tracing' } }); + + expect(fixture.startNavigationSpan).not.toHaveBeenCalled(); + }); + + it.each(['loading', 'error'])('does not create a navigation for an intermediate %s route', localName => { + const fixture = createRouterFixture(); + fixture.instrument(); + const endPageload = vi.spyOn(fixture.pageloadSpan, 'end'); + + fixture.routeWillChange({ from: { name: 'index' }, to: { name: `tracing.${localName}`, localName } }); + + expect(endPageload).not.toHaveBeenCalled(); + expect(fixture.startNavigationSpan).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9f36e06e9ed8..e86252691726 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -61,6 +61,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, mastraIntegration, @@ -94,7 +96,6 @@ export { parameterize, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, @@ -174,6 +175,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/hono/package.json b/packages/hono/package.json index 7f74b8a8c21e..9dbaaf6647cb 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -77,7 +77,7 @@ "@sentry/conventions": "^0.23.0" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.x", + "@cloudflare/workers-types": "^4.x || ^5.x", "@hono/node-server": "^1.x || ^2.x", "@sentry/bun": "11.0.0-rc.0", "@sentry/cloudflare": "11.0.0-rc.0", diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts index 9f107d33636c..ce8cacd0d264 100644 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts @@ -6,3 +6,28 @@ export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean { return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); } + +/** + * Returns true only for requests the tunnel rewrite (see `setUpTunnelRewriteRules`) would serve. + * + * This decides whether the user's middleware is skipped, so it must never be broader than the rewrite: + * anything it matches that Next.js does not rewrite to Sentry reaches the app without middleware. + */ +export function isSentryTunnelRequest(request: Request, tunnelPath: string): boolean { + // The SDK transport only ever sends POST requests + if (request.method !== 'POST') { + return false; + } + + const url = new URL(request.url); + + if (url.pathname !== tunnelPath && url.pathname !== `${tunnelPath}/`) { + return false; + } + + // Next.js evaluates `has` conditions against the last value of a repeated query param, so every value has to qualify + return ['o', 'p'].every(key => { + const values = url.searchParams.getAll(key); + return values.length > 0 && values.every(value => /^\d+$/.test(value)); + }); +} diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 04d5ba04ae80..c9367433b123 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -9,7 +9,7 @@ import { withIsolationScope, } from '@sentry/core'; import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; -import { isPathnameUnderSentryTunnelRoute } from '../common/utils/tunnelPathnameMatch'; +import { isSentryTunnelRequest } from '../common/utils/tunnelPathnameMatch'; import type { EdgeRouteHandler } from '../edge/types'; /** @@ -35,21 +35,15 @@ export function wrapMiddlewareWithSentry( // TODO: This can never work with Turbopack, need to remove it for consistency between builds. if (tunnelRoute && typeof tunnelRoute === 'string') { const req: unknown = args[0]; - // Check if the current request matches the tunnel route - if (req instanceof Request) { - const url = new URL(req.url); - const isTunnelRequest = isPathnameUnderSentryTunnelRoute(url.pathname, tunnelRoute); - - if (isTunnelRequest) { - // Create a simple response that mimics NextResponse.next() so we don't need to import Next.js internals here - // https://github.com/vercel/next.js/blob/c12c9c1f78ad384270902f0890dc4cd341408105/packages/next/src/server/web/spec-extension/response.ts#L146 - return new Response(null, { - status: 200, - headers: { - 'x-middleware-next': '1', - }, - }) as ReturnType; - } + if (req instanceof Request && isSentryTunnelRequest(req, tunnelRoute)) { + // Create a simple response that mimics NextResponse.next() so we don't need to import Next.js internals here + // https://github.com/vercel/next.js/blob/c12c9c1f78ad384270902f0890dc4cd341408105/packages/next/src/server/web/spec-extension/response.ts#L146 + return new Response(null, { + status: 200, + headers: { + 'x-middleware-next': '1', + }, + }) as ReturnType; } } diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index ae4b0a91d84a..5d21f2653b3a 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -137,8 +137,8 @@ async function warnAboutUncoveredSourcemaps( } } -const SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\/[#@] sourceMappingURL=[^\n]+$/; -const CSS_SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\*[#@] sourceMappingURL=[^\n]+\*\/$/; +const SOURCEMAPPING_URL_COMMENT_REGEX = /(?:^|\n)\/\/[#@] sourceMappingURL=[^\s'"`*]+\s*$/; +const CSS_SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\*[#@] sourceMappingURL=[^\s*]+\s*\*\/\s*$/; /** * Strips sourceMappingURL comments from all JS/MJS/CJS/CSS files in the given directory. diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 4ec25dbf0e94..5f0990ac6829 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -22,12 +22,25 @@ export function getNextjsVersion(): string | undefined { return undefined; } +// Anchored at this package so `next` (a peer dependency) still resolves when the process cwd is not +// the Next.js project root, e.g. custom servers or monorepos started from the repo root. +let sdkRequire: NodeJS.Require; +/*! rollup-include-cjs-only */ +sdkRequire = createRequire(__filename); +/*! rollup-include-cjs-only-end */ +/*! rollup-include-esm-only */ +sdkRequire = createRequire(import.meta.url); +/*! rollup-include-esm-only-end */ + function resolveNextjsPackageJson(): string | undefined { - try { - return createRequire(`${process.cwd()}/`).resolve('next/package.json'); - } catch { - return undefined; + for (const nodeRequire of [createRequire(`${process.cwd()}/`), sdkRequire]) { + try { + return nodeRequire.resolve('next/package.json'); + } catch { + // try next + } } + return undefined; } /** diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 098f54581b58..e54e9867cc89 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -3,6 +3,13 @@ import type { Client } from '@sentry/core'; import type * as SentryCore from '@sentry/core'; import type * as SentryReact from '@sentry/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Loaded for their side effect: `setup()` re-imports these for every test, and the first of +// those imports also pays for transforming the whole `@sentry/react` graph. Charged to a test +// that ran into the 5s timeout on a loaded CI runner; charged to collection it is untimed. +// Later imports only re-evaluate an already transformed graph, which is cheap. +import '@sentry/core'; +import '@sentry/react'; +import '../../src/client/routing/appRouterRoutingInstrumentation'; import type * as AppRouterInstrumentation from '../../src/client/routing/appRouterRoutingInstrumentation'; import type { RouteManifest } from '../../src/config/manifest/types'; diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts index ebff37a09c5a..b7a81d3fc1c0 100644 --- a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -740,4 +740,108 @@ describe('stripSourceMappingURLComments', () => { expect(content).not.toContain('sourceMappingURL'); } }); + + it('does not modify minified files with a sourceMappingURL marker inside a string literal', async () => { + const filePath = path.join(tmpDir, 'chunks', 'minified.js'); + const originalContent = `const worker = 'self.onmessage = () => {};\\n//# sourceMappingURL=worker.js.map\\n'; use(worker);`; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it('does not modify files ending with a template literal containing a sourceMappingURL marker', async () => { + const filePath = path.join(tmpDir, 'chunks', 'template.js'); + const originalContent = 'const s = `line1\n//# sourceMappingURL=worker.js.map`;'; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it('strips sourceMappingURL comment from files consisting only of the comment', async () => { + const filePath = path.join(tmpDir, 'chunks', 'comment-only.js'); + await fs.promises.writeFile(filePath, '//# sourceMappingURL=comment-only.js.map'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(''); + }); + + it('strips sourceMappingURL comment with a data: URI', async () => { + const filePath = path.join(tmpDir, 'chunks', 'inline.js'); + await fs.promises.writeFile(filePath, 'var a = 1;\n//# sourceMappingURL=data:application/json;base64,eyJ2IjozfQ=='); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('var a = 1;'); + }); + + it('strips sourceMappingURL comment followed by trailing whitespace', async () => { + const filePath = path.join(tmpDir, 'chunks', 'trailing-whitespace.js'); + await fs.promises.writeFile(filePath, 'var a = 1;\n//# sourceMappingURL=trailing-whitespace.js.map \r\n'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('var a = 1;'); + }); + + it('strips sourceMappingURL comment from CSS files when it is on the same line', async () => { + const filePath = path.join(tmpDir, 'chunks', 'same-line.css'); + await fs.promises.writeFile(filePath, '.foo{color:red}/*# sourceMappingURL=same-line.css.map */\n'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('.foo{color:red}'); + }); + + it('does not strip past an earlier sourceMappingURL comment in minified CSS files', async () => { + const filePath = path.join(tmpDir, 'chunks', 'concat.css'); + await fs.promises.writeFile(filePath, '.a{}/*# sourceMappingURL=a.css.map*/.b{}/*x*/'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('.a{}/*# sourceMappingURL=a.css.map*/.b{}/*x*/'); + }); + + it('does not modify files ending with a block comment containing a sourceMappingURL marker', async () => { + const filePath = path.join(tmpDir, 'chunks', 'block-comment.js'); + const originalContent = 'var a = 1;\n/*\n//# sourceMappingURL=block-comment.js.map*/'; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it('does not strip a sourceMappingURL comment that is not at the end of the file', async () => { + const filePath = path.join(tmpDir, 'chunks', 'not-at-eof.js'); + const originalContent = '//# sourceMappingURL=not-at-eof.js.map\nvar a = 1;'; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it.fails('strips sourceMappingURL comment from JS files when it is on the same line', async () => { + const filePath = path.join(tmpDir, 'chunks', 'same-line.js'); + await fs.promises.writeFile(filePath, 'var a=1;//# sourceMappingURL=same-line.js.map'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('var a=1;'); + }); }); diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 01b5e59af28e..62632d4bc7aa 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -1,7 +1,25 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as os from 'os'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as util from '../../src/config/util'; describe('util', () => { + describe('getNextjsVersion', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the installed Next.js version', () => { + expect(util.getNextjsVersion()).toMatch(/^\d+\.\d+\.\d+/); + }); + + it('returns the installed Next.js version when cwd is not the project root', () => { + const expected = util.getNextjsVersion(); + vi.spyOn(process, 'cwd').mockReturnValue(os.tmpdir()); + + expect(util.getNextjsVersion()).toBe(expected); + }); + }); + describe('supportsProductionCompileHook', () => { describe('supported versions', () => { it('returns true for Next.js 15.4.1', () => { diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index 7d5f4029bd94..e2059853ce96 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -112,7 +112,7 @@ describe('wrapMiddlewareWithSentry', () => { const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); // Create a mock Request that matches the tunnel route - const mockRequest = new Request('https://example.com/monitoring/tunnel?o=123'); + const mockRequest = new Request('https://example.com/monitoring/tunnel?o=123&p=456', { method: 'POST' }); const result = await wrappedOriginal(mockRequest); @@ -209,17 +209,37 @@ describe('wrapMiddlewareWithSentry', () => { expect(result).toBe(mockReturnValue); }); - test('should skip processing for tunnel sub-paths under tunnelRoute', async () => { - (globalThis as any)._sentryRewritesTunnelPath = '/api/t'; + test('should skip processing for the tunnel route with a trailing slash', async () => { + (globalThis as any)._sentryRewritesTunnelPath = '/monitoring'; const origFunction: EdgeRouteHandler = vi.fn(async () => ({ status: 200 })); const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); - const mockRequest = new Request('https://example.com/api/t/envelope?o=1'); + await wrappedOriginal(new Request('https://example.com/monitoring/?o=123&p=456&r=us', { method: 'POST' })); + + expect(origFunction).not.toHaveBeenCalled(); + }); + + test.each([ + ['a sub-path of the tunnel route', 'https://example.com/monitoring/anything/at/all?o=123&p=456', 'POST'], + ['a tunnel request without query params', 'https://example.com/monitoring', 'POST'], + ['a tunnel request without project id', 'https://example.com/monitoring?o=123', 'POST'], + ['a tunnel request with non-numeric ids', 'https://example.com/monitoring?o=abc&p=456', 'POST'], + ['a tunnel request with a repeated non-numeric org id', 'https://example.com/monitoring?o=123&o=abc&p=456', 'POST'], + ['a tunnel request with a repeated empty project id', 'https://example.com/monitoring?o=123&p=456&p=', 'POST'], + ['a non-POST tunnel request', 'https://example.com/monitoring?o=123&p=456', 'GET'], + ])('should run the middleware for %s', async (_, url, method) => { + (globalThis as any)._sentryRewritesTunnelPath = '/monitoring'; + + const mockReturnValue = { status: 200 }; + const origFunction: EdgeRouteHandler = vi.fn(async (..._args) => mockReturnValue); + const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); + + const mockRequest = new Request(url, { method }); const result = await wrappedOriginal(mockRequest); - expect(origFunction).not.toHaveBeenCalled(); - expect(result).toBeDefined(); + expect(origFunction).toHaveBeenCalledWith(mockRequest); + expect(result).toBe(mockReturnValue); }); }); diff --git a/packages/node/src/eve.ts b/packages/node/src/eve.ts new file mode 100644 index 000000000000..4e1d93f20e71 --- /dev/null +++ b/packages/node/src/eve.ts @@ -0,0 +1,78 @@ +import { type Integration, setConversationId } from '@sentry/core'; +import { eveIntegration } from '@sentry/server-utils'; +import { init } from './sdk'; +import type { NodeOptions } from './types'; + +/** + * Options for {@link eveInstrumentation}: every `Sentry.init` option, plus how to derive the + * conversation id. + */ +export interface EveInstrumentationOptions extends NodeOptions { + /** + * Derive the Sentry conversation id from the eve session. Defaults to the durable session id + * (`session.id`), which is stable across every turn of a session and so groups them into one + * conversation. Return `null`/`undefined` to skip setting it for a turn. + */ + getConversationId?: (context: { session: { id: string } }) => string | null | undefined; +} + +/** + * The `defineInstrumentation` provider shape this returns. Typed structurally rather than importing + * from `eve`, so the SDK carries no dependency on the framework — the shape is checked at the + * `defineInstrumentation(...)` call site in the user's app instead. + */ +interface EveInstrumentationProvider { + setup: () => void; + events: { + 'turn.started': (event: { sessionId: string }) => void; + 'step.attempt.started': (event: { scope: { sessionId: string } }) => void; + }; +} + +/** + * All-in-one Sentry setup for an [eve](https://eve.dev) agent, wired into a single + * `agent/instrumentation/*.ts` provider file. + * + * @example + * ```ts + * // agent/instrumentation/sentry.ts + * import * as Sentry from '@sentry/node'; + * import { defineInstrumentation } from 'eve/instrumentation'; + * + * export default defineInstrumentation(Sentry.eveInstrumentation({ dsn: '__DSN__' })); + * ``` + * + * It: + * - runs `Sentry.init` with the given options at server startup (`setup`), adding + * {@link eveIntegration} so gen_ai inputs/outputs are recorded by default (eve stamps every AI SDK + * call with `recordInputs`/`recordOutputs: false`); + * - tags each turn's AI spans with a Sentry conversation id via `turn.started` / `step.attempt.started` + * events — the instrumentation-provider equivalent of `eveConversationHook`. Provider event handlers + * receive the session id on the event (`event.sessionId` / `event.scope.sessionId`), unlike hook + * handlers, which read it from their context. + */ +export function eveInstrumentation(options: EveInstrumentationOptions = {}): EveInstrumentationProvider { + const { getConversationId, ...initOptions } = options; + + const setConversationIdFromSession = (sessionId: string): void => { + setConversationId(getConversationId ? getConversationId({ session: { id: sessionId } }) : sessionId); + }; + + return { + setup() { + init({ ...initOptions, integrations: withEveIntegration(initOptions.integrations) }); + }, + events: { + 'turn.started': event => setConversationIdFromSession(event.sessionId), + 'step.attempt.started': event => setConversationIdFromSession(event.scope.sessionId), + }, + }; +} + +function withEveIntegration(integrations: NodeOptions['integrations']): NodeOptions['integrations'] { + const eve = eveIntegration(); + if (typeof integrations === 'function') { + return (defaults: Integration[]) => [...integrations(defaults), eve]; + } + return [...(integrations ?? []), eve]; +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3754f8408f86..c4f2edc27610 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -13,6 +13,7 @@ export { genericPoolIntegration, googleGenAIIntegration, graphqlIntegration, + groqIntegration, hapiIntegration, kafkaIntegration, knexIntegration, @@ -29,6 +30,7 @@ export { mistralAIIntegration, mysql2Integration, openAIIntegration, + togetherAIIntegration, postgresIntegration, postgresJsIntegration, redisIntegration, @@ -218,8 +220,8 @@ export { defaultStackParser, getSentryRelease } from './sdk/api'; export { makeNodeTransport } from './transports'; export { createGetModuleFromFilename } from './utils/module'; -export { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; -export { eveConversationHook, getInstrumentedModuleNames } from '@sentry/server-utils'; +export { eveConversationHook, eveIntegration, getInstrumentedModuleNames } from '@sentry/server-utils'; +export { eveInstrumentation } from './eve'; export { httpServerIntegration } from './integrations/http/httpServerIntegration'; export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration'; export { processSessionIntegration } from './integrations/processSession'; diff --git a/packages/node/src/integrations/node-fetch/types.ts b/packages/node/src/integrations/node-fetch/types.ts index 4a5d38c5bc86..3f0e6a477537 100644 --- a/packages/node/src/integrations/node-fetch/types.ts +++ b/packages/node/src/integrations/node-fetch/types.ts @@ -87,7 +87,16 @@ export interface UndiciInstrumentationConfig; /** Function called once response headers have been received */ responseHook?: ResponseHookFunction; - /** Map the following HTTP headers to span attributes. */ + /** + * Capture the listed HTTP headers as span attributes + * (`http.request.header.` / `http.response.header.`). + * + * Privacy filtering still applies to every header listed here. A header keeps its value only if + * `dataCollection.httpHeaders` permits it: + * - Sensitive names (`authorization`, `cookie`, ...) always show up as `[Filtered]`. + * - Names on the `deny` list show up as `[Filtered]`. + * - If an `allow` list is configured, a header must appear there as well, or it shows up as `[Filtered]`. + */ headersToSpanAttributes?: { requestHeaders?: string[]; responseHeaders?: string[]; diff --git a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts index 62fc4992ef4f..89eed4800d46 100644 --- a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts +++ b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts @@ -40,6 +40,7 @@ import { getUrlQuery, filterCollectedUrl, filterCollectedUrlQuery, + httpHeadersToSpanAttributes, } from '@sentry/core'; import { addFetchRequestBreadcrumb, addTracePropagationHeadersToFetchRequest } from '../../utils/outgoingFetchRequest'; import { @@ -312,16 +313,21 @@ function onRequestHeaders(config: NodeFetchOptions, { request, socket }: Request // After hooks have been processed (which may modify request headers) // we can collect the headers based on the configuration - if (config.headersToSpanAttributes?.requestHeaders) { + const client = getClient(); + if (config.headersToSpanAttributes?.requestHeaders && client) { const headersToAttribs = new Set(config.headersToSpanAttributes.requestHeaders.map(n => n.toLowerCase())); const headersMap = parseRequestHeaders(request); + const allowlisted: Record = {}; for (const [name, value] of headersMap.entries()) { if (headersToAttribs.has(name)) { - const attrValue = Array.isArray(value) ? value : [value]; - spanAttributes[`http.request.header.${name}`] = attrValue; + allowlisted[name] = value; } } + + // An entry in `headersToSpanAttributes` does not exempt a header from the `dataCollection` + // filtering, so the allowlisted subset goes through the same pipeline as any other header. + Object.assign(spanAttributes, httpHeadersToSpanAttributes(allowlisted, client.getDataCollectionOptions())); } span.setAttributes(spanAttributes); @@ -354,10 +360,12 @@ function onResponseHeaders(config: NodeFetchOptions, { request, response }: Resp () => undefined, ); - if (config.headersToSpanAttributes?.responseHeaders) { + const client = getClient(); + if (config.headersToSpanAttributes?.responseHeaders && client) { const headersToAttribs = new Set(); config.headersToSpanAttributes?.responseHeaders.forEach(name => headersToAttribs.add(name.toLowerCase())); + const allowlisted: Record = {}; for (let idx = 0; idx < response.headers.length; idx = idx + 2) { const nameBuf = response.headers[idx]; const valueBuf = response.headers[idx + 1]; @@ -365,17 +373,18 @@ function onResponseHeaders(config: NodeFetchOptions, { request, response }: Resp continue; } const name = nameBuf.toString().toLowerCase(); - const value = valueBuf; if (headersToAttribs.has(name)) { - const attrName = `http.response.header.${name}`; - if (!Object.prototype.hasOwnProperty.call(spanAttributes, attrName)) { - spanAttributes[attrName] = [value.toString()]; - } else { - (spanAttributes[attrName] as string[]).push(value.toString()); - } + (allowlisted[name] ??= []).push(valueBuf.toString()); } } + + // An entry in `headersToSpanAttributes` does not exempt a header from the `dataCollection` + // filtering, so the allowlisted subset goes through the same pipeline as any other header. + Object.assign( + spanAttributes, + httpHeadersToSpanAttributes(allowlisted, client.getDataCollectionOptions(), 'response'), + ); } span.setAttributes(spanAttributes); diff --git a/packages/node/test/eve.test.ts b/packages/node/test/eve.test.ts new file mode 100644 index 000000000000..11176944a0a5 --- /dev/null +++ b/packages/node/test/eve.test.ts @@ -0,0 +1,42 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { eveInstrumentation } from '../src/eve'; + +describe('eveInstrumentation', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('exposes a setup and turn.started / step.attempt.started events', () => { + const provider = eveInstrumentation(); + + expect(typeof provider.setup).toBe('function'); + expect(Object.keys(provider.events).sort()).toEqual(['step.attempt.started', 'turn.started']); + }); + + test('sets the session id as the conversation id from turn.started (event.sessionId)', () => { + const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined); + + eveInstrumentation().events['turn.started']({ sessionId: 'sess_abc' }); + + expect(setConversationId).toHaveBeenCalledWith('sess_abc'); + }); + + test('sets it from step.attempt.started (event.scope.sessionId) too', () => { + const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined); + + eveInstrumentation().events['step.attempt.started']({ scope: { sessionId: 'sess_resumed' } }); + + expect(setConversationId).toHaveBeenCalledWith('sess_resumed'); + }); + + test('honors a custom getConversationId', () => { + const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined); + + eveInstrumentation({ getConversationId: context => `conv-${context.session.id}` }).events['turn.started']({ + sessionId: 'xyz', + }); + + expect(setConversationId).toHaveBeenCalledWith('conv-xyz'); + }); +}); diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index 50d5a61a2828..6d8adaf2ea77 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -1,9 +1,27 @@ import { captureException, getClient, getCurrentScope } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { H3Error } from 'h3'; import type { CapturedErrorContext } from 'nitropack/types'; -import { extractErrorContext } from '../utils'; +import { extractErrorContext, getEventRequestInfo } from '../utils'; + +/** + * Returns the status code of an error thrown by h3, or `undefined` for any other error. + * + * Mirrors each h3 major's own `isError` instead of importing h3: an `h3` import puts this module + * behind Nuxt 5's transitional Nitro v2 compatibility layer, and `nitro/h3` does not resolve on Nuxt 3/4. + * h3 v2 (Nitro v3) recognizes its errors by name and stores the code on + * `status`, h3 v1 (Nitro v2) by a static flag on the class and on `statusCode`. + */ +function getH3ErrorStatusCode(error: Error): number | undefined { + const isH3Error = + error.name === 'HTTPError' || (error.constructor as { __h3_error__?: boolean } | undefined)?.__h3_error__ === true; + + if (!isH3Error) { + return undefined; + } + + const { status, statusCode } = error as { status?: number; statusCode?: number }; + return status ?? statusCode; +} /** * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. @@ -20,10 +38,12 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture return; } + const statusCode = getH3ErrorStatusCode(error); + // Do not handle 404 and 422 - if (error instanceof H3Error) { + if (statusCode !== undefined) { // Do not report if status code is 3xx or 4xx - if (error.statusCode >= 300 && error.statusCode < 500) { + if (statusCode >= 300 && statusCode < 500) { return; } @@ -39,10 +59,7 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture } } - const { method, path } = { - method: errorContext.event?._method ? errorContext.event._method : '', - path: errorContext.event?._path ? errorContext.event._path : null, - }; + const { method = '', path } = getEventRequestInfo(errorContext.event); if (path) { getCurrentScope().setTransactionName(`${method} ${path}`); diff --git a/packages/nuxt/src/runtime/plugins/update-route-name.server.ts b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts index 72e3d9452e7e..7774e3610ba3 100644 --- a/packages/nuxt/src/runtime/plugins/update-route-name.server.ts +++ b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts @@ -1,6 +1,6 @@ import type { NitroAppPlugin } from 'nitro/types'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; -import type { H3Event } from 'h3'; +import type { H3Event } from 'nitro/h3'; export default (nitroApp => { // @ts-expect-error Hook in Nuxt 5 (Nitro 3) is called 'response' https://nitro.build/docs/plugins#available-hooks diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 5a8e9c3db701..becb6ef55d19 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,9 +1,30 @@ import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; -import { captureException, debug, getClient, getTraceMetaTags } from '@sentry/core'; +import { captureException, debug, getClient, getTraceMetaTags, isObjectLike } from '@sentry/core'; import type { CapturedErrorContext } from 'nitropack/types'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import type { ComponentPublicInstance } from 'vue'; +/** + * Reads the request method and path off the event Nitro passes to its `error` hook. + * + * h3 v1 (Nitro v2) exposes `method` and `path` getters. h3 v2 (Nitro v3) has neither: the method lives + * on the web `Request` in `req`, and the path on the parsed `url`. + */ +export function getEventRequestInfo(event: unknown): { method?: string; path?: string } { + if (!isObjectLike(event)) { + return {}; + } + + const { method, path, req, url } = event as { + method?: string; + path?: string; + req?: { method?: string }; + url?: { pathname?: string }; + }; + + return { method: method ?? req?.method, path: path ?? url?.pathname }; +} + /** * Extracts the relevant context information from the error context (H3Event in Nitro Error) * and created a structured context object. @@ -16,8 +37,9 @@ export function extractErrorContext(errorContext: CapturedErrorContext | undefin } if (errorContext.event) { - ctx.method = errorContext.event._method; - ctx.path = errorContext.event._path; + const { method, path } = getEventRequestInfo(errorContext.event); + ctx.method = method; + ctx.path = path; } if (Array.isArray(errorContext.tags)) { diff --git a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts index 8e166a5ff4cc..bcf7f01690be 100644 --- a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts +++ b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts @@ -1,19 +1,20 @@ import * as SentryCore from '@sentry/core'; import * as SentryCoreServer from '@sentry/core/server'; import { H3Error } from 'h3'; +import { HTTPError } from 'nitro/h3'; import type { CapturedErrorContext } from 'nitropack/types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sentryCaptureErrorHook } from '../../../src/runtime/hooks/captureErrorHook'; +const setTransactionName = vi.fn(); + vi.mock('@sentry/core', async importOriginal => { const mod = await importOriginal(); return { ...(mod as any), captureException: vi.fn(), getClient: vi.fn(), - getCurrentScope: vi.fn(() => ({ - setTransactionName: vi.fn(), - })), + getCurrentScope: vi.fn(() => ({ setTransactionName })), }; }); @@ -25,17 +26,38 @@ vi.mock('@sentry/core/server', async importOriginal => { }; }); -vi.mock('../../../src/runtime/utils', () => ({ +vi.mock('../../../src/runtime/utils', async importOriginal => ({ + ...(await importOriginal()), extractErrorContext: vi.fn(() => ({ test: 'context' })), })); -describe('sentryCaptureErrorHook', () => { - const mockErrorContext: CapturedErrorContext = { - event: { - _method: 'GET', - _path: '/test-path', - } as any, - }; +// Nuxt 3/4 run Nitro v2 on h3 v1, Nuxt 5 runs Nitro v3 on h3 v2. The two majors differ in both the +// error class the hook sees and the shape of the event it reads the request from. +const h3Majors = [ + { + name: 'h3 v1 (Nitro v2)', + httpError: (message: string, statusCode: number): Error => { + const error = new H3Error(message); + error.statusCode = statusCode; + return error; + }, + event: { method: 'GET', path: '/test-path' }, + }, + { + name: 'h3 v2 (Nitro v3)', + httpError: (message: string, statusCode: number): Error => new HTTPError({ message, status: statusCode }), + event: { req: new Request('http://localhost/test-path'), url: new URL('http://localhost/test-path') }, + }, +]; + +// The two classes disagree on what the constructor puts on `cause` (h3 v2 stores the whole details +// object), so it is set directly: what is under test is how the hook reads `cause`, not h3. +function withCause(error: Error, cause: unknown): Error { + return Object.defineProperty(error, 'cause', { value: cause, configurable: true }); +} + +describe.each(h3Majors)('sentryCaptureErrorHook - $name', ({ httpError, event }) => { + const mockErrorContext = { event } as unknown as CapturedErrorContext; beforeEach(() => { vi.clearAllMocks(); @@ -58,27 +80,26 @@ describe('sentryCaptureErrorHook', () => { ); }); - it('should skip H3Error with 4xx status codes', async () => { - const error = new H3Error('Not found'); - error.statusCode = 404; + it('sets the transaction name from the request method and path', async () => { + await sentryCaptureErrorHook(new Error('Test error'), mockErrorContext); - await sentryCaptureErrorHook(error, mockErrorContext); + expect(setTransactionName).toHaveBeenCalledWith('GET /test-path'); + }); + + it('should skip HTTP errors with 4xx status codes', async () => { + await sentryCaptureErrorHook(httpError('Not found', 404), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should skip H3Error with 3xx status codes', async () => { - const error = new H3Error('Redirect'); - error.statusCode = 302; - - await sentryCaptureErrorHook(error, mockErrorContext); + it('should skip HTTP errors with 3xx status codes', async () => { + await sentryCaptureErrorHook(httpError('Redirect', 302), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should capture H3Error with 5xx status codes', async () => { - const error = new H3Error('Server error'); - error.statusCode = 500; + it('should capture HTTP errors with 5xx status codes', async () => { + const error = httpError('Server error', 500); await sentryCaptureErrorHook(error, mockErrorContext); @@ -90,7 +111,7 @@ describe('sentryCaptureErrorHook', () => { ); }); - it('should skip H3Error when cause has __sentry_captured__ flag', async () => { + it('should skip HTTP errors when cause has __sentry_captured__ flag', async () => { const originalError = new Error('Original error'); // Mark the original error as already captured by middleware Object.defineProperty(originalError, '__sentry_captured__', { @@ -98,51 +119,44 @@ describe('sentryCaptureErrorHook', () => { enumerable: false, }); - const h3Error = new H3Error('Wrapped error', { cause: originalError }); - h3Error.statusCode = 500; - - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(withCause(httpError('Wrapped error', 500), originalError), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should capture H3Error when cause does not have __sentry_captured__ flag', async () => { - const originalError = new Error('Original error'); - const h3Error = new H3Error('Wrapped error', { cause: originalError }); - h3Error.statusCode = 500; + it('should capture HTTP errors when cause does not have __sentry_captured__ flag', async () => { + const error = withCause(httpError('Wrapped error', 500), new Error('Original error')); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), ); }); - it('should capture H3Error when cause is not an object', async () => { - const h3Error = new H3Error('Error with string cause', { cause: 'string cause' }); - h3Error.statusCode = 500; + it('should capture HTTP errors when cause is not an object', async () => { + const error = withCause(httpError('Error with string cause', 500), 'string cause'); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), ); }); - it('should capture H3Error when there is no cause', async () => { - const h3Error = new H3Error('Error without cause'); - h3Error.statusCode = 500; + it('should capture HTTP errors when there is no cause', async () => { + const error = httpError('Error without cause', 500); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), @@ -154,10 +168,35 @@ describe('sentryCaptureErrorHook', () => { getOptions: () => ({ enableNitroErrorHandler: false }), }); - const error = new Error('Test error'); - - await sentryCaptureErrorHook(error, mockErrorContext); + await sentryCaptureErrorHook(new Error('Test error'), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); }); + +describe('sentryCaptureErrorHook - errors that only look like h3 errors', () => { + beforeEach(() => { + vi.clearAllMocks(); + (SentryCore.getClient as any).mockReturnValue({ getOptions: () => ({}) }); + }); + + it('still reports a plain error that carries a 4xx `statusCode`', async () => { + const error = Object.assign(new Error('Upstream API returned 404'), { statusCode: 404 }); + + await sentryCaptureErrorHook(error, {} as CapturedErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.anything()); + }); + + it('still reports a third-party `HTTPError` whose status lives on `response`', async () => { + // The packages "ky" and "got" name their errors `HTTPError` but keep the status on `response`, not on the error + const error = Object.assign(new Error('Request failed with status code 404'), { + name: 'HTTPError', + response: { status: 404 }, + }); + + await sentryCaptureErrorHook(error, {} as CapturedErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.anything()); + }); +}); diff --git a/packages/nuxt/test/runtime/utils.test.ts b/packages/nuxt/test/runtime/utils.test.ts index fe1ebd94fdf3..e930f63afde0 100644 --- a/packages/nuxt/test/runtime/utils.test.ts +++ b/packages/nuxt/test/runtime/utils.test.ts @@ -14,8 +14,8 @@ describe('extractErrorContext', () => { it('extracts properties from errorContext and drops them if missing', () => { const context = { event: { - _method: 'GET', - _path: '/test', + method: 'GET', + path: '/test', }, tags: ['tag1', 'tag2'], }; @@ -29,7 +29,7 @@ describe('extractErrorContext', () => { const partialContext = { event: { - _path: '/test', + path: '/test', }, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -37,6 +37,18 @@ describe('extractErrorContext', () => { expect(extractErrorContext(partialContext)).toEqual({ path: '/test' }); }); + it('reads method and path from an h3 v2 (Nitro v3) event, which has no `method`/`path` getters', () => { + const context = { + event: { + req: new Request('http://localhost/test?query=1', { method: 'POST' }), + url: new URL('http://localhost/test?query=1'), + }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(extractErrorContext(context)).toEqual({ method: 'POST', path: '/test' }); + }); + it('handles errorContext.tags correctly, including when absent or of unexpected type', () => { const contextWithTags = { tags: ['tag1', 'tag2'], diff --git a/packages/nuxt/test/vite/orchestrion.test.ts b/packages/nuxt/test/vite/orchestrion.test.ts index 544de8c4ba60..9461699412b6 100644 --- a/packages/nuxt/test/vite/orchestrion.test.ts +++ b/packages/nuxt/test/vite/orchestrion.test.ts @@ -21,14 +21,18 @@ function createMockNuxt(options: { _prepare?: boolean; dev?: boolean } = {}) { } describe('setupOrchestrion', () => { - beforeAll(() => { + beforeAll(async () => { vi.doMock('@sentry/server-utils/orchestrion/config', () => ({ INSTRUMENTED_MODULE_NAMES: ['mysql', 'ioredis'], })); vi.doMock('@sentry/server-utils/orchestrion/rollup', () => ({ sentryOrchestrionPlugin: mockSentryOrchestrionPlugin, })); - }); + // The module reaches `@sentry/core` and `@nuxt/kit` through `./utils`. Transforming those + // charged the first test, which timed out on slower CI runners. The tests never reset the + // module registry, so this one evaluation is the one they all reuse. + await import('../../src/vite/orchestrion'); + }, 60_000); afterAll(() => { vi.doUnmock('@sentry/server-utils/orchestrion/config'); diff --git a/packages/remix/package.json b/packages/remix/package.json index 552c740c04e5..556a64501145 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -58,6 +58,7 @@ }, "dependencies": { "@remix-run/router": "^1.23.4", + "@sentry/bundler-plugins": "10.67.0", "@sentry/conventions": "^0.23.0", "@sentry/core": "11.0.0-rc.0", "@sentry/node": "11.0.0-rc.0", diff --git a/packages/remix/scripts/sentry-upload-sourcemaps.js b/packages/remix/scripts/sentry-upload-sourcemaps.js index a5394e23b69c..28d705fc7e68 100755 --- a/packages/remix/scripts/sentry-upload-sourcemaps.js +++ b/packages/remix/scripts/sentry-upload-sourcemaps.js @@ -63,6 +63,9 @@ const argv = yargs(process.argv.slice(2)) ' [--deleteAfterUpload true|false]\n\n' + 'This CLI tool will upload sourcemaps to Sentry for the given release.\n' + 'It has defaults for URL prefix and build path for Remix builds, but you can override them.\n\n' + + 'Vite-based Remix apps should use `sentryRemixVitePlugin()` from `@sentry/remix/vite` instead,\n' + + 'which injects debug IDs and uploads source maps as part of the build. Running both injects a\n' + + 'second debug ID per chunk, which breaks source map resolution.\n\n' + 'If you need a more advanced configuration, you can use `sentry-cli` instead.\n' + 'https://github.com/getsentry/sentry-cli', ) diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index 3436abae61f2..543dd239a67e 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -108,7 +108,6 @@ export { zodErrorsIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, spanToStaticSpanJSON, spanToJSON, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index f89345ae2de5..f019b947d1ab 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -85,7 +85,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setCurrentClient, setExtra, diff --git a/packages/remix/src/vite/index.ts b/packages/remix/src/vite/index.ts index 3689266f2f44..b38abdda0c80 100644 --- a/packages/remix/src/vite/index.ts +++ b/packages/remix/src/vite/index.ts @@ -1,6 +1,7 @@ import type { Plugin } from 'vite'; import { makeOrchestrionPlugin } from './orchestrionPlugin'; import { makeRouteManifestPlugin } from './routeManifestPlugin'; +import { makeAddSentryVitePlugin, makeEnableSourceMapsPlugin } from './sourceMaps'; import type { SentryRemixVitePluginOptions } from './types'; export type { SentryRemixVitePluginOptions }; @@ -9,8 +10,9 @@ export type { SentryRemixVitePluginOptions }; * Sentry Vite plugins for Remix. * * Add these to your Vite configuration to - * - inject the Remix route manifest, so client-side transactions are parameterized, and - * - build-time instrument supported server-side dependencies (such as database clients). + * - inject the Remix route manifest, so client-side transactions are parameterized, + * - build-time instrument supported server-side dependencies (such as database clients), and + * - inject debug IDs and upload source maps to Sentry. * * @example * ```typescript @@ -24,11 +26,34 @@ export type { SentryRemixVitePluginOptions }; * remix(), * sentryRemixVitePlugin({ * appDirPath: './app', + * org: 'your-org', + * project: 'your-project', + * authToken: process.env.SENTRY_AUTH_TOKEN, * }), * ], * }); * ``` */ export function sentryRemixVitePlugin(options: SentryRemixVitePluginOptions = {}): Plugin[] { - return [makeRouteManifestPlugin(options), makeOrchestrionPlugin(options)]; + const plugins: Plugin[] = [makeRouteManifestPlugin(options), makeOrchestrionPlugin(options)]; + + // Uploading from the dev server would create a new set of artifacts on every restart. + if (process.env.NODE_ENV === 'development') { + return plugins; + } + + // Added even when source maps are disabled: the bundler plugin also applies bundle size + // optimizations, module metadata, the application key and release management, and it already + // skips the upload itself. + // + // Order matters: Vite passes the already-merged config to every `config` hook, so the deletion + // plugin has to read `build.sourcemap` before `makeEnableSourceMapsPlugin` sets it to 'hidden'. + plugins.push(...makeAddSentryVitePlugin(options)); + + // `'disable-upload'` still generates them - debug IDs are injected and the user uploads by hand. + if (options.sourcemaps?.disable !== true) { + plugins.push(makeEnableSourceMapsPlugin(options)); + } + + return plugins; } diff --git a/packages/remix/src/vite/sourceMaps.ts b/packages/remix/src/vite/sourceMaps.ts new file mode 100644 index 000000000000..e53860f39c28 --- /dev/null +++ b/packages/remix/src/vite/sourceMaps.ts @@ -0,0 +1,189 @@ +import * as path from 'path'; +import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; +import type { Plugin, UserConfig } from 'vite'; +import type { SentryRemixVitePluginOptions } from './types'; + +type FilesToDeleteAfterUpload = string | string[] | undefined; + +/** + * Adds the Sentry bundler plugin, which injects debug IDs and uploads source maps to Sentry. + */ +export function makeAddSentryVitePlugin(options: SentryRemixVitePluginOptions): Plugin[] { + const { + applicationKey, + authToken, + bundleSizeOptimizations, + debug, + errorHandler, + headers, + moduleMetadata, + org, + project, + release, + sentryUrl, + silent, + sourcemaps, + telemetry, + } = options; + + // The default depends on `build.sourcemap`, which is only known once Vite resolves the config. + let resolveFilesToDeleteAfterUpload: ((value: FilesToDeleteAfterUpload) => void) | undefined; + const filesToDeleteAfterUploadPromise = new Promise(resolve => { + resolveFilesToDeleteAfterUpload = resolve; + }); + + const configPlugin: Plugin = { + name: 'sentry-remix-files-to-delete-after-upload', + apply: 'build', + enforce: 'post', + config(config) { + const userFilesToDelete = sourcemaps?.filesToDeleteAfterUpload; + + // Only clean up after ourselves: if the user asked for source maps, they are theirs to keep. + // Scoped to the build output rather than `./**/*.map`, which the bundler plugin globs without + // ignoring `node_modules` and then deletes with `force: true`. + // + // `disable: 'disable-upload'` injects debug IDs but leaves uploading to the user, and the + // bundler plugin deletes in a `finally` block even when it skipped the upload - so defaulting + // the deletion there would remove the very maps they still have to upload by hand. + if ( + typeof userFilesToDelete === 'undefined' && + typeof config.build?.sourcemap === 'undefined' && + !sourcemaps?.disable + ) { + // Derived from the configured `outDir` rather than hardcoding `build`, so a custom Remix + // `buildDirectory` still gets cleaned up. Remix runs a client and an SSR build, each with + // its own `outDir`, so each pass deletes only what it emitted. + // + // Separators are normalised because `glob` reads a backslash as an escape, and both path + // flavours are checked because `path.isAbsolute` only recognises the host platform's form. + // Only a relative path may take the `./` prefix: `.//abs/path/**/*.map` matches nothing. + const outDir = (config.build?.outDir || 'build').replace(/\\/g, '/').replace(/\/+$/, ''); + const isAbsoluteOutDir = path.posix.isAbsolute(outDir) || path.win32.isAbsolute(outDir); + const outDirGlob = isAbsoluteOutDir ? outDir : `./${outDir.replace(/^\.\//, '')}`; + const filesToDelete = [`${outDirGlob}/**/*.map`]; + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify( + filesToDelete, + )}\` to delete generated source maps after they were uploaded to Sentry.`, + ); + } + resolveFilesToDeleteAfterUpload?.(filesToDelete); + } else { + resolveFilesToDeleteAfterUpload?.(userFilesToDelete); + } + }, + }; + + const sentryPlugins = sentryVitePlugin({ + applicationKey, + authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN, + bundleSizeOptimizations, + debug: debug ?? false, + errorHandler, + headers, + moduleMetadata, + org: org ?? process.env.SENTRY_ORG, + project: project ?? process.env.SENTRY_PROJECT, + release, + silent, + sourcemaps: { + assets: sourcemaps?.assets, + disable: sourcemaps?.disable, + ignore: sourcemaps?.ignore, + rewriteSources: sourcemaps?.rewriteSources, + resolveSourceMap: sourcemaps?.resolveSourceMap, + filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, + }, + telemetry: telemetry ?? true, + url: sentryUrl, + _metaOptions: { + telemetry: { + metaFramework: 'remix', + }, + }, + }); + + return [configPlugin, ...sentryPlugins]; +} + +/** + * Enables "hidden" source maps if the user did not configure them. + */ +export function makeEnableSourceMapsPlugin(options: SentryRemixVitePluginOptions): Plugin { + return { + name: 'sentry-remix-update-source-map-setting', + apply: 'build', + enforce: 'post', + // Returning only the changed key: Vite concatenates arrays when merging a `config` return + // value, so echoing the whole config back duplicates `ssr.noExternal`, `resolve.alias` etc. + config(viteConfig) { + return { + build: { + sourcemap: getUpdatedSourceMapSettings(viteConfig, options), + }, + }; + }, + }; +} + +/** There are 3 ways to set up source map generation (https://github.com/getsentry/sentry-javascript/issues/13993) + * + * 1. User explicitly disabled source maps + * - keep this setting (emit a warning that errors won't be unminified in Sentry) + * - we won't upload anything + * + * 2. Users enabled source map generation (true, 'hidden', 'inline'). + * - keep this setting (don't do anything - like deletion - besides uploading) + * + * 3. Users didn't set source maps generation + * - we enable 'hidden' source maps generation + * - configure `filesToDeleteAfterUpload` to delete all .map files (we emit a log about this) + * + * --> only exported for testing + */ +export function getUpdatedSourceMapSettings( + viteConfig: UserConfig, + sentryPluginOptions?: SentryRemixVitePluginOptions, +): boolean | 'inline' | 'hidden' { + const viteUserSourceMapSetting = viteConfig.build?.sourcemap; + const settingKey = 'vite.build.sourcemap'; + const debug = sentryPluginOptions?.debug; + + if (viteUserSourceMapSetting === false) { + if (debug) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] Source map generation is currently disabled in your Vite configuration (\`${settingKey}: false\`). Sentry won't override this setting. Without source maps, code snippets on the Sentry Issues page will remain minified.`, + ); + } else { + // eslint-disable-next-line no-console + console.warn('[Sentry] Source map generation is disabled in your Vite configuration.'); + } + + return viteUserSourceMapSetting; + } + + if (viteUserSourceMapSetting && ['hidden', 'inline', true].includes(viteUserSourceMapSetting)) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] We discovered \`${settingKey}\` is set to \`${viteUserSourceMapSetting.toString()}\`. Sentry will keep this source map setting.`, + ); + } + + return viteUserSourceMapSetting; + } + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] Enabled source map generation in the build options with \`${settingKey}: 'hidden'\`. The source maps will be deleted after they were uploaded to Sentry.`, + ); + } + + return 'hidden'; +} diff --git a/packages/remix/src/vite/types.ts b/packages/remix/src/vite/types.ts index cf12a0fb3372..45a8ac76f572 100644 --- a/packages/remix/src/vite/types.ts +++ b/packages/remix/src/vite/types.ts @@ -1,4 +1,6 @@ -export type SentryRemixVitePluginOptions = { +import type { BuildTimeOptionsBase } from '@sentry/core'; + +export type SentryRemixVitePluginOptions = BuildTimeOptionsBase & { /** * Path to the app directory (where routes folder is located). * Can be relative to project root or absolute. @@ -8,15 +10,4 @@ export type SentryRemixVitePluginOptions = { * @example '/absolute/path/to/app' */ appDirPath?: string; - - /** - * Build-time instrumentation of server-side dependencies (e.g. `mysql`, `ioredis`, - * `@remix-run/server-runtime`): the plugin injects `diagnostics_channel` publishers into the - * bundled SSR output, so the SDK traces them without monkey-patching. - * - * Set to `false` to opt out. - * - * @default true - */ - buildTimeInstrumentation?: boolean; }; diff --git a/packages/remix/test/vite/index.test.ts b/packages/remix/test/vite/index.test.ts index efaff6ddda83..c105a19789df 100644 --- a/packages/remix/test/vite/index.test.ts +++ b/packages/remix/test/vite/index.test.ts @@ -26,6 +26,27 @@ vi.mock('@sentry/server-utils/orchestrion/vite', () => ({ sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options), })); +type CapturedSentryOptions = { + applicationKey?: string; + bundleSizeOptimizations?: { excludeTracing?: boolean }; + sourcemaps?: { filesToDeleteAfterUpload?: Promise }; +}; + +let capturedSentryOptions: CapturedSentryOptions | undefined; + +vi.mock('@sentry/bundler-plugins/vite', () => ({ + sentryVitePlugin: (options: CapturedSentryOptions) => { + capturedSentryOptions = options; + return [{ name: 'sentry-vite-plugin' }]; + }, +})); + +const SOURCE_MAP_PLUGINS = [ + 'sentry-remix-files-to-delete-after-upload', + 'sentry-vite-plugin', + 'sentry-remix-update-source-map-setting', +]; + const NODE_CONFIG = { ssr: { target: 'node' } } as UserConfig; const WORKER_CONFIG = { ssr: { target: 'webworker' } } as UserConfig; // Remix's own Vite plugin never sets `ssr.target`, so a Cloudflare app is only recognizable by its @@ -48,15 +69,78 @@ function callHook(hook: unknown, ...args: unknown[]): unknown { describe('sentryRemixVitePlugin', () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); + capturedSentryOptions = undefined; }); - it('returns the route manifest plugin and the orchestrion plugin', () => { + // Vite hands every `config` hook the already-merged config, so the deletion plugin has to read + // `build.sourcemap` before `makeEnableSourceMapsPlugin` sets it. Running the hooks in isolation + // hides that, which is why this drives them in plugin order over one shared config. + it('still deletes the generated source maps once the hooks run in plugin order', async () => { const plugins = sentryRemixVitePlugin(); + const config: UserConfig = {}; - expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-remix-route-manifest', 'code-transformer']); + for (const plugin of plugins) { + if (!plugin.config) { + continue; + } + + const result = callHook(plugin.config, config, BUILD_ENV) as UserConfig | null; + const sourcemap = result?.build?.sourcemap; + + if (sourcemap !== undefined) { + config.build = { ...config.build, sourcemap }; + } + } + + await expect(capturedSentryOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['./build/**/*.map']); + }); + + it('returns the route manifest, orchestrion and source map plugins', () => { + const plugins = sentryRemixVitePlugin(); + + expect(plugins.map(plugin => plugin.name)).toEqual([ + 'sentry-remix-route-manifest', + 'code-transformer', + ...SOURCE_MAP_PLUGINS, + ]); expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined }); }); + // Uploading from the dev server would upload a new set of artifacts on every restart. + it('leaves out the source map plugins in development', () => { + vi.stubEnv('NODE_ENV', 'development'); + + const plugins = sentryRemixVitePlugin(); + + expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-remix-route-manifest', 'code-transformer']); + }); + + // Disabling source maps must not drop the bundler plugin: it also applies bundle size + // optimizations, module metadata, the application key and release management, and it skips the + // upload on its own. + it('only drops the source map setting plugin when source maps are disabled', () => { + const plugins = sentryRemixVitePlugin({ sourcemaps: { disable: true } }); + + expect(plugins.map(plugin => plugin.name)).toEqual([ + 'sentry-remix-route-manifest', + 'code-transformer', + 'sentry-remix-files-to-delete-after-upload', + 'sentry-vite-plugin', + ]); + }); + + it('forwards the non-source-map options when source maps are disabled', () => { + sentryRemixVitePlugin({ + sourcemaps: { disable: true }, + applicationKey: 'my-app-key', + bundleSizeOptimizations: { excludeTracing: true }, + }); + + expect(capturedSentryOptions?.applicationKey).toBe('my-app-key'); + expect(capturedSentryOptions?.bundleSizeOptimizations).toEqual({ excludeTracing: true }); + }); + it('adds an inert orchestrion plugin when `buildTimeInstrumentation` is `false`', () => { const plugins = sentryRemixVitePlugin({ buildTimeInstrumentation: false }); @@ -64,6 +148,13 @@ describe('sentryRemixVitePlugin', () => { expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-disabled'); }); + // Turning off the build-time transform says nothing about source maps. + it('keeps the source map plugins when `buildTimeInstrumentation` is `false`', () => { + const plugins = sentryRemixVitePlugin({ buildTimeInstrumentation: false }); + + expect(plugins.map(plugin => plugin.name)).toEqual(expect.arrayContaining(SOURCE_MAP_PLUGINS)); + }); + it('keeps the upstream `enforce: "pre"` but defers its `config` hook to the end', () => { const orchestrion = sentryRemixVitePlugin()[1] as { enforce?: string; config?: { order?: string } }; diff --git a/packages/remix/test/vite/sourceMaps.test.ts b/packages/remix/test/vite/sourceMaps.test.ts new file mode 100644 index 000000000000..4e400e2b8305 --- /dev/null +++ b/packages/remix/test/vite/sourceMaps.test.ts @@ -0,0 +1,209 @@ +import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; +import type { UserConfig } from 'vite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getUpdatedSourceMapSettings, + makeAddSentryVitePlugin, + makeEnableSourceMapsPlugin, +} from '../../src/vite/sourceMaps'; + +let capturedOptions: SentryVitePluginOptions | undefined; + +const sentryVitePluginSpy = vi.fn((options: SentryVitePluginOptions) => { + capturedOptions = options; + return [{ name: 'sentry-vite-plugin' }]; +}); + +vi.mock('@sentry/bundler-plugins/vite', () => ({ + sentryVitePlugin: (options: SentryVitePluginOptions) => sentryVitePluginSpy(options), +})); + +beforeEach(() => { + vi.clearAllMocks(); + capturedOptions = undefined; + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + delete process.env.SENTRY_AUTH_TOKEN; +}); + +describe('makeEnableSourceMapsPlugin', () => { + it('returns a build-time plugin that updates the source map setting', () => { + const plugin = makeEnableSourceMapsPlugin({}); + + expect(plugin.name).toBe('sentry-remix-update-source-map-setting'); + expect(plugin.apply).toBe('build'); + expect(plugin.enforce).toBe('post'); + }); + + it('enables hidden source maps through its config hook', () => { + const plugin = makeEnableSourceMapsPlugin({}); + const config = plugin.config as (config: UserConfig) => UserConfig; + + expect(config({}).build?.sourcemap).toBe('hidden'); + }); +}); + +describe('getUpdatedSourceMapSettings', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + it('keeps source maps disabled when the user turned them off', () => { + expect(getUpdatedSourceMapSettings({ build: { sourcemap: false } })).toBe(false); + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Source map generation is disabled')); + }); + + it.each([['hidden'], ['inline'], [true]] as ('hidden' | 'inline' | boolean)[][])( + 'keeps the explicit `%s` setting', + setting => { + expect(getUpdatedSourceMapSettings({ build: { sourcemap: setting } })).toBe(setting); + }, + ); + + it.each([[undefined], ['invalid'], [null]])('enables hidden source maps when the setting is %s', setting => { + expect(getUpdatedSourceMapSettings({ build: { sourcemap: setting as any } })).toBe('hidden'); + }); + + it('enables hidden source maps when there is no build config', () => { + expect(getUpdatedSourceMapSettings({})).toBe('hidden'); + }); +}); + +describe('makeAddSentryVitePlugin', () => { + it('forwards the user-specified options to the bundler plugin', () => { + const errorHandler = vi.fn(); + + makeAddSentryVitePlugin({ + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + sentryUrl: 'https://custom.sentry.io', + headers: { 'X-Custom-Header': 'value' }, + silent: true, + errorHandler, + release: { name: 'my-release' }, + bundleSizeOptimizations: { excludeTracing: true }, + }); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + url: 'https://custom.sentry.io', + headers: { 'X-Custom-Header': 'value' }, + silent: true, + errorHandler, + release: { name: 'my-release' }, + bundleSizeOptimizations: { excludeTracing: true }, + }), + ); + }); + + it('falls back to the Sentry environment variables', () => { + process.env.SENTRY_ORG = 'env-org'; + process.env.SENTRY_PROJECT = 'env-project'; + process.env.SENTRY_AUTH_TOKEN = 'env-token'; + + makeAddSentryVitePlugin({}); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ org: 'env-org', project: 'env-project', authToken: 'env-token' }), + ); + }); + + it('reports remix as the metaFramework', () => { + makeAddSentryVitePlugin({}); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ _metaOptions: { telemetry: { metaFramework: 'remix' } } }), + ); + }); + + // Unlike React Router - which uploads from `sentryOnBuildEnd` and therefore has to keep the + // bundler plugin's own upload switched off - this plugin *is* the uploader, so `disable` must + // stay whatever the user chose. + it('leaves the source map upload enabled', () => { + makeAddSentryVitePlugin({}); + + expect(capturedOptions?.sourcemaps?.disable).toBeUndefined(); + }); + + it('deletes the generated source maps when the user configured neither setting', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({}); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['./build/**/*.map']); + }); + + // Remix's `buildDirectory` is configurable, and it runs a client and an SSR build with their own + // `outDir`s - a hardcoded `./build/**/*.map` would leave a custom output directory's maps on disk. + it('scopes the deletion glob to the configured outDir', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({ build: { outDir: 'dist/client' } }); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['./dist/client/**/*.map']); + }); + + // Vite resolves `outDir` to an absolute path by the time this hook runs, and `.//abs/path` would + // match nothing - leaving the maps in the production output. + it('does not prefix an absolute outDir', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({ build: { outDir: '/tmp/app/build/client' } }); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual([ + '/tmp/app/build/client/**/*.map', + ]); + }); + + // `glob` reads a backslash as an escape, so an unnormalised Windows `outDir` matches nothing and + // leaves the maps on disk. Both path flavours are checked, so this holds on POSIX runners too. + it('normalises a Windows absolute outDir', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({ build: { outDir: 'C:\\proj\\build\\client' } }); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual([ + 'C:/proj/build/client/**/*.map', + ]); + }); + + it('keeps the source maps when the user set their own build.sourcemap', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({ build: { sourcemap: 'hidden' } }); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toBeUndefined(); + }); + + // `disable: 'disable-upload'` injects debug IDs but leaves the upload to the user. The bundler + // plugin deletes in a `finally` block even when it skipped uploading, so defaulting the deletion + // here would remove the maps they still have to upload by hand. + it('keeps the source maps when only the upload is disabled', async () => { + const plugins = makeAddSentryVitePlugin({ sourcemaps: { disable: 'disable-upload' } }); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({}); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toBeUndefined(); + }); + + it('honours a user-specified filesToDeleteAfterUpload', async () => { + const plugins = makeAddSentryVitePlugin({ sourcemaps: { filesToDeleteAfterUpload: ['./dist/**/*.map'] } }); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({}); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['./dist/**/*.map']); + }); +}); diff --git a/packages/server-runtime-injection/src/register.ts b/packages/server-runtime-injection/src/register.ts index 9d130ab41c53..38b55efe48d7 100644 --- a/packages/server-runtime-injection/src/register.ts +++ b/packages/server-runtime-injection/src/register.ts @@ -28,6 +28,22 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); } +/** + * Deno's `nextLoad` reports no `format` for a `.json` file, where Node reports `'json'`. With any + * load hook installed, Deno's CJS loader then compiles the JSON as JavaScript and `require()` of it + * throws `SyntaxError: Unexpected token ':'`. Restoring the format is enough, and only Deno needs + * it: on Node the format is never missing. + */ +function withDenoJsonFormat(loadHook: Function): Function { + return (url: string, context: unknown, nextLoad: Function) => { + const result = loadHook(url, context, nextLoad) as { format?: string }; + if (result?.format === undefined && url.endsWith('.json')) { + result.format = 'json'; + } + return result; + }; +} + /** * Emit an always-on warning. Unlike `debug.warn` (gated behind `debug: true`), this reaches every * user — otherwise a broken transform silently records no channel-based spans. @@ -165,7 +181,7 @@ export function registerDiagnosticsChannelInjection(): void { try { if (typeof mod.registerHooks === 'function' && stableSyncHooks) { initialize({ instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS }); - mod.registerHooks({ resolve, load }); + mod.registerHooks({ resolve, load: globalAny.Deno ? withDenoJsonFormat(load) : load }); debug.log('Registered diagnostics-channel injection via Module.registerHooks()'); } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 diff --git a/packages/server-utils/src/ai/flue/constants.ts b/packages/server-utils/src/ai/flue/constants.ts index ae568d24312a..6a3bbaaefaa9 100644 --- a/packages/server-utils/src/ai/flue/constants.ts +++ b/packages/server-utils/src/ai/flue/constants.ts @@ -1,3 +1,7 @@ +export const FLUE_INTEGRATION_NAME = 'Flue' as const; + +export const FLUE_MODULE_NAME = '@flue/runtime'; + export const FLUE_ORIGIN = 'auto.ai.flue'; /** diff --git a/packages/server-utils/src/eve.ts b/packages/server-utils/src/eve.ts index 8ab70981f41b..5c35d858542d 100644 --- a/packages/server-utils/src/eve.ts +++ b/packages/server-utils/src/eve.ts @@ -1,4 +1,5 @@ -import { setConversationId } from '@sentry/core'; +import { defineIntegration, setConversationId } from '@sentry/core'; +import { markEveGenAiRecordingDefault } from './integrations/vercel-ai/gen-ai-recording-mode'; /** * The subset of eve's hook context (`HookContext` from `eve/hooks`) this helper reads. Typed @@ -60,3 +61,15 @@ export function eveConversationHook(options: EveConversationHookOptions = {}): { }, }; } + +/** + * Integration that handles tracing instrumentation of [eve](https://eve.dev) apps. + */ +export const eveIntegration = defineIntegration(() => { + return { + name: 'Eve' as const, + setup(client) { + markEveGenAiRecordingDefault(client); + }, + }; +}); diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 57fa3f913cda..54e82b0dae6f 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -13,7 +13,7 @@ export type { InstrumentationConfig } from './orchestrion/apmTypes'; // `orchestrion/bundler/moduleInjectedTransform.ts`); it is a plain runtime // helper with no orchestrion build-time dependency. export { orchestrionModuleInjected } from './utils/moduleInjected'; -export { eveConversationHook } from './eve'; +export { eveConversationHook, eveIntegration } from './eve'; export { getInstrumentedModuleNames } from './orchestrion/config'; export { fastifyIntegration, @@ -43,6 +43,7 @@ export { knexIntegration } from './integrations/knex'; export { langChainIntegration } from './integrations/langchain'; export { langGraphIntegration } from './integrations/langgraph'; export { createFlueInstrumentation } from './ai/flue'; +export { flueIntegration } from './integrations/flue'; export type { FlueOptions } from './ai/flue'; export { mastraIntegration } from './integrations/mastra'; export { SentryMastraExporter } from './ai/mastra'; @@ -50,6 +51,8 @@ export { lruMemoizerIntegration } from './integrations/lru-memoizer'; export { mongoIntegration } from './integrations/mongodb'; export { mongooseIntegration } from './integrations/mongoose'; export { mistralAIIntegration } from './integrations/mistral'; +export { groqIntegration } from './integrations/groq'; +export { togetherAIIntegration } from './integrations/together-ai'; export { mysqlIntegration } from './integrations/mysql'; export { mysql2Integration } from './integrations/mysql2'; export { openAIIntegration } from './integrations/openai'; diff --git a/packages/server-utils/src/integrations/flue.ts b/packages/server-utils/src/integrations/flue.ts new file mode 100644 index 000000000000..dacf328d72ce --- /dev/null +++ b/packages/server-utils/src/integrations/flue.ts @@ -0,0 +1,67 @@ +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, GLOBAL_OBJ } from '@sentry/core'; +import type { FlueOptions } from '../ai/flue'; +import { createFlueInstrumentation } from '../ai/flue'; +import { FLUE_INTEGRATION_NAME, FLUE_MODULE_NAME } from '../ai/flue/constants'; +import { DEBUG_BUILD } from '../debug-build'; + +type FlueInstrumentFn = (instrumentation: ReturnType) => unknown; + +/** + * The `instrument` we last registered against, so a second `setup()` in the same isolate is a + * no-op. Cloudflare runs `init()` per request, and with `cacheClient: false` that reaches here + * every time: in production Flue throws on the repeat, and under `vite dev` it disposes our + * previous registration instead — ending the turn and tool spans of every in-flight request. + * + * Keyed on the binding rather than a bare boolean so a fresh `@flue/runtime` instance (a new + * isolate reusing this module, a test swapping the marker) still registers. + */ +let registeredBinding: FlueInstrumentFn | undefined; + +/** + * Register the instrumentation with Flue on the user's behalf, when the runtime binding is available. + * + * Flue is registered rather than patched — `instrument()` writes into module-scope state — so this + * needs a reference to that module's own binding. In a bundled worker there is no `node_modules` to + * resolve one from, so `@sentry/cloudflare/vite` splices a static `@flue/runtime` import into this + * module at build time and stashes the namespace on the global marker. Outside that setup the marker + * is empty and this no-ops, leaving the user's own `instrument(Sentry.createFlueInstrumentation())` + * as the way in. + */ +const _flueIntegration = ((options: FlueOptions = {}) => { + return { + name: FLUE_INTEGRATION_NAME, + setup() { + const provided = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.providedModules?.[FLUE_MODULE_NAME]; + const instrument = provided?.instrument as FlueInstrumentFn | undefined; + + if (typeof instrument !== 'function') { + DEBUG_BUILD && debug.log('[Flue] no provided `@flue/runtime` binding; skipping auto-registration'); + return; + } + + if (instrument === registeredBinding) { + DEBUG_BUILD && debug.log('[Flue] already registered in this isolate; skipping auto-registration'); + return; + } + + try { + instrument(createFlueInstrumentation(options)); + registeredBinding = instrument; + } catch (error) { + // Never rethrow: `setup()` runs inside `Sentry.init()`, which core calls unguarded and + // Cloudflare calls per request, so throwing here would take down the request handler. + if ((error as Error | undefined)?.name === 'InstrumentationAlreadyInstalledError') { + // The app owns the key and we will never win it, so stop rebuilding the instrumentation + // (two 1000-entry `LRUMap`s) on every later `init()`. + registeredBinding = instrument; + DEBUG_BUILD && debug.log('[Flue] already instrumented by the app; skipping auto-registration'); + } else { + debug.warn('[Flue] auto-registration failed; Flue spans will not be recorded:', error); + } + } + }, + }; +}) satisfies IntegrationFn; + +export const flueIntegration = defineIntegration(_flueIntegration); diff --git a/packages/server-utils/src/integrations/groq.ts b/packages/server-utils/src/integrations/groq.ts new file mode 100644 index 000000000000..cbbb8a156630 --- /dev/null +++ b/packages/server-utils/src/integrations/groq.ts @@ -0,0 +1,23 @@ +import { defineIntegration } from '@sentry/core'; +import { CHANNELS } from '../orchestrion/channels'; +import { groqModuleNames } from '../orchestrion/config/groq'; +import { createOpenAiCompatibleIntegration } from './openai-compatible'; + +// Exported so LangChain can add it to its provider-skip list: `@langchain/groq` drives `groq-sdk`, so +// a `ChatGroq` call would otherwise open both a LangChain span and this integration's Groq span. +export const GROQ_INTEGRATION_NAME = 'Groq' as const; + +/** + * Instruments the `groq-sdk` client (chat completions and embeddings). Groq speaks the OpenAI wire format, + * so this reuses the openai span/streaming logic; see `createOpenAiCompatibleIntegration`. Requires the + * Sentry runtime hook or bundler plugin so the diagnostics channels get injected into `groq-sdk`. + */ +export const groqIntegration = defineIntegration( + createOpenAiCompatibleIntegration({ + integrationName: GROQ_INTEGRATION_NAME, + providerName: 'groq', + origin: 'auto.ai.groq', + moduleNames: groqModuleNames, + channels: { chat: CHANNELS.GROQ_CHAT, embeddings: CHANNELS.GROQ_EMBEDDINGS }, + }), +); diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index 6e319bfc5e2b..fa6cf1759906 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -21,6 +21,8 @@ import { openAIIntegration } from './openai'; import { anthropicAIIntegration } from './anthropic'; import { googleGenAIIntegration } from './google-genai'; import { mistralAIIntegration } from './mistral'; +import { groqIntegration } from './groq'; +import { togetherAIIntegration } from './together-ai'; import { postgresJsIntegration } from './postgres-js'; import { firebaseIntegration } from './firebase'; import { expressIntegration } from './express'; @@ -59,6 +61,8 @@ export function getTracingIntegrations(): Integration[] { anthropicAIIntegration(), googleGenAIIntegration(), mistralAIIntegration(), + groqIntegration(), + togetherAIIntegration(), postgresJsIntegration(), firebaseIntegration(), ]; diff --git a/packages/server-utils/src/integrations/langchain.ts b/packages/server-utils/src/integrations/langchain.ts index 52fb4a438a2c..0e8016853445 100644 --- a/packages/server-utils/src/integrations/langchain.ts +++ b/packages/server-utils/src/integrations/langchain.ts @@ -10,6 +10,7 @@ import type { LangChainOptions } from '../ai/langchain/types'; import { _INTERNAL_mergeLangChainCallbackHandler } from '../ai/langchain/utils'; import { MISTRAL_INTEGRATION_NAME } from '../ai/mistral/constants'; import { OPENAI_INTEGRATION_NAME } from '../ai/openai/constants'; +import { GROQ_INTEGRATION_NAME } from './groq'; import { CHANNELS } from '../orchestrion/channels'; import { langchainEmbeddingsChannels } from '../orchestrion/config/langchain'; import { bindTracingChannelToSpan } from '../tracing-channel'; @@ -27,6 +28,8 @@ const SKIPPED_PROVIDERS = [ ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME, MISTRAL_INTEGRATION_NAME, + // `@langchain/groq` drives `groq-sdk`, so ChatGroq calls must not also open the Groq integration's span. + GROQ_INTEGRATION_NAME, ]; // The chat-model channels carry the live args array of `invoke(input, options)` / `_streamIterator(input, options)`. diff --git a/packages/server-utils/src/integrations/openai-compatible.ts b/packages/server-utils/src/integrations/openai-compatible.ts new file mode 100644 index 000000000000..ae5c76a86135 --- /dev/null +++ b/packages/server-utils/src/integrations/openai-compatible.ts @@ -0,0 +1,156 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { Integration, IntegrationFn, Span, SpanAttributeValue } from '@sentry/core'; +import { + _INTERNAL_shouldSkipAiProviderWrapping, + getClient, + hasSpanStreamingEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, +} from '@sentry/core'; +import { GEN_AI_PROVIDER_NAME } from '@sentry/conventions/attributes'; +import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; +import { addRequestAttributes, extractRequestAttributes } from '../ai/openai'; +import { instrumentStream } from '../ai/openai/streaming'; +import type { OpenAiOptions } from '../ai/openai/types'; +import { addResponseAttributes } from '../ai/openai/utils'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import { bindTracingChannelToSpan } from '../tracing-channel'; + +/** + * Describes an OpenAI-compatible provider whose SDK mirrors the openai wire format (Groq, Together, ...), + * so the span building, streaming and response parsing can all be reused from `ai/openai`. + */ +export interface OpenAiCompatibleProvider { + /** Integration name; also the key used to skip double-wrapping when another provider drives the SDK. */ + integrationName: string; + /** Value reported on `gen_ai.provider.name`. */ + providerName: string; + /** Span origin, `auto.ai.`. */ + origin: string; + /** The instrumented `module.name`s (from the orchestrion config's `getModuleNames`). */ + moduleNames: string[]; + /** The fully-qualified orchestrion channels this provider publishes to. */ + channels: { chat: string; embeddings: string }; +} + +/** + * The context orchestrion shares across the tracing-channel lifecycle hooks: `arguments` is the live args + * array passed to `Completions.create(body, options)`, and Node's `tracingChannel` attaches `result` when + * the returned promise settles. + */ +interface OpenAiCompatibleChannelContext { + arguments: unknown[]; + result?: unknown; +} + +/** + * Builds a diagnostics-channel integration for an OpenAI-compatible provider SDK. It subscribes to the + * `orchestrion::{chat,embeddings}` channels injected into the SDK's `create` methods, so it + * requires the Sentry runtime hook or bundler plugin. Everything below the channel — request/response + * attributes and streaming — is shared with the openai integration; only the provider name and origin + * differ. + */ +export function createOpenAiCompatibleIntegration( + provider: T, +): (options?: OpenAiOptions) => Integration & { name: T['integrationName'] } { + const instrumentedChannels = [ + { channel: provider.channels.chat, operation: 'chat' }, + { channel: provider.channels.embeddings, operation: 'embeddings' }, + ] as const; + + function instrument(options: OpenAiOptions): void { + for (const { channel, operation } of instrumentedChannels) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, provider, options), + { + beforeSpanEnd: (span, data) => { + addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); + }, + // Streaming: the result is a `Stream` consumed later, so instrument it and let it end the span. + deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + }, + ); + } + } + + return ((options: OpenAiOptions = {}) => { + return { + name: provider.integrationName, + setup(client) { + invokeOrchestrionInstrumentation(client, provider.moduleNames, instrument, [options]); + }, + }; + }) satisfies IntegrationFn; +} + +/** + * Build the span for an instrumented `create` call. + * Returning `undefined` opts the payload out so no span is opened. + */ +function createGenAiSpan( + data: OpenAiCompatibleChannelContext, + operation: string, + provider: OpenAiCompatibleProvider, + options: OpenAiOptions, +): Span | undefined { + // When another provider (e.g. LangChain) is driving the SDK, it records the spans itself and marks this + // provider as skipped; skip here to avoid double spans. + if (_INTERNAL_shouldSkipAiProviderWrapping(provider.integrationName)) { + return undefined; + } + + const args = data.arguments ?? []; + const params = args[0] as Record | undefined; + + const { recordInputs } = resolveAIRecordingOptions(options); + + // `extractRequestAttributes` defaults the provider to openai; override it for the concrete provider. + const attributes = extractRequestAttributes(args, operation, recordInputs); + attributes[GEN_AI_PROVIDER_NAME] = provider.providerName; + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = provider.origin; + const model = (params?.model as string) || 'unknown'; + const client = getClient(); + + const span = startInactiveSpan({ + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: model !== 'unknown' || !(client && hasSpanStreamingEnabled(client)) ? `${operation} ${model}` : operation, + op: getGenAiSpanOp(operation), + attributes: attributes as Record, + }); + + if (recordInputs && params) { + addRequestAttributes(span, params, operation); + } + + return span; +} + +type AsyncIterableStream = { [Symbol.asyncIterator]: () => AsyncIterator }; + +function isAsyncIterable(value: unknown): value is AsyncIterableStream { + return !!value && typeof (value as AsyncIterableStream)[Symbol.asyncIterator] === 'function'; +} + +/** + * For a streaming `create({ stream: true })` the result is a `Stream` the caller consumes later. We can't + * swap what `create` returns, but the `Stream` in `data.result` is the same instance the caller holds and + * `asyncEnd` fires before the caller iterates — so we patch its async iterator in place to run through + * `instrumentStream`, which accumulates the streamed attributes and ends the span when iteration finishes. + * Only a streaming call resolves to an async-iterable, so that check alone distinguishes it. Returns `true` + * to hand span-ending ownership to `instrumentStream`; `false` for non-streaming/errored results, which end + * via the normal `beforeSpanEnd` path. + */ +function wrapStreamResult(span: Span, data: OpenAiCompatibleChannelContext, options: OpenAiOptions): boolean { + const result = data.result; + if (!isAsyncIterable(result)) { + return false; + } + + const { recordOutputs } = resolveAIRecordingOptions(options); + const iterate = result[Symbol.asyncIterator].bind(result); + const instrumented = instrumentStream({ [Symbol.asyncIterator]: iterate }, span, recordOutputs ?? false); + result[Symbol.asyncIterator] = () => instrumented; + + return true; +} diff --git a/packages/server-utils/src/integrations/together-ai.ts b/packages/server-utils/src/integrations/together-ai.ts new file mode 100644 index 000000000000..614e8b17a831 --- /dev/null +++ b/packages/server-utils/src/integrations/together-ai.ts @@ -0,0 +1,19 @@ +import { defineIntegration } from '@sentry/core'; +import { CHANNELS } from '../orchestrion/channels'; +import { togetherAiModuleNames } from '../orchestrion/config/together-ai'; +import { createOpenAiCompatibleIntegration } from './openai-compatible'; + +/** + * Instruments the `together-ai` client (chat completions and embeddings). Together speaks the OpenAI wire + * format, so this reuses the openai span/streaming logic; see `createOpenAiCompatibleIntegration`. Requires + * the Sentry runtime hook or bundler plugin so the diagnostics channels get injected into `together-ai`. + */ +export const togetherAIIntegration = defineIntegration( + createOpenAiCompatibleIntegration({ + integrationName: 'TogetherAI', + providerName: 'together_ai', + origin: 'auto.ai.together_ai', + moduleNames: togetherAiModuleNames, + channels: { chat: CHANNELS.TOGETHER_CHAT, embeddings: CHANNELS.TOGETHER_EMBEDDINGS }, + }), +); diff --git a/packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts b/packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts new file mode 100644 index 000000000000..719b6830dac7 --- /dev/null +++ b/packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts @@ -0,0 +1,22 @@ +import type { Client } from '@sentry/core'; + +const eveRecordingClients = new WeakSet(); + +/** + * Mark this client as running under eve, so the Vercel AI channel subscriber records gen_ai + * inputs/outputs by default. + * + * eve stamps every AI SDK call with `recordInputs`/`recordOutputs: false` as its framework default + * (its content-capture default is "public conversations only"). That per-call flag on the + * `ai:telemetry` channel event otherwise outranks the global `dataCollection.genAI` setting, so + * message content would be dropped even when the user wants it. In this mode the per-call flag is + * treated as eve's default rather than an end-user decision: an explicit `recordInputs`/`recordOutputs` + * on the integration or a `dataCollection.genAI` setting still takes precedence. + */ +export function markEveGenAiRecordingDefault(client: Client): void { + eveRecordingClients.add(client); +} + +export function isEveGenAiRecordingDefault(client: Client): boolean { + return eveRecordingClients.has(client); +} diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index 924cde857b6b..76b1ddda69fc 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -49,6 +49,7 @@ import { } from '@sentry/core'; import type { TracingChannel } from 'node:diagnostics_channel'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../ai/core/gen-ai-attributes'; +import { isEveGenAiRecordingDefault } from './gen-ai-recording-mode'; import type { GenAiOptions } from '../../ai/core/utils'; import { getProviderMetadataAttributes, LAST_STEP_ONLY_USAGE_KEYS } from '../../ai/vercel-ai'; import { WORKERS_AI_INTEGRATION_NAME } from '../../ai/workers-ai/constants'; @@ -750,11 +751,13 @@ function getRecordingOptions( recordInputs: boolean; recordOutputs: boolean; } { - const genAI = getClient()?.getDataCollectionOptions().genAI; + const client = getClient(); + const genAI = client?.getDataCollectionOptions().genAI; + const eveMode = client ? isEveGenAiRecordingDefault(client) : false; return { - recordInputs: resolveRecording(channelOptions.recordInputs, event.recordInputs, genAI?.inputs), - recordOutputs: resolveRecording(channelOptions.recordOutputs, event.recordOutputs, genAI?.outputs), + recordInputs: resolveRecording(channelOptions.recordInputs, event.recordInputs, genAI?.inputs, eveMode), + recordOutputs: resolveRecording(channelOptions.recordOutputs, event.recordOutputs, genAI?.outputs, eveMode), }; } @@ -767,11 +770,23 @@ function getRecordingOptions( * `experimental_telemetry: { isEnabled: true }`. The `ai:telemetry` channel does not expose `isEnabled` * (nor a resolved recording flag), so that per-call default cannot be reproduced here — v7 users who * want inputs/outputs recorded must enable `dataCollection.genAI` or set `recordInputs`/`recordOutputs`. + * + * Under `eveMode` (set by `eveIntegration()`) the per-call flag is eve's blanket framework default + * rather than an end-user decision, so it is skipped: an explicit `dataCollection.genAI` still wins, + * otherwise recording defaults to `true`. An integration-level option outranks both regardless. */ -function resolveRecording(integrationOption: unknown, perCallOption: unknown, globalDefault: unknown): boolean { +function resolveRecording( + integrationOption: unknown, + perCallOption: unknown, + globalDefault: unknown, + eveMode = false, +): boolean { if (typeof integrationOption === 'boolean') { return integrationOption; } + if (eveMode) { + return typeof globalDefault === 'boolean' ? globalDefault : true; + } if (typeof perCallOption === 'boolean') { return perCallOption; } diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 65ab3e56899e..a88495d6ed8c 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -7,6 +7,7 @@ import { firebaseChannels } from './config/firebase'; import { genericPoolChannels } from './config/generic-pool'; import { googleGenAiChannels } from './config/google-genai'; import { graphqlChannels } from './config/graphql'; +import { groqChannels } from './config/groq'; import { hapiChannels } from './config/hapi'; import { ioredisChannels } from './config/ioredis'; import { kafkajsChannels } from './config/kafkajs'; @@ -28,6 +29,7 @@ import { postgresJsChannels } from './config/postgres'; import { redisChannels } from './config/redis'; import { remixChannels } from './config/remix'; import { tediousChannels } from './config/tedious'; +import { togetherAiChannels } from './config/together-ai'; import { vercelAiChannels } from './config/vercel-ai'; /** @@ -56,6 +58,7 @@ export const CHANNELS = { ...genericPoolChannels, ...googleGenAiChannels, ...graphqlChannels, + ...groqChannels, ...hapiChannels, ...ioredisChannels, ...kafkajsChannels, @@ -77,6 +80,7 @@ export const CHANNELS = { ...redisChannels, ...remixChannels, ...tediousChannels, + ...togetherAiChannels, ...vercelAiChannels, } as const; diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 73d7c164cfe7..881a38da55fd 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -30,6 +30,8 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'anthropicAIIntegration', modules: ['@anthropic-ai/sdk'] }, { exportName: 'googleGenAIIntegration', modules: ['@google/genai'] }, { exportName: 'mistralAIIntegration', modules: ['@mistralai/mistralai'] }, + { exportName: 'groqIntegration', modules: ['groq-sdk'] }, + { exportName: 'togetherAIIntegration', modules: ['together-ai'] }, { exportName: 'vercelAIIntegration', modules: ['ai'] }, { exportName: 'langChainIntegration', @@ -43,6 +45,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ }, { exportName: 'langGraphIntegration', modules: ['@langchain/langgraph'] }, { exportName: 'mastraIntegration', modules: ['@mastra/core'] }, + { exportName: 'flueIntegration', modules: ['@flue/runtime'] }, { exportName: 'awsIntegration', modules: ['@aws-sdk/smithy-client', '@smithy/core', '@smithy/smithy-client'] }, { exportName: 'firebaseIntegration', modules: ['@firebase/firestore', 'firebase-functions'] }, { exportName: 'amqplibIntegration', modules: ['amqplib'] }, diff --git a/packages/server-utils/src/orchestrion/config/flue.ts b/packages/server-utils/src/orchestrion/config/flue.ts new file mode 100644 index 000000000000..da6841a1ae9b --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/flue.ts @@ -0,0 +1,12 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { registrationOnly } from './registration-only'; + +/** + * Flue publishes no diagnostics channels and needs none: it is instrumented by registering with + * `instrument()`, not by patching call sites. Transforming the entry is only how the module's + * integration gets registered at evaluation time, which is what installs it on a bundler-only SDK + * like `@sentry/cloudflare`. + */ +export const flueConfig = [ + registrationOnly({ name: '@flue/runtime', versionRange: '>=2.0.0 <3.0.0', filePath: 'dist/index.mjs' }), +] satisfies InstrumentationConfig[]; diff --git a/packages/server-utils/src/orchestrion/config/groq.ts b/packages/server-utils/src/orchestrion/config/groq.ts new file mode 100644 index 000000000000..b5f6a4039d60 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/groq.ts @@ -0,0 +1,17 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +import { getModuleNames } from './module-names'; +import { openAiCompatibleConfig } from './openai-compatible'; + +// `groq-sdk` is a Stainless-generated, OpenAI-compatible SDK — see `openAiCompatibleConfig`. +export const groqConfig = openAiCompatibleConfig({ + name: 'groq-sdk', + versionRange: '>=0.3.0 <2', +}) satisfies InstrumentationConfig[]; + +export const groqModuleNames = getModuleNames(groqConfig); + +export const groqChannels = { + GROQ_CHAT: 'orchestrion:groq-sdk:chat', + GROQ_EMBEDDINGS: 'orchestrion:groq-sdk:embeddings', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 723c5857b8f1..0ed9055935d4 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -11,6 +11,7 @@ import { firebaseConfig } from './firebase'; import { genericPoolConfig } from './generic-pool'; import { googleGenAiConfig } from './google-genai'; import { graphqlConfig } from './graphql'; +import { groqConfig } from './groq'; import { hapiConfig } from './hapi'; import { ioredisConfig } from './ioredis'; import { kafkajsConfig } from './kafkajs'; @@ -19,6 +20,7 @@ import { koaConfig } from './koa'; import { langchainConfig } from './langchain'; import { langgraphConfig } from './langgraph'; import { lruMemoizerConfig } from './lru-memoizer'; +import { flueConfig } from './flue'; import { mastraConfig } from './mastra'; import { mistralConfig } from './mistral'; import { mongodbConfig } from './mongodb'; @@ -32,6 +34,7 @@ import { postgresJsConfig } from './postgres'; import { redisConfig } from './redis'; import { remixConfig } from './remix'; import { tediousConfig } from './tedious'; +import { togetherAiConfig } from './together-ai'; import { vercelAiConfig } from './vercel-ai'; // Kept sorted alphabetically by module so concurrent additions insert at different // points rather than all appending to the end (fewer merge conflicts). @@ -59,6 +62,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...genericPoolConfig, ...googleGenAiConfig, ...graphqlConfig, + ...groqConfig, ...hapiConfig, ...ioredisConfig, ...kafkajsConfig, @@ -67,6 +71,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...langchainConfig, ...langgraphConfig, ...lruMemoizerConfig, + ...flueConfig, ...mastraConfig, ...mistralConfig, ...mongodbConfig, @@ -80,6 +85,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...redisConfig, ...remixConfig, ...tediousConfig, + ...togetherAiConfig, ...vercelAiConfig, ]; diff --git a/packages/server-utils/src/orchestrion/config/openai-compatible.ts b/packages/server-utils/src/orchestrion/config/openai-compatible.ts new file mode 100644 index 000000000000..1c4e8fad0370 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/openai-compatible.ts @@ -0,0 +1,27 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +/** + * Many providers (Groq, Together, ...) ship a Stainless-generated SDK that mirrors the classic `openai` + * file layout: `resources/chat/completions.{js,mjs}` (class `Completions`) and + * `resources/embeddings.{js,mjs}` (class `Embeddings`), each with a `create(body, options)` that returns + * a thenable `APIPromise` — so `kind: 'Auto'` resolves to `wrapPromise`, and a streaming call resolves to + * the same OpenAI-style async-iterable `Stream` the openai integration already knows how to consume. These + * SDKs ship dual CJS/ESM and the matcher compares `filePath` exactly, hence one entry per built file. + * + * Because the wire format is OpenAI-compatible, the span building, streaming and response parsing are all + * reused from `ai/openai`; only the module name, channels and `gen_ai.provider.name` differ per provider. + */ +export function openAiCompatibleConfig(module: { name: string; versionRange: string }): InstrumentationConfig[] { + return [ + ...['resources/chat/completions.js', 'resources/chat/completions.mjs'].map(filePath => ({ + channelName: 'chat', + module: { ...module, filePath }, + functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, + })), + ...['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ + channelName: 'embeddings', + module: { ...module, filePath }, + functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Auto' as const }, + })), + ]; +} diff --git a/packages/server-utils/src/orchestrion/config/together-ai.ts b/packages/server-utils/src/orchestrion/config/together-ai.ts new file mode 100644 index 000000000000..82a7f4ed006e --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/together-ai.ts @@ -0,0 +1,17 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +import { getModuleNames } from './module-names'; +import { openAiCompatibleConfig } from './openai-compatible'; + +// `together-ai` is a Stainless-generated, OpenAI-compatible SDK — see `openAiCompatibleConfig`. +export const togetherAiConfig = openAiCompatibleConfig({ + name: 'together-ai', + versionRange: '>=0.6.0 <1', +}) satisfies InstrumentationConfig[]; + +export const togetherAiModuleNames = getModuleNames(togetherAiConfig); + +export const togetherAiChannels = { + TOGETHER_CHAT: 'orchestrion:together-ai:chat', + TOGETHER_EMBEDDINGS: 'orchestrion:together-ai:embeddings', +} as const; diff --git a/packages/server-utils/test/ai/lib/tracing/flue.test.ts b/packages/server-utils/test/ai/lib/tracing/flue.test.ts new file mode 100644 index 000000000000..5817e1033871 --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/flue.test.ts @@ -0,0 +1,638 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Event, Span } from '@sentry/core'; +import { + _INTERNAL_clearAiProviderSkips, + _INTERNAL_shouldSkipAiProviderWrapping, + _INTERNAL_skipAiProviderWrapping, + getMainCarrier, + setCurrentClient, + spanToStaticSpanJSON, + startInactiveSpan, + startSpan, +} from '@sentry/core'; +import { ANTHROPIC_AI_INTEGRATION_NAME } from '../../../../src/ai/anthropic-ai/constants'; +import { createFlueInstrumentation } from '../../../../src/ai/flue'; +import { MAX_TRACKED_FLUE_SPANS } from '../../../../src/ai/flue/constants'; +import { GOOGLE_GENAI_INTEGRATION_NAME } from '../../../../src/ai/google-genai/constants'; +import type { FlueInstrumentation, FlueObservation } from '../../../../src/ai/flue/types'; +import { OPENAI_INTEGRATION_NAME } from '../../../../src/ai/openai/constants'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +const AGENT_OP = { type: 'agent', operationId: 'op_1' }; +const AGENT_CTX = { agentName: 'Hello', submissionId: 'sub_1' }; +const INNER_CTX = { conversationId: 'conv_1' }; + +/** A settled turn as Flue reports it, with the field names `ModelRequestInfo`/`ModelResponse` use. */ +function turn(overrides: Partial = {}): FlueObservation { + return { + type: 'turn', + turnId: 'turn_1', + submissionId: 'sub_1', + operationId: 'op_1', + request: { + requestedModel: 'claude-haiku-4.5', + providerId: 'anthropic', + temperature: 0.7, + maxTokens: 1024, + reasoningLevel: 'high', + serverAddress: 'api.anthropic.com', + serverPort: 443, + }, + response: { + responseId: 'resp_1', + finishReason: 'stop', + usage: { + input: 924, + output: 57, + totalTokens: 981, + cacheRead: 0, + cacheWrite: 0, + cost: { input: 0.000924, output: 0.000275, total: 0.001199, cacheRead: 0, cacheWrite: 0 }, + }, + }, + ...overrides, + }; +} + +describe('createFlueInstrumentation', () => { + let endedSpans: Span[]; + let client: TestClient; + let instrumentation: FlueInstrumentation; + + beforeEach(() => { + _INTERNAL_clearAiProviderSkips(); + getMainCarrier().__SENTRY__ = undefined; + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle: 'stream', + }), + ); + setCurrentClient(client); + client.init(); + + endedSpans = []; + client.on('spanEnd', span => endedSpans.push(span)); + instrumentation = createFlueInstrumentation(); + }); + + afterEach(() => { + _INTERNAL_clearAiProviderSkips(); + getMainCarrier().__SENTRY__ = undefined; + }); + + /** Run `fn` inside an agent operation, the way Flue's interceptor would. */ + function withAgent(fn: () => Promise | T): Promise { + return instrumentation.interceptor(AGENT_OP, AGENT_CTX, async () => fn()); + } + + function agentSpans(): ReturnType[] { + return endedSpans + .map(span => spanToStaticSpanJSON(span)) + .filter(json => json.data['sentry.op'] === 'gen_ai.invoke_agent'); + } + + function findSpan(description: string): ReturnType | undefined { + return endedSpans.map(span => spanToStaticSpanJSON(span)).find(json => json.description === description); + } + + // Flue drives the providers through `pi-ai`, which bundles the `openai` / `@anthropic-ai/sdk` / + // `@google/genai` clients those integrations patch, so their spans duplicate the turn span. + // Not at construction: if `instrument()` rejects the object, suppressing the provider + // integrations would leave the app with no `gen_ai.chat` spans at all. + it('skips raw provider wrapping on first use, not on construction', async () => { + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + + await withAgent(() => undefined); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); + }); + + // The registry is reset per client (`_setupIntegrations` clears it, and Cloudflare calls `init()` + // per request), so a one-shot call at construction is wiped by the next reset. + it('re-applies the provider skip after the registry is cleared', async () => { + await withAgent(() => undefined); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + + _INTERNAL_clearAiProviderSkips(); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + + await withAgent(() => undefined); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + }); + + // The guard has to hold for every provider, not just the first: another integration may have + // registered a skip for one of them already, which would otherwise short-circuit the rest. + it('applies the skip to every provider when only some are already registered', async () => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + + await withAgent(() => undefined); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(true); + }); + + // A turn whose stream is abandoned never emits the settled `turn` that would remove it, so the + // tracker is capped. Eviction has to end the span it drops, or it is never sent. + it('ends the oldest chat span when the turn tracker overflows', async () => { + await withAgent(() => { + for (let i = 0; i <= MAX_TRACKED_FLUE_SPANS; i++) { + instrumentation.observe({ type: 'turn_start', turnId: `turn_${i}`, operationId: 'op_1' }, {}); + } + }); + + expect(endedSpans.filter(span => spanToStaticSpanJSON(span).data['sentry.op'] === 'gen_ai.chat')).toHaveLength(1); + }); + + it('names agent spans `invoke_agent {name}` and sets the gen_ai op', async () => { + await withAgent(() => undefined); + + const json = findSpan('invoke_agent Hello'); + expect(json?.data['sentry.op']).toBe('gen_ai.invoke_agent'); + expect(json?.data['sentry.origin']).toBe('auto.ai.flue'); + expect(json?.data['gen_ai.operation.name']).toBe('invoke_agent'); + expect(json?.data['gen_ai.agent.name']).toBe('Hello'); + }); + + // The agent span opens before the conversation is known — the submission-scoped operation names + // the agent, and the conversation arrives on the observations that follow. + it('sets the conversation id on the agent span from the observations', async () => { + await withAgent(() => { + instrumentation.observe( + { type: 'turn_start', turnId: 'turn_1', operationId: 'op_1', conversationId: 'conv_1' }, + {}, + ); + }); + + expect(findSpan('invoke_agent Hello')?.data['gen_ai.conversation.id']).toBe('conv_1'); + }); + + // The re-entered agent operation carries no `submissionId` and must not open a second span. + it('does not open a second agent span for the re-entry', async () => { + await withAgent(() => instrumentation.interceptor(AGENT_OP, INNER_CTX, async () => undefined)); + + const agentSpans = endedSpans + .map(span => spanToStaticSpanJSON(span)) + .filter(json => json.data['sentry.op'] === 'gen_ai.invoke_agent'); + expect(agentSpans).toHaveLength(1); + }); + + // A durable submission is resumed later, with nothing linking it to the request that enqueued it. + // Flue replays that request's `traceparent`, so the agent span should continue from it. + describe('trace continuation', () => { + const TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const CARRIER = { traceparent: `00-${TRACE_ID}-bbbbbbbbbbbbbbbb-01` }; + + function agentTraceId(): string | undefined { + return endedSpans.map(span => spanToStaticSpanJSON(span)).find(json => json.description === 'invoke_agent Hello') + ?.trace_id; + } + + it('continues the trace from the replayed traceparent', async () => { + await instrumentation.interceptor(AGENT_OP, { ...AGENT_CTX, traceCarrier: CARRIER }, async () => undefined); + + expect(agentTraceId()).toBe(TRACE_ID); + }); + + it('ignores a malformed traceparent', async () => { + await instrumentation.interceptor( + AGENT_OP, + { ...AGENT_CTX, traceCarrier: { traceparent: 'not-a-traceparent' } }, + async () => undefined, + ); + + // Asserting a well-formed id, not just "not the carrier's": the span has to still be opened + // on a fresh trace, and `not.toBe` alone would also pass if no span were opened at all. + expect(agentTraceId()).toMatch(/^[0-9a-f]{32}$/); + expect(agentTraceId()).not.toBe(TRACE_ID); + }); + + // An in-process dispatch is genuinely part of the surrounding trace; continuing the persisted + // one would detach it from the request it is actually running inside. + it('keeps the active trace when one is already running', async () => { + let requestTraceId: string | undefined; + + await startSpan({ name: 'incoming request' }, async span => { + requestTraceId = spanToStaticSpanJSON(span).trace_id; + await instrumentation.interceptor(AGENT_OP, { ...AGENT_CTX, traceCarrier: CARRIER }, async () => undefined); + }); + + expect(agentTraceId()).toBe(requestTraceId); + }); + }); + + // The spanned operation carries no `agentName` — only the submission wrapper does, and that opens + // no span. The name arrives on the observations instead. + it('names the agent span from the observations', async () => { + await instrumentation.interceptor(AGENT_OP, {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_1', agentName: 'Hello' }, {}); + }); + + const json = findSpan('invoke_agent Hello'); + expect(json).toBeDefined(); + expect(json?.data['gen_ai.agent.name']).toBe('Hello'); + }); + + // Delegation nests a second agent operation inside the first, in its own session. Flue defers + // each to a microtask, so the helper mirrors that rather than calling the interceptor directly. + describe('subagent delegation', () => { + function runNested(operationId: string, ctx: Record, next: () => Promise): Promise { + return Promise.resolve().then(() => instrumentation.interceptor({ type: 'agent', operationId }, ctx, next)); + } + + it('opens one agent span per invocation rather than folding the delegate into its parent', async () => { + await runNested('op_parent', {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_parent', conversationId: 'conv_parent' }, {}); + // The tool's task delegation, then the delegate's own prompt. + return runNested('op_child', {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_child', conversationId: 'conv_child' }, {}); + }); + }); + + expect(agentSpans()).toHaveLength(2); + }); + + // Asserted on conversation rather than agent name: the observation stream reports the root + // agent's name for both operations, so the delegate's own name never reaches us. + it('keeps each invocation on its own conversation', async () => { + await runNested('op_parent', {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_parent', conversationId: 'conv_parent' }, {}); + return runNested('op_child', {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_child', conversationId: 'conv_child' }, {}); + }); + }); + + // The delegate ends first, so order is inner-to-outer. + expect(agentSpans().map(json => json.data['gen_ai.conversation.id'])).toEqual(['conv_child', 'conv_parent']); + }); + }); + + it('does not span operations other than `agent`', async () => { + await instrumentation.interceptor({ type: 'model', turnId: 'turn_1' }, {}, async () => undefined); + + expect(endedSpans).toHaveLength(0); + }); + + // The `model` and `tool` operations open no span of their own; they make the span `observe` + // already opened active, so the provider's HTTP call and the tool's own work nest inside it + // rather than landing beside it as siblings of the agent invocation. + it('makes the turn span active for the model operation it wraps', async () => { + await withAgent(async () => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + await instrumentation.interceptor({ type: 'model', turnId: 'turn_1' }, {}, async () => { + startInactiveSpan({ name: 'provider request' }).end(); + }); + instrumentation.observe(turn(), {}); + }); + + expect(findSpan('provider request')?.parent_span_id).toBe(findSpan('chat claude-haiku-4.5')?.span_id); + }); + + it('makes the tool span active for the tool operation it wraps', async () => { + await withAgent(async () => { + instrumentation.observe( + { type: 'tool_start', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, + {}, + ); + await instrumentation.interceptor({ type: 'tool', toolCallId: 'call_1' }, {}, async () => { + startInactiveSpan({ name: 'tool work' }).end(); + }); + instrumentation.observe({ type: 'tool', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, {}); + }); + + expect(findSpan('tool work')?.parent_span_id).toBe(findSpan('execute_tool get_weather')?.span_id); + }); + + it('opens a chat span on turn_start and completes it from the settled turn', async () => { + await withAgent(() => { + instrumentation.observe( + { type: 'turn_start', turnId: 'turn_1', operationId: 'op_1', conversationId: 'conv_1' }, + {}, + ); + instrumentation.observe(turn(), {}); + }); + + const json = findSpan('chat claude-haiku-4.5'); + expect(json?.data['sentry.op']).toBe('gen_ai.chat'); + expect(json?.data['sentry.origin']).toBe('auto.ai.flue'); + expect(json?.data['gen_ai.request.model']).toBe('claude-haiku-4.5'); + expect(json?.data['gen_ai.provider.name']).toBe('anthropic'); + expect(json?.data['gen_ai.response.id']).toBe('resp_1'); + expect(json?.data['gen_ai.response.finish_reasons']).toBe('["stop"]'); + expect(json?.data['gen_ai.conversation.id']).toBe('conv_1'); + }); + + // Flue computes costs itself; the provider SDKs report none. + it('records token usage and Flue-computed cost on the chat span', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instrumentation.observe(turn(), {}); + }); + + const json = findSpan('chat claude-haiku-4.5'); + expect(json?.data['gen_ai.usage.input_tokens']).toBe(924); + expect(json?.data['gen_ai.usage.output_tokens']).toBe(57); + expect(json?.data['gen_ai.usage.total_tokens']).toBe(981); + expect(json?.data['gen_ai.cost.total_tokens']).toBe(0.001199); + }); + + it('records the model-call tuning and provider endpoint', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1', purpose: 'agent' }, {}); + instrumentation.observe(turn(), {}); + }); + + const json = findSpan('chat claude-haiku-4.5'); + expect(json?.data['gen_ai.request.temperature']).toBe(0.7); + expect(json?.data['gen_ai.request.max_tokens']).toBe(1024); + expect(json?.data['gen_ai.request.reasoning.level']).toBe('high'); + expect(json?.data['server.address']).toBe('api.anthropic.com'); + expect(json?.data['server.port']).toBe(443); + // Distinguishes a compaction turn from a user-facing one. + expect(json?.data['flue.turn.purpose']).toBe('agent'); + }); + + it('marks a failed turn as errored', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instrumentation.observe(turn({ isError: true }), {}); + }); + + expect(findSpan('chat claude-haiku-4.5')?.status).toBe('internal_error'); + }); + + // A turn that fails before the provider bills anything reports every counter as 0; writing those + // reads as a real zero-cost call. + it('omits usage entirely when a failed turn produced no tokens', async () => { + const empty = { + input: 0, + output: 0, + totalTokens: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0 }, + }; + + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instrumentation.observe(turn({ isError: true, response: { usage: empty } }), {}); + }); + + const json = findSpan('chat claude-haiku-4.5'); + expect(json).toBeDefined(); + expect(json?.data['gen_ai.usage.total_tokens']).toBeUndefined(); + expect(json?.data['gen_ai.cost.total_tokens']).toBeUndefined(); + }); + + describe('content recording', () => { + const requestContent = { + type: 'turn_request', + turnId: 'turn_1', + request: { + requestedModel: 'claude-haiku-4.5', + input: { + systemPrompt: 'You are helpful.', + messages: [{ role: 'user', content: 'hi' }], + tools: [{ name: 'get_weather', description: 'weather', parameters: {} }], + }, + }, + } satisfies FlueObservation; + + async function record(instr: FlueInstrumentation): Promise { + await instr.interceptor(AGENT_OP, AGENT_CTX, async () => { + instr.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instr.observe(requestContent, {}); + instr.observe(turn({ response: { ...turn().response, output: { role: 'assistant' } } }), {}); + instr.observe( + { + type: 'tool_start', + toolCallId: 'c1', + toolName: 'get_weather', + args: { city: 'Berlin' }, + operationId: 'op_1', + }, + {}, + ); + instr.observe( + { type: 'tool', toolCallId: 'c1', toolName: 'get_weather', result: 'sunny', operationId: 'op_1' }, + {}, + ); + }); + } + + it('records messages, instructions, tool definitions, arguments and results by default', async () => { + await record(instrumentation); + + const chat = findSpan('chat claude-haiku-4.5'); + expect(chat?.data['gen_ai.system_instructions']).toBe('You are helpful.'); + expect(chat?.data['gen_ai.input.messages']).toContain('"role":"user"'); + expect(chat?.data['gen_ai.output.messages']).toContain('"role":"assistant"'); + expect(chat?.data['gen_ai.tool.definitions']).toContain('get_weather'); + + const tool = findSpan('execute_tool get_weather'); + expect(tool?.data['gen_ai.tool.call.arguments']).toBe('{"city":"Berlin"}'); + expect(tool?.data['gen_ai.tool.call.result']).toBe('sunny'); + }); + + it('omits inputs when recordInputs is false but keeps outputs', async () => { + await record(createFlueInstrumentation({ recordInputs: false })); + + const chat = findSpan('chat claude-haiku-4.5'); + expect(chat?.data['gen_ai.input.messages']).toBeUndefined(); + expect(chat?.data['gen_ai.system_instructions']).toBeUndefined(); + expect(chat?.data['gen_ai.tool.definitions']).toBeUndefined(); + expect(chat?.data['gen_ai.output.messages']).toBeDefined(); + expect(findSpan('execute_tool get_weather')?.data['gen_ai.tool.call.arguments']).toBeUndefined(); + }); + + // The client is replaced per request on Cloudflare, so options captured once at construction + // would be the wrong ones for every later request. + it('follows the current client when it is replaced', async () => { + const instr = createFlueInstrumentation(); + await record(instr); + expect(findSpan('chat claude-haiku-4.5')?.data['gen_ai.input.messages']).toBeDefined(); + + endedSpans.length = 0; + const strict = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle: 'stream', + dataCollection: { genAI: { inputs: false, outputs: false } }, + }), + ); + setCurrentClient(strict); + strict.init(); + strict.on('spanEnd', span => endedSpans.push(span)); + + await record(instr); + + const chat = findSpan('chat claude-haiku-4.5'); + expect(chat).toBeDefined(); + expect(chat?.data['gen_ai.input.messages']).toBeUndefined(); + }); + + it('omits outputs when recordOutputs is false but keeps inputs', async () => { + await record(createFlueInstrumentation({ recordOutputs: false })); + + const chat = findSpan('chat claude-haiku-4.5'); + expect(chat?.data['gen_ai.output.messages']).toBeUndefined(); + expect(chat?.data['gen_ai.input.messages']).toBeDefined(); + expect(findSpan('execute_tool get_weather')?.data['gen_ai.tool.call.result']).toBeUndefined(); + }); + }); + + it('emits execute_tool spans keyed by tool call id', async () => { + await withAgent(() => { + instrumentation.observe( + { type: 'tool_start', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, + {}, + ); + instrumentation.observe({ type: 'tool', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, {}); + }); + + const json = findSpan('execute_tool get_weather'); + expect(json?.data['sentry.op']).toBe('gen_ai.execute_tool'); + expect(json?.data['sentry.origin']).toBe('auto.ai.flue'); + expect(json?.data['gen_ai.operation.name']).toBe('execute_tool'); + expect(json?.data['gen_ai.tool.name']).toBe('get_weather'); + }); + + // Flue catches whatever the tool threw and feeds it back to the model as a tool result, so + // nothing reaches the SDK's global handlers. Without an explicit capture there is an errored span + // and no error event at all. + it('captures an error event for a failed tool, rebuilt from `errorInfo`', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'tool_start', toolCallId: 'c1', toolName: 'boom', operationId: 'op_1' }, {}); + instrumentation.observe( + { + type: 'tool', + toolCallId: 'c1', + toolName: 'boom', + operationId: 'op_1', + isError: true, + errorInfo: { type: 'Error', name: 'TypeError', message: 'kaboom', stack: 'TypeError: kaboom\n at run' }, + }, + {}, + ); + }); + + await client.flush(); + + const exception = client.event?.exception?.values?.[0]; + expect(exception?.type).toBe('TypeError'); + expect(exception?.value).toBe('kaboom'); + expect(exception?.mechanism?.type).toBe('auto.ai.flue'); + // Handled: Flue caught the throw and returned it to the model, so no global hook sees it. + expect(exception?.mechanism?.handled).toBe(true); + }); + + // Flue swallows the throw, so nothing else reports it. If a second capture path is ever added, + // or the error starts propagating to the global handlers, this catches the duplicate. + it('captures a failed tool exactly once', async () => { + const events: Event[] = []; + TestClient.sendEventCalled = event => events.push(event); + + try { + await withAgent(() => { + instrumentation.observe({ type: 'tool_start', toolCallId: 'c1', toolName: 'boom', operationId: 'op_1' }, {}); + instrumentation.observe( + { + type: 'tool', + toolCallId: 'c1', + toolName: 'boom', + operationId: 'op_1', + isError: true, + errorInfo: { name: 'Error', message: 'kaboom' }, + }, + {}, + ); + }); + await client.flush(); + } finally { + TestClient.sendEventCalled = undefined; + } + + expect(events.filter(event => event.exception?.values?.length)).toHaveLength(1); + }); + + it('does not capture an error event for a tool that succeeded', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'tool_start', toolCallId: 'c1', toolName: 'ok', operationId: 'op_1' }, {}); + instrumentation.observe({ type: 'tool', toolCallId: 'c1', toolName: 'ok', operationId: 'op_1' }, {}); + }); + await client.flush(); + + expect(client.event).toBeUndefined(); + }); + + it('marks a failed tool call as errored', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'tool_start', toolCallId: 'call_1', toolName: 'boom', operationId: 'op_1' }, {}); + instrumentation.observe( + { type: 'tool', toolCallId: 'call_1', toolName: 'boom', isError: true, operationId: 'op_1' }, + {}, + ); + }); + + expect(findSpan('execute_tool boom')?.status).toBe('internal_error'); + }); + + // Two agent runs overlap on a busy server. With shared closure state the second run is mistaken + // for a re-entry of the first: it gets no span, and its conversation id lands on the first's span. + it('keeps concurrent agent runs separate', async () => { + const runA = instrumentation.interceptor({ type: 'agent', operationId: 'op_a' }, { agentName: 'A' }, async () => { + instrumentation.observe( + { type: 'turn_start', turnId: 'turn_a', operationId: 'op_a', conversationId: 'conv_a' }, + {}, + ); + // B starts while A is still open. + await instrumentation.interceptor({ type: 'agent', operationId: 'op_b' }, { agentName: 'B' }, async () => { + instrumentation.observe( + { type: 'turn_start', turnId: 'turn_b', operationId: 'op_b', conversationId: 'conv_b' }, + {}, + ); + instrumentation.observe(turn({ turnId: 'turn_b', operationId: 'op_b' }), {}); + }); + instrumentation.observe(turn({ turnId: 'turn_a', operationId: 'op_a' }), {}); + }); + await runA; + + const agentA = findSpan('invoke_agent A'); + const agentB = findSpan('invoke_agent B'); + expect(agentA).toBeDefined(); + expect(agentB).toBeDefined(); + + // Each run keeps its own conversation; neither is overwritten by the other. + expect(agentA?.data['gen_ai.conversation.id']).toBe('conv_a'); + expect(agentB?.data['gen_ai.conversation.id']).toBe('conv_b'); + }); + + it('ignores a settled turn or tool it never opened a span for', async () => { + await withAgent(() => { + instrumentation.observe(turn({ turnId: 'never_started' }), {}); + instrumentation.observe({ type: 'tool', toolCallId: 'never_started', toolName: 'x' }, {}); + }); + + expect(endedSpans.map(span => spanToStaticSpanJSON(span).description)).toEqual(['invoke_agent Hello']); + }); + + it('ends spans still open at dispose', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instrumentation.observe( + { type: 'tool_start', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, + {}, + ); + }); + // Never settled, so the span keeps the unqualified name it opened with. + expect(findSpan('chat')).toBeUndefined(); + + instrumentation.dispose(); + + expect(findSpan('chat')).toBeDefined(); + expect(findSpan('execute_tool get_weather')).toBeDefined(); + }); +}); diff --git a/packages/server-utils/test/eve.test.ts b/packages/server-utils/test/eve.test.ts index 2212f7b8e387..1e8b8dfa4bc2 100644 --- a/packages/server-utils/test/eve.test.ts +++ b/packages/server-utils/test/eve.test.ts @@ -1,6 +1,7 @@ import * as SentryCore from '@sentry/core'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import { eveConversationHook } from '../src/eve'; +import { eveConversationHook, eveIntegration } from '../src/eve'; +import { isEveGenAiRecordingDefault } from '../src/integrations/vercel-ai/gen-ai-recording-mode'; describe('eveConversationHook', () => { afterEach(() => { @@ -53,3 +54,19 @@ describe('eveConversationHook', () => { expect(setConversationId).toHaveBeenCalledWith(returnValue); }); }); + +describe('eveIntegration', () => { + test('is named Eve', () => { + expect(eveIntegration().name).toBe('Eve'); + }); + + test('marks the client so the Vercel AI subscriber records gen_ai content by default', () => { + const client = {} as SentryCore.Client; + + expect(isEveGenAiRecordingDefault(client)).toBe(false); + + eveIntegration().setup?.(client); + + expect(isEveGenAiRecordingDefault(client)).toBe(true); + }); +}); diff --git a/packages/server-utils/test/integrations/flue.test.ts b/packages/server-utils/test/integrations/flue.test.ts new file mode 100644 index 000000000000..9ebd62921ae4 --- /dev/null +++ b/packages/server-utils/test/integrations/flue.test.ts @@ -0,0 +1,105 @@ +import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { flueIntegration } from '../../src/integrations/flue'; + +function setProvidedFlue(instrument: unknown): void { + const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {} as NonNullable); + (marker as { providedModules?: Record }).providedModules = { + '@flue/runtime': { instrument }, + }; +} + +function clearMarker(): void { + delete (GLOBAL_OBJ as { __SENTRY_ORCHESTRION__?: unknown }).__SENTRY_ORCHESTRION__; +} + +/** Flue's own error for a duplicate `instrument()`, which sets `name` on the instance. */ +function alreadyInstalledError(): Error { + const error = new Error('An instrumentation is already installed for this key'); + error.name = 'InstrumentationAlreadyInstalledError'; + return error; +} + +describe('flueIntegration', () => { + afterEach(() => { + clearMarker(); + vi.restoreAllMocks(); + }); + + it('registers the instrumentation when a Flue binding is provided', () => { + const instrument = vi.fn(); + setProvidedFlue(instrument); + + flueIntegration().setup?.({} as never); + + expect(instrument).toHaveBeenCalledTimes(1); + }); + + it('does nothing when no Flue binding is on the marker', () => { + clearMarker(); + + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + }); + + it('registers once per isolate, however often `init()` reruns', () => { + // Cloudflare calls `init()` per request; with `cacheClient: false` `setup()` runs every time. + // Under `vite dev` Flue answers a repeat by disposing our previous registration, which ends + // the turn and tool spans of every in-flight request. + const instrument = vi.fn(); + setProvidedFlue(instrument); + + flueIntegration().setup?.({} as never); + flueIntegration().setup?.({} as never); + + expect(instrument).toHaveBeenCalledTimes(1); + }); + + it('registers again when a new Flue binding is provided', () => { + const first = vi.fn(); + setProvidedFlue(first); + flueIntegration().setup?.({} as never); + + const second = vi.fn(); + setProvidedFlue(second); + flueIntegration().setup?.({} as never); + + expect(second).toHaveBeenCalledTimes(1); + }); + + it('swallows a duplicate registration from an app that also calls instrument()', () => { + setProvidedFlue( + vi.fn(() => { + throw alreadyInstalledError(); + }), + ); + + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + }); + + it('stops rebuilding the instrumentation once the app owns the registration', () => { + const instrument = vi.fn(() => { + throw alreadyInstalledError(); + }); + setProvidedFlue(instrument); + + flueIntegration().setup?.({} as never); + flueIntegration().setup?.({} as never); + + expect(instrument).toHaveBeenCalledTimes(1); + }); + + it('warns but never throws when registration fails for any other reason', () => { + // `setup()` runs inside `Sentry.init()`, which core calls unguarded — throwing would take + // down the Cloudflare request handler. + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + const error = new TypeError('instrument is not a function'); + setProvidedFlue( + vi.fn(() => { + throw error; + }), + ); + + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[Flue] auto-registration failed'), error); + }); +}); diff --git a/packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts b/packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts new file mode 100644 index 000000000000..4c40743e4aea --- /dev/null +++ b/packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts @@ -0,0 +1,64 @@ +import { GEN_AI_INPUT_MESSAGES } from '@sentry/conventions/attributes'; +import { getClient, getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { markEveGenAiRecordingDefault } from '../../../src/integrations/vercel-ai/gen-ai-recording-mode'; +import { createSpanFromMessage } from '../../../src/integrations/vercel-ai/vercel-ai-dc-subscriber'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +// eve stamps every AI SDK call with `recordInputs: false`, which the channel subscriber otherwise +// honors over the global default. `eveIntegration()` (via `markEveGenAiRecordingDefault`) flips that so +// content is recorded by default under eve, while explicit settings still win. +describe('Vercel AI recording under eve', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(dataCollection: { genAI?: { inputs?: boolean } } = {}): void { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + dataCollection, + }), + ); + setCurrentClient(client); + client.init(); + } + + // A `generateText` operation carrying input messages and eve's per-call `recordInputs: false`. + function recordedInputMessages(): unknown { + const message = { + type: 'generateText', + event: { + messages: [{ role: 'user', content: 'What is the weather in Paris?' }], + recordInputs: false, + }, + } as Parameters[0]; + const span = createSpanFromMessage(message, {}); + return (spanToStaticSpanJSON(span!).data ?? {})[GEN_AI_INPUT_MESSAGES]; + } + + it('honors the per-call recordInputs:false without eve mode', () => { + setupClient(); + + expect(recordedInputMessages()).toBeUndefined(); + }); + + it('records inputs by default under eve despite the per-call recordInputs:false', () => { + setupClient(); + markEveGenAiRecordingDefault(getClient()!); + + expect(recordedInputMessages()).toContain('What is the weather in Paris?'); + }); + + it('still honors an explicit dataCollection.genAI opt-out under eve', () => { + setupClient({ genAI: { inputs: false } }); + markEveGenAiRecordingDefault(getClient()!); + + expect(recordedInputMessages()).toBeUndefined(); + }); +}); diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index 78d489dc2175..3d9a76415b9c 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -64,6 +64,21 @@ describe('orchestrion config — channel-subscriber coverage', () => { }); }); +describe('orchestrion config — Flue', () => { + it('transforms @flue/runtime', () => { + expect(SENTRY_INSTRUMENTATIONS.map(i => i.module.name)).toContain('@flue/runtime'); + }); + + it('is force-bundled as a side effect of being instrumented', () => { + expect(INSTRUMENTED_MODULE_NAMES).toContain('@flue/runtime'); + }); + + // Registration-only configs carry a custom transform the runtime loader cannot apply. + it('excludes @flue/runtime from the runtime loader', () => { + expect(SENTRY_RUNTIME_INSTRUMENTATIONS.map(i => i.module.name)).not.toContain('@flue/runtime'); + }); +}); + describe('orchestrion config — custom instrumentations', () => { const customInstrumentation = { module: { name: 'my-lib' } } as InstrumentationConfig; diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index da4972e07590..ce66e7c5374f 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -89,7 +89,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setCurrentClient, setExtra, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index b850e256ba38..c2074839dfbc 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -88,7 +88,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setCurrentClient, setExtra, diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index 696d1ff1809d..22927842682c 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -56,7 +56,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setCurrentClient, setExtra, diff --git a/packages/sveltekit/test/index.workerd.test.ts b/packages/sveltekit/test/index.workerd.test.ts index 1326f3ef4064..1e9799a1c864 100644 --- a/packages/sveltekit/test/index.workerd.test.ts +++ b/packages/sveltekit/test/index.workerd.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getCloudflareExecutionContext, setCloudflareExecutionContextFallback } from '../src/server-common/utils'; +// Loaded for its side effect: `index.workerd` re-exports this, and its `@sentry/cloudflare` graph +// is the bulk of the import each test makes. Transforming it charged the first test, which timed +// out on slower CI runners. Importing the re-exported module rather than `index.workerd` itself +// leaves the registry state the tests depend on alone: they need their own `cloudflare:workers` +// mock in place before `index.workerd` first evaluates. +import '../src/worker'; describe('workerd entry point', () => { afterEach(() => { diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index 90b91481305b..9dca196f072d 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -1,6 +1,11 @@ import type { BrowserOptions } from '@sentry/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Loaded for its side effect: the module under test reaches `@sentry/core`, and transforming that +// graph is charged to whichever test imports it first, which timed out on slower CI runners. +// Importing it here pays that once at collect time instead. +import '@sentry/core'; + describe('applyTunnelRouteOption()', () => { beforeEach(() => { vi.resetModules(); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 983df5a23b7f..9d32602e0c76 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -85,7 +85,6 @@ export { consoleIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, spanToStaticSpanJSON, spanToJSON, @@ -118,3 +117,4 @@ export { VercelEdgeClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { winterCGFetchIntegration } from './integrations/wintercg-fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts index 217efe00df2d..1922782e193b 100644 --- a/packages/vercel-edge/src/integrations/wintercg-fetch.ts +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -1,163 +1,9 @@ -import type { - Client, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, - Span, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'WinterCGFetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -export interface Options { - /** - * Whether breadcrumbs should be recorded for requests - * Defaults to true - */ - breadcrumbs: boolean; - - /** - * Function determining whether or not to create spans to track outgoing requests to the given URL. - * By default, spans will be created for all outgoing requests. - */ - shouldCreateSpanForRequest?: (url: string) => boolean; -} - -const _winterCGFetch = ((options: Partial = {}) => { - const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs; - const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - - const _createSpanUrlMap = new LRUMap(100); - const _headersUrlMap = new LRUMap(100); - - const spans: Record = {}; - - /** Decides whether to attach trace data to the outgoing fetch request */ - function _shouldAttachTraceData(url: string): boolean { - const client = getClient(); - - if (!client) { - return false; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - /** Helper that wraps shouldCreateSpanForRequest option */ - function _shouldCreateSpan(url: string): boolean { - if (shouldCreateSpanForRequest === undefined) { - return true; - } - - const cachedDecision = _createSpanUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = shouldCreateSpanForRequest(url); - _createSpanUrlMap.set(url, decision); - return decision; - } - - return { - name: INTEGRATION_NAME, - setupOnce() { - addFetchInstrumentationHandler(handlerData => { - const client = getClient(); - if (!client || !HAS_CLIENT_MAP.get(client)) { - return; - } - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.wintercg_fetch', - }); - - if (breadcrumbs) { - createBreadcrumb(handlerData); - } - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** * Creates spans and attaches tracing headers to fetch requests on WinterCG runtimes. */ -export const winterCGFetchIntegration = defineIntegration(_winterCGFetch); - -function createBreadcrumb(handlerData: HandlerDataFetch): void { - const { startTimestamp, endTimestamp } = handlerData; - - // We only capture complete fetch requests - if (!endTimestamp) { - return; - } - - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - - if (handlerData.error) { - const hint: FetchBreadcrumbHint = { - data: handlerData.error, - input: handlerData.args, - startTimestamp, - endTimestamp, - }; - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); - } else { - const response = handlerData.response as Response | undefined; - - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - - const hint: FetchBreadcrumbHint = { - input: handlerData.args, - response, - startTimestamp, - endTimestamp, - }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); - } -} +export const winterCGFetchIntegration = createFetchIntegration({ + name: 'WinterCGFetch', + spanOrigin: 'auto.http.wintercg_fetch', +}); diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index 9d9ffcd755f4..85c24eb11683 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -1,210 +1,49 @@ -import type { HandlerDataFetch, Integration } from '@sentry/core'; -import * as sentryCore from '@sentry/core'; -import { createStackParser } from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TransactionEvent } from '@sentry/core'; +import { createStackParser, setCurrentClient, startSpan } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { VercelEdgeClient } from '../src/index'; import { winterCGFetchIntegration } from '../src/integrations/wintercg-fetch'; +import { getDefaultIntegrations } from '../src/sdk'; -class FakeClient extends VercelEdgeClient { - public getIntegrationByName(name: string): T | undefined { - return name === 'WinterCGFetch' ? (winterCGFetchIntegration() as T) : undefined; - } -} - -const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryCore, 'addFetchInstrumentationHandler'); -const instrumentFetchRequestSpy = vi.spyOn(sentryCore, 'instrumentFetchRequest'); -const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); +// The behavior lives in `createFetchIntegration` and is covered by +// `packages/core/test/lib/integrations/fetch.test.ts`. This only pins the wiring. +describe('winterCGFetchIntegration', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); -describe('WinterCGFetch instrumentation', () => { - let client: FakeClient; + it('is named `Fetch` and is enabled by default', () => { + expect(winterCGFetchIntegration().name).toBe('WinterCGFetch'); + expect(getDefaultIntegrations().map(integration => integration.name)).toContain('WinterCGFetch'); + }); - beforeEach(() => { - vi.clearAllMocks(); + it('creates `http.client` spans with the `auto.http.wintercg_fetch` origin', async () => { + vi.stubGlobal('fetch', () => Promise.resolve(new Response('ok'))); - client = new FakeClient({ + const transactions: TransactionEvent[] = []; + const client = new VercelEdgeClient({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, - integrations: [], - transport: () => ({ - send: () => Promise.resolve({}), - flush: () => Promise.resolve(true), - }), - tracePropagationTargets: ['http://my-website.com/'], + traceLifecycle: 'static', + integrations: [winterCGFetchIntegration()], stackParser: createStackParser(), - }); - - vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( - startHandlerData, - expect.any(Function), - expect.any(Function), - expect.any(Object), - { spanOrigin: 'auto.http.wintercg_fetch' }, - ); - - const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); - expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); - - // tracePropagationTargets match regardless of casing - expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); - expect(shouldAttachTraceData('https://WWW.3RD-PARTY-WEBSITE.at/')).toBe(false); - - expect(shouldCreateSpan('http://my-website.com/')).toBe(true); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); - }); - - it('should not instrument if client is not setup', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - // integration.setup!(client) is not called! - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should properly apply the `shouldCreateSpanForRequest` option', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration({ - shouldCreateSpanForRequest(url) { - return url === 'http://only-acceptable-url.com/'; + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + beforeSendTransaction(event) { + transactions.push(event); + return null; }, }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldCreateSpan('http://only-acceptable-url.com/')).toBe(true); - expect(shouldCreateSpan('http://my-website.com/')).toBe(false); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(false); - }); - - it('should create a breadcrumb for an outgoing request', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + setCurrentClient(client); + client.init(); - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(addBreadcrumbSpy).toBeCalledWith( - { - category: 'fetch', - data: { - method: 'POST', - status_code: 201, - url: 'http://my-website.com/', - }, - type: 'http', - }, - { - endTimestamp, - input: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' }, - startTimestamp, - }, - ); - }); - - it('should not create a breadcrumb for an outgoing request if `breadcrumbs: false` is set', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration({ breadcrumbs: false }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; + await startSpan({ name: 'parent', op: 'test' }, async () => { + await fetch('http://my-website.com/').then(response => response.text()); + }); - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); + const parent = transactions.find(event => event.transaction === 'parent'); + const clientSpan = parent?.spans?.find(span => span.op === 'http.client'); - expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + expect(clientSpan).toBeDefined(); + expect(clientSpan?.origin).toBe('auto.http.wintercg_fetch'); }); }); diff --git a/yarn.lock b/yarn.lock index 2c1b930b29dd..e74384208891 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8061,11 +8061,96 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" +"@sentry/bundler-plugins@10.67.0": + version "10.67.0" + resolved "https://sfw.security.sentry.io/npm/@sentry/bundler-plugins/-/bundler-plugins-10.67.0.tgz#0b43b5a19a372a80a9f25464484937b3417aa43e" + integrity sha512-HKLhbMZJsabZlXTog8CTa1ReeDW/mf1cwN7O8K+DKhe/kGHB3whHRseqsyjxyJwPdzC/0lM+8rgfqgxpc7jR9A== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/cli" "^2.58.6" + "@sentry/core" "10.67.0" + dotenv "^17.4.2" + find-up "^5.0.0" + glob "^13.0.6" + magic-string "~0.30.8" + +"@sentry/cli-darwin@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz#38fd82751014b287e58e99ef948d01ca1e09f41d" + integrity sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA== + +"@sentry/cli-linux-arm64@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz#6e660e457af7928c1be8191c77646801fe3fa6a0" + integrity sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g== + +"@sentry/cli-linux-arm@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz#41256912d636193d2a67985b6c9b3efbbe6a47c9" + integrity sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw== + +"@sentry/cli-linux-i686@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz#278e7696d82e51dfbfd7d82ec125dda65d249a43" + integrity sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg== + +"@sentry/cli-linux-x64@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz#57860d46ac3397c33bbcc6224ac19b7de2502c18" + integrity sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q== + +"@sentry/cli-win32-arm64@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz#9335a5d2411381dca1d6b11fdd71a4342b375fc3" + integrity sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A== + +"@sentry/cli-win32-i686@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz#2afd19536ef111af43538ccc5f9c8c0b179d930e" + integrity sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg== + +"@sentry/cli-win32-x64@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz#8d0e70b5660cc82a7763a4bbe9346cf18e49e07e" + integrity sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA== + +"@sentry/cli@^2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli/-/cli-2.58.6.tgz#72edb4977d822757511b279e006b00f139e24945" + integrity sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg== + dependencies: + https-proxy-agent "^5.0.0" + node-fetch "^2.6.7" + progress "^2.0.3" + proxy-from-env "^1.1.0" + which "^2.0.2" + optionalDependencies: + "@sentry/cli-darwin" "2.58.6" + "@sentry/cli-linux-arm" "2.58.6" + "@sentry/cli-linux-arm64" "2.58.6" + "@sentry/cli-linux-i686" "2.58.6" + "@sentry/cli-linux-x64" "2.58.6" + "@sentry/cli-win32-arm64" "2.58.6" + "@sentry/cli-win32-i686" "2.58.6" + "@sentry/cli-win32-x64" "2.58.6" + "@sentry/conventions@0.23.0", "@sentry/conventions@^0.23.0": version "0.23.0" resolved "https://sfw.security.sentry.io/npm/@sentry/conventions/-/conventions-0.23.0.tgz#26ecad45f91bcf70940325ab9af2b9544fa18242" integrity sha512-+Euyo7CNVecoOusgUnq27oV97oshifgceKfnGnuKfXrPUVWA/cisvii3E1rlvy2yBrKvl9K1gCEo1Jqog5zjEQ== +"@sentry/conventions@^0.16.0": + version "0.16.0" + resolved "https://sfw.security.sentry.io/npm/@sentry/conventions/-/conventions-0.16.0.tgz#3b58d15714cf44dca1518496c00749eec5525009" + integrity sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ== + +"@sentry/core@10.67.0": + version "10.67.0" + resolved "https://sfw.security.sentry.io/npm/@sentry/core/-/core-10.67.0.tgz#38afb5484d2581d40b212303e8a1abba2daea7a2" + integrity sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w== + dependencies: + "@sentry/conventions" "^0.16.0" + "@sentry/node-cpu-profiler@^2.4.4": version "2.4.4" resolved "https://registry.yarnpkg.com/@sentry/node-cpu-profiler/-/node-cpu-profiler-2.4.4.tgz#b275d249bd6e0c98c1e4f2ebc87acf702e6ae04d" @@ -9696,21 +9781,11 @@ dependencies: "@types/node" "*" -"@types/source-list-map@*": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.6.tgz#164e169dd061795b50b83c19e4d3be09f8d3a454" - integrity sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g== - "@types/symlink-or-copy@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/symlink-or-copy/-/symlink-or-copy-1.2.0.tgz#4151a81b4052c80bc2becbae09f3a9ec010a9c7a" integrity sha512-Lja2xYuuf2B3knEsga8ShbOdsfNOtzT73GyJmZyY7eGl2+ajOqrs8yM5ze0fsSoYwvA6bw7/Qr7OZ7PEEmYwWg== -"@types/tapable@^1": - version "1.0.12" - resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.12.tgz#bc2cab12e87978eee89fb21576b670350d6d86ab" - integrity sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q== - "@types/tough-cookie@*": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" @@ -9721,13 +9796,6 @@ resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== -"@types/uglify-js@*": - version "3.17.5" - resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.5.tgz#905ce03a3cbbf2e31cbefcbc68d15497ee2e17df" - integrity sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ== - dependencies: - source-map "^0.6.1" - "@types/unist@*", "@types/unist@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.0.tgz#988ae8af1e5239e89f9fbb1ade4c935f4eeedf9a" @@ -9743,27 +9811,6 @@ resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" integrity sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA== -"@types/webpack-sources@*": - version "3.2.3" - resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-3.2.3.tgz#b667bd13e9fa15a9c26603dce502c7985418c3d8" - integrity sha512-4nZOdMwSPHZ4pTEZzSp0AsTM4K7Qmu40UKW4tJDiOVs20UzYF9l+qUe4s0ftfN0pin06n+5cWWDJXH+sbhAiDw== - dependencies: - "@types/node" "*" - "@types/source-list-map" "*" - source-map "^0.7.3" - -"@types/webpack@npm:@types/webpack@^4": - version "4.41.40" - resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.40.tgz#41ea11cfafe08de24c3ef410c58976350667e2d1" - integrity sha512-u6kMFSBM9HcoTpUXnL6mt2HSzftqb3JgYV6oxIgL2dl6sX6aCa5k6SOkzv5DuZjBTPUE/dJltKtwwuqrkZHpfw== - dependencies: - "@types/node" "*" - "@types/tapable" "^1" - "@types/uglify-js" "*" - "@types/webpack-sources" "*" - anymatch "^3.0.0" - source-map "^0.6.0" - "@types/whatwg-url@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-13.0.0.tgz#2b11e32772fd321c0dedf4d655953ea8ce587b2a" @@ -11210,7 +11257,7 @@ any-promise@^1.1.0: resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= -anymatch@^3.0.0, anymatch@^3.1.1, anymatch@^3.1.3, anymatch@~3.1.2: +anymatch@^3.1.1, anymatch@^3.1.3, anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== @@ -14044,9 +14091,9 @@ deterministic-object-hash@^2.0.2: base-64 "^1.0.0" devalue@^5.1.1, devalue@^5.8.1, devalue@^5.9.0: - version "5.9.0" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.9.0.tgz#5d30db41a0db9171cf4ee9dbf6617e0b77cd7c2a" - integrity sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A== + version "5.9.2" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.9.2.tgz#2a3a8ad21904c6a630bf7bb160acb7ca2fa1e467" + integrity sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w== devlop@^1.0.0, devlop@^1.1.0: version "1.1.0" @@ -16883,6 +16930,11 @@ gray-matter@^4.0.3: section-matter "^1.0.0" strip-bom-string "^1.0.0" +groq-sdk@1.6.0: + version "1.6.0" + resolved "https://sfw.security.sentry.io/npm/groq-sdk/-/groq-sdk-1.6.0.tgz#6938848229935425e29aa5a9b225b50f4fbc89a9" + integrity sha512-ggMgo6n84aZdNObgkSbSxWrdAq4wABsViIRb5e4N15bMUtDLZVhqlgZOI7X4TeqdBMdaVUOuUdZxAZIp06u0vw== + gtoken@^5.0.4: version "5.2.1" resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-5.2.1.tgz#4dae1fea17270f457954b4a45234bba5fc796d16" @@ -23291,6 +23343,11 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= +progress@^2.0.3: + version "2.0.3" + resolved "https://sfw.security.sentry.io/npm/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" @@ -23365,6 +23422,11 @@ proxy-from-env@2.1.0, proxy-from-env@^2.1.0: resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://sfw.security.sentry.io/npm/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + proxy@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/proxy/-/proxy-2.1.1.tgz#45f9b307508ffcae12bdc71678d44a4ab79cbf8b" @@ -26443,6 +26505,11 @@ toad-cache@^3.7.0: resolved "https://registry.yarnpkg.com/toad-cache/-/toad-cache-3.7.1.tgz#33441aab508e15a35fb5292c61ee3322c0853822" integrity sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ== +together-ai@0.54.0: + version "0.54.0" + resolved "https://sfw.security.sentry.io/npm/together-ai/-/together-ai-0.54.0.tgz#dd1345de5662ef5c4778da9d9f8bc184db07c17a" + integrity sha512-SiAzvfHCQMaX/omwpCpbFJmUFP8vUQntT2wpAgfUknOc/UpdjF0mORg/IHIAXfa35FurNyCjfBPsexHaHbpy0w== + toidentifier@1.0.1, toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"