diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ab5a04f0014..3329cc6c800b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: branches: - develop - master + - v10 - v9 - v8 - release/** diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 0f186ad9a7a0..5942bdb0c355 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -5,6 +5,7 @@ on: branches: - develop - master + - v10 - v9 - v8 - release/** @@ -12,6 +13,7 @@ on: branches: - develop - master + - v10 - v9 - v8 diff --git a/.size-limit.js b/.size-limit.js index 4ff4c7d05f7f..48466c55abb3 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -400,12 +400,12 @@ module.exports = [ import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '154 KB', + limit: '190 KB', disablePlugins: ['@size-limit/esbuild'], }, { name: '@sentry/node/import (ESM hook with diagnostics-channel injection)', - path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/import-hook.mjs'], + path: ['packages/server-utils/build/esm/orchestrion/runtime/hook.js', 'packages/node/build/import-hook.mjs'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, limit: '76 KB', @@ -480,7 +480,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '480 KiB', + limit: '490 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/.version.json b/.version.json index ca1be03ae45c..3d31b65ccec6 100644 --- a/.version.json +++ b/.version.json @@ -1,4 +1,4 @@ { "_comment": "Auto-generated by scripts/bump-version.js. Used by the gitflow sync workflow to detect version bumps. Do not edit manually.", - "version": "10.67.0" + "version": "10.73.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e2dedf989564..e16918310df8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,206 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.73.0 + +### Important Changes + +- **feat(v10/nextjs): Add `@sentry/nextjs/config` entry point ([#23766](https://github.com/getsentry/sentry-javascript/pull/23766))** + + `withSentryConfig` is now available from `@sentry/nextjs/config`, the path it moves to in v11. Importing it from `@sentry/nextjs` still works on v10 but logs a warning once, so you can change your `next.config` file today and upgrade to v11 without touching it again. + + ```js + // next.config.mjs + - import { withSentryConfig } from '@sentry/nextjs'; + + import { withSentryConfig } from '@sentry/nextjs/config'; + ``` + +### Other Changes + +- feat(v10/node): Deprecate `shouldHandleError` on `setupExpressErrorHandler` and `setupFasitfyErrorHandler` ([#23734](https://github.com/getsentry/sentry-javascript/pull/23734)) +- fix(v10/cloudflare): Instrument Durable Object handlers installed as read-only properties ([#23769](https://github.com/getsentry/sentry-javascript/pull/23769)) + +
+ Internal Changes + +- test(v10/nextjs): Drop nextjs-16-cf-workers canary variant ([#23775](https://github.com/getsentry/sentry-javascript/pull/23775)) + +
+ +## 10.72.0 + +### Important Changes + +- **AI integrations no longer report errors that propagate to the caller ([#23638](https://github.com/getsentry/sentry-javascript/pull/23638), [#23639](https://github.com/getsentry/sentry-javascript/pull/23639), [#23640](https://github.com/getsentry/sentry-javascript/pull/23640))** + + Across all AI integrations (OpenAI, Anthropic, Google GenAI, LangChain, and LangGraph), the SDK no longer sends an event to Sentry for errors that the AI framework propagates to your code. Previously the instrumentation reported these as unhandled (`handled: false`) before your own error handling ran, so an error your code caught still showed up in Sentry as an unhandled crash. The span is still marked as errored and the error still propagates, so reporting is left to your application: if your code does not handle the error, it reaches Sentry's global error handlers and is captured as unhandled, just like any other uncaught error. Errors that a provider surfaces as data on an otherwise successful response (such as Anthropic error-shaped responses or Google GenAI blocked content) are still captured, since your code never sees them propagate. + +- **feat(v10/cloudflare): Add `rpcTracePropagationBindings` ([#23737](https://github.com/getsentry/sentry-javascript/pull/23737), [#23738](https://github.com/getsentry/sentry-javascript/pull/23738))** + + The new `rpcTracePropagationBindings` option names the `env` bindings that outgoing RPC calls propagate trace context to. Strings match a binding name exactly, regular expressions match by pattern, and the default empty array propagates to nothing. RPC has no headers to carry trace context, so the SDK appends it as a trailing argument that only a Sentry-instrumented receiver removes again. List only the bindings whose receiver you know runs Sentry. Setting the option takes precedence over `enableRpcTracePropagation`, which is now deprecated. When you build with the Sentry Cloudflare Vite plugin, the bindings that resolve to this worker (its own Durable Objects and self service bindings) are derived from your wrangler config and added for you. + +### Other Changes + +- fix(v10/astro): support astro v7 route patterns properly ([#23657](https://github.com/getsentry/sentry-javascript/pull/23657)) +- fix(v10/bundler-plugins): Preserve full file path in component annotation source maps ([#23595](https://github.com/getsentry/sentry-javascript/pull/23595)) +- fix(v10/core): Store child span timeout handle in `_childSpanTimeoutID` ([#23673](https://github.com/getsentry/sentry-javascript/pull/23673)) +- fix(v10/node): Only end the process session when it is still ok ([#23731](https://github.com/getsentry/sentry-javascript/pull/23731)) +- fix(v10/react-router): Use react-router's own instrumentation types instead of a mirrored copy ([#23589](https://github.com/getsentry/sentry-javascript/pull/23589)) +- fix(v10/replay): Suppress Worker destroyed error on session expiry ([#23654](https://github.com/getsentry/sentry-javascript/pull/23654)) +- fix(v10/server-utils): Keep orchestrion registration out of tree-shaking ([#23591](https://github.com/getsentry/sentry-javascript/pull/23591)) +- fix(v10/server-utils): Stop shipping orchestrion bundler plugins as production dependencies ([#23667](https://github.com/getsentry/sentry-javascript/pull/23667)) +- fix(v10/server-utils): Support openai v7 in auto-instrumentation ([#23713](https://github.com/getsentry/sentry-javascript/pull/23713)) +- fix(v10/sveltekit): Detect native tracing in flattened SvelteKit 3 config ([#23656](https://github.com/getsentry/sentry-javascript/pull/23656)) + +
+ Internal Changes + +- chore(v10): Add external contributor to CHANGELOG.md ([#23626](https://github.com/getsentry/sentry-javascript/pull/23626)) +- docs(v10): Changelog + contributor credit for AI caller-handled error fixes ([#23641](https://github.com/getsentry/sentry-javascript/pull/23641)) +- test(v10/e2e): Fix scripts for nuxt dev server ([#23658](https://github.com/getsentry/sentry-javascript/pull/23658)) +- test(v10/e2e): Look up events via the organization trace endpoint ([#23680](https://github.com/getsentry/sentry-javascript/pull/23680)) +- test(v10/e2e): Look up the symbolicated event via the eventids endpoint ([#23681](https://github.com/getsentry/sentry-javascript/pull/23681)) + +
+ +Work in this release was contributed by @ryanrho-mercor, @lux-in-tenebris-lucet, and @suhailopensource. Thank you for your contributions! + +## 10.71.0 + +### Important Changes + +- **feat(v10/core)!: Enable logs by default ([#23311](https://github.com/getsentry/sentry-javascript/pull/23311))** + +The `enableLogs` client option now defaults to `true`, so Sentry Logs work without any manual opt-in. Nothing is captured unless you call the `Sentry.logger.*` APIs or add a log-forwarding integration (such as `consoleLoggingIntegration`, `pinoIntegration`, or the winston transport), and you can set `enableLogs: false` to opt out. Although a default change like this would normally land in a major release, we are shipping it in a minor after careful consideration, since it sends no data on its own and only takes effect once you actively use the logging APIs or a logging integration. + +### Other Changes + +- feat(v10/core): Deprecate `scope.clear()` method ([#23231](https://github.com/getsentry/sentry-javascript/pull/23231)) +- fix(v10/core): Bound child span tracking on long-lived spans ([#23406](https://github.com/getsentry/sentry-javascript/pull/23406)) +- fix(v10/core): Read Supabase PostgREST headers from `Headers` instances ([#23241](https://github.com/getsentry/sentry-javascript/pull/23241)) +- fix(v10/hono): Use `captureException` from scope, not from `Client` ([#23280](https://github.com/getsentry/sentry-javascript/pull/23280)) +- fix(v10/nuxt): Delete source maps after Nitro finishes building ([#23508](https://github.com/getsentry/sentry-javascript/pull/23508)) +- fix(v10/react-router): Carry multi-byte UTF-8 across SSR stream chunk boundaries ([#23421](https://github.com/getsentry/sentry-javascript/pull/23421)) +- fix(v10/react): Match TanStack Router pageload against the router location ([#23494](https://github.com/getsentry/sentry-javascript/pull/23494)) + +
+ Internal Changes + +- test(v10/nextjs): Add e2e app for a user-owned OpenTelemetry setup ([#23278](https://github.com/getsentry/sentry-javascript/pull/23278)) + +
+ +## 10.70.0 + +- feat(v10/core): Support stable MCP SDK v2 ([#22986](https://github.com/getsentry/sentry-javascript/pull/22986)) +- feat(v10/deps): Bump `@sentry/node-cpu-profiler` to 2.4.3 ([#22992](https://github.com/getsentry/sentry-javascript/pull/22992)) +- feat(v10/solid,solidstart): Support `@solidjs/router` v1 ([#23163](https://github.com/getsentry/sentry-javascript/pull/23163)) +- fix(v10/cloudflare): Fork the isolation scope for Durable Object methods ([#23189](https://github.com/getsentry/sentry-javascript/pull/23189)) +- fix(v10/cloudflare): Get original waituntil in workflows ([#23192](https://github.com/getsentry/sentry-javascript/pull/23192)) +- fix(v10/cloudflare): Instrument DO RPC methods on the prototype, not a Proxy ([#23190](https://github.com/getsentry/sentry-javascript/pull/23190)) +- fix(v10/cloudflare): Set agent conversation id on the `onRequest` path ([#22985](https://github.com/getsentry/sentry-javascript/pull/22985)) +- fix(v10/cloudflare): Set conversation id independent of session name ([#23193](https://github.com/getsentry/sentry-javascript/pull/23193)) +- fix(v10/cloudflare): Try/catch on non-configurable prototypes ([#23191](https://github.com/getsentry/sentry-javascript/pull/23191)) +- fix(v10/cloudflare): Use gen_ai.agent.name for class names ([#22987](https://github.com/getsentry/sentry-javascript/pull/22987)) +- fix(v10/core,browser): Handle errors from other realms ([#23201](https://github.com/getsentry/sentry-javascript/pull/23201)) +- fix(v10/core): Sample errors after `beforeSend` while preserving session updates ([#22819](https://github.com/getsentry/sentry-javascript/pull/22819)) +- fix(v10/hono): Include originalException in captured exception hint ([#22990](https://github.com/getsentry/sentry-javascript/pull/22990)) +- fix(v10/nextjs): `meriyah` issue for `standalone` build ([#23055](https://github.com/getsentry/sentry-javascript/pull/23055)) +- fix(v10/nextjs): Remove tracing from middleware wrappers ([#22904](https://github.com/getsentry/sentry-javascript/pull/22904)) +- fix(v10/profiling-node): Respect profileSessionSampleRate in trace profile lifecycle ([#22940](https://github.com/getsentry/sentry-javascript/pull/22940)) +- fix(v10/react-router): Preserve `sourcemaps.disable` when `unstable_sentryVitePluginOptions` is set ([#22966](https://github.com/getsentry/sentry-javascript/pull/22966)) +- fix(v10/react): Remove routes from shared set on `` unmount ([#22948](https://github.com/getsentry/sentry-javascript/pull/22948)) +- fix(v10/sveltekit): Export `metrics` from worker entry point ([#23027](https://github.com/getsentry/sentry-javascript/pull/23027)) + +
+ Internal Changes + +- test(v10/e2e): Add missing `@sentry/core` dep to nextjs-16-userfeedback ([#23009](https://github.com/getsentry/sentry-javascript/pull/23009)) +- test(v10/e2e): Fix failing `sveltekit-3` test ([#23016](https://github.com/getsentry/sentry-javascript/pull/23016)) +- test(v10/e2e): Fix type error in nextjs ai-error tests ([#23011](https://github.com/getsentry/sentry-javascript/pull/23011)) +- test(v10/e2e): Pin tanstackstart-react e2e deps to unblock tunnel tests ([#23048](https://github.com/getsentry/sentry-javascript/pull/23048)) + +
+ +Work in this release was contributed by @davidmurdoch, @Jxxunnn, and @kamilogorek. Thank you for your contributions! + +## 10.69.0 + +### Important Changes + +- **feat(v10/cloudflare): Add `instrumentAgentWithSentry` for Cloudflare Agents ([#22786](https://github.com/getsentry/sentry-javascript/pull/22786))** + +The Cloudflare SDK adds a new `instrumentAgentWithSentry` API for [Cloudflare Agents](https://agents.cloudflare.com/). It works like `instrumentDurableObjectWithSentry` for `Agent` classes from the `agents` SDK and additionally creates spans for `@callable` RPC methods and automatically sets the `conversationId` based on the agent's name. When building with the Sentry Vite plugin, Agents are instrumented automatically ([#22788](https://github.com/getsentry/sentry-javascript/pull/22788)). + +### Other Changes + +- feat(v10/cloudflare): Add Spotlight integration for local dev event forwarding ([#22796](https://github.com/getsentry/sentry-javascript/pull/22796)) +- feat(v10/cloudflare): Add wranglerConfigPath to Vite options ([#22803](https://github.com/getsentry/sentry-javascript/pull/22803)) +- feat(v10/cloudflare): Filter framework-internal Durable Object storage spans ([#22770](https://github.com/getsentry/sentry-javascript/pull/22770)) +- feat(v10/cloudflare): Instrument Agents automatically ([#22788](https://github.com/getsentry/sentry-javascript/pull/22788)) +- feat(v10/cloudflare): Rotate agent conversation id on chat clear ([#22787](https://github.com/getsentry/sentry-javascript/pull/22787)) +- fix(v10/cloudflare): Also skip cf: prefixed DOs ([#22802](https://github.com/getsentry/sentry-javascript/pull/22802)) +- fix(v10/cloudflare): Filter `CREATE INDEX` spans on `cf_`-prefixed tables ([#22767](https://github.com/getsentry/sentry-javascript/pull/22767)) +- fix(v10/cloudflare): Prevent AI provider skips ([#22771](https://github.com/getsentry/sentry-javascript/pull/22771)) +- fix(v10/core): Summarize SQLite upserts so Durable Object `cf_` spans stay filtered ([#22766](https://github.com/getsentry/sentry-javascript/pull/22766)) +- fix(v10/effect): Set `sentry.origin` on logs from `SentryEffectLogger` ([#22806](https://github.com/getsentry/sentry-javascript/pull/22806)) +- fix(v10/gatsby): Add React 19 to peer dependency range ([#22675](https://github.com/getsentry/sentry-javascript/pull/22675)) +- fix(v10/node): Unpin `@apm-js-collab/code-transformer-bundler-plugins` ([#22678](https://github.com/getsentry/sentry-javascript/pull/22678)) +- fix(v10/server-utils): Do not inject dc into client bundle ([#22765](https://github.com/getsentry/sentry-javascript/pull/22765)) + +
+ Internal Changes + +- test(v10/cloudflare): Pin mcp as agent depends on it ([#22769](https://github.com/getsentry/sentry-javascript/pull/22769)) + +
+ +## 10.68.0 + +- feat(cloudflare): Add @sentry/cloudflare/vite orchestrion plugin ([#21967](https://github.com/getsentry/sentry-javascript/pull/21967)) +- feat(nestjs): Support WebSocket errors in SentryGlobalFilter ([#22224](https://github.com/getsentry/sentry-javascript/pull/22224)) +- feat(node,server-utils): Set `cache.key` on dataloader spans and capture redis delete operations as `cache.remove` ([#22389](https://github.com/getsentry/sentry-javascript/pull/22389)) +- feat(server-utils): Allow integrations to be part of marker ([#22094](https://github.com/getsentry/sentry-javascript/pull/22094)) +- feat(server-utils): Migrate `FirebaseInstrumentation` to orchestrion ([#22141](https://github.com/getsentry/sentry-javascript/pull/22141)) +- feat(server-utils): Warn when bundler config has instrumented module in external ([#22379](https://github.com/getsentry/sentry-javascript/pull/22379)) +- feat(v10): Add `http.route` attribute to `http.server` spans with parameterized routes ([#22564](https://github.com/getsentry/sentry-javascript/pull/22564)) +- feat(v10): Add `url.full` and `url.path` to `http.server` spans ([#22533](https://github.com/getsentry/sentry-javascript/pull/22533)) +- feat(v10/cloudflare): Auto-instrument Durable Object classes ([#22541](https://github.com/getsentry/sentry-javascript/pull/22541)) +- feat(v10/cloudflare): Auto-instrument the worker entry with withSentry ([#22540](https://github.com/getsentry/sentry-javascript/pull/22540)) +- feat(v10/cloudflare): Auto-instrument WorkerEntrypoint classes ([#22543](https://github.com/getsentry/sentry-javascript/pull/22543)) +- feat(v10/cloudflare): Auto-instrument Workflow classes ([#22542](https://github.com/getsentry/sentry-javascript/pull/22542)) +- feat(v10/cloudflare): Read wrangler config and resolve the Sentry options module ([#22538](https://github.com/getsentry/sentry-javascript/pull/22538)) +- feat(v10/core): Add `instrumentStateGraph` API ([#22491](https://github.com/getsentry/sentry-javascript/pull/22491)) +- feat(v10/core): Add `url.full` attribute to core fetch instrumentation ([#22436](https://github.com/getsentry/sentry-javascript/pull/22436)) +- feat(v10/core): Support filtering `stackFrameVariables` by variable name ([#22526](https://github.com/getsentry/sentry-javascript/pull/22526)) +- feat(v10/react-router): Make instrumentation API the default ([#22525](https://github.com/getsentry/sentry-javascript/pull/22525)) +- fix(cloudflare,deno,node): Align types of vercelai ([#22343](https://github.com/getsentry/sentry-javascript/pull/22343)) +- fix(core): Instrument Anthropic client in place instead of via a deep proxy ([#22305](https://github.com/getsentry/sentry-javascript/pull/22305)) +- fix(replay): Set text/javascript MIME type on compression worker Blob ([#22377](https://github.com/getsentry/sentry-javascript/pull/22377)) +- fix(sveltekit): Adapt frame rewriting for kit 3 ([#22407](https://github.com/getsentry/sentry-javascript/pull/22407)) +- fix(v10): Pin `@apm-js-collab/code-transformer-bundler-plugins` to 0.7.1 ([#22497](https://github.com/getsentry/sentry-javascript/pull/22497)) +- fix(v10/cloudflare): Import prismaIntegration from server-utils ([#22535](https://github.com/getsentry/sentry-javascript/pull/22535)) +- fix(v10/core): Avoid `functionToStringIntegration` causing infinite recursions ([#22527](https://github.com/getsentry/sentry-javascript/pull/22527)) +- fix(v10/core): Avoid propagating `baggage: "undefined"` when DSC is missing ([#22440](https://github.com/getsentry/sentry-javascript/pull/22440)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#22342](https://github.com/getsentry/sentry-javascript/pull/22342)) +- chore: Add external contributor to CHANGELOG.md ([#22405](https://github.com/getsentry/sentry-javascript/pull/22405)) +- chore(deps): Bump axios from 1.16.0 to 1.18.0 in /dev-packages/e2e-tests/test-applications/nestjs-basic ([#22395](https://github.com/getsentry/sentry-javascript/pull/22395)) +- chore(deps): Bump morgan from 1.10.0 to 1.11.0 ([#22187](https://github.com/getsentry/sentry-javascript/pull/22187)) +- chore(size-limit): weekly auto-bump ([#22182](https://github.com/getsentry/sentry-javascript/pull/22182)) +- ci(v10): Add `v10` to build and license-compliance branch filters ([#22499](https://github.com/getsentry/sentry-javascript/pull/22499)) +- feat(deps): Bump axios from 1.16.0 to 1.18.0 ([#22396](https://github.com/getsentry/sentry-javascript/pull/22396)) +- ref(server-utils): Remove unneeded orchestrion config ([#22384](https://github.com/getsentry/sentry-javascript/pull/22384)) +- ref(server-utils): Small fastify cleanup ([#22385](https://github.com/getsentry/sentry-javascript/pull/22385)) +- test: Remove unnecessary test waits ([#22383](https://github.com/getsentry/sentry-javascript/pull/22383)) +- test(sveltekit-3): Fix import `defineEnvVars` from `@sveltejs/kit/env` ([#22390](https://github.com/getsentry/sentry-javascript/pull/22390)) +- test(v10/cloudflare): Add Vite-build support to the integration-test runner ([#22539](https://github.com/getsentry/sentry-javascript/pull/22539)) + +
+ Work in this release was contributed by @psh4607 and @trinitiwowka. Thank you for your contributions! ## 10.67.0 @@ -77,10 +277,6 @@ Work in this release was contributed by @psh4607 and @trinitiwowka. Thank you fo Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! -### Other Changes - -- fix(replay): Set `text/javascript` MIME type on the compression worker Blob ([#22377](https://github.com/getsentry/sentry-javascript/pull/22377)) - ## 10.66.0 - chore(node-core): Deprecate `@sentry/node-core` package ([#22285](https://github.com/getsentry/sentry-javascript/pull/22285)) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 3b0952904a10..4adf29932a03 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "10.67.0", + "version": "10.73.0", "main": "index.js", "license": "MIT", "engines": { @@ -60,9 +60,9 @@ "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.56.0", "@sentry/rrweb": "2.43.2", - "@sentry/browser": "10.67.0", - "@sentry/replay": "10.67.0", - "@sentry/opentelemetry": "10.67.0", + "@sentry/browser": "10.73.0", + "@sentry/replay": "10.73.0", + "@sentry/opentelemetry": "10.73.0", "@sentry/conventions": "0.16.0", "@supabase/supabase-js": "2.49.3", "axios": "1.18.0", diff --git a/dev-packages/browser-integration-tests/suites/public-api/captureException/cross-realm-error-cause/subject.js b/dev-packages/browser-integration-tests/suites/public-api/captureException/cross-realm-error-cause/subject.js new file mode 100644 index 000000000000..8735e8b46a1b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/public-api/captureException/cross-realm-error-cause/subject.js @@ -0,0 +1,15 @@ +const iframe = document.createElement('iframe'); + +iframe.srcdoc = ` + + + diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/index.ts new file mode 100644 index 000000000000..df01c6e476cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/index.ts @@ -0,0 +1,12 @@ +import { streamText } from 'ai'; + +// The worker imports an orchestrion-instrumented module (`ai`), so the server +// bundle is expected to contain `diagnostics_channel` injections. +export default { + async fetch(request: Request): Promise { + if (new URL(request.url).pathname === '/worker') { + return new Response(`streamText: ${typeof streamText}`); + } + return new Response('not found', { status: 404 }); + }, +}; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/test.ts new file mode 100644 index 000000000000..3d858b0a3068 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/test.ts @@ -0,0 +1,33 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +function readBundles(dir: string): string { + return readdirSync(dir, { withFileTypes: true, recursive: true }) + .filter(entry => entry.isFile() && /\.m?js$/.test(entry.name)) + .map(entry => readFileSync(join(entry.parentPath, entry.name), 'utf8')) + .join('\n'); +} + +// Regression test: orchestrion splices `node:diagnostics_channel` calls into +// instrumented modules, which only exist server-side. When a worker ships +// browser assets, Vite produces a `client` bundle next to the server (worker) +// bundle — and the injected `tracingChannel` calls used to land in the client +// bundle too, where they throw `X is not a function` in the browser. +it('injects diagnostics_channel calls into the server bundle only, not the client bundle', async ({ signal }) => { + const runner = createRunner(__dirname).start(signal); + + // Waits for `vite build` + wrangler boot and proves the instrumented worker + // still runs. + const response = await runner.makeRequest('get', '/worker'); + expect(response).toBe('streamText: function'); + + // The worker imports `ai`, so the server bundle must actually be + // instrumented — otherwise a plugin that never runs would also pass. + const workerBundle = readBundles(join(__dirname, 'dist', 'cloudflare_vite_dc_client_build')); + expect(workerBundle).toContain('orchestrion:ai:streamText'); + + const clientBundle = readBundles(join(__dirname, 'dist', 'client')); + expect(clientBundle).not.toContain('orchestrion:ai'); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/vite.config.mts new file mode 100644 index 000000000000..541d36ac0a61 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/vite.config.mts @@ -0,0 +1,14 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + useDiagnosticsChannelInjection: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/wrangler.jsonc new file mode 100644 index 000000000000..6c4f68500e1e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-dc-client-build", + // `main` points at the source entry; the runner detects `vite.config.mts`, runs + // `vite build`, and serves the built output (so the orchestrion transform runs). + "main": "index.ts", + "compatibility_date": "2026-04-26", + "compatibility_flags": ["nodejs_compat"], + // Giving the worker assets makes the Cloudflare Vite plugin produce a browser + // (`client`) bundle next to the server (worker) bundle — the setup where the + // orchestrion plugin must not touch the client output. + "assets": { + "directory": "./dist/client", + }, +} diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index 78de7fb40aa3..cdf184b04839 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/deno-integration-tests", - "version": "10.67.0", + "version": "10.73.0", "license": "MIT", "engines": { "node": ">=18" @@ -15,8 +15,8 @@ "test:unit": "deno test --allow-net --allow-read --allow-run --allow-env --no-check" }, "dependencies": { - "@sentry/core": "10.67.0", - "@sentry/deno": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/deno": "10.73.0", "mysql": "^2.18.1", "pg": "^8.22.0" }, diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 12c885301b66..ea720ac2c5ae 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/e2e-tests", - "version": "10.67.0", + "version": "10.73.0", "license": "MIT", "private": true, "scripts": { diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tracing.dynamic.test.ts index a5bbc408862c..548b709fdbcf 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tracing.dynamic.test.ts @@ -242,6 +242,8 @@ test.describe('nested SSR routes (client, server, server request)', () => { 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.fetch', url: expect.stringContaining('/api/user/myUsername123.json'), + 'http.url': 'http://localhost:3030/api/user/myUsername123.json', + 'url.full': 'http://localhost:3030/api/user/myUsername123.json', }, }); diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index aaf618146769..ac87f4cc79ca 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -12,11 +12,11 @@ "test:assert": "TEST_ENV=production playwright test" }, "dependencies": { - "@astrojs/node": "^11.0.0-alpha.0", + "@astrojs/node": "^11.1.4", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", - "astro": "beta" + "astro": "^7.2.4" }, "volta": { "node": "22.22.0", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json index 6c834c1d9f47..5b3e52642a92 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json @@ -14,26 +14,35 @@ "test:dev": "TEST_ENV=development playwright test" }, "dependencies": { - "@cloudflare/ai-chat": "^0.7.1", - "@sentry/cloudflare": "^10.53.1", - "agents": "^0.13.1", - "react": "^19.2.6", - "react-dom": "^19.2.6" + "@cloudflare/ai-chat": "^0.10.0", + "@sentry/cloudflare": "^10.68.0", + "@sentry/core": "^10.68.0", + "agents": "latest", + "ai": "^6.0.235", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "workers-ai-provider": "^3.3.1" }, "devDependencies": { "@playwright/test": "~1.56.0", - "@cloudflare/vite-plugin": "^1.37.2", - "@cloudflare/workers-types": "^4.20260520.1", + "@cloudflare/vite-plugin": "^1.47.0", + "@cloudflare/workers-types": "^5.20260727.1", "@sentry-internal/test-utils": "link:../../../test-utils", - "@types/node": "^24.12.4", - "@types/react": "^19.2.15", + "@types/node": "^26.1.2", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "globals": "^17.6.0", + "@types/ws": "^8.18.1", + "@babel/core": "^8.0.1", + "@babel/plugin-proposal-decorators": "^8.0.2", + "@vitejs/plugin-react": "^6.0.4", + "globals": "^17.8.0", "typescript": "~6.0.3", - "vite": "^8.0.14", - "wrangler": "^4.93.0", - "ws": "^8.20.1" + "vite": "^8.1.5", + "wrangler": "^4.114.0", + "ws": "^8.21.1" + }, + "sentryTest": { + "optional": true }, "volta": { "node": "24.15.0", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/agent-socket.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/agent-socket.ts new file mode 100644 index 000000000000..96437eb47738 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/agent-socket.ts @@ -0,0 +1,90 @@ +import WebSocket from 'ws'; + +type AgentReply = { type?: string; id?: string; done?: boolean }; + +/** Sends a single frame over a WS to the given agent instance and resolves once `isDone` matches a reply. */ +function driveAgentSocket( + baseURL: string, + binding: string, + instance: string, + frame: unknown, + isDone: (reply: AgentReply) => boolean, + timeoutLabel: string, +): Promise { + const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${binding}/${instance}`; + + return new Promise((resolveSocket, rejectSocket) => { + const socket = new WebSocket(wsUrl); + const timeout = setTimeout(() => { + socket.close(); + rejectSocket(new Error(`Timed out waiting for ${timeoutLabel}`)); + }, 15_000); + + socket.on('open', () => { + socket.send(JSON.stringify(frame)); + }); + + socket.on('message', data => { + try { + const parsed = JSON.parse(data.toString()) as AgentReply; + if (isDone(parsed)) { + clearTimeout(timeout); + socket.close(); + resolveSocket(); + } + } catch { + // Ignore non-JSON / unrelated frames. + } + }); + + socket.on('error', err => { + clearTimeout(timeout); + rejectSocket(err); + }); + }); +} + +/** Opens a chat WebSocket to `/agents//`, sends one `cf_agent_use_chat_request` frame. */ +export function sendChatMessage( + baseURL: string, + options: { binding: string; instance: string; prompt: string }, +): Promise { + const id = `chat-${options.instance}`; + const frame = { + type: 'cf_agent_use_chat_request', + id, + init: { + method: 'POST', + body: JSON.stringify({ + messages: [{ id: 'msg-1', role: 'user', parts: [{ type: 'text', text: options.prompt }] }], + }), + }, + }; + + return driveAgentSocket( + baseURL, + options.binding, + options.instance, + frame, + reply => reply.type === 'cf_agent_use_chat_response' && reply.id === id && !!reply.done, + 'chat response', + ); +} + +/** Opens a WebSocket to `/agents//`, sends one RPC frame, resolves on the reply. */ +export function callRpc( + baseURL: string, + options: { binding: string; instance: string; method: string; args: unknown[] }, +): Promise { + const id = `rpc-${options.method}`; + const frame = { type: 'rpc', id, method: options.method, args: options.args }; + + return driveAgentSocket( + baseURL, + options.binding, + options.instance, + frame, + reply => reply.type === 'rpc' && reply.id === id && !!reply.done, + `RPC reply to "${options.method}"`, + ); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/ai-streaming.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/ai-streaming.test.ts new file mode 100644 index 000000000000..35933bc035b0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/ai-streaming.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Drives Workers AI through the real Cloudflare Agents SDK + Vercel AI SDK + `workers-ai-provider` +// stack (the OpenAI-compatible SSE shape, `choices[].delta.content`) from an Agent's `onRequest`. +// Asserts the streaming response text is captured on the gen_ai span — the regression seen in +// production where only input + usage survived. The model output is read from +// `gen_ai.output.messages`, so the streaming instrumentation must emit it alongside the +// deprecated `gen_ai.response.text`. +function assertGenAiStreamingSpan(spans: Array> | undefined): void { + const genAiSpan = (spans ?? []).find(span => span.op === 'gen_ai.chat'); + + expect(genAiSpan).toBeDefined(); + expect(genAiSpan.origin).toBe('auto.ai.cloudflare.workers_ai'); + expect(genAiSpan.data).toEqual( + expect.objectContaining({ + 'sentry.origin': 'auto.ai.cloudflare.workers_ai', + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': '@cf/meta/llama-3.1-8b-instruct', + 'gen_ai.response.streaming': true, + 'gen_ai.response.text': 'The capital of France is Paris.', + 'gen_ai.output.messages': JSON.stringify([ + { role: 'assistant', parts: [{ type: 'text', content: 'The capital of France is Paris.' }] }, + ]), + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.usage.total_tokens': 23, + // The conversation id is minted by the SDK and persisted per agent instance, not derived from + // the instance name, so only its shape is stable: `uuid4()` without dashes. + 'gen_ai.conversation.id': expect.stringMatching(/^[0-9a-f]{32}$/), + }), + ); +} + +test('captures Workers AI streaming output when driven via an Agent', async ({ request, baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /agents/my-agent/test' && + (transactionEvent.spans ?? []).some(span => span.op === 'gen_ai.chat') + ); + }); + + const response = await request.get(`${baseURL}/agents/my-agent/test`); + expect(response.ok()).toBe(true); + + const transaction = await transactionPromise; + assertGenAiStreamingSpan(transaction.spans); +}); + +test('captures Workers AI streaming output when driven via an AIChatAgent', async ({ request, baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /agents/my-chat-agent/test' && + (transactionEvent.spans ?? []).some(span => span.op === 'gen_ai.chat') + ); + }); + + const response = await request.get(`${baseURL}/agents/my-chat-agent/test`); + expect(response.ok()).toBe(true); + + const transaction = await transactionPromise; + assertGenAiStreamingSpan(transaction.spans); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts index bdd5bd22b8c0..0146f4e8724f 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -test('@callable() methods work correctly with Sentry instrumentDurableObjectWithSentry', async ({ page, baseURL }) => { +test('@callable() methods work correctly with Sentry instrumentAgentWithSentry', async ({ page, baseURL }) => { const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { return ( transactionEvent.transaction === 'GET /agents/my-agent/user-123' && @@ -9,6 +9,16 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith ); }); + // The greet() call goes over the websocket, so its storage spans land in a webSocketMessage + // transaction. Filter for the one carrying our put span — control messages produce their own + // webSocketMessage transactions without storage spans. + const storageTransactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return ( + transactionEvent.transaction === 'webSocketMessage' && + (transactionEvent.spans ?? []).some(span => span.description === 'durable_object_storage_put') + ); + }); + await page.goto(baseURL!); await expect(page.getByText('Connected')).toBeVisible(); @@ -32,24 +42,7 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith culture: { timezone: expect.any(String) }, runtime: { name: 'cloudflare' }, }, - spans: expect.arrayContaining([ - expect.objectContaining({ - data: { - 'db.operation.name': 'get', - 'db.system.name': 'cloudflare.durable_object.storage', - 'sentry.op': 'db', - 'sentry.origin': 'auto.db.cloudflare.durable_object', - }, - description: 'durable_object_storage_get', - op: 'db', - origin: 'auto.db.cloudflare.durable_object', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }), - ]), + spans: [], start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: 'GET /agents/my-agent/user-123', @@ -72,4 +65,124 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith packages: expect.any(Array), }, }); + + // greet() touches 6 storage keys: 2 user ops + 3 framework-internal keys (cf_, __ps_, /) that + // must be filtered + 1 allowlisted cf_ key. Spans carry no key attribute, so filtering can only + // be verified by count — exactly these 3 storage spans (in execution order) should survive, and + // any framework-internal span leaking through shows up as an extra entry here. + const storageTransaction = await storageTransactionPromise; + + const storageSpans = (storageTransaction.spans ?? []).filter( + span => span.origin === 'auto.db.cloudflare.durable_object', + ); + + expect(storageSpans).toEqual([ + expect.objectContaining({ + data: { + 'db.operation.name': 'put', + 'db.system.name': 'cloudflare.durable_object.storage', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.cloudflare.durable_object', + }, + description: 'durable_object_storage_put', + op: 'db', + origin: 'auto.db.cloudflare.durable_object', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + expect.objectContaining({ + data: { + 'db.operation.name': 'get', + 'db.system.name': 'cloudflare.durable_object.storage', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.cloudflare.durable_object', + }, + description: 'durable_object_storage_get', + op: 'db', + origin: 'auto.db.cloudflare.durable_object', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + expect.objectContaining({ + data: { + 'db.operation.name': 'get', + 'db.system.name': 'cloudflare.durable_object.storage', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.cloudflare.durable_object', + }, + description: 'durable_object_storage_get', + op: 'db', + origin: 'auto.db.cloudflare.durable_object', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }), + ]); +}); + +test('does not emit db.query spans for the agents runtime `cf_`-prefixed internal tables', async ({ + page, + baseURL, +}) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /agents/my-agent/user-123' && + transactionEvent.contexts?.trace?.parent_span_id !== undefined + ); + }); + + await page.goto(baseURL!); + + await expect(page.getByText('Connected')).toBeVisible(); + await page.getByRole('button', { name: 'Call Agent' }).click(); + await expect(page.getByText('Hello, World!')).toBeVisible(); + + const transaction = await transactionPromise; + + // The agents runtime constantly queries its own `cf_agents_*` / `cf_agent_*` bookkeeping tables. + // These are framework internals and are filtered out by default, so no such span should leak. + const internalTableSpans = (transaction.spans ?? []).filter( + span => span.op === 'db.query' && /\bcf_/.test((span.data?.['db.query.summary'] as string) ?? ''), + ); + + expect(internalTableSpans).toEqual([]); +}); + +test('creates an rpc span named after the @callable() method', async ({ page, baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return ( + transactionEvent.transaction === 'webSocketMessage' && + (transactionEvent.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet') + ); + }); + + await page.goto(baseURL!); + + await expect(page.getByText('Connected')).toBeVisible(); + await page.getByRole('button', { name: 'Call Agent' }).click(); + await expect(page.getByText('Hello, World!')).toBeVisible(); + + const transaction = await transactionPromise; + + const rpcSpans = (transaction.spans ?? []).filter(span => span.op === 'rpc'); + expect(rpcSpans).toHaveLength(1); + + expect(rpcSpans[0]).toEqual( + expect.objectContaining({ + op: 'rpc', + description: 'greet', + origin: 'auto.faas.cloudflare.agents', + data: expect.objectContaining({ + 'gen_ai.agent.name': 'MyBaseAgent', + }), + }), + ); }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/chat-conversation.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/chat-conversation.test.ts new file mode 100644 index 000000000000..a7e8307e2dea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/chat-conversation.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { callRpc, sendChatMessage } from './agent-socket'; + +const AGENT_INSTANCE = 'chat-conv-instance'; + +// In the Agents model one agent instance is one conversation. The SDK mints the conversation id +// itself and persists it in the instance's Durable Object storage rather than deriving it from the +// caller-chosen instance name, so the assertion is on the shape of the id — `uuid4()` from +// `@sentry/core`, i.e. 32 hex characters without dashes. +const UUID_PATTERN = /^[0-9a-f]{32}$/; + +function getGenAiSpan(spans: Array> | undefined): Record { + const genAiSpan = (spans ?? []).find(span => span.op === 'gen_ai.chat'); + expect(genAiSpan).toBeDefined(); + + return genAiSpan as Record; +} + +test('stamps the conversation id on gen_ai spans created inside a chat turn', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return ( + transactionEvent.transaction === 'webSocketMessage' && + (transactionEvent.spans ?? []).some(span => span.op === 'gen_ai.chat') + ); + }); + + await sendChatMessage(baseURL!, { + binding: 'my-chat-agent', + instance: AGENT_INSTANCE, + prompt: 'What is the capital of France?', + }); + + const transaction = await transactionPromise; + + expect(getGenAiSpan(transaction.spans).data['gen_ai.conversation.id']).toMatch(UUID_PATTERN); +}); + +// The agent calls `Sentry.setConversationId('conv_manual_e2e')` at the start of `onChatMessage`, the +// recipe the docs recommend for keying a conversation on the app's own id. The manual id must win +// over the SDK-minted uuid that the instrumentation put on the scope before the handler ran. +test('a conversation id set manually inside onChatMessage wins over the SDK-minted one', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return ( + transactionEvent.transaction === 'webSocketMessage' && + (transactionEvent.spans ?? []).some(span => span.op === 'gen_ai.chat') + ); + }); + + await sendChatMessage(baseURL!, { + binding: 'my-manual-chat-agent', + instance: 'chat-manual-instance', + prompt: 'What is the capital of France?', + }); + + const transaction = await transactionPromise; + + expect(getGenAiSpan(transaction.spans).data['gen_ai.conversation.id']).toBe('conv_manual_e2e'); +}); + +// Same recipe on the other two entry points the SDK wraps: the manual id must win regardless of +// which handler the agent uses for its AI work. +test('a conversation id set manually inside onRequest wins over the SDK-minted one', async ({ request, baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /agents/my-manual-chat-agent/chat-manual-request-instance' && + (transactionEvent.spans ?? []).some(span => span.op === 'gen_ai.chat') + ); + }); + + const response = await request.get(`${baseURL}/agents/my-manual-chat-agent/chat-manual-request-instance`); + expect(response.ok()).toBe(true); + + const transaction = await transactionPromise; + + expect(getGenAiSpan(transaction.spans).data['gen_ai.conversation.id']).toBe('conv_manual_e2e'); +}); + +test('a conversation id set manually inside a callable RPC method wins over the SDK-minted one', async ({ + baseURL, +}) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return (transactionEvent.spans ?? []).some(span => span.op === 'gen_ai.chat'); + }); + + await callRpc(baseURL!, { + binding: 'my-manual-chat-agent', + instance: 'chat-manual-rpc-instance', + method: 'runAiTurn', + args: [], + }); + + const transaction = await transactionPromise; + + expect(getGenAiSpan(transaction.spans).data['gen_ai.conversation.id']).toBe('conv_manual_e2e'); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/chat-rpc.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/chat-rpc.test.ts new file mode 100644 index 000000000000..64dc206d004b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/chat-rpc.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { callRpc } from './agent-socket'; + +const AGENT_INSTANCE = 'chat-rpc-instance'; + +test('creates an rpc span for a @callable() invocation on an AIChatAgent', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + return ( + transactionEvent.transaction === 'webSocketMessage' && + (transactionEvent.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet') + ); + }); + + await callRpc(baseURL!, { binding: 'my-chat-agent', instance: AGENT_INSTANCE, method: 'greet', args: ['World'] }); + + const transaction = await transactionPromise; + + const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet'); + expect(rpcSpan).toEqual( + expect.objectContaining({ + op: 'rpc', + description: 'greet', + origin: 'auto.faas.cloudflare.agents', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker-configuration.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker-configuration.d.ts index 7e95ce232f43..c68101bcc04a 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker-configuration.d.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker-configuration.d.ts @@ -4,11 +4,13 @@ interface __BaseEnv_Env { CF_VERSION_METADATA: WorkerVersionMetadata; E2E_TEST_DSN: string; MyAgent: DurableObjectNamespace; + MyChatAgent: DurableObjectNamespace; + MyManualChatAgent: DurableObjectNamespace; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import('./worker/index'); - durableNamespaces: 'MyAgent'; + durableNamespaces: 'MyAgent' | 'MyChatAgent' | 'MyManualChatAgent'; } interface Env extends __BaseEnv_Env {} } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts index fa795b85d8f0..cdefd6957a92 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts @@ -1,39 +1,123 @@ import * as Sentry from '@sentry/cloudflare'; -import { routeAgentRequest, Agent, callable } from 'agents'; +import { instrumentWorkersAiClient } from '@sentry/core'; +import { AIChatAgent } from '@cloudflare/ai-chat'; +import { Agent, callable, routeAgentRequest } from 'agents'; +import { streamText } from 'ai'; +import { createWorkersAI } from 'workers-ai-provider'; +import { MockAi } from './mocks'; -class MyBaseAgent extends Agent { +const MODEL = '@cf/meta/llama-3.1-8b-instruct'; + +const sentryOptions = (env: Env) => ({ + traceLifecycle: 'static' as const, + dsn: env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1, + enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MyAgent', 'MyChatAgent', 'MyManualChatAgent'], + // Keep gen_ai spans embedded in the transaction (instead of streamed as a separate envelope + // container) so they can be asserted on `transaction.spans`. + streamGenAiSpans: false, + durableObjectStorageSpanAllowlist: ['cf_user_key'], +}); + +/** + * In production `env.AI` is auto-instrumented by `@sentry/cloudflare`. There is no real AI + * binding offline, so we instrument the mock binding manually and drive it through the real + * Vercel AI SDK + `workers-ai-provider` stack (the OpenAI-compatible SSE shape). + */ +function streamWorkersAi(): Response { + const ai = instrumentWorkersAiClient(new MockAi(), { recordInputs: true, recordOutputs: true }); + const workersai = createWorkersAI({ binding: ai as unknown as Ai }); + + const result = streamText({ + model: workersai(MODEL), + prompt: 'What is the capital of France?', + }); + + return result.toTextStreamResponse(); +} + +class MyBaseAgent extends Agent { + @callable() + async greet(name: string): Promise { + // User keys — instrumented, spans expected + await this.ctx.storage.put('test', 'any value'); + await this.ctx.storage.get('test'); + + // Framework-internal keys (agents/partyserver/MCP OAuth conventions) — filtered, no spans expected + await this.ctx.storage.put('cf_e2e_internal', 'bookkeeping'); + await this.ctx.storage.get('__ps_name'); + await this.ctx.storage.get('/oauth/client/token'); + + // Allowlisted cf_ key — span expected + await this.ctx.storage.get('cf_user_key'); + + return `Hello, ${name}!`; + } + + async onRequest(): Promise { + return streamWorkersAi(); + } +} + +class MyChatAgentBase extends AIChatAgent { @callable() async greet(name: string): Promise { return `Hello, ${name}!`; } + + async onRequest(): Promise { + return streamWorkersAi(); + } + + async onChatMessage(): Promise { + // The gen_ai turn must run inside `onChatMessage` (not `onRequest`) so it happens while the + // SDK has set the conversation id on the scope for this chat turn — that is what + // `conversationIdIntegration` reads to stamp `gen_ai.conversation.id` onto the span. + return streamWorkersAi(); + } +} + +// Not exported: the Workers runtime rejects any module export that isn't a handler/Durable Object. +const MANUAL_CONVERSATION_ID = 'conv_manual_e2e'; + +// Mirrors the docs recipe: the app keys conversations on its own id rather than the SDK-minted one. +class MyManualChatAgentBase extends AIChatAgent { + @callable() + async runAiTurn(): Promise { + Sentry.setConversationId(MANUAL_CONVERSATION_ID); + + // Unlike onRequest/onChatMessage nothing consumes the returned stream here, so the gen_ai span + // only finishes if we drain it ourselves. + return streamWorkersAi().text(); + } + + async onRequest(): Promise { + Sentry.setConversationId(MANUAL_CONVERSATION_ID); + + return streamWorkersAi(); + } + + async onChatMessage(): Promise { + Sentry.setConversationId(MANUAL_CONVERSATION_ID); + + return streamWorkersAi(); + } } -export const MyAgent = Sentry.instrumentDurableObjectWithSentry( - (env: Env) => ({ - dsn: env.E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, - tracesSampleRate: 1, - enableRpcTracePropagation: true, - }), - MyBaseAgent, -); - -export default Sentry.withSentry( - (env: Env) => ({ - dsn: env.E2E_TEST_DSN, - tunnel: `http://localhost:3031/`, - tracesSampleRate: 1, - enableRpcTracePropagation: true, - }), - { - async fetch(request: Request, env: Env): Promise { - const agentResponse = await routeAgentRequest(request, env); - - if (agentResponse) { - return agentResponse; - } - - return new Response(null, { status: 404 }); - }, - } satisfies ExportedHandler, -); +export const MyAgent = Sentry.instrumentAgentWithSentry(sentryOptions, MyBaseAgent); +export const MyChatAgent = Sentry.instrumentAgentWithSentry(sentryOptions, MyChatAgentBase); +export const MyManualChatAgent = Sentry.instrumentAgentWithSentry(sentryOptions, MyManualChatAgentBase); + +export default Sentry.withSentry(sentryOptions, { + async fetch(request: Request, env: Env): Promise { + const agentResponse = await routeAgentRequest(request, env); + + if (agentResponse) { + return agentResponse; + } + + return new Response(null, { status: 404 }); + }, +} satisfies ExportedHandler); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/mocks.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/mocks.ts new file mode 100644 index 000000000000..4fba65c6a777 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/mocks.ts @@ -0,0 +1,44 @@ +import { simulateReadableStream } from 'ai'; + +function createSseStream(events: string[]): ReadableStream { + const encoder = new TextEncoder(); + return simulateReadableStream({ + initialDelayInMs: 0, + chunkDelayInMs: 0, + chunks: events.map(event => encoder.encode(`data: ${event}\n\n`)), + }); +} + +/** + * Minimal mock of the Cloudflare Workers AI binding (`env.AI`) that emits the + * OpenAI-compatible streaming shape (`choices[].delta.content`) — the format models + * routed through `workers-ai-provider` stream when driven by an Agent / `AIChatAgent`. + * + * This is the format `workers-ai-provider` receives from `binding.run(..., { stream: true })` + * for models routed through the OpenAI-compatible endpoint (which the Agents SDK uses). + * The native `{ response }` shape is covered separately. + */ +export class MockAi { + public async run(_model: string, inputs: Record): Promise { + await new Promise(resolve => setTimeout(resolve, 10)); + + if (inputs?.stream === true) { + return createSseStream([ + '{"choices":[{"index":0,"delta":{"content":"The capital "},"finish_reason":null}]}', + '{"choices":[{"index":0,"delta":{"content":"of France "},"finish_reason":null}]}', + '{"choices":[{"index":0,"delta":{"content":"is Paris."},"finish_reason":"stop"}]}', + '{"usage":{"prompt_tokens":15,"completion_tokens":8,"total_tokens":23}}', + '[DONE]', + ]); + } + + return { + response: 'The capital of France is Paris.', + usage: { + prompt_tokens: 15, + completion_tokens: 8, + total_tokens: 23, + }, + }; + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-agent/wrangler.jsonc index de8b5998eac4..9c4cc5f9a16e 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/wrangler.jsonc @@ -23,10 +23,14 @@ "compatibility_flags": ["nodejs_compat"], "durable_objects": { - "bindings": [{ "name": "MyAgent", "class_name": "MyAgent" }], + "bindings": [ + { "name": "MyAgent", "class_name": "MyAgent" }, + { "name": "MyChatAgent", "class_name": "MyChatAgent" }, + { "name": "MyManualChatAgent", "class_name": "MyManualChatAgent" }, + ], }, - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }], + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent", "MyChatAgent", "MyManualChatAgent"] }], "version_metadata": { "binding": "CF_VERSION_METADATA", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/.gitignore new file mode 100644 index 000000000000..3f64191fe034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/.gitignore @@ -0,0 +1,4 @@ +dist +.wrangler +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json new file mode 100644 index 000000000000..bf1f6c26c7ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/package.json @@ -0,0 +1,38 @@ +{ + "name": "cloudflare-autoinstrument", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "typecheck": "tsc --noEmit", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "dependencies": { + "@cloudflare/ai-chat": "^0.10.0", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "agents": "^0.20.0" + }, + "devDependencies": { + "@babel/core": "^8.0.1", + "@babel/plugin-proposal-decorators": "^8.0.2", + "@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", + "@types/ws": "^8.18.1", + "typescript": "~6.0.3", + "vite": "^8.1.5", + "wrangler": "^4.114.0", + "ws": "^8.21.1" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/playwright.config.ts new file mode 100644 index 000000000000..61beb3ea57c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/playwright.config.ts @@ -0,0 +1,16 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +// `vite build` runs the Sentry auto-instrument transform over the worker entry; +// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy` +// redirect) serves the built output. The tests therefore assert on the wrapping +// the plugin injected at build time, not on anything in the source entry. +export default getPlaywrightConfig( + { + startCommand: 'pnpm preview', + port: 8787, + }, + { + workers: '100%', + retries: 0, + }, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/base.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/base.ts new file mode 100644 index 000000000000..a3fd5b7a5fc9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/base.ts @@ -0,0 +1,12 @@ +import { Agent } from 'agents'; + +/** + * An Agent base class living outside the worker entry. `DerivedAgent` in + * `index.ts` extends this, so the plugin only learns it is an Agent by + * following the import into this module and resolving `MyBase -> Agent`. + */ +export class MyBase extends Agent { + async onRequest(): Promise { + return Response.json({ agent: 'derived' }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/env.d.ts new file mode 100644 index 000000000000..ba9639c4f109 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/env.d.ts @@ -0,0 +1,7 @@ +interface Env { + E2E_TEST_DSN: string; + MyAgent: DurableObjectNamespace; + MyChatAgent: DurableObjectNamespace; + DerivedAgent: DurableObjectNamespace; + PlainDO: DurableObjectNamespace; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/index.ts new file mode 100644 index 000000000000..5026f1fbd13b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/index.ts @@ -0,0 +1,73 @@ +import { AIChatAgent } from '@cloudflare/ai-chat'; +import { Agent, callable, routeAgentRequest } from 'agents'; +import { DurableObject } from 'cloudflare:workers'; +import { MyBase } from './base'; + +// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of +// `@sentry/cloudflare`. Everything below is wrapped at build time by +// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`, +// which reads wrangler.jsonc, wraps the default export with `withSentry`, and +// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents, +// `instrumentDurableObjectWithSentry` for the plain Durable Object. +// +// Options come from `instrument.server.ts` next to this entry. + +/** Agent whose base class (`Agent`) is imported directly into the entry. */ +export class MyAgent extends Agent { + @callable() + async greet(name: string): Promise { + return `Hello, ${name}! (from MyAgent)`; + } + + async onRequest(): Promise { + return Response.json({ agent: 'plain' }); + } +} + +/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */ +export class MyChatAgent extends AIChatAgent { + @callable() + async greet(name: string): Promise { + return `Hello, ${name}! (from MyChatAgent)`; + } + + async onRequest(): Promise { + return Response.json({ agent: 'chat' }); + } +} + +/** Agent whose base class lives in `./base` — resolvable only across modules. */ +export class DerivedAgent extends MyBase { + @callable() + async greet(name: string): Promise { + return `Hello, ${name}! (from DerivedAgent)`; + } +} + +/** + * A genuine Durable Object. Configured identically to the Agents above, so it + * proves detection discriminates rather than upgrading every DO binding. + */ +export class PlainDO extends DurableObject { + async fetch(): Promise { + return Response.json({ durableObject: true }); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/plain-do') { + const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance')); + return stub.fetch(request); + } + + const agentResponse = await routeAgentRequest(request, env); + if (agentResponse) { + return agentResponse; + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/instrument.server.ts new file mode 100644 index 000000000000..1b1cc4dcefbd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/instrument.server.ts @@ -0,0 +1,14 @@ +// The auto-instrument plugin picks this file up by convention (it sits next to +// the worker entry named in wrangler's `main`) and imports its default export as +// the options callback for every wrapper it injects. +export default (env: Env) => ({ + traceLifecycle: 'static' as const, + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + enableRpcTracePropagation: true, + tracesSampleRate: 1.0, + transportOptions: { + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/start-event-proxy.mjs new file mode 100644 index 000000000000..2e0f00ecdb57 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-autoinstrument', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/agent-socket.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/agent-socket.ts new file mode 100644 index 000000000000..87721b93d029 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/agent-socket.ts @@ -0,0 +1,46 @@ +import WebSocket from 'ws'; + +type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown }; + +/** + * Opens a WebSocket to `/agents//`, sends one RPC frame, and + * resolves with the reply's `result` — the method's return value — once it arrives. + */ +export function callRpc( + baseURL: string, + options: { binding: string; instance: string; method: string; args: unknown[] }, +): Promise { + const id = `rpc-${options.method}`; + const frame = { type: 'rpc', id, method: options.method, args: options.args }; + const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`; + + return new Promise((resolveSocket, rejectSocket) => { + const socket = new WebSocket(wsUrl); + const timeout = setTimeout(() => { + socket.close(); + rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`)); + }, 15_000); + + socket.on('open', () => { + socket.send(JSON.stringify(frame)); + }); + + socket.on('message', data => { + try { + const parsed = JSON.parse(data.toString()) as AgentReply; + if (parsed.type === 'rpc' && parsed.id === id && parsed.done) { + clearTimeout(timeout); + socket.close(); + resolveSocket(parsed.result); + } + } catch { + // Ignore non-JSON / unrelated frames. + } + }); + + socket.on('error', err => { + clearTimeout(timeout); + rejectSocket(err); + }); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/autoinstrument.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/autoinstrument.test.ts new file mode 100644 index 000000000000..4a8d4d8f3bce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tests/autoinstrument.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { callRpc } from './agent-socket'; + +// The worker entry (`src/index.ts`) contains no Sentry calls at all — every +// wrapper below was injected by the Vite auto-instrument plugin at build time. +// Any transaction arriving here therefore proves the injection happened. + +test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => { + return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do'); + }); + + const res = await fetch(`${baseURL}/plain-do`); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ durableObject: true }); + + const transaction = await transactionPromise; + + expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare'); + // `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here + // proves the plugin sourced its options callback from that file rather than + // falling back to reading configuration off `env`. + expect(transaction.environment).toBe('qa'); +}); + +// Each of these three classes is registered in wrangler.jsonc exactly like the +// plain Durable Object below — only the base-class chain marks them as Agents. +// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by +// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct +// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap. +for (const { title, binding, agentClass } of [ + { + title: 'an Agent subclass declared in the entry', + binding: 'my-agent', + agentClass: 'MyAgent', + }, + { + title: 'an AIChatAgent subclass declared in the entry', + binding: 'my-chat-agent', + agentClass: 'MyChatAgent', + }, + { + title: 'an Agent subclass whose base class lives in another module', + binding: 'derived-agent', + agentClass: 'DerivedAgent', + }, +]) { + test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => { + const instance = `${binding}-instance`; + + const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => { + return ( + event.transaction === 'webSocketMessage' && + (event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet') + ); + }); + + // Each agent's greet() returns a string naming its class, so the reply + // identifies exactly which class handled the call. + const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] }); + expect(reply).toBe(`Hello, World! (from ${agentClass})`); + + const transaction = await transactionPromise; + const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet'); + + expect(rpcSpan).toEqual( + expect.objectContaining({ + op: 'rpc', + description: 'greet', + origin: 'auto.faas.cloudflare.agents', + data: expect.objectContaining({ + // Read back off the instance at runtime (`_ParentClass.name`), so it + // confirms the wrapper landed on the user's real class. Matched loosely + // because the transform renames the class it wraps to + // `__SENTRY_ORIGINAL___` and the bundler infers that name. + 'gen_ai.agent.name': expect.stringContaining(agentClass), + }), + }), + ); + }); +} + +test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => { + return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do'); + }); + + const res = await fetch(`${baseURL}/plain-do`); + expect(res.status).toBe(200); + + const transaction = await transactionPromise; + + // A plain Durable Object must NOT pick up agent instrumentation: detection has + // to discriminate, not blanket-upgrade every `durable_objects` binding. + const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents'); + expect(agentSpans).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/tsconfig.json new file mode 100644 index 000000000000..94b03468f288 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/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-autoinstrument/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/vite.config.ts new file mode 100644 index 000000000000..55e45b9a9b6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/vite.config.ts @@ -0,0 +1,19 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import agents from 'agents/vite'; +import { defineConfig } from 'vite'; + +// `agents()` supplies the TC39 decorator transform that `@callable()` needs. +// `autoInstrumentation` is the plugin under test: it rewrites `src/index.ts` at +// build time so the entry itself contains no Sentry calls. +export default defineConfig({ + plugins: [ + agents(), + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/wrangler.jsonc new file mode 100644 index 000000000000..d3765273c352 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/wrangler.jsonc @@ -0,0 +1,32 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-autoinstrument", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"], + + // Every class below is registered the same way — as a Durable Object binding. + // An `agents` Agent *is* a Durable Object, so wrangler offers no way to say + // "this one is an Agent". Only the base-class chain distinguishes them, which + // is exactly what the plugin's detection has to work out at build time: + // + // MyAgent -> extends Agent (entry-local) => agent + // MyChatAgent -> extends AIChatAgent (entry-local) => agent + // DerivedAgent -> extends ./base#MyBase -> Agent => agent + // PlainDO -> extends DurableObject => durableObject + "durable_objects": { + "bindings": [ + { "name": "MyAgent", "class_name": "MyAgent" }, + { "name": "MyChatAgent", "class_name": "MyChatAgent" }, + { "name": "DerivedAgent", "class_name": "DerivedAgent" }, + { "name": "PlainDO", "class_name": "PlainDO" }, + ], + }, + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["MyAgent", "MyChatAgent", "DerivedAgent", "PlainDO"], + }, + ], +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json index 0ed62c51b406..63a749ebad82 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/package.json @@ -14,7 +14,7 @@ "test:dev": "TEST_ENV=development playwright test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/sdk": "1.29.0", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", "agents": "0.11.9", "zod": "^4.3.6" diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts index 30f22d2e39ee..b500bd2349ad 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/src/index.ts @@ -4,13 +4,15 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import * as z from 'zod'; class MyMCPAgentBase extends McpAgent> { - #mcpServer = new McpServer({ - name: 'cloudflare-mcp-agent', - version: '1.0.0', - }); + #mcpServer = Sentry.wrapMcpServerWithSentry( + new McpServer({ + name: 'cloudflare-mcp-agent', + version: '1.0.0', + }), + ); get server() { - return Sentry.wrapMcpServerWithSentry(this.#mcpServer); + return this.#mcpServer; } async init(): Promise { @@ -31,7 +33,6 @@ class MyMCPAgentBase extends McpAgent> { if (span) { span.setAttribute('mcp.tool.name', 'my-tool'); span.setAttribute('mcp.tool.extra', 'from-mcpagent'); - span.setAttribute('mcp.tool.input', JSON.stringify({ message })); } return { @@ -53,7 +54,13 @@ export const MyMCPAgent = Sentry.instrumentDurableObjectWithSentry( environment: 'qa', tunnel: `http://localhost:3031/`, tracesSampleRate: 1.0, - dataCollection: { userInfo: true }, + dataCollection: { + userInfo: true, + genAI: { + inputs: false, + outputs: false, + }, + }, debug: true, transportOptions: { bufferSize: 1000, @@ -68,7 +75,15 @@ export default Sentry.withSentry( environment: 'qa', tunnel: `http://localhost:3031/`, tracesSampleRate: 1.0, - dataCollection: { userInfo: true }, + // The worker and the Durable Object share one cached client per isolate, so the entrypoint that + // initializes first decides the data collection settings for both. + dataCollection: { + userInfo: true, + genAI: { + inputs: false, + outputs: false, + }, + }, debug: true, transportOptions: { bufferSize: 1000, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts index cde74a76aa27..f87929d9c89f 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp-agent/tests/index.test.ts @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForRequest } from '@sentry-internal/test-utils'; test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ baseURL }) => { + const privateMessage = 'cloudflare-agent-private-capture-policy-message'; const mcpToolWaiter = waitForRequest('cloudflare-mcp-agent', event => { const transaction = event.envelope[1][0][1]; return ( @@ -66,16 +67,18 @@ test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ bas params: { name: 'my-tool', arguments: { - message: 'hello from MCPAgent test', + message: privateMessage, }, }, }), }); expect(response.status).toBe(200); + await expect(response.text()).resolves.toContain(`Tool my-tool: ${privateMessage}`); const mcpData = await mcpToolWaiter; const mcpEvent = mcpData.envelope[1][0][1]; + const traceData = mcpEvent.contexts?.trace?.data; expect(mcpEvent.contexts?.trace?.trace_id).toBe(mcpData.envelope[0].trace.trace_id); expect(mcpEvent.contexts?.trace).toEqual({ @@ -90,7 +93,12 @@ test('sends spans for MCP tool calls via MCPAgent (DurableObject)', async ({ bas 'mcp.method.name': 'tools/call', 'mcp.tool.name': 'my-tool', 'mcp.tool.extra': 'from-mcpagent', - 'mcp.tool.input': '{"message":"hello from MCPAgent test"}', + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', }), }); + expect(traceData?.['mcp.request.argument.message']).toBeUndefined(); + expect(traceData?.['mcp.tool.result.content']).toBeUndefined(); + expect(traceData?.['mcp.tool.input']).toBeUndefined(); + expect(JSON.stringify(traceData)).not.toContain(privateMessage); }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json index df6fa844b888..4dcddbb427e1 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/package.json @@ -14,10 +14,10 @@ "test:dev": "TEST_ENV=development playwright test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/server": "2.0.0", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", - "agents": "0.3.10", - "zod": "^3.25.76" + "agents": "0.20.1", + "zod": "^4.2.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240725.0", @@ -30,11 +30,5 @@ "volta": { "node": "24.15.0", "extends": "../../package.json" - }, - "pnpm": { - "overrides": { - "strip-literal": "~2.0.0", - "@modelcontextprotocol/sdk": "1.25.2" - } } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts index 8e4e01d6f744..26c734afdcd4 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/src/index.ts @@ -11,9 +11,55 @@ * Learn more at https://developers.cloudflare.com/workers/ */ import * as Sentry from '@sentry/cloudflare'; -import { createMcpHandler } from 'agents/mcp'; -import * as z from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler } from 'agents/mcp/server'; +import { z } from 'zod'; + +function createServer() { + const server = Sentry.wrapMcpServerWithSentry( + new McpServer({ + name: 'cloudflare-mcp', + version: '2.0.0', + }), + ); + + server.registerTool( + 'my-tool', + { + title: 'My Tool', + description: 'My Tool Description', + inputSchema: z.object({ + message: z.string(), + }), + }, + async ({ message }) => { + const span = Sentry.getActiveSpan(); + + await new Promise(resolve => setTimeout(resolve, 500)); + + if (span) { + span.setAttribute('mcp.tool.name', 'my-tool'); + span.setAttribute('mcp.tool.extra', 'ƸӜƷ'); + span.setAttribute('mcp.tool.input', JSON.stringify({ message })); + } + + return { + content: [ + { + type: 'text' as const, + text: `Tool my-tool: ${message}`, + }, + ], + }; + }, + ); + + return server; +} + +const mcpHandler = createMcpHandler(createServer, { + route: '/mcp', +}); export default Sentry.withSentry( (env: Env) => ({ @@ -30,54 +76,13 @@ export default Sentry.withSentry( }), { async fetch(request, env, ctx) { - const server = new McpServer({ - name: 'cloudflare-mcp', - version: '1.0.0', - }); - const span = Sentry.getActiveSpan(); if (span) { span.setAttribute('mcp.server.extra', ' /|\ ^._.^ /|\ '); } - server.registerTool( - 'my-tool', - { - title: 'My Tool', - description: 'My Tool Description', - inputSchema: { - message: z.string(), - }, - }, - async ({ message }) => { - const span = Sentry.getActiveSpan(); - - // simulate a long running tool - await new Promise(resolve => setTimeout(resolve, 500)); - - if (span) { - span.setAttribute('mcp.tool.name', 'my-tool'); - span.setAttribute('mcp.tool.extra', 'ƸӜƷ'); - span.setAttribute('mcp.tool.input', JSON.stringify({ message })); - } - - return { - content: [ - { - type: 'text' as const, - text: `Tool my-tool: ${message}`, - }, - ], - }; - }, - ); - - const handler = createMcpHandler(Sentry.wrapMcpServerWithSentry(server), { - route: '/mcp', - }); - - return handler(request, env, ctx); + return mcpHandler(request, env, ctx); }, } satisfies ExportedHandler, ); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts index 8ce8b693499e..540a724a9313 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mcp/tests/index.test.ts @@ -1,32 +1,59 @@ import { expect, test } from '@playwright/test'; import { waitForRequest } from '@sentry-internal/test-utils'; -test('sends spans for MCP tool calls', async ({ baseURL }) => { - const spanRequestWaiter = waitForRequest('cloudflare-mcp', event => { - const transaction = event.envelope[1][0][1]; - return typeof transaction !== 'string' && 'transaction' in transaction && transaction.transaction === 'POST /mcp'; - }); +const APP_NAME = 'cloudflare-mcp'; + +function getTransaction(eventData: Awaited>) { + const event = eventData.envelope[1][0][1]; + return typeof event !== 'string' && 'transaction' in event ? event : undefined; +} + +function requireTransaction(eventData: Awaited>) { + const event = getTransaction(eventData); + if (!event) { + throw new Error('Expected a transaction event'); + } + return event; +} + +test.describe.configure({ mode: 'serial' }); - const spanMcpWaiter = waitForRequest('cloudflare-mcp', event => { - const transaction = event.envelope[1][0][1]; +test('sends spans for MCP 2026-07-28 tool calls', async ({ baseURL }) => { + const url = `${baseURL}/mcp?protocol=modern`; + const requestWaiter = waitForRequest(APP_NAME, eventData => { + const event = getTransaction(eventData); + return event?.transaction === 'POST /mcp' && event.contexts?.trace?.data?.['url.full'] === url; + }); + const mcpWaiter = waitForRequest(APP_NAME, eventData => { + const event = getTransaction(eventData); return ( - typeof transaction !== 'string' && - 'transaction' in transaction && - transaction.transaction === 'tools/call my-tool' + event?.transaction === 'tools/call my-tool' && + event.contexts?.trace?.data?.['mcp.protocol.version'] === '2026-07-28' ); }); - const response = await fetch(`${baseURL}/mcp`, { + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', + 'MCP-Protocol-Version': '2026-07-28', + 'Mcp-Method': 'tools/call', + 'Mcp-Name': 'my-tool', }, body: JSON.stringify({ jsonrpc: '2.0', - id: 1, + id: 'modern-tool-call', method: 'tools/call', params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { + name: 'cloudflare-modern-client', + version: '2.0.0', + }, + 'io.modelcontextprotocol/clientCapabilities': {}, + }, name: 'my-tool', arguments: { message: 'ʕっ•ᴥ•ʔっ', @@ -36,75 +63,103 @@ test('sends spans for MCP tool calls', async ({ baseURL }) => { }); expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + jsonrpc: '2.0', + id: 'modern-tool-call', + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'Tool my-tool: ʕっ•ᴥ•ʔっ' }], + }, + }); - const requestData = await spanRequestWaiter; - const mcpData = await spanMcpWaiter; - - const requestEvent = requestData.envelope[1][0][1]; - const mcpEvent = mcpData.envelope[1][0][1]; - - // Check that the events have contexts - // this is for TypeScript type safety - if ( - typeof mcpEvent === 'string' || - !('contexts' in mcpEvent) || - typeof requestEvent === 'string' || - !('contexts' in requestEvent) - ) { - throw new Error("Events don't have contexts"); - } + const requestData = await requestWaiter; + const mcpData = await mcpWaiter; + const requestEvent = requireTransaction(requestData); + const mcpEvent = requireTransaction(mcpData); + const requestTrace = requestEvent.contexts?.trace; + const mcpTrace = mcpEvent.contexts?.trace; - expect(mcpEvent.contexts?.trace?.trace_id).toBe((mcpData.envelope[0].trace as any).trace_id); + expect(requestTrace?.op).toBe('http.server'); + expect(requestTrace?.origin).toBe('auto.http.cloudflare'); + expect(requestTrace?.status).toBe('ok'); + expect(requestTrace?.data?.['sentry.origin']).toBe('auto.http.cloudflare'); + expect(requestTrace?.data?.['sentry.op']).toBe('http.server'); + expect(requestTrace?.data?.['sentry.source']).toBe('url'); + expect(requestTrace?.data?.['http.request.method']).toBe('POST'); + expect(requestTrace?.data?.['url.path']).toBe('/mcp'); + expect(requestTrace?.data?.['url.full']).toBe(url); + expect(requestTrace?.data?.['url.port']).toBe('38787'); + expect(requestTrace?.data?.['url.scheme']).toBe('http:'); + expect(requestTrace?.data?.['server.address']).toBe('localhost'); + expect(requestTrace?.data?.['http.request.body.size']).toBe(341); + expect(requestTrace?.data?.['user_agent.original']).toBe('node'); + expect(requestTrace?.data?.['http.request.header.content_type']).toBe('application/json'); + expect(requestTrace?.data?.['network.protocol.name']).toBe('HTTP/1.1'); + expect(requestTrace?.data?.['http.response.status_code']).toBe(200); + expect(requestTrace?.data?.['mcp.server.extra']).toBe(' /|\ ^._.^ /|\ '); + expect(mcpTrace?.trace_id).toBe(requestTrace?.trace_id); + expect(mcpTrace?.trace_id).toBe((mcpData.envelope[0].trace as { trace_id: string }).trace_id); + expect(mcpTrace?.parent_span_id).toBe(requestTrace?.span_id); expect(requestData.envelope[0].event_id).not.toBe(mcpData.envelope[0].event_id); + expect(mcpTrace?.op).toBe('mcp.server'); + expect(mcpTrace?.origin).toBe('auto.function.mcp_server'); + // MCP spans never set a status explicitly, and on v10 an unset status serializes as `undefined` + expect(mcpTrace?.status).toBeUndefined(); + expect(mcpTrace?.data?.['mcp.transport']).toBe('PerRequestHTTPServerTransport'); + expect(mcpTrace?.data?.['network.transport']).toBe('tcp'); + expect(mcpTrace?.data?.['mcp.protocol.version']).toBe('2026-07-28'); + expect(mcpTrace?.data?.['mcp.client.name']).toBe('cloudflare-modern-client'); + expect(mcpTrace?.data?.['mcp.client.version']).toBe('2.0.0'); + expect(mcpTrace?.data?.['mcp.server.name']).toBe('cloudflare-mcp'); + expect(mcpTrace?.data?.['mcp.server.version']).toBe('2.0.0'); + expect(mcpTrace?.data?.['mcp.method.name']).toBe('tools/call'); + expect(mcpTrace?.data?.['mcp.request.id']).toBe('modern-tool-call'); + expect(mcpTrace?.data?.['mcp.tool.name']).toBe('my-tool'); + expect(mcpTrace?.data?.['mcp.request.argument.message']).toBe('"ʕっ•ᴥ•ʔっ"'); + expect(mcpTrace?.data?.['mcp.tool.result.content_count']).toBe(1); + expect(mcpTrace?.data?.['mcp.tool.result.content']).toBe('Tool my-tool: ʕっ•ᴥ•ʔっ'); +}); - expect(requestEvent.contexts?.trace).toEqual({ - span_id: expect.any(String), - trace_id: expect.any(String), - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - 'sentry.op': 'http.server', - 'sentry.source': 'url', - 'sentry.sample_rate': 1, - 'http.request.method': 'POST', - 'url.path': '/mcp', - 'url.full': 'http://localhost:38787/mcp', - 'url.port': '38787', - 'url.scheme': 'http:', - 'server.address': 'localhost', - 'http.request.body.size': 120, - 'user_agent.original': 'node', - 'http.request.header.content_type': 'application/json', - 'network.protocol.name': 'HTTP/1.1', - 'mcp.server.extra': ' /|\ ^._.^ /|\ ', - 'http.response.status_code': 200, - }), - op: 'http.server', - status: 'ok', - origin: 'auto.http.cloudflare', +test('keeps sending spans for legacy-compatible MCP tool calls', async ({ baseURL }) => { + const url = `${baseURL}/mcp?protocol=legacy`; + const mcpWaiter = waitForRequest(APP_NAME, eventData => { + const event = getTransaction(eventData); + return ( + event?.transaction === 'tools/call my-tool' && + event.contexts?.trace?.data?.['mcp.request.argument.message'] === '"legacy protocol request"' + ); }); - expect(mcpEvent.contexts?.trace).toEqual({ - trace_id: expect.any(String), - parent_span_id: requestEvent.contexts?.trace?.span_id, - span_id: expect.any(String), - op: 'mcp.server', - origin: 'auto.function.mcp_server', - data: { - 'sentry.origin': 'auto.function.mcp_server', - 'sentry.op': 'mcp.server', - 'sentry.source': 'route', - 'mcp.transport': 'WorkerTransport', - 'network.transport': 'unknown', - 'network.protocol.version': '2.0', - 'mcp.method.name': 'tools/call', - 'mcp.request.id': '1', - 'mcp.tool.name': 'my-tool', - 'mcp.request.argument.message': '"ʕっ•ᴥ•ʔっ"', - 'mcp.tool.extra': 'ƸӜƷ', - 'mcp.tool.input': '{"message":"ʕっ•ᴥ•ʔっ"}', - 'mcp.tool.result.content_count': 1, - 'mcp.tool.result.content_type': 'text', - 'mcp.tool.result.content': 'Tool my-tool: ʕっ•ᴥ•ʔっ', + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'legacy-tool-call', + method: 'tools/call', + params: { + name: 'my-tool', + arguments: { + message: 'legacy protocol request', + }, + }, + }), }); + + expect(response.status).toBe(200); + + const mcpEvent = requireTransaction(await mcpWaiter); + const trace = mcpEvent.contexts?.trace; + + expect(trace?.op).toBe('mcp.server'); + expect(trace?.status).toBeUndefined(); + expect(trace?.data?.['mcp.transport']).toBe('WebStandardStreamableHTTPServerTransport'); + expect(trace?.data?.['mcp.method.name']).toBe('tools/call'); + expect(trace?.data?.['mcp.request.id']).toBe('legacy-tool-call'); + expect(trace?.data?.['mcp.tool.name']).toBe('my-tool'); + expect(trace?.data?.['mcp.protocol.version']).toBeUndefined(); + expect(trace?.data?.['mcp.tool.result.content']).toBe('Tool my-tool: legacy protocol request'); }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts index e5c5c6df7aa4..05877f619e3b 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts @@ -103,7 +103,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), { async fetch(request, env) { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts index 703483303c3f..9920cc9c6e83 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts @@ -101,7 +101,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), { async fetch(request, env) { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts index 5edabc8e1b8b..3e0424bd9d7f 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts @@ -118,7 +118,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), MyWorker, ); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts index 64d2f5c71b63..7a6e202de6bb 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/server-transactions.test.ts @@ -14,6 +14,7 @@ test('Sends parameterized transaction name to Sentry', async ({ page }) => { expect(transaction).toBeDefined(); expect(transaction.transaction).toBe('GET user/:id'); + expect(transaction.contexts?.trace?.data?.['http.route']).toBe('user/:id'); }); test('Sends form data with action span', async ({ page }) => { @@ -271,6 +272,7 @@ test('Sends two linked transactions (server & client) to Sentry', async ({ page const pageLoadParentSpanId = pageloadTransaction.contexts?.trace?.parent_span_id; expect(httpServerTransaction.transaction).toBe('GET http://localhost:3030/'); + expect(httpServerTransaction.contexts?.trace?.data?.['http.route']).toBeUndefined(); expect(pageloadTransaction.transaction).toBe('/'); expect(httpServerTraceId).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts index d16e8239990c..4d020389aa94 100644 --- a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts @@ -4,9 +4,30 @@ import { test } from 'vitest'; const authToken = process.env.E2E_TEST_AUTH_TOKEN; const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; -const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT; const EVENT_POLLING_TIMEOUT = 90_000; +/** + * The event serializer emits source context as `[lineNo, line]` pairs spanning the frame's line + * and its surroundings, rather than the separate pre/context/post fields of the raw event. + */ +interface SerializedFrame { + lineNo: number | null; + colNo: number | null; + context: [number, string][] | null; +} + +function splitFrameContext(frame: SerializedFrame): Record { + const context = frame.context ?? []; + + return { + preContext: context.filter(([lineNo]) => lineNo < (frame.lineNo ?? 0)).map(([, line]) => line), + contextLine: context.find(([lineNo]) => lineNo === frame.lineNo)?.[1], + postContext: context.filter(([lineNo]) => lineNo > (frame.lineNo ?? 0)).map(([, line]) => line), + lineno: frame.lineNo, + colno: frame.colNo, + }; +} + test( 'Find symbolicated event on sentry', async ({ expect }) => { @@ -23,10 +44,18 @@ test( while (!timedOut) { await new Promise(resolve => setTimeout(resolve, 2000)); // poll every two seconds - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${eventId}/json/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); + const response = await fetch(`https://sentry.io/api/0/organizations/${sentryTestOrgSlug}/eventids/${eventId}/`, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + + // This is org scoped, so the auth token needs `org:read` on top of the project scopes. + // That never resolves by waiting, so fail loudly rather than timing out. + if (response.status === 401 || response.status === 403) { + throw new Error( + `Event lookup was rejected with ${response.status}: ${await response.text()}. ` + + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', + ); + } // Only allow ok responses or 404 if (!response.ok) { @@ -34,16 +63,16 @@ test( continue; } - const eventPayload = await response.json(); - const frames = eventPayload.exception?.values?.[0]?.stacktrace?.frames; + const { event } = await response.json(); + const exception = event.entries.find((entry: { type: string }) => entry.type === 'exception'); + const frames: SerializedFrame[] = exception.data.values[0].stacktrace.frames; const topFrame = frames[frames.length - 1]; - expect({ - preContext: topFrame?.pre_context, - contextLine: topFrame?.context_line, - postContext: topFrame?.post_context, - lineno: topFrame?.lineno, - colno: topFrame?.colno, - }).toMatchSnapshot(); + + if (topFrame === undefined) { + throw new Error('Symbolicated event has no stack frames.'); + } + + expect(splitFrameContext(topFrame)).toMatchSnapshot(); return; } diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts index fccf12f5790c..5e8b7d123809 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/src/app.ts @@ -22,7 +22,6 @@ Sentry.init({ traceLifecycle: 'stream', tracesSampleRate: 1, dataCollection: { userInfo: true }, - enableLogs: true, }); const port = 3030; 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 8dbd8870a3b6..256835014fd1 100644 --- a/dev-packages/e2e-tests/test-applications/deno/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/deno/src/app.ts @@ -24,7 +24,6 @@ Sentry.init({ tunnel: 'http://localhost:3031/', tracesSampleRate: 1, dataCollection: { userInfo: true }, - enableLogs: true, }); const port = 3030; diff --git a/dev-packages/e2e-tests/test-applications/effect-3-browser/src/index.js b/dev-packages/e2e-tests/test-applications/effect-3-browser/src/index.js index 4e9cb70d6e44..d5be4900e256 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-browser/src/index.js +++ b/dev-packages/e2e-tests/test-applications/effect-3-browser/src/index.js @@ -19,7 +19,6 @@ const AppLayer = Layer.mergeAll( release: 'e2e-test', environment: 'qa', tunnel: 'http://localhost:3031', - enableLogs: true, }), Layer.setTracer(Sentry.SentryEffectTracer), Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger), diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts index 899adfb4aa98..9de243f3cab3 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts @@ -15,7 +15,6 @@ const SentryLive = Layer.mergeAll( debug: !!process.env.DEBUG, tunnel: 'http://localhost:3031/', tracesSampleRate: 1, - enableLogs: true, }), Layer.setTracer(Sentry.SentryEffectTracer), Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger), diff --git a/dev-packages/e2e-tests/test-applications/effect-4-browser/src/index.js b/dev-packages/e2e-tests/test-applications/effect-4-browser/src/index.js index 1748b4200ce1..05827a758475 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-browser/src/index.js +++ b/dev-packages/e2e-tests/test-applications/effect-4-browser/src/index.js @@ -19,7 +19,6 @@ const AppLayer = Layer.mergeAll( release: 'e2e-test', environment: 'qa', tunnel: 'http://localhost:3031', - enableLogs: true, }), Logger.layer([Sentry.SentryEffectLogger]), Layer.succeed(Tracer.Tracer, Sentry.SentryEffectTracer), diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts index 5ebfef33be77..1109f1a412b9 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts @@ -16,7 +16,6 @@ const SentryLive = Layer.mergeAll( debug: !!process.env.DEBUG, tunnel: 'http://localhost:3031/', tracesSampleRate: 1, - enableLogs: true, }), Logger.layer([Sentry.SentryEffectLogger]), Layer.succeed(Tracer.Tracer, Sentry.SentryEffectTracer), diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts index ebef35588a74..d7ddfe686b9a 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts @@ -21,6 +21,12 @@ export function addRoutes(app: HonoType<{ Bindings?: { E2E_TEST_DSN: string } }> }); }); + app.get('/linked-error', () => { + const cause = new Error('Failure 1'); + const errorCause = new Error('Failure 2', { cause }); + throw new Error('Failure 3', { cause: errorCause }); + }); + app.get('/http-exception/:code', c => { // oxlint-disable-next-line typescript/no-explicit-any const code = Number(c.req.param('code')) as any; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts index fbe758c708a9..ad4148fa35c2 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -36,6 +36,48 @@ test.describe('route handler errors', () => { expect(errorEvent.contexts?.trace?.trace_id).toBe(transactionEvent.contexts?.trace?.trace_id); }); + + test('captures three linked errors', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.some(exception => exception.value === 'Failure 3'); + }); + + const response = await fetch(`${baseURL}/linked-error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values).toHaveLength(3); + + const firstCause = errorEvent.exception?.values?.[0]; + expect(firstCause?.value).toBe('Failure 1'); + expect(firstCause?.mechanism).toEqual({ + exception_id: 2, + handled: false, + parent_id: 1, + source: 'cause', + type: 'auto.http.hono.context_error', // should be 'chained' (general issue with LinkedErrors) + }); + + const secondCause = errorEvent.exception?.values?.[1]; + expect(secondCause?.value).toBe('Failure 2'); + expect(secondCause?.mechanism).toEqual({ + exception_id: 1, + handled: true, + parent_id: 0, + source: 'cause', + type: 'chained', + }); + + const capturedError = errorEvent.exception?.values?.[2]; + expect(capturedError?.value).toBe('Failure 3'); + expect(capturedError?.mechanism).toEqual({ + exception_id: 0, + handled: true, + type: 'generic', // should be 'auto.http.hono.context_error' (general issue with LinkedErrors) + }); + + expect(errorEvent.transaction).toBe('GET /linked-error'); + }); }); test.describe('HTTPException errors', () => { diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts index 375c56a845d6..31fd0c8f6970 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts index 7270ad211909..23a11f67b0a3 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-8/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts index d56ddf007e9c..d6bec81e67db 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tests/transactions.test.ts @@ -49,6 +49,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts index 0a23c1766b38..1da006fca893 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/propagation.test.ts @@ -64,6 +64,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.path': `/test-outgoing-http/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -103,6 +105,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -193,6 +197,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.path': `/test-outgoing-fetch/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -232,6 +238,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts index 9ca18ec0888f..344d2440a9da 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules-decorator/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction from module', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/example-module/transaction', + 'url.full': 'http://localhost:3030/example-module/transaction', + 'url.path': '/example-module/transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts index ddfcb1192edf..b0b9e71a4bfe 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction from module', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/example-module/transaction', + 'url.full': 'http://localhost:3030/example-module/transaction', + 'url.path': '/example-module/transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-error.test.ts index 81bf9d04ba97..609585079b0e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-error.test.ts @@ -12,7 +12,7 @@ test('should create AI spans with correct attributes and error linking', async ( ); const errorEventPromise = waitForError('nextjs-15', async errorEvent => { - return errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); + return !!errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); }); await page.goto('/ai-error-test'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json index 04cbff8d9eed..c892c58ec0ce 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/package.json @@ -45,12 +45,6 @@ "build-command": "pnpm test:build-latest", "label": "nextjs-16-cf-workers (latest)" } - ], - "optionalVariants": [ - { - "build-command": "pnpm test:build-canary", - "label": "nextjs-16-cf-workers (canary)" - } ] } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts new file mode 100644 index 000000000000..31814184db88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { isDevMode } from './isDevMode'; + +/** + * The orchestrion bundler plugins are build-time-only, and their module-scope side effects break + * on Workers (an unawaited `WebAssembly.compile()` crashed every cold start, issue #22794). The + * worker bundle OpenNext produces must therefore never contain them: importing `@sentry/nextjs` + * on the server has to keep the plugin graph out of the deployed artifact. + */ +test('worker bundle does not contain the orchestrion bundler plugins', () => { + test.skip(isDevMode, 'requires the production worker build'); + + const openNextDir = path.resolve(__dirname, '..', '.open-next'); + expect(fs.existsSync(path.join(openNextDir, 'worker.js'))).toBe(true); + + // `assets` holds the static client files; everything else is code the worker can run. + const serverFiles = collectJsFiles(openNextDir).filter( + filePath => !filePath.startsWith(path.join(openNextDir, 'assets')), + ); + expect(serverFiles.length).toBeGreaterThan(0); + + const markers = ['code-transformer-bundler-plugins', '__codeTransformerWebpackDiagnostics']; + const leaks = serverFiles.filter(filePath => { + const content = fs.readFileSync(filePath, 'utf8'); + return markers.some(marker => content.includes(marker)); + }); + + expect(leaks.map(filePath => path.relative(openNextDir, filePath))).toEqual([]); +}); + +function collectJsFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + return collectJsFiles(fullPath); + } + return /\.(js|mjs|cjs)$/.test(entry.name) ? [fullPath] : []; + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/.gitignore new file mode 100644 index 000000000000..ae044ec5ad53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/.gitignore @@ -0,0 +1,46 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +event-dumps + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/api/server-error/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/api/server-error/route.ts new file mode 100644 index 000000000000..0dd10b5702a3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/api/server-error/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export function GET() { + throw new Error('nextjs-16-standalone-server-error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/favicon.ico new file mode 100644 index 000000000000..718d6fea4835 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/global-error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import NextError from 'next/error'; +import { useEffect } from 'react'; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/page.tsx new file mode 100644 index 000000000000..2135ef0fd540 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/app/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Next.js 16 Standalone Output Test

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/eslint.config.mjs @@ -0,0 +1,19 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + }, +]; + +export default eslintConfig; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/instrumentation-client.ts new file mode 100644 index 000000000000..57415e4094e2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/instrumentation-client.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + dataCollection: { userInfo: true }, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/next.config.ts new file mode 100644 index 000000000000..0e2f1e16c89d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/next.config.ts @@ -0,0 +1,13 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'standalone', + // Pin the tracing root to this app so the standalone server always ends up at + // .next/standalone/server.js, even when the app runs inside the SDK monorepo. + outputFileTracingRoot: __dirname, +}; + +export default withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/package.json new file mode 100644 index 000000000000..20a9ff147fdb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/package.json @@ -0,0 +1,56 @@ +{ + "name": "nextjs-16-standalone", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "copy-standalone-assets": "cp -r public .next/standalone/public && cp -r .next/static .next/standalone/.next/static", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "start": "node .next/standalone/server.js", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:build": "pnpm install && pnpm build && pnpm copy-standalone-assets", + "test:build-webpack": "pnpm install && pnpm build-webpack && pnpm copy-standalone-assets", + "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build && pnpm copy-standalone-assets", + "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build && pnpm copy-standalone-assets", + "test:assert": "pnpm test:prod" + }, + "dependencies": { + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "import-in-the-middle": "^2", + "next": "16.2.3", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^8" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "^16", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-webpack", + "label": "nextjs-16-standalone (webpack)", + "assert-command": "pnpm test:assert" + }, + { + "build-command": "pnpm test:build", + "label": "nextjs-16-standalone (turbopack)", + "assert-command": "pnpm test:assert" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/playwright.config.mjs new file mode 100644 index 000000000000..36538cd8f356 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/playwright.config.mjs @@ -0,0 +1,13 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (testEnv !== 'production') { + throw new Error(`Unknown test env: ${testEnv} - the standalone output only exists for production builds`); +} + +const config = getPlaywrightConfig({ + startCommand: 'PORT=3030 node .next/standalone/server.js', + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/sentry.edge.config.ts new file mode 100644 index 000000000000..4e12ee74604b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/sentry.edge.config.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + dataCollection: { userInfo: true }, + // debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/sentry.server.config.ts new file mode 100644 index 000000000000..4e12ee74604b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/sentry.server.config.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + dataCollection: { userInfo: true }, + // debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/start-event-proxy.mjs new file mode 100644 index 000000000000..89c563e70477 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-16-standalone', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/nextjs-16-standalone-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/standalone.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/standalone.test.ts new file mode 100644 index 000000000000..85fa4fe105af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tests/standalone.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends a server transaction from the standalone server', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-standalone', transactionEvent => { + return transactionEvent.transaction === 'GET /'; + }); + + await page.goto('/'); + + const transactionEvent = await transactionPromise; + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); +}); + +test('captures an error thrown in a route handler', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-16-standalone', errorEvent => { + return errorEvent.exception?.values?.some(value => value.value === 'nextjs-16-standalone-server-error') ?? false; + }); + + const transactionEventPromise = waitForTransaction('nextjs-16-standalone', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/server-error' && transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + request.get('/api/server-error').catch(() => { + // expected to fail + }); + + const errorEvent = await errorEventPromise; + const transactionEvent = await transactionEventPromise; + + expect(errorEvent.exception?.values?.[0]?.value).toBe('nextjs-16-standalone-server-error'); + expect(transactionEvent.contexts?.trace?.status).toBe('internal_error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-standalone/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.mjs new file mode 100644 index 000000000000..5086e5695292 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.mjs @@ -0,0 +1,12 @@ +// Deliberately `.mjs`: Next loads it through Node's own ESM loader rather than compiling it, which is the only +// config format that exercises `@sentry/nextjs/config` as a plain-Node ESM consumer. +import { withSentryConfig } from '@sentry/nextjs/config'; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + trailingSlash: true, +}; + +export default withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts index cfdfe12d0c27..7bcb11c8c7af 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; // These tests verify that pageload transactions are correctly named when -// trailingSlash: true is enabled in next.config.ts, even when a catch-all +// trailingSlash: true is enabled in next.config.mjs, even when a catch-all // route exists. See: https://github.com/getsentry/sentry-javascript/issues/19241 test('should create a correctly named pageload transaction for a static route', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/package.json index b30636cd3576..d77e4cd57286 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-userfeedback/package.json @@ -11,6 +11,7 @@ "test:assert": "pnpm test:prod" }, "dependencies": { + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "@types/node": "^20", "@types/react": "^19", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts index 62e6798773bd..fa40bc41da46 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts @@ -12,7 +12,7 @@ test('should create AI spans with correct attributes and error linking', async ( ); const errorEventPromise = waitForError('nextjs-16', async errorEvent => { - return errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); + return !!errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); }); await page.goto('/ai-error-test'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 5386c75f31a9..d471308e58ec 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -21,6 +21,11 @@ test('Should create a transaction for middleware', async ({ request }) => { expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); expect(middlewareTransaction.transaction_info?.source).toBe('route'); + // The `Middleware.execute` OTEL root span is the only middleware span. The build-time + // `wrapMiddlewareWithSentry` wrapper used to start a second, redundant one nested inside it. + const nestedMiddlewareSpans = middlewareTransaction.spans?.filter(span => span.op === 'http.server.middleware'); + expect(nestedMiddlewareSpans).toHaveLength(0); + // Assert that isolation scope works properly expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/.gitignore new file mode 100644 index 000000000000..ae044ec5ad53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/.gitignore @@ -0,0 +1,46 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +event-dumps + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/app/api/telemetry/[id]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/app/api/telemetry/[id]/route.ts new file mode 100644 index 000000000000..2bf13af13d08 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/app/api/telemetry/[id]/route.ts @@ -0,0 +1,20 @@ +import { metrics, trace } from '@opentelemetry/api'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + return trace.getTracer('nextjs-custom-otel').startActiveSpan('telemetry-handler', span => { + const { traceId, spanId } = span.spanContext(); + + metrics.getMeter('nextjs-custom-otel').createCounter('otlp.test.count').add(1, { id }); + + Sentry.captureException(new Error(`This is an exception with id ${id}`)); + + span.end(); + + return Response.json({ traceId, spanId }); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/app/page.tsx new file mode 100644 index 000000000000..753b8859885c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Next.js app with user-owned OpenTelemetry tracing and metrics

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/eslint.config.mjs @@ -0,0 +1,19 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + }, +]; + +export default eslintConfig; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/instrumentation-client.ts new file mode 100644 index 000000000000..115a6667cb4c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/instrumentation-client.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/instrumentation.ts new file mode 100644 index 000000000000..c3761ac260bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/instrumentation.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + // Order matters: the app's OpenTelemetry SDK claims the global tracer provider first, and + // Sentry then attaches to it instead of setting up its own. + await import('./otel.server.config'); + await import('./sentry.server.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/next.config.ts similarity index 72% rename from dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts rename to dev-packages/e2e-tests/test-applications/nextjs-custom-otel/next.config.ts index 80946b61ec01..6699b3dd2c33 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/next.config.ts @@ -1,9 +1,7 @@ import { withSentryConfig } from '@sentry/nextjs'; import type { NextConfig } from 'next'; -const nextConfig: NextConfig = { - trailingSlash: true, -}; +const nextConfig: NextConfig = {}; export default withSentryConfig(nextConfig, { silent: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/otel-receiver.ts b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/otel-receiver.ts new file mode 100644 index 000000000000..029f85ac7903 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/otel-receiver.ts @@ -0,0 +1,110 @@ +import { createServer } from 'node:http'; + +export const OTLP_RECEIVER_PORT = 3033; + +export interface CollectedSpan { + traceId: string; + spanId: string; + name: string; +} + +export interface CollectedMetric { + name: string; + value: number; + attributes: Record; +} + +const collectedSpans: CollectedSpan[] = []; +const collectedMetrics: CollectedMetric[] = []; + +interface OtlpAnyValue { + stringValue?: string; + intValue?: string | number; + doubleValue?: number; + boolValue?: boolean; +} + +function flattenAttributes(attributes: { key: string; value: OtlpAnyValue }[] = []): Record { + const flattened: Record = {}; + + for (const { key, value } of attributes) { + const rawValue = value.stringValue ?? value.intValue ?? value.doubleValue ?? value.boolValue; + if (rawValue !== undefined) { + flattened[key] = String(rawValue); + } + } + + return flattened; +} + +function collectSpans(body: any): void { + for (const resourceSpan of body?.resourceSpans ?? []) { + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + for (const span of scopeSpan.spans ?? []) { + collectedSpans.push({ traceId: span.traceId, spanId: span.spanId, name: span.name }); + } + } + } +} + +function collectMetrics(body: any): void { + for (const resourceMetric of body?.resourceMetrics ?? []) { + for (const scopeMetric of resourceMetric.scopeMetrics ?? []) { + for (const metric of scopeMetric.metrics ?? []) { + // Only counters are recorded by this app, so `sum` is the only shape that needs handling. + for (const dataPoint of metric.sum?.dataPoints ?? []) { + collectedMetrics.push({ + name: metric.name, + value: Number(dataPoint.asInt ?? dataPoint.asDouble ?? 0), + attributes: flattenAttributes(dataPoint.attributes), + }); + } + } + } + } +} + +async function readJsonBody(stream: AsyncIterable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +/** + * Stands in for the OTLP backend the app would export to in production, so the test can assert what + * the user's OpenTelemetry SDK actually put on the wire. + * + * It deliberately runs as a plain `node:http` server rather than a Next.js route: exporting into the + * Next.js server would make every export request produce spans of its own, which would then be + * exported again. + */ +export function startOtlpReceiver(): void { + const server = createServer((req, res) => { + void (async () => { + if (req.method === 'POST' && req.url === '/v1/traces') { + collectSpans(await readJsonBody(req)); + res.writeHead(200, { 'content-type': 'application/json' }).end('{}'); + return; + } + + if (req.method === 'POST' && req.url === '/v1/metrics') { + collectMetrics(await readJsonBody(req)); + res.writeHead(200, { 'content-type': 'application/json' }).end('{}'); + return; + } + + if (req.method === 'GET' && req.url === '/collected') { + res + .writeHead(200, { 'content-type': 'application/json' }) + .end(JSON.stringify({ spans: collectedSpans, metrics: collectedMetrics })); + return; + } + + res.writeHead(404).end(); + })(); + }); + + server.listen(OTLP_RECEIVER_PORT); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/otel.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/otel.server.config.ts new file mode 100644 index 000000000000..5a3ab521a48a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/otel.server.config.ts @@ -0,0 +1,51 @@ +import { metrics } from '@opentelemetry/api'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { MeterProvider, PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { SentryContextManager } from '@sentry/nextjs'; +import { SentryPropagator } from '@sentry/opentelemetry'; +import { OTLP_RECEIVER_PORT, startOtlpReceiver } from './otel-receiver'; + +// Next.js can run `register()` more than once in dev, which would leave a second receiver fighting +// for the port and a second set of providers losing the race to register globally. +const globalWithOtelFlag = globalThis as typeof globalThis & { __otelRegistered?: boolean }; + +if (!globalWithOtelFlag.__otelRegistered) { + globalWithOtelFlag.__otelRegistered = true; + + startOtlpReceiver(); + + const resource = resourceFromAttributes({ 'service.name': 'nextjs-custom-otel' }); + const otlpBaseUrl = `http://localhost:${OTLP_RECEIVER_PORT}`; + + // The user owns tracing. Sentry's context manager and propagator are handed to the user's + // provider (the documented `skipOpenTelemetrySetup` path) so Sentry's scopes still ride on the + // OpenTelemetry context. No `SentrySpanProcessor` or `SentrySampler`: Sentry sends no spans here. + new NodeTracerProvider({ + resource, + spanProcessors: [ + new BatchSpanProcessor(new OTLPTraceExporter({ url: `${otlpBaseUrl}/v1/traces` }), { + scheduledDelayMillis: 100, + }), + ], + }).register({ + contextManager: new SentryContextManager(), + propagator: new SentryPropagator(), + }); + + metrics.setGlobalMeterProvider( + new MeterProvider({ + resource, + readers: [ + new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ url: `${otlpBaseUrl}/v1/metrics` }), + exportIntervalMillis: 500, + exportTimeoutMillis: 500, + }), + ], + }), + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/package.json new file mode 100644 index 000000000000..e443661221e2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/package.json @@ -0,0 +1,60 @@ +{ + "name": "nextjs-custom-otel", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "dev:webpack": "next dev --webpack", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "start": "next start", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack playwright test", + "test:build": "pnpm install && pnpm build", + "test:build-webpack": "pnpm install && pnpm build-webpack", + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-metrics": "^2.9.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@opentelemetry/sdk-trace-node": "^2.9.0", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/opentelemetry": "file:../../packed/sentry-opentelemetry-packed.tgz", + "import-in-the-middle": "^2", + "next": "16.3.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^8" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "^16", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-webpack", + "label": "nextjs-custom-otel (webpack)", + "assert-command": "pnpm test:assert-webpack" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/playwright.config.mjs new file mode 100644 index 000000000000..f727698c0983 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/playwright.config.mjs @@ -0,0 +1,30 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'development-webpack') { + return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/sentry.server.config.ts new file mode 100644 index 000000000000..83f981ab17a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/sentry.server.config.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + + // Errors only: no `tracesSampleRate`, so Sentry starts no spans and sends no transactions. + + // The app brings its own OpenTelemetry SDK, which already owns the global tracer provider. + // Errors are put on the active OpenTelemetry trace by `setupEventContextTrace`, which the SDK + // installs regardless of this option. + skipOpenTelemetrySetup: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/start-event-proxy.mjs new file mode 100644 index 000000000000..e9dc02331edb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-custom-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/tests/otel-telemetry.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/tests/otel-telemetry.test.ts new file mode 100644 index 000000000000..0fbf59830e82 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/tests/otel-telemetry.test.ts @@ -0,0 +1,121 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +const OTLP_RECEIVER_URL = 'http://localhost:3033'; + +interface CollectedSpan { + traceId: string; + spanId: string; + name: string; +} + +interface CollectedMetric { + name: string; + value: number; + attributes: Record; +} + +async function triggerTelemetry(baseURL: string, id: string): Promise<{ traceId: string; spanId: string }> { + const response = await fetch(`${baseURL}/api/telemetry/${id}`); + return (await response.json()) as { traceId: string; spanId: string }; +} + +interface Collected { + spans: CollectedSpan[]; + metrics: CollectedMetric[]; +} + +async function waitForCollected(select: (collected: Collected) => T | undefined, description: string): Promise { + const deadline = Date.now() + 15_000; + + while (Date.now() < deadline) { + const response = await fetch(`${OTLP_RECEIVER_URL}/collected`); + const collected = (await response.json()) as Collected; + + const match = select(collected); + if (match !== undefined) { + return match; + } + + await new Promise(resolve => setTimeout(resolve, 200)); + } + + throw new Error(`Timed out waiting for ${description} to be exported over OTLP`); +} + +const waitForExportedMetric = (id: string): Promise => + waitForCollected( + ({ metrics }) => metrics.find(metric => metric.name === 'otlp.test.count' && metric.attributes.id === id), + `the metric for id ${id}`, + ); + +const waitForExportedSpan = (spanId: string): Promise => + waitForCollected(({ spans }) => spans.find(span => span.spanId === spanId), `the span ${spanId}`); + +test('stamps errors with the trace of the active OpenTelemetry span', async ({ baseURL }) => { + const errorEventPromise = waitForError('nextjs-custom-otel', event => { + return event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + const { traceId, spanId } = await triggerTelemetry(baseURL as string, '123'); + const errorEvent = await errorEventPromise; + + // `setupEventContextTrace` sets the trace context on `preprocessEvent`, which the final context + // merge keeps, so it also carries the Next.js span this handler ran under as `parent_span_id`. + expect(errorEvent.contexts?.trace).toMatchObject({ trace_id: traceId, span_id: spanId }); +}); + +test('keeps exporting the app-owned metrics over OTLP', async ({ baseURL }) => { + await triggerTelemetry(baseURL as string, '234'); + + const metric = await waitForExportedMetric('234'); + + expect(metric).toEqual({ name: 'otlp.test.count', value: 1, attributes: { id: '234' } }); +}); + +test('keeps exporting the app-owned spans over OTLP', async ({ baseURL }) => { + const { traceId, spanId } = await triggerTelemetry(baseURL as string, '345'); + + const span = await waitForExportedSpan(spanId); + + expect(span).toEqual({ traceId, spanId, name: 'telemetry-handler' }); +}); + +test('sends no transactions to Sentry', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('nextjs-custom-otel', () => true); + const errorPromise = waitForError('nextjs-custom-otel', event => { + return event.exception?.values?.[0]?.value === 'This is an exception with id 456'; + }); + + await triggerTelemetry(baseURL as string, '456'); + // Proves the request's telemetry reached the proxy, so the absence check below is not vacuous. + await errorPromise; + + // Absence can only be time bounded. This guards against Sentry's tracing defaults changing under + // the app, which would emit a transaction for every request, well inside this window. + const transaction = await Promise.race([ + transactionPromise, + new Promise(resolve => setTimeout(() => resolve(undefined), 3000)), + ]); + + expect(transaction).toBeUndefined(); +}); + +test('keeps concurrent requests on separate traces', async ({ baseURL }) => { + const errorEventPromises = ['567', '678'].map(id => + waitForError('nextjs-custom-otel', event => { + return event.exception?.values?.[0]?.value === `This is an exception with id ${id}`; + }), + ); + + const [first, second] = await Promise.all([ + triggerTelemetry(baseURL as string, '567'), + triggerTelemetry(baseURL as string, '678'), + ]); + + const [firstError, secondError] = await Promise.all(errorEventPromises); + + expect(first.traceId).not.toBe(second.traceId); + expect(firstError.contexts?.trace?.trace_id).toBe(first.traceId); + expect(secondError.contexts?.trace?.trace_id).toBe(second.traceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-custom-otel/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-tracing.test.ts index f2863b4e5095..02b59cecb84e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-tracing.test.ts @@ -42,7 +42,7 @@ test('should trace orpc server component', async ({ page }) => { 'http.method': 'POST', 'http.target': '/rpc/planet/list', 'next.rsc': false, - 'http.route': '/rpc/[[...rest]]/route', + 'http.route': '/rpc/[[...rest]]', 'next.route': '/rpc/[[...rest]]', 'http.status_code': 200, }, @@ -99,7 +99,7 @@ test('should trace orpc client component', async ({ page }) => { 'http.method': 'POST', 'http.target': '/rpc/planet/find', 'next.rsc': false, - 'http.route': '/rpc/[[...rest]]/route', + 'http.route': '/rpc/[[...rest]]', 'next.route': '/rpc/[[...rest]]', 'http.status_code': 200, }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index 03539a781ec1..905cdcdbdba7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -75,6 +75,7 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru type: 'fetch', url: 'http://localhost:3030/', 'http.url': 'http://localhost:3030/', + 'url.full': 'http://localhost:3030/', 'server.address': 'localhost:3030', 'sentry.op': 'http.client', 'sentry.origin': 'auto.http.wintercg_fetch', diff --git a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts index f6991ed7a75a..de7e8671022e 100644 --- a/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-connect/tests/transactions.test.ts @@ -24,6 +24,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json index 4460adbd034c..1f745b394192 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/package.json @@ -12,17 +12,17 @@ }, "dependencies": { "@cfworker/json-schema": "^4.0.0", - "@modelcontextprotocol/server": "2.0.0-alpha.2", - "@modelcontextprotocol/node": "2.0.0-alpha.2", + "@modelcontextprotocol/node": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "@sentry/node": "file:../../packed/sentry-node-packed.tgz", "@types/express": "^4.17.21", "@types/node": "^18.19.1", "express": "^4.21.2", "typescript": "~5.0.0", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "devDependencies": { - "@modelcontextprotocol/client": "2.0.0-alpha.2", + "@modelcontextprotocol/client": "2.0.0", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/core": "file:../../packed/sentry-core-packed.tgz" diff --git a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts index 776725c11cf2..a5413565ee03 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-mcp-v2/tests/mcp.test.ts @@ -3,9 +3,7 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; import { Client } from '@modelcontextprotocol/client'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -test('Should record transactions for MCP handlers using @modelcontextprotocol/sdk v2 (register* API)', async ({ - baseURL, -}) => { +test('records transactions for stable MCP SDK v2 handlers using the register API', async ({ baseURL }) => { const transport = new StreamableHTTPClientTransport(new URL(`${baseURL}/mcp`)); const client = new Client({ diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts index 9dbce2a05ac9..1b9d488958c7 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts index fb11235943b2..cf1790853c86 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/playwright.config.mjs index 39bf757e0fd8..9401872607c3 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/playwright.config.mjs @@ -7,8 +7,8 @@ const expressPort = 3030; */ const config = { testDir: './tests', - /* Maximum time one test can run for. */ - timeout: 150_000, + /* Maximum time one test can run for. Spans take ~2min to become queryable via the trace endpoint. */ + timeout: 210_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/src/app.ts index ca5d61f742d9..5f0d9e855b4a 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/src/app.ts @@ -1,14 +1,21 @@ import * as Sentry from '@sentry/node'; let lastTransactionId: string | undefined; +let lastTransactionTraceId: string | undefined; +let lastErrorTraceId: string | undefined; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, includeLocalVariables: true, tracesSampleRate: 1, + beforeSend(event) { + lastErrorTraceId = event.contexts?.trace?.trace_id; + return event; + }, beforeSendTransaction(event) { lastTransactionId = event.event_id; + lastTransactionTraceId = event.contexts?.trace?.trace_id; return event; }, }); @@ -36,6 +43,7 @@ app.get('/test-transaction', function (req, res) { res.send({ transactionId: lastTransactionId, + traceId: lastTransactionTraceId, }); }); }); @@ -45,7 +53,7 @@ app.get('/test-error', async function (req, res) { await Sentry.flush(2000); - res.send({ exceptionId }); + res.send({ exceptionId, traceId: lastErrorTraceId }); }); app.get('/test-exception/:id', function (req, _res) { diff --git a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/tests/send-to-sentry.test.ts index 7f699fa111f9..5e7282e58d3c 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/tests/send-to-sentry.test.ts @@ -1,45 +1,22 @@ import { expect, test } from '@playwright/test'; - -const EVENT_POLLING_TIMEOUT = 90_000; - -const authToken = process.env.E2E_TEST_AUTH_TOKEN; -const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; -const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT; +import { EVENT_POLLING_OPTIONS, findErrorInTrace, findTransactionInTrace } from './utils/sentry-api'; test('Sends exception to Sentry', async ({ baseURL }) => { const response = await fetch(`${baseURL}/test-error`); - const { exceptionId } = await response.json(); - - const url = `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionId}/`; + const { exceptionId, traceId } = await response.json(); - console.log(`Polling for error eventId: ${exceptionId}`); + console.log(`Polling for error eventId: ${exceptionId} in trace: ${traceId}`); - await expect - .poll( - async () => { - const response = await fetch(url, { headers: { Authorization: `Bearer ${authToken}` } }); - return response.status; - }, - { timeout: EVENT_POLLING_TIMEOUT }, - ) - .toBe(200); + await expect.poll(() => findErrorInTrace(traceId, exceptionId), EVENT_POLLING_OPTIONS).toBeDefined(); }); test('Sends transaction to Sentry', async ({ baseURL }) => { const response = await fetch(`${baseURL}/test-transaction`); - const { transactionId } = await response.json(); - - const url = `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionId}/`; + const { transactionId, traceId } = await response.json(); - console.log(`Polling for transaction eventId: ${transactionId}`); + console.log(`Polling for transaction eventId: ${transactionId} in trace: ${traceId}`); await expect - .poll( - async () => { - const response = await fetch(url, { headers: { Authorization: `Bearer ${authToken}` } }); - return response.status; - }, - { timeout: EVENT_POLLING_TIMEOUT }, - ) - .toBe(200); + .poll(() => findTransactionInTrace(traceId, transactionId), EVENT_POLLING_OPTIONS) + .toMatchObject({ op: 'e2e-test' }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/tests/utils/sentry-api.ts b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/tests/utils/sentry-api.ts new file mode 100644 index 000000000000..31e2adbd21c5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-send-to-sentry/tests/utils/sentry-api.ts @@ -0,0 +1,72 @@ +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; + +/** + * 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 the organization trace endpoint. Spans, errors and + * occurrences all share this shape and are discriminated by `event_type`. + */ +export interface TraceItem { + event_id?: string; + /** On spans this is the event id of the transaction the span belongs to. */ + transaction_id?: string; + event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check'; + op?: string; + is_transaction?: boolean; + children?: TraceItem[]; + errors?: TraceItem[]; + occurrences?: TraceItem[]; +} + +export async function fetchTrace(traceId: string): Promise { + const response = await fetch( + `https://sentry.io/api/0/organizations/${sentryTestOrgSlug}/trace/${traceId}/?statsPeriod=1h`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + // The trace endpoint is org scoped, so the auth token needs `org:read` on top of the + // project scopes the other assertions rely on. That never resolves by waiting, so fail + // loudly instead of polling until the timeout and reporting it as a missing event. + if (response.status === 401 || response.status === 403) { + throw new Error( + `Trace lookup for ${traceId} was rejected with ${response.status}: ${await response.text()}. ` + + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', + ); + } + + // Empty traces and the occasional rate limit are expected while polling, so treat anything + // else that is not a success as "not there yet" -- but log it, since a rejected request and + // a trace that has not landed are otherwise indistinguishable. + if (!response.ok) { + console.log(`Trace lookup for ${traceId} returned ${response.status}: ${await response.text()}`); + return []; + } + + return await response.json(); +} + +/** + * 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 ?? []), + ]); +} + +export async function findErrorInTrace(traceId: string, eventId: string): Promise { + return flattenTrace(await fetchTrace(traceId)).find(item => item.event_type === 'error' && item.event_id === eventId); +} + +export async function findTransactionInTrace(traceId: string, eventId: string): Promise { + return flattenTrace(await fetchTrace(traceId)).find(item => item.is_transaction && item.transaction_id === eventId); +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts index 5a0d1afa4141..f02a6afff084 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-streaming/src/app.ts @@ -7,7 +7,6 @@ Sentry.init({ debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - enableLogs: true, traceLifecycle: 'stream', integrations: [ Sentry.spanStreamingIntegration(), diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts index 9a7f6f07d8bc..032ec0415036 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts @@ -13,7 +13,6 @@ Sentry.init({ debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - enableLogs: true, integrations: [Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], }); diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts index ba9632aaf952..c44da1ed290f 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts index dc755f95d062..78ede69aeed8 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts @@ -1,4 +1,6 @@ import * as Sentry from '@sentry/node'; +// Keep the dedicated MCP server evaluation ahead of initialization without loading Express before its instrumentation. +import './mcpCapturePolicyServer'; declare global { namespace globalThis { @@ -13,7 +15,6 @@ Sentry.init({ debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - enableLogs: true, integrations: [ Sentry.nativeNodeFetchIntegration({ headersToSpanAttributes: { diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts index 72c4535a3d6f..116e63849adc 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/mcp.ts @@ -5,6 +5,7 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import { wrapMcpServerWithSentry } from '@sentry/node'; +import { capturePolicyServer } from './mcpCapturePolicyServer'; // Helper to check if request is an initialize request (compatible with all MCP SDK versions) function isInitializeRequest(body: unknown): boolean { @@ -60,6 +61,26 @@ server.tool('always-error', {}, async () => { }); const transports: Record = {}; +const capturePolicyTransports: Record = {}; + +mcpRouter.get('/capture-policy/sse', async (_, res) => { + const transport = new SSEServerTransport('/capture-policy/messages', res); + capturePolicyTransports[transport.sessionId] = transport; + res.on('close', () => { + delete capturePolicyTransports[transport.sessionId]; + }); + await capturePolicyServer.connect(transport); +}); + +mcpRouter.post('/capture-policy/messages', async (req, res) => { + const sessionId = req.query.sessionId; + const transport = capturePolicyTransports[sessionId as string]; + if (transport) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); mcpRouter.get('/sse', async (_, res) => { const transport = new SSEServerTransport('/messages', res); diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/mcpCapturePolicyServer.ts b/dev-packages/e2e-tests/test-applications/node-express/src/mcpCapturePolicyServer.ts new file mode 100644 index 000000000000..4a85031c8bd1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express/src/mcpCapturePolicyServer.ts @@ -0,0 +1,16 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { wrapMcpServerWithSentry } from '@sentry/node'; +import { z } from 'zod'; + +export const capturePolicyServer = wrapMcpServerWithSentry( + new McpServer({ + name: 'Capture-Policy', + version: '1.0.0', + }), +); + +capturePolicyServer.tool('capture-policy', { message: z.string() }, async ({ message }) => { + return { + content: [{ type: 'text', text: `Capture policy result: ${message}` }], + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts index 504bfaffcd27..d732b4c77dc1 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/mcp.test.ts @@ -180,6 +180,49 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { }); }); +test('resolves capture policy when the MCP server is wrapped before Sentry.init', async ({ baseURL }) => { + const transport = new SSEClientTransport(new URL(`${baseURL}/capture-policy/sse`)); + const client = new Client({ + name: 'capture-policy-client', + version: '1.0.0', + }); + await client.connect(transport); + + const toolTransactionPromise = waitForTransaction('node-express', transactionEvent => { + return transactionEvent.transaction === 'tools/call capture-policy'; + }); + const privateMessage = 'node-v1-private-capture-policy-message'; + + const toolResult = await client.callTool({ + name: 'capture-policy', + arguments: { + message: privateMessage, + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: `Capture policy result: ${privateMessage}`, + type: 'text', + }, + ], + }); + + const toolTransaction = await toolTransactionPromise; + const traceData = toolTransaction.contexts?.trace?.data; + + expect(traceData?.['mcp.method.name']).toBe('tools/call'); + expect(traceData?.['mcp.tool.name']).toBe('capture-policy'); + expect(traceData?.['mcp.tool.result.content_count']).toBe(1); + expect(traceData?.['mcp.tool.result.content_type']).toBe('text'); + expect(traceData?.['mcp.request.argument.message']).toBeUndefined(); + expect(traceData?.['mcp.tool.result.content']).toBeUndefined(); + expect(JSON.stringify(traceData)).not.toContain(privateMessage); + + await client.close(); +}); + /** * Tests for StreamableHTTPServerTransport (wrapper transport pattern) * diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts index c0c286da3345..5d995d844f93 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts index 1cdfd67a4851..41028122b492 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/propagation.test.ts @@ -64,6 +64,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.path': `/test-outgoing-http/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -103,6 +105,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -193,6 +197,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.path': `/test-outgoing-fetch/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -232,6 +238,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts index 6c53f21bd869..22b12c322169 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/transactions.test.ts @@ -28,6 +28,8 @@ test.skip('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts index 6e6b20b916e8..4b3e79b8b21d 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/propagation.test.ts @@ -64,6 +64,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.path': `/test-outgoing-http/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -103,6 +105,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -193,6 +197,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.path': `/test-outgoing-fetch/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -232,6 +238,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts index b9a41cd4e572..7209031eb53c 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts index 4e903edf05b5..c7f833701f52 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/propagation.test.ts @@ -64,6 +64,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.path': `/test-outgoing-http/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -103,6 +105,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -193,6 +197,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.path': `/test-outgoing-fetch/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -232,6 +238,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts index b4460cde2a21..f90bcf06b717 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts index bd6540b088d3..bfd71c2be730 100644 --- a/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-hapi/tests/transactions.test.ts @@ -22,6 +22,8 @@ test('Sends successful transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-success', + 'url.full': 'http://localhost:3030/test-success', + 'url.path': '/test-success', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts index 592c5a4717f4..dcb952069bef 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa/tests/propagation.test.ts @@ -63,6 +63,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-http/${id}`, + 'url.path': `/test-outgoing-http/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -102,6 +104,8 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -192,6 +196,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.full': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'url.path': `/test-outgoing-fetch/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', @@ -231,6 +237,8 @@ test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.full': `http://localhost:3030/test-inbound-headers/${id}`, + 'url.path': `/test-inbound-headers/${id}`, 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts index f86901e0dee4..8952ec88a8ae 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts index 49d35cb9e85f..12753312cdb2 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/tests/sampling.test.ts @@ -21,6 +21,8 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/task', + 'url.full': 'http://localhost:3030/task', + 'url.path': '/task', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts index 299d3c2b80ec..b128a537b856 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts @@ -35,6 +35,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts index ea9b6ae57545..6d5d78ff521c 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts @@ -17,7 +17,6 @@ Sentry.init({ // Tracing is completely disabled // Custom OTEL setup skipOpenTelemetrySetup: true, - enableLogs: true, }); // Create and configure NodeTracerProvider diff --git a/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts index ba77e6a3b294..b77c0a610512 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel/tests/transactions.test.ts @@ -35,6 +35,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json index 73b0c59e8a24..3ac4f2494e97 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/package.json @@ -27,8 +27,7 @@ }, "pnpm": { "overrides": { - "ofetch": "1.4.0", - "@vercel/nft": "0.29.4" + "ofetch": "1.4.0" } }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash index a1831f1e8e76..4affbb553b0e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt-start-dev-server.bash @@ -25,8 +25,11 @@ if [ ! -f ".nuxt/dev/sentry.server.config.mjs" ]; then fi # 3. Cleanup +# `pkill -P` only kills direct children, so the grandchild dev server holding the +# port survives; newer Nuxt's directory-scoped dev lock then blocks the real start. echo "Found .nuxt/dev/sentry.server.config.mjs, stopping 'nuxt dev' process..." -pkill -P $DEV_PID || kill $DEV_PID +pkill -P $DEV_PID 2>/dev/null +kill $DEV_PID 2>/dev/null # Wait for port to be released echo "Waiting for port $TEMP_PORT to be released..." @@ -38,7 +41,13 @@ while lsof -i :$TEMP_PORT > /dev/null 2>&1 && [ $COUNTER -lt 10 ]; do done if lsof -i :$TEMP_PORT > /dev/null 2>&1; then - echo "WARNING: Port $TEMP_PORT still in use after 10 seconds, proceeding anyway..." + echo "Port $TEMP_PORT still in use, killing remaining processes bound to it..." + lsof -t -i :$TEMP_PORT | xargs -r kill -9 2>/dev/null + sleep 1 +fi + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "WARNING: Port $TEMP_PORT still in use, proceeding anyway..." else echo "Port $TEMP_PORT released successfully" fi diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash index a1831f1e8e76..4affbb553b0e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/nuxt-start-dev-server.bash @@ -25,8 +25,11 @@ if [ ! -f ".nuxt/dev/sentry.server.config.mjs" ]; then fi # 3. Cleanup +# `pkill -P` only kills direct children, so the grandchild dev server holding the +# port survives; newer Nuxt's directory-scoped dev lock then blocks the real start. echo "Found .nuxt/dev/sentry.server.config.mjs, stopping 'nuxt dev' process..." -pkill -P $DEV_PID || kill $DEV_PID +pkill -P $DEV_PID 2>/dev/null +kill $DEV_PID 2>/dev/null # Wait for port to be released echo "Waiting for port $TEMP_PORT to be released..." @@ -38,7 +41,13 @@ while lsof -i :$TEMP_PORT > /dev/null 2>&1 && [ $COUNTER -lt 10 ]; do done if lsof -i :$TEMP_PORT > /dev/null 2>&1; then - echo "WARNING: Port $TEMP_PORT still in use after 10 seconds, proceeding anyway..." + echo "Port $TEMP_PORT still in use, killing remaining processes bound to it..." + lsof -t -i :$TEMP_PORT | xargs -r kill -9 2>/dev/null + sleep 1 +fi + +if lsof -i :$TEMP_PORT > /dev/null 2>&1; then + echo "WARNING: Port $TEMP_PORT still in use, proceeding anyway..." else echo "Port $TEMP_PORT released successfully" fi diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx index 4ae0c208e998..640af4ca99c3 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/index.tsx @@ -102,6 +102,22 @@ const DeepTeamRoutes = () => ( ); +// Two independent descendant trees that each contribute a single-segment param leaf +// (`:fooId` / `:barId`). Because `allRoutes` is a shared module-level set, once both have mounted a +// navigation into one can pick up the param name from the other, yielding a hybrid name like +// `/bar/:fooId` instead of `/bar/:barId` (see issue #22782). +const FooRoutes = () => ( + + Foo} /> + +); + +const BarRoutes = () => ( + + Bar} /> + +); + const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( @@ -109,6 +125,8 @@ root.render( } /> } /> } /> + } /> + } /> } /> , diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx index e0b372a51965..ff6b75a0a536 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/src/pages/Index.tsx @@ -16,6 +16,12 @@ const Index = () => { navigate deep member + + navigate foo + + + navigate bar + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts index 3c4598be922a..9e25f8d65d9d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-6-descendant-routes/tests/transactions.test.ts @@ -293,6 +293,66 @@ test('resolves deep wildcard chain with three levels of nesting - pageload', asy }); }); +test('does not mix param names across independent descendant routers', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const fooNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts?.trace?.data?.['url.path'] === '/foo/123' + ); + }); + + const barNavigationTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts?.trace?.data?.['url.path'] === '/bar/456' + ); + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + // Mount the first descendant router (`foo/*` -> `:fooId`), which populates the shared `allRoutes` set. + const [, fooNavigationTxn] = await Promise.all([page.locator('id=foo-navigation').click(), fooNavigationTxnPromise]); + + expect((await page.innerHTML('#root')).includes('Foo')).toBe(true); + expect(fooNavigationTxn).toMatchObject({ + transaction: '/foo/:fooId', + transaction_info: { source: 'route' }, + }); + + // Return to the index so we can navigate into the second, unrelated descendant router client-side. + // A fresh page load would reset the module-level `allRoutes` and hide the bug. + await page.goBack(); + await page.locator('id=bar-navigation').waitFor(); + + // Now mount the second descendant router (`bar/*` -> `:barId`). With the accumulation bug, the name + // comes out as the hybrid `/bar/:fooId`. + const [, barNavigationTxn] = await Promise.all([page.locator('id=bar-navigation').click(), barNavigationTxnPromise]); + + expect((await page.innerHTML('#root')).includes('Bar')).toBe(true); + expect(barNavigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react.reactrouter_v6', + data: { + 'sentry.source': 'route', + 'url.template': '/bar/:barId', + 'url.path': '/bar/456', + }, + }, + }, + transaction: '/bar/:barId', + transaction_info: { + source: 'route', + }, + }); +}); + test('resolves deep wildcard chain with three levels of nesting - navigation', async ({ page }) => { const pageloadTxnPromise = waitForTransaction('react-router-6-descendant-routes', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/playwright.config.mjs index 566614052236..e37a753b9cff 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/playwright.config.mjs @@ -5,8 +5,8 @@ import { devices } from '@playwright/test'; */ const config = { testDir: './tests', - /* Maximum time one test can run for. */ - timeout: 150_000, + /* Maximum time one test can run for. Spans take ~2min to become queryable via the trace endpoint. */ + timeout: 210_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/globals.d.ts index ffa61ca49acc..4c48f7834434 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/globals.d.ts +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/globals.d.ts @@ -1,5 +1,11 @@ +interface RecordedEvent { + eventId: string; + traceId: string; + op?: string; +} + interface Window { - recordedTransactions?: string[]; - capturedExceptionId?: string; + recordedTransactions?: RecordedEvent[]; + capturedException?: RecordedEvent; sentryReplayId?: string; } diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/index.tsx index 3a87a53ffdfa..036648d4e849 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/index.tsx @@ -44,16 +44,22 @@ Object.defineProperty(window, 'sentryReplayId', { }, }); +// The trace id is recorded alongside the event id because events are looked up through the +// organization trace endpoint, which is keyed by trace rather than by event. Sentry.addEventProcessor(event => { - if ( - event.type === 'transaction' && - (event.contexts?.trace?.op === 'pageload' || event.contexts?.trace?.op === 'navigation') - ) { - const eventId = event.event_id; - if (eventId) { - window.recordedTransactions = window.recordedTransactions || []; - window.recordedTransactions.push(eventId); - } + const eventId = event.event_id; + const traceId = event.contexts?.trace?.trace_id; + const op = event.contexts?.trace?.op; + + if (!eventId || !traceId) { + return event; + } + + if (event.type === 'transaction' && (op === 'pageload' || op === 'navigation')) { + window.recordedTransactions = window.recordedTransactions || []; + window.recordedTransactions.push({ eventId, traceId, op }); + } else if (!event.type && event.exception) { + window.capturedException = { eventId, traceId }; } return event; diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/pages/Index.tsx index f339eb867d6c..a52f808a92a4 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/src/pages/Index.tsx @@ -10,8 +10,7 @@ const Index = () => { value="Capture Exception" id="exception-button" onClick={() => { - const eventId = Sentry.captureException(new Error('I am an error!')); - window.capturedExceptionId = eventId; + Sentry.captureException(new Error('I am an error!')); }} /> diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/send-to-sentry.test.ts index dc33d271bc18..4ddd7af0ce29 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/send-to-sentry.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { ReplayRecordingData } from './fixtures/ReplayRecordingData'; +import { EVENT_POLLING_OPTIONS, findErrorInTrace, findTransactionInTrace } from './utils/sentry-api'; const EVENT_POLLING_TIMEOUT = 90_000; @@ -13,77 +14,39 @@ test('Sends an exception to Sentry', async ({ page }) => { const exceptionButton = page.locator('id=exception-button'); await exceptionButton.click(); - const exceptionIdHandle = await page.waitForFunction(() => window.capturedExceptionId); - const exceptionEventId = await exceptionIdHandle.jsonValue(); + const capturedExceptionHandle = await page.waitForFunction(() => window.capturedException); + const capturedException = await capturedExceptionHandle.jsonValue(); - console.log(`Polling for error eventId: ${exceptionEventId}`); + if (capturedException === undefined) { + throw new Error("Application didn't record the captured exception."); + } - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${exceptionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); + const { eventId, traceId } = capturedException; - return response.status; - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toBe(200); + console.log(`Polling for error eventId: ${eventId} in trace: ${traceId}`); + + await expect.poll(() => findErrorInTrace(traceId, eventId), EVENT_POLLING_OPTIONS).toBeDefined(); }); test('Sends a pageload transaction to Sentry', async ({ page }) => { await page.goto('/'); - const recordedTransactionsHandle = await page.waitForFunction(() => { - if (window.recordedTransactions && window.recordedTransactions?.length >= 1) { - return window.recordedTransactions; - } else { - return undefined; - } - }); - const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + const transactionHandle = await page.waitForFunction(() => + window.recordedTransactions?.find(transaction => transaction.op === 'pageload'), + ); + const pageloadTransaction = await transactionHandle.jsonValue(); - if (recordedTransactionEventIds === undefined) { - throw new Error("Application didn't record any transaction event IDs."); + if (pageloadTransaction === undefined) { + throw new Error("Application didn't record a pageload transaction."); } - let hadPageLoadTransaction = false; - - console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); - - await Promise.all( - recordedTransactionEventIds.map(async transactionEventId => { - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - - if (response.ok) { - const data = await response.json(); - - if (data.contexts.trace.op === 'pageload') { - hadPageLoadTransaction = true; - } - } - - return response.status; - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toBe(200); - }), - ); + const { eventId, traceId } = pageloadTransaction; + + console.log(`Polling for pageload transaction eventId: ${eventId} in trace: ${traceId}`); - expect(hadPageLoadTransaction).toBe(true); + await expect + .poll(() => findTransactionInTrace(traceId, eventId), EVENT_POLLING_OPTIONS) + .toMatchObject({ op: 'pageload' }); }); test('Sends a navigation transaction to Sentry', async ({ page }) => { @@ -95,51 +58,22 @@ test('Sends a navigation transaction to Sentry', async ({ page }) => { const linkElement = page.locator('id=navigation'); await linkElement.click(); - const recordedTransactionsHandle = await page.waitForFunction(() => { - if (window.recordedTransactions && window.recordedTransactions?.length >= 2) { - return window.recordedTransactions; - } else { - return undefined; - } - }); - const recordedTransactionEventIds = await recordedTransactionsHandle.jsonValue(); + const transactionHandle = await page.waitForFunction(() => + window.recordedTransactions?.find(transaction => transaction.op === 'navigation'), + ); + const navigationTransaction = await transactionHandle.jsonValue(); - if (recordedTransactionEventIds === undefined) { - throw new Error("Application didn't record any transaction event IDs."); + if (navigationTransaction === undefined) { + throw new Error("Application didn't record a navigation transaction."); } - let hadPageNavigationTransaction = false; - - console.log(`Polling for transaction eventIds: ${JSON.stringify(recordedTransactionEventIds)}`); - - await Promise.all( - recordedTransactionEventIds.map(async transactionEventId => { - await expect - .poll( - async () => { - const response = await fetch( - `https://sentry.io/api/0/projects/${sentryTestOrgSlug}/${sentryTestProject}/events/${transactionEventId}/`, - { headers: { Authorization: `Bearer ${authToken}` } }, - ); - - if (response.ok) { - const data = await response.json(); - if (data.contexts.trace.op === 'navigation') { - hadPageNavigationTransaction = true; - } - } - - return response.status; - }, - { - timeout: EVENT_POLLING_TIMEOUT, - }, - ) - .toBe(200); - }), - ); + const { eventId, traceId } = navigationTransaction; + + console.log(`Polling for navigation transaction eventId: ${eventId} in trace: ${traceId}`); - expect(hadPageNavigationTransaction).toBe(true); + await expect + .poll(() => findTransactionInTrace(traceId, eventId), EVENT_POLLING_OPTIONS) + .toMatchObject({ op: 'navigation' }); }); test('Sends a Replay recording to Sentry', async ({ browser }) => { diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/utils/sentry-api.ts b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/utils/sentry-api.ts new file mode 100644 index 000000000000..31e2adbd21c5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/utils/sentry-api.ts @@ -0,0 +1,72 @@ +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; + +/** + * 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 the organization trace endpoint. Spans, errors and + * occurrences all share this shape and are discriminated by `event_type`. + */ +export interface TraceItem { + event_id?: string; + /** On spans this is the event id of the transaction the span belongs to. */ + transaction_id?: string; + event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check'; + op?: string; + is_transaction?: boolean; + children?: TraceItem[]; + errors?: TraceItem[]; + occurrences?: TraceItem[]; +} + +export async function fetchTrace(traceId: string): Promise { + const response = await fetch( + `https://sentry.io/api/0/organizations/${sentryTestOrgSlug}/trace/${traceId}/?statsPeriod=1h`, + { headers: { Authorization: `Bearer ${authToken}` } }, + ); + + // The trace endpoint is org scoped, so the auth token needs `org:read` on top of the + // project scopes the other assertions rely on. That never resolves by waiting, so fail + // loudly instead of polling until the timeout and reporting it as a missing event. + if (response.status === 401 || response.status === 403) { + throw new Error( + `Trace lookup for ${traceId} was rejected with ${response.status}: ${await response.text()}. ` + + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', + ); + } + + // Empty traces and the occasional rate limit are expected while polling, so treat anything + // else that is not a success as "not there yet" -- but log it, since a rejected request and + // a trace that has not landed are otherwise indistinguishable. + if (!response.ok) { + console.log(`Trace lookup for ${traceId} returned ${response.status}: ${await response.text()}`); + return []; + } + + return await response.json(); +} + +/** + * 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 ?? []), + ]); +} + +export async function findErrorInTrace(traceId: string, eventId: string): Promise { + return flattenTrace(await fetchTrace(traceId)).find(item => item.event_type === 'error' && item.event_id === eventId); +} + +export async function findTransactionInTrace(traceId: string, eventId: string): Promise { + return flattenTrace(await fetchTrace(traceId)).find(item => item.is_transaction && item.transaction_id === eventId); +} diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json index 747162d0bd75..113ad5848d09 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/package.json @@ -18,7 +18,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@solidjs/meta": "^0.29.4", - "@solidjs/router": "^0.15.0", + "@solidjs/router": "^1.0.0", "@solidjs/start": "^1.0.2", "@solidjs/testing-library": "^0.8.7", "@testing-library/jest-dom": "^6.4.2", diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json index a9d1d6b91da3..9c5d70880608 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/package.json @@ -18,7 +18,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@solidjs/meta": "^0.29.4", - "@solidjs/router": "^0.15.0", + "@solidjs/router": "^1.0.0", "@solidjs/start": "^1.0.2", "@solidjs/testing-library": "^0.8.7", "@testing-library/jest-dom": "^6.4.2", diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json index c97a130c92b1..995cbbaaf772 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/package.json @@ -18,7 +18,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@solidjs/meta": "^0.29.4", - "@solidjs/router": "^0.15.0", + "@solidjs/router": "^1.0.0", "@solidjs/start": "^1.0.2", "@solidjs/testing-library": "^0.8.7", "@testing-library/jest-dom": "^6.4.2", diff --git a/dev-packages/e2e-tests/test-applications/solidstart/package.json b/dev-packages/e2e-tests/test-applications/solidstart/package.json index 7e382b6dc54b..bd4a98fe3f16 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/package.json +++ b/dev-packages/e2e-tests/test-applications/solidstart/package.json @@ -18,7 +18,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@solidjs/meta": "^0.29.4", - "@solidjs/router": "^0.15.0", + "@solidjs/router": "^1.0.0", "@solidjs/start": "^1.0.2", "@solidjs/testing-library": "^0.8.7", "@testing-library/jest-dom": "^6.4.2", diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.server.test.ts index c6de70d0e6a1..9c2668a7c6c8 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-kit-tracing/tests/tracing.server.test.ts @@ -82,6 +82,8 @@ test('server pageload request span has nested request span for sub request', asy 'http.method': 'GET', 'http.route': '/api/users', 'http.url': 'http://localhost:3030/api/users', + 'url.full': 'http://localhost:3030/api/users', + 'url.path': '/api/users', 'sentry.op': 'http.server', 'sentry.origin': 'auto.http.sveltekit', 'sentry.source': 'route', diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tsconfig.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/tsconfig.json index 115dd34bec96..9c4c106640f3 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "./.svelte-kit/tsconfig.json", + "extends": "$app/tsconfig", "compilerOptions": { "allowJs": true, "esModuleInterop": true, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts index d5067459d565..292d4bb71757 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-cloudflare-pages/src/hooks.server.ts @@ -1,5 +1,5 @@ import { E2E_TEST_DSN } from '$env/static/private'; -import { handleErrorWithSentry, initCloudflareSentryHandle, sentryHandle } from '@sentry/sveltekit'; +import { handleErrorWithSentry, initCloudflareSentryHandle, sentryHandle, metrics } from '@sentry/sveltekit'; import { sequence } from '@sveltejs/kit/hooks'; export const handleError = handleErrorWithSentry(); @@ -10,4 +10,8 @@ export const handle = sequence( tracesSampleRate: 1.0, }), sentryHandle(), + ({ event, resolve }) => { + metrics.count('requests'); + return resolve(event); + }, ); diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/tanstack-router/package.json index 65086e5b4953..9e9f8660a899 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/package.json @@ -9,7 +9,9 @@ "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:basepath": "E2E_TEST_BASEPATH=/app pnpm test:build", + "test:assert:basepath": "E2E_TEST_BASEPATH=/app pnpm test:assert" }, "dependencies": { "@sentry/react": "file:../../packed/sentry-react-packed.tgz", @@ -30,5 +32,14 @@ }, "volta": { "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:basepath", + "assert-command": "pnpm test:assert:basepath", + "label": "tanstack-router (basepath)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx index 3c2ed2905383..de4fca6ff30a 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/src/main.tsx @@ -87,9 +87,25 @@ const redirectRoute = createRoute({ }, }); -const routeTree = rootRoute.addChildren([indexRoute, redirectRoute, postsRoute.addChildren([postIdRoute])]); +// Dynamic enough to absorb basepath segments if they ever leak into route matching (see #23253). +const catchAllRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '$a/$b/$c', + component: function CatchAll() { + return
Catch all
; + }, +}); + +const routeTree = rootRoute.addChildren([ + indexRoute, + redirectRoute, + catchAllRoute, + postsRoute.addChildren([postIdRoute]), +]); + +declare const __APP_BASEPATH__: string; -const router = createRouter({ routeTree }); +const router = createRouter({ routeTree, ...(__APP_BASEPATH__ ? { basepath: __APP_BASEPATH__ } : {}) }); declare const __APP_DSN__: string; diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/tests/basepath.test.ts b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/basepath.test.ts new file mode 100644 index 000000000000..bd3040c429a3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/basepath.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +// Only meaningful in the `tanstack-router (basepath)` variant, where the router is created with +// `basepath: '/app'`. The rest of the suite runs in both variants. +const BASE = process.env.E2E_TEST_BASEPATH || ''; + +test.describe('router basepath', () => { + test.skip(!BASE, 'Only runs in the basepath variant'); + + // `window.location.pathname` carries the basepath, but the router never sees it. Matching the + // pageload against the raw browser path let the catch-all `/$a/$b/$c` route absorb `app` as a + // param instead of matching `/posts/$postId`. + test('does not leak the basepath into the matched route params', async ({ page }) => { + const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`${BASE}/posts/456`); + + const rootSpan = await transactionPromise; + + // `onResolved` later merges the correct params in, but never clears the ones the bad initial + // match already set, so the stale `a`/`b`/`c` params survive on the span. Keys are passed as + // arrays because `toHaveProperty` would otherwise read the dots as a nested lookup. + const traceData = rootSpan.contexts?.trace?.data; + expect(traceData).not.toHaveProperty(['url.path.params.a']); + expect(traceData).not.toHaveProperty(['url.path.params.b']); + expect(traceData).not.toHaveProperty(['url.path.params.c']); + expect(traceData).toHaveProperty(['url.path.params.postId'], '456'); + expect(traceData).toHaveProperty(['url.template'], '/posts/$postId'); + }); + + // The first test only checks the span. The scope transaction is a separate value: it is set once + // when the pageload span starts, and the later `updateName` in `onResolved` does not rewrite it. + // So even when the sent transaction name is correct, errors captured after the pageload still + // carry the name from the initial match. This test checks that scope transaction. + test('attributes errors to the matched route for the whole page lifetime', async ({ page }) => { + const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + const errorPromise = waitForError('tanstack-router', async errorEvent => { + return errorEvent.exception?.values?.[0]?.value === 'Error thrown after pageload'; + }); + + await page.goto(`${BASE}/posts/456`); + await transactionPromise; + + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Error thrown after pageload'); + }, 0); + }); + + const errorEvent = await errorPromise; + + expect(errorEvent.transaction).toBe('/posts/$postId'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts index 06708292089e..13a1d859a828 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/tests/routing-instrumentation.test.ts @@ -1,12 +1,14 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; +const BASE = process.env.E2E_TEST_BASEPATH || ''; + test('sends a pageload transaction with a parameterized URL', async ({ page }) => { const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => { return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); - await page.goto(`/posts/456`); + await page.goto(`${BASE}/posts/456`); const rootSpan = await transactionPromise; @@ -43,7 +45,7 @@ test('sends pageload transaction with web vitals measurements', async ({ page }) return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); - await page.goto(`/`); + await page.goto(`${BASE}/`); const transaction = await transactionPromise; @@ -94,7 +96,7 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; }); - await page.goto(`/`); + await page.goto(`${BASE}/`); await pageloadTxnPromise; await page.waitForTimeout(5000); @@ -138,7 +140,7 @@ test('sends a pageload transaction with resolved URL attrs after same-route redi }); // `/posts/999` matches `/posts/$postId` initially, then `beforeLoad` redirects to `/posts/2`. - await page.goto(`/posts/999`); + await page.goto(`${BASE}/posts/999`); const pageloadTxn = await pageloadTxnPromise; @@ -174,7 +176,7 @@ test('sends a pageload transaction named after the resolved route when a redirec // Visiting `/redirect` directly throws `redirect({ to: '/posts/$postId', params: { postId: '1' } })` // in `beforeLoad` during the initial pageload, so the pageload span must be renamed to the target route. - await page.goto(`/redirect`); + await page.goto(`${BASE}/redirect`); const pageloadTxn = await pageloadTxnPromise; @@ -210,7 +212,7 @@ test('sends a navigation transaction when a redirect is thrown in beforeLoad', a return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; }); - await page.goto(`/`); + await page.goto(`${BASE}/`); await pageloadTxnPromise; await page.locator('#redirect-link').click(); @@ -247,7 +249,7 @@ test('sends a navigation transaction for a normal navigation that happens after return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; }); - await page.goto(`/`); + await page.goto(`${BASE}/`); await pageloadTxnPromise; // First trigger a redirect-driven navigation. Upstream (TanStack/router#3920) this leaves the diff --git a/dev-packages/e2e-tests/test-applications/tanstack-router/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstack-router/vite.config.ts index bd51f2f9679a..f863bf6d3085 100644 --- a/dev-packages/e2e-tests/test-applications/tanstack-router/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstack-router/vite.config.ts @@ -1,11 +1,15 @@ import react from '@vitejs/plugin-react-swc'; import { defineConfig } from 'vite'; +const basepath = process.env.E2E_TEST_BASEPATH || ''; + // https://vitejs.dev/config/ export default defineConfig({ + base: basepath ? `${basepath}/` : '/', plugins: [react()], define: { __APP_DSN__: JSON.stringify(process.env.E2E_TEST_DSN), + __APP_BASEPATH__: JSON.stringify(basepath), }, preview: { port: 3030, diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 76ffca39ab99..4a93da01c4df 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -24,8 +24,8 @@ }, "dependencies": { "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", - "@tanstack/react-start": "^1.136.0", - "@tanstack/react-router": "^1.136.0", + "@tanstack/react-start": "1.168.35", + "@tanstack/react-router": "1.170.18", "react": "^19.2.0", "react-dom": "^19.2.0", "nitro": "latest || *" diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/instrument.mjs b/dev-packages/e2e-tests/test-applications/tsx-express/instrument.mjs index ddc96c7c17fc..f3dd95215d03 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/tsx-express/instrument.mjs @@ -6,5 +6,4 @@ Sentry.init({ debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - enableLogs: true, }); diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts index 35fe8f17bd94..c76c7653d30f 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/tsx-express/tests/transactions.test.ts @@ -23,6 +23,8 @@ test('Sends an API route transaction', async ({ baseURL }) => { 'otel.kind': 'SERVER', 'http.response.status_code': 200, 'http.url': 'http://localhost:3030/test-transaction', + 'url.full': 'http://localhost:3030/test-transaction', + 'url.path': '/test-transaction', 'http.host': 'localhost:3030', 'net.host.name': 'localhost', 'http.method': 'GET', diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json index 5ae0ff242e67..9dd144be62e7 100644 --- a/dev-packages/external-contributor-gh-action/package.json +++ b/dev-packages/external-contributor-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/external-contributor-gh-action", "description": "An internal Github Action to add external contributors to the CHANGELOG.md file.", - "version": "10.67.0", + "version": "10.73.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 3ddf0637b856..c7a119947107 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-core-integration-tests", - "version": "10.67.0", + "version": "10.73.0", "license": "MIT", "engines": { "node": ">=18" @@ -33,8 +33,8 @@ "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/semantic-conventions": "^1.43.0", - "@sentry/core": "10.67.0", - "@sentry/node-core": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/node-core": "10.73.0", "body-parser": "^2.2.2", "cors": "^2.8.5", "cron": "^3.1.6", diff --git a/dev-packages/node-core-integration-tests/suites/public-api/logs/subject.ts b/dev-packages/node-core-integration-tests/suites/public-api/logs/subject.ts index c9581495b64c..6bd17215ec93 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/logs/subject.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/logs/subject.ts @@ -6,7 +6,6 @@ const client = new Sentry.NodeClient({ transport: loggingTransport, stackParser: Sentry.defaultStackParser, integrations: [], - enableLogs: true, dataCollection: { userInfo: true }, }); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 99079255881a..77195c89c999 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-integration-tests", - "version": "10.67.0", + "version": "10.73.0", "license": "MIT", "engines": { "node": ">=18" @@ -50,11 +50,11 @@ "@nestjs/platform-express": "^11", "@prisma/adapter-pg": "7.8.0", "@prisma/client": "6.15.0", - "@sentry/aws-serverless": "10.67.0", - "@sentry/core": "10.67.0", - "@sentry/server-utils": "10.67.0", - "@sentry/hono": "10.67.0", - "@sentry/node": "10.67.0", + "@sentry/aws-serverless": "10.73.0", + "@sentry/core": "10.73.0", + "@sentry/server-utils": "10.73.0", + "@sentry/hono": "10.73.0", + "@sentry/node": "10.73.0", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", @@ -105,7 +105,7 @@ }, "devDependencies": { "@sentry/conventions": "0.16.0", - "@sentry-internal/test-utils": "10.67.0", + "@sentry-internal/test-utils": "10.73.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", diff --git a/dev-packages/node-integration-tests/suites/consola/instrument.mjs b/dev-packages/node-integration-tests/suites/consola/instrument.mjs index 938f870a3b89..cededb10ce20 100644 --- a/dev-packages/node-integration-tests/suites/consola/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/consola/instrument.mjs @@ -5,6 +5,5 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0.0', environment: 'test', - enableLogs: true, transport: loggingTransport, }); diff --git a/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs b/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs index 1e72e58fb043..42c938885229 100644 --- a/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs +++ b/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs @@ -4,6 +4,5 @@ Sentry.init({ dsn: process.env.SENTRY_DSN, release: '1.0', tracesSampleRate: 1.0, - enableLogs: true, integrations: [Sentry.pinoIntegration({ autoInstrument: false })], }); diff --git a/dev-packages/node-integration-tests/suites/pino/instrument.mjs b/dev-packages/node-integration-tests/suites/pino/instrument.mjs index 74d9f0e95119..009af91971f3 100644 --- a/dev-packages/node-integration-tests/suites/pino/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/pino/instrument.mjs @@ -4,6 +4,5 @@ Sentry.init({ dsn: process.env.SENTRY_DSN, release: '1.0', tracesSampleRate: 1.0, - enableLogs: true, integrations: [Sentry.pinoIntegration({ error: { levels: ['error', 'fatal'] } })], }); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-disabled.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-disabled.js new file mode 100644 index 000000000000..e9695c1a4aae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-disabled.js @@ -0,0 +1,24 @@ +/* eslint-disable no-unused-vars */ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + includeLocalVariables: true, + dataCollection: { stackFrameVariables: false }, + transport: loggingTransport, +}); + +process.on('uncaughtException', () => { + // do nothing - this will prevent the Error below from closing this process +}); + +function one(name) { + const keepVar = 'keep me'; + + throw new Error('Enough!'); +} + +setTimeout(() => { + one('some name'); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-filtered.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-filtered.js new file mode 100644 index 000000000000..48e6e36b83a3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-filtered.js @@ -0,0 +1,25 @@ +/* eslint-disable no-unused-vars */ +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + includeLocalVariables: true, + dataCollection: { stackFrameVariables: { deny: ['secretVar'] } }, + transport: loggingTransport, +}); + +process.on('uncaughtException', () => { + // do nothing - this will prevent the Error below from closing this process +}); + +function one(name) { + const keepVar = 'keep me'; + const secretVar = 'filter me'; + + throw new Error('Enough!'); +} + +setTimeout(() => { + one('some name'); +}, 1000); 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 6c042d3ecf1f..b0802b96f263 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 @@ -130,6 +130,36 @@ module.exports = { out_of_app_function };`, .completed(); }); + test('Filters local variables by name via dataCollection.stackFrameVariables', async () => { + await createRunner(__dirname, 'local-variables-filtered.js') + .expect({ + event: event => { + const frame = event.exception?.values?.[0]?.stacktrace?.frames?.find(frame => frame.function === 'one'); + + expect(frame?.vars).toEqual({ + name: 'some name', + keepVar: 'keep me', + secretVar: '[Filtered]', + }); + }, + }) + .start() + .completed(); + }); + + test('Does not attach local variables when dataCollection.stackFrameVariables is false', async () => { + await createRunner(__dirname, 'local-variables-disabled.js') + .expect({ + event: event => { + for (const frame of event.exception?.values?.[0]?.stacktrace?.frames || []) { + expect(frame.vars).toBeUndefined(); + } + }, + }) + .start() + .completed(); + }); + test('Should handle different function name formats', async () => { await createRunner(__dirname, 'local-variables-name-matching.js') .expect({ diff --git a/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts index d2afb5948b2a..74a43eb5356a 100644 --- a/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts +++ b/dev-packages/node-integration-tests/suites/public-api/logger/scenario.ts @@ -5,7 +5,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0.0', environment: 'test', - enableLogs: true, transport: loggingTransport, }); diff --git a/dev-packages/node-integration-tests/suites/public-api/logs-disabled/subject.ts b/dev-packages/node-integration-tests/suites/public-api/logs-disabled/subject.ts new file mode 100644 index 000000000000..6f6112423614 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/logs-disabled/subject.ts @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +const client = new Sentry.NodeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enableLogs: false, + transport: loggingTransport, + stackParser: Sentry.defaultStackParser, + integrations: [], +}); + +const scope = new Sentry.Scope(); +scope.setClient(client); +client.init(); + +async function run(): Promise { + Sentry.logger.info('this log should not be captured', {}, { scope }); + + // Flush the log buffer before the sentinel is captured. If the disable path is + // broken, the leaked log envelope is sent here and arrives before the error, + // failing the ordered `event` expectation. If logs are correctly disabled, + // the buffer is empty and only the sentinel error is delivered. + await client.flush(); + + scope.captureException(new Error('sentinel_error')); + + await client.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +void run(); diff --git a/dev-packages/node-integration-tests/suites/public-api/logs-disabled/test.ts b/dev-packages/node-integration-tests/suites/public-api/logs-disabled/test.ts new file mode 100644 index 000000000000..eab0632bb8e4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/logs-disabled/test.ts @@ -0,0 +1,30 @@ +import { afterAll, describe, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('logs disabled', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // With `enableLogs: false` the log is dropped at capture time, so it never reaches + // the transport. The sentinel error is the only envelope we expect — if a log + // envelope were emitted, it would arrive before the error and fail the assertion. + test('does not capture logs when enableLogs is disabled', async () => { + const runner = createRunner(__dirname, 'subject.ts') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'sentinel_error', + }, + ], + }, + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-response-error.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-response-error.mjs new file mode 100644 index 000000000000..2a485287795f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-response-error.mjs @@ -0,0 +1,45 @@ +import Anthropic from '@anthropic-ai/sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + // Anthropic can hand back an error-shaped body on an otherwise successful (HTTP 200) response. + // The SDK resolves it as data, so the caller never sees a thrown error. + // @see https://docs.anthropic.com/en/api/errors#error-shapes + app.post('/anthropic/v1/messages', (_req, res) => { + res + .status(200) + .set('x-request-id', 'mock-response-error') + .json({ type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => resolve(server)); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Anthropic({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}/anthropic`, + }); + + // Resolves with the error-shaped body; no try/catch because nothing is thrown. + await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + max_tokens: 100, + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 5546aa8abd66..3be4ac609d69 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -40,17 +40,6 @@ describe('Anthropic integration', () => { transaction: 'main', }; - const EXPECTED_MODEL_ERROR = { - exception: { - values: [ - { - type: 'Error', - value: '404 Model not found', - }, - ], - }, - }; - const EXPECTED_STREAM_EVENT_HANDLER_MESSAGE = { message: 'stream event from user-added event listener captured', }; @@ -58,7 +47,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-with-response.mjs', 'instrument.mjs', (createRunner, test) => { test('preserves .withResponse() and .asResponse() for non-streaming and streaming', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -94,15 +82,7 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates anthropic related spans with genAI recording disabled', async () => { - const runner = createRunner(); - - // The orchestrion path only marks the errored span; unlike the OTel path it does not - // capture the handled `error-model` rejection as an event. - if (!isOrchestrionEnabled()) { - runner.expect({ event: EXPECTED_MODEL_ERROR }); - } - - await runner + await createRunner() .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) .expect({ span: container => { @@ -149,15 +129,7 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates anthropic related spans with genAI recording enabled', async () => { - const runner = createRunner(); - - // The orchestrion path only marks the errored span; unlike the OTel path it does not - // capture the handled `error-model` rejection as an event. - if (!isOrchestrionEnabled()) { - runner.expect({ event: EXPECTED_MODEL_ERROR }); - } - - await runner + await createRunner() .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) .expect({ span: container => { @@ -237,7 +209,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates anthropic related spans with custom options', async () => { await createRunner() - .expect({ event: EXPECTED_MODEL_ERROR }) .expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }) .expect({ span: container => { @@ -302,7 +273,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument.mjs', (createRunner, test) => { test('streams produce spans with token usage and metadata (PII false)', async () => { await createRunner() - .ignore('event') .expect({ transaction: EXPECTED_STREAM_SPANS_PII_FALSE }) .expect({ span: container => { @@ -364,7 +334,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stream.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('streams record response text when PII true', async () => { await createRunner() - .ignore('event') .expect({ transaction: EXPECTED_STREAM_SPANS_PII_TRUE }) .expect({ span: container => { @@ -415,7 +384,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stream-nested-create.mjs', 'instrument.mjs', (createRunner, test) => { test('traces a create() invoked from a stream event handler (dedup does not over-suppress)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -446,7 +414,6 @@ describe('Anthropic integration', () => { const EXPECTED_TOOL_CALLS_JSON = '[{"type":"tool_use","id":"tool_weather_1","name":"weather","input":{"city":"Paris"}}]'; await createRunner() - .ignore('event') .expect({ transaction: {}, }) @@ -476,7 +443,6 @@ describe('Anthropic integration', () => { const EXPECTED_TOOL_CALLS_JSON = '[{"type":"tool_use","id":"tool_weather_2","name":"weather","input":{"city":"Paris"}}]'; await createRunner() - .ignore('event') .expect({ transaction: {}, }) @@ -517,6 +483,9 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stream-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('handles streaming errors correctly', async () => { await createRunner() + // Stream errors surface via the MessageStream `error` event; attaching that listener stops it + // being raised as an unhandled rejection, so the instrumentation captures it. This test only + // asserts the spans. .ignore('event') .expect({ transaction: EXPECTED_STREAM_ERROR_SPANS }) .expect({ @@ -574,7 +543,6 @@ describe('Anthropic integration', () => { createEsmAndCjsTests(__dirname, 'scenario-errors.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('handles tool errors and model retrieval errors correctly', async () => { await createRunner() - .ignore('event') .expect({ transaction: EXPECTED_ERROR_SPANS }) .expect({ span: container => { @@ -717,7 +685,6 @@ describe('Anthropic integration', () => { test('extracts system instructions from messages', async () => { const expectedInstructions = JSON.stringify([{ type: 'text', content: 'You are a helpful assistant' }]); await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -833,4 +800,27 @@ describe('Anthropic integration', () => { }); }, ); + createEsmAndCjsTests(__dirname, 'scenario-response-error.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures error-shaped responses returned as data', async () => { + await createRunner() + // The API returns the error as data on a 200 response, never as a thrown error to the caller, + // so the instrumentation intentionally captures it as an event. + .unordered() + .expect({ + event: { + exception: { + values: [ + { + value: 'Overloaded', + mechanism: { type: 'auto.ai.anthropic.anthropic_error', handled: false }, + }, + ], + }, + }, + }) + .expect({ transaction: { transaction: 'main' } }) + .start() + .completed(); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 3332ff1d862c..acc2a405df86 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -34,7 +34,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates google genai related spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -88,7 +87,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates google genai related spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -139,7 +137,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates google genai related spans with custom options', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -176,7 +173,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates google genai related spans with tool calls', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -237,7 +233,21 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument.mjs', (createRunner, test) => { test('creates google genai streaming spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') + // The provider surfaces blocked content within the stream and never returns it to the caller as + // a thrown error, so the instrumentation intentionally captures it as an event. + .unordered() + .expect({ + event: { + exception: { + values: [ + { + value: 'Content blocked: The prompt was blocked due to safety concerns', + mechanism: { type: 'auto.ai.google_genai', handled: false }, + }, + ], + }, + }, + }) .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -295,7 +305,21 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-streaming.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates google genai streaming spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') + // The provider surfaces blocked content within the stream and never returns it to the caller as + // a thrown error, so the instrumentation intentionally captures it as an event. + .unordered() + .expect({ + event: { + exception: { + values: [ + { + value: 'Content blocked: The prompt was blocked due to safety concerns', + mechanism: { type: 'auto.ai.google_genai', handled: false }, + }, + ], + }, + }, + }) .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -400,7 +424,6 @@ describe('Google GenAI integration', () => { (createRunner, test) => { test('extracts system instructions from messages', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -424,7 +447,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { test('creates google genai embeddings spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -463,7 +485,6 @@ describe('Google GenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates google genai embeddings spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index ac0ac3780a38..69740b8bdaf4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -1,4 +1,5 @@ import { createTestServer } from '@sentry-internal/test-utils'; +import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; @@ -130,6 +131,8 @@ describe('httpIntegration', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: `http://localhost:${port}/test`, + [URL_FULL]: `http://localhost:${port}/test?a=1&b=2`, + [URL_PATH]: '/test', ...getCommonHttpRequestHeaders(), }); }, @@ -172,6 +175,8 @@ describe('httpIntegration', () => { 'sentry.sample_rate': 1, 'sentry.source': 'route', url: `http://localhost:${port}/test`, + [URL_FULL]: `http://localhost:${port}/test?a=1&b=2`, + [URL_PATH]: '/test', 'http.request.header.content_length': '9', 'http.request.header.content_type': 'text/plain;charset=UTF-8', ...getCommonHttpRequestHeaders(), diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index ed342ce9d1a2..d15f134f21c8 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -33,7 +33,6 @@ describe('LangChain integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates langchain related spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -90,7 +89,6 @@ describe('LangChain integration', () => { test('does not create duplicate spans from double module patching', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -111,7 +109,6 @@ describe('LangChain integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates langchain related spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -170,7 +167,6 @@ describe('LangChain integration', () => { createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument.mjs', (createRunner, test) => { test('creates langchain spans with tool calls', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -250,7 +246,6 @@ describe('LangChain integration', () => { createEsmTests(__dirname, 'scenario-openai-before-langchain.mjs', 'instrument.mjs', (createRunner, test) => { test('demonstrates timing issue with duplicate spans', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -287,7 +282,6 @@ describe('LangChain integration', () => { (createRunner, test) => { test('extracts system instructions from messages', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -311,7 +305,6 @@ describe('LangChain integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chain.mjs', 'instrument.mjs', (createRunner, test) => { test('uses runName for chain spans instead of unknown_chain', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -357,7 +350,6 @@ describe('LangChain integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { test('creates embedding spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -394,7 +386,6 @@ describe('LangChain integration', () => { test('does not create duplicate embedding spans from double module patching', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -413,7 +404,6 @@ describe('LangChain integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates embedding spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 817aef2923ca..944e5bdc05a4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -28,7 +28,6 @@ describe('LangGraph integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('should instrument LangGraph with default PII settings', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'langgraph-test' } }) .expect({ span: container => { @@ -67,7 +66,6 @@ describe('LangGraph integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('should instrument LangGraph with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'langgraph-test' } }) .expect({ span: container => { @@ -107,7 +105,6 @@ describe('LangGraph integration', () => { createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('should capture tools from LangGraph agent', { timeout: 30000 }, async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'langgraph-tools-test' } }) .expect({ span: container => { @@ -173,7 +170,6 @@ describe('LangGraph integration', () => { createEsmAndCjsTests(__dirname, 'scenario-thread-id.mjs', 'instrument.mjs', (createRunner, test) => { test('should capture thread_id as gen_ai.conversation.id', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'langgraph-thread-id-test' } }) .expect({ span: container => { @@ -219,7 +215,6 @@ describe('LangGraph integration', () => { (createRunner, test) => { test('extracts system instructions from messages', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -242,7 +237,6 @@ describe('LangGraph integration', () => { createEsmAndCjsTests(__dirname, 'scenario-resume.mjs', 'instrument.mjs', (createRunner, test) => { test('should not throw when invoke is called with null input (resume scenario)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'langgraph-resume-test', @@ -372,7 +366,6 @@ describe('LangGraph integration', () => { createEsmAndCjsTests(__dirname, 'agent-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => { test('should instrument createReactAgent with agent and chat spans', { timeout: 30000 }, async () => { await createRunner() - .ignore('event') .expect({ transaction: event => { const spans = event.spans ?? []; @@ -411,7 +404,6 @@ describe('LangGraph integration', () => { createEsmAndCjsTests(__dirname, 'agent-tools-scenario.mjs', 'instrument-agent.mjs', (createRunner, test) => { test('should create tool execution spans for createReactAgent with tools', { timeout: 30000 }, async () => { await createRunner() - .ignore('event') .expect({ transaction: event => { const spans = event.spans ?? []; @@ -463,7 +455,6 @@ describe('LangGraph integration', () => { createEsmAndCjsTests(__dirname, 'scenario-stategraph-chat.mjs', 'instrument-agent.mjs', (createRunner, test) => { test('auto-injects langchain handler for plain StateGraph and emits chat spans', { timeout: 30000 }, async () => { await createRunner() - .ignore('event') .expect({ transaction: event => { const spans = event.spans ?? []; diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts index 0895e6dffd19..08d287330704 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts @@ -79,7 +79,6 @@ describe('OpenAI Tool Calls integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai tool calls related spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -327,7 +326,6 @@ describe('OpenAI Tool Calls integration', () => { createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai tool calls related spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 3f102d360e40..1d9fb46e66c4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -33,7 +33,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai related spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -333,7 +332,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai related spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -705,7 +703,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates openai related spans with custom options', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -813,7 +810,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai related spans with genAI recording disabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -946,7 +942,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai related spans with genAI recording enabled', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1332,7 +1327,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-conversation.mjs', 'instrument.mjs', (createRunner, test) => { test('captures conversation ID from Conversations API and previous_response_id', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'conversation-test', @@ -1452,7 +1446,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-manual-conversation-id.mjs', 'instrument.mjs', (createRunner, test) => { test('attaches manual conversation ID set via setConversationId() to all chat spans', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'chat-with-manual-conversation-id', @@ -1485,7 +1478,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-separate-scope-1.mjs', 'instrument.mjs', (createRunner, test) => { test('isolates conversation IDs across separate scopes - conversation 1', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'GET /chat/conversation-1', @@ -1517,7 +1509,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-separate-scope-2.mjs', 'instrument.mjs', (createRunner, test) => { test('isolates conversation IDs across separate scopes - conversation 2', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'GET /chat/conversation-2', @@ -1553,7 +1544,6 @@ describe('OpenAI integration', () => { (createRunner, test) => { test('extracts system instructions from messages', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1580,7 +1570,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-with-response.mjs', 'instrument.mjs', (createRunner, test) => { test('preserves .withResponse() method and works correctly', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -1611,7 +1600,6 @@ describe('OpenAI integration', () => { createEsmAndCjsTests(__dirname, 'scenario-vision.mjs', 'instrument-with-truncation.mjs', (createRunner, test) => { test('redacts inline base64 image data in vision requests', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts index 0530d1575845..310759e5f251 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts @@ -36,7 +36,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with genAI recording disabled (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -346,7 +345,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with genAI recording enabled (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -728,7 +726,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with custom options (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main' } }) .expect({ span: container => { @@ -801,7 +798,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with genAI recording disabled (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', @@ -944,7 +940,6 @@ describe('OpenAI integration (V6)', () => { (createRunner, test) => { test('creates openai related spans with genAI recording enabled (v6)', async () => { await createRunner() - .ignore('event') .expect({ transaction: { transaction: 'main', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-span-streaming.mjs new file mode 100644 index 000000000000..5e0b6fb5592f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-span-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument.mjs new file mode 100644 index 000000000000..2097d76a4eff --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument.mjs @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, + beforeSendTransaction: event => { + if (event.transaction.includes('/openai/')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-chat.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-chat.mjs new file mode 100644 index 000000000000..6031b6861f5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-chat.mjs @@ -0,0 +1,278 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + // Chat completions endpoint + app.post('/openai/chat/completions', (req, res) => { + const { model, stream } = req.body; + + // Handle error model + if (model === 'error-model') { + res.status(500).set('x-request-id', 'mock-request-error').end('Internal server error'); + return; + } + + if (stream) { + // Streaming response + 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: model, + choices: [{ delta: { role: 'assistant', content: '' }, index: 0 }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: model, + choices: [{ delta: { content: 'Hello from OpenAI streaming!' }, index: 0 }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: model, + choices: [{ delta: {}, index: 0, 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 { + // Non-streaming response + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello from OpenAI mock!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }); + } + }); + + // Responses API endpoint + app.post('/openai/responses', (req, res) => { + const { model, stream } = req.body; + + // Handle error model + if (model === 'error-model') { + res.status(500).set('x-request-id', 'mock-request-error').end('Internal server error'); + return; + } + + if (stream) { + // Streaming response - using event-based format with 'response' field + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const events = [ + { + type: 'response.created', + response: { + id: 'resp_stream_456', + object: 'response', + created_at: 1677652310, + model: model, + status: 'in_progress', + }, + }, + { + type: 'response.output_text.delta', + delta: 'Streaming response to: Test streaming responses API', + response: { + id: 'resp_stream_456', + model: model, + created_at: 1677652310, + }, + }, + { + type: 'response.completed', + response: { + id: 'resp_stream_456', + object: 'response', + created_at: 1677652310, + model: model, + status: 'completed', + output_text: 'Test streaming responses API', + usage: { + input_tokens: 6, + output_tokens: 10, + total_tokens: 16, + }, + }, + }, + ]; + + events.forEach((event, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(event)}\n\n`); + if (index === events.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + } else { + // Non-streaming response + res.send({ + id: 'resp_mock456', + object: 'response', + created_at: 1677652290, + model: model, + output: [ + { + type: 'message', + id: 'msg_mock_output_1', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: `Response to: ${req.body.input}`, + annotations: [], + }, + ], + }, + ], + output_text: `Response to: ${req.body.input}`, + status: 'completed', + usage: { + input_tokens: 5, + output_tokens: 8, + total_tokens: 13, + }, + }); + } + }); + + 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 OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // First test: basic chat completion + await client.chat.completions.create({ + model: 'gpt-3.5-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, + }); + + // Second test: responses API + await client.responses.create({ + model: 'gpt-3.5-turbo', + input: 'Translate this to French: Hello', + instructions: 'You are a translator', + }); + + // Third test: error handling in chat completions + try { + await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // Error is expected and handled + } + + // Fourth test: chat completions streaming + const stream1 = await client.chat.completions.create({ + model: 'gpt-4', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Tell me about streaming' }, + ], + stream: true, + temperature: 0.8, + }); + + // Consume the stream to trigger span instrumentation + for await (const chunk of stream1) { + // Stream chunks are processed automatically by instrumentation + void chunk; // Prevent unused variable warning + } + + // Fifth test: responses API streaming + const stream2 = await client.responses.create({ + model: 'gpt-4', + input: 'Test streaming responses API', + instructions: 'You are a streaming assistant', + stream: true, + }); + + for await (const chunk of stream2) { + void chunk; + } + + // Sixth test: error handling in streaming context + try { + const errorStream = await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + stream: true, + }); + + // Try to consume the stream (this should not execute) + for await (const chunk of errorStream) { + void chunk; + } + } catch { + // Error is expected and handled + } + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-embeddings.mjs new file mode 100644 index 000000000000..42c6a94c5199 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-embeddings.mjs @@ -0,0 +1,81 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + // Embeddings endpoint + app.post('/openai/embeddings', (req, res) => { + const { model } = req.body; + + // Handle error model + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + // Return embeddings response + res.send({ + object: 'list', + data: [ + { + object: 'embedding', + embedding: [0.1, 0.2, 0.3], + index: 0, + }, + ], + model: model, + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }); + }); + + 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 OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + // First test: embeddings API + await client.embeddings.create({ + input: 'Embedding test!', + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + + // Second test: embeddings API error model + try { + await client.embeddings.create({ + input: 'Error embedding test!', + model: 'error-model', + }); + } catch { + // Error is expected and handled + } + + // Third test: embeddings API with multiple inputs + await client.embeddings.create({ + input: ['First input text', 'Second input text', 'Third input text'], + model: 'text-embedding-3-small', + }); + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts new file mode 100644 index 000000000000..37af30b8f488 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts @@ -0,0 +1,235 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { afterAll, expect } from 'vitest'; +import { + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; +import { conditionalTest, isOrchestrionEnabled } from '../../../../utils/index'; + +// openai 7 requires Node.js 22 — its only breaking change over v6 — so this suite is skipped on the +// Node 20 CI leg rather than pinning the whole matrix to the newer runtime. +conditionalTest({ min: 22 })('OpenAI integration (V7)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + // The per-attribute extraction is version-independent and already covered by the v4/v5 suite. + // What a new major puts at risk is whether the transformer still matches the resource files at + // all, so these assert that each instrumented `create` produces a span with the right shape. + createEsmAndCjsTests( + __dirname, + 'scenario-chat.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('instruments chat completions, the responses API and streaming on openai v7', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: { transaction: 'main' } }) + .expect({ + span: container => { + expect(container.items).toHaveLength(6); + + const chatCompletionSpan = container.items.find( + span => span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'chatcmpl-mock123', + ); + expect(chatCompletionSpan).toBeDefined(); + expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo'); + expect(chatCompletionSpan!.status).toBe('ok'); + expect(chatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(chatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: isOrchestrionEnabled() ? 'auto.ai.orchestrion.openai' : 'auto.ai.openai', + }); + expect(chatCompletionSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ + type: 'string', + value: 'openai', + }); + expect(chatCompletionSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ + type: 'string', + value: 'gpt-3.5-turbo', + }); + expect(chatCompletionSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]).toEqual({ + type: 'string', + value: '["stop"]', + }); + expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ + type: 'integer', + value: 10, + }); + expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ + type: 'integer', + value: 15, + }); + expect(chatCompletionSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ + type: 'integer', + value: 25, + }); + + // The responses API is a separate instrumented resource file from chat completions. + const responsesSpan = container.items.find( + span => span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'resp_mock456', + ); + expect(responsesSpan).toBeDefined(); + expect(responsesSpan!.name).toBe('chat gpt-3.5-turbo'); + expect(responsesSpan!.status).toBe('ok'); + expect(responsesSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ + type: 'string', + value: 'chat', + }); + expect(responsesSpan!.attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]).toEqual({ + type: 'string', + value: 'gpt-3.5-turbo', + }); + + // Streaming goes through the patched async iterator rather than `beforeSpanEnd`, so it + // is the part most likely to break if the `Stream` shape changes across a major. + const streamingSpan = container.items.find( + span => span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'chatcmpl-stream-123', + ); + expect(streamingSpan).toBeDefined(); + expect(streamingSpan!.name).toBe('chat gpt-4'); + expect(streamingSpan!.status).toBe('ok'); + expect(streamingSpan!.attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toEqual({ + type: 'boolean', + value: true, + }); + expect(streamingSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ + type: 'integer', + value: 12, + }); + expect(streamingSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ + type: 'integer', + value: 18, + }); + expect(streamingSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ + type: 'integer', + value: 30, + }); + + const errorSpan = container.items.find(span => span.name === 'chat error-model' && span.status !== 'ok'); + expect(errorSpan).toBeDefined(); + expect(errorSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: isOrchestrionEnabled() ? 'auto.ai.orchestrion.openai' : 'auto.ai.openai', + }); + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '7.5.0', + }, + }, + ); + + // Embeddings publish to a different channel than chat, and match a resource file at the package + // root rather than under a nested directory, so they need their own coverage. + createEsmAndCjsTests( + __dirname, + 'scenario-embeddings.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('instruments the embeddings API on openai v7', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: { transaction: 'main' } }) + .expect({ + span: container => { + const embeddingSpans = container.items.filter( + span => span.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]?.value === 'embeddings', + ); + expect(embeddingSpans).toHaveLength(3); + + const singleEmbeddingSpan = embeddingSpans.find( + span => span.name === 'embeddings text-embedding-3-small' && span.status === 'ok', + ); + expect(singleEmbeddingSpan).toBeDefined(); + expect(singleEmbeddingSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.embeddings', + }); + expect(singleEmbeddingSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: isOrchestrionEnabled() ? 'auto.ai.orchestrion.openai' : 'auto.ai.openai', + }); + expect(singleEmbeddingSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ + type: 'string', + value: 'openai', + }); + + const errorEmbeddingSpan = embeddingSpans.find(span => span.name === 'embeddings error-model'); + expect(errorEmbeddingSpan).toBeDefined(); + expect(errorEmbeddingSpan!.status).not.toBe('ok'); + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '7.5.0', + }, + }, + ); + // Span streaming is the default trace lifecycle, so cover it too. The span buffer flushes on a 5s + // timer per trace, which splits this scenario's spans across envelopes under CI load, and the + // runner asserts against one envelope at a time — so this only asserts on the first call's span, + // which is always in the first flush. The exhaustive assertions above stay on the static lifecycle, + // where every span arrives in a single envelope. + createEsmAndCjsTests( + __dirname, + 'scenario-chat.mjs', + 'instrument-span-streaming.mjs', + (createRunner, test) => { + test('instruments chat completions on openai v7 with span streaming enabled', async () => { + await createRunner() + .ignore('event') + .expect({ + span: container => { + const chatCompletionSpan = container.items.find( + span => span.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]?.value === 'chatcmpl-mock123', + ); + expect(chatCompletionSpan).toBeDefined(); + expect(chatCompletionSpan!.name).toBe('chat gpt-3.5-turbo'); + expect(chatCompletionSpan!.status).toBe('ok'); + expect(chatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toEqual({ + type: 'string', + value: 'gen_ai.chat', + }); + expect(chatCompletionSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toEqual({ + type: 'string', + value: isOrchestrionEnabled() ? 'auto.ai.orchestrion.openai' : 'auto.ai.openai', + }); + expect(chatCompletionSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ + type: 'string', + value: 'openai', + }); + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '7.5.0', + }, + }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/winston/instrument.mjs b/dev-packages/node-integration-tests/suites/winston/instrument.mjs index 4906aea85032..f21bdc650450 100644 --- a/dev-packages/node-integration-tests/suites/winston/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/winston/instrument.mjs @@ -5,7 +5,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0.0', environment: 'test', - enableLogs: true, transport: loggingTransport, debug: true, }); diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json index 375fc8f019bc..2fb717375068 100644 --- a/dev-packages/rollup-utils/package.json +++ b/dev-packages/rollup-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/rollup-utils", - "version": "10.67.0", + "version": "10.73.0", "description": "Rollup utilities used at Sentry for the Sentry JavaScript SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/rollup-utils", diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index 1f4c02bb2da3..fe59944d48a1 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/size-limit-gh-action", "description": "An internal Github Action to compare the current size of a PR against the one on develop.", - "version": "10.67.0", + "version": "10.73.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 3836cb5d91db..3d6c8bfaca56 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "10.67.0", + "version": "10.73.0", "name": "@sentry-internal/test-utils", "author": "Sentry", "license": "MIT", @@ -49,7 +49,7 @@ }, "devDependencies": { "@playwright/test": "~1.56.0", - "@sentry/core": "10.67.0", + "@sentry/core": "10.73.0", "@types/ws": "^8.18.1", "eslint-plugin-regexp": "^3.1.0" }, diff --git a/package.json b/package.json index 70e127288d58..284af91d875a 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,8 @@ "**/nx/minimatch": "10.2.5", "**/ng-packagr/postcss-url/minimatch": "3.1.5", "**/@angular-devkit/build-angular/minimatch": "5.1.9", - "**/nitropack/rollup-plugin-visualizer": "^6.0.3" + "**/nitropack/rollup-plugin-visualizer": "^6.0.3", + "vite": "^6.4.3" }, "version": "0.0.0", "name": "sentry-javascript" diff --git a/packages/angular/package.json b/packages/angular/package.json index 5a98e02303e3..e7431f993c3b 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Angular", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular", @@ -21,8 +21,8 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "10.67.0", - "@sentry/core": "10.67.0", + "@sentry/browser": "10.73.0", + "@sentry/core": "10.73.0", "@sentry/conventions": "^0.16.0", "tslib": "^2.4.1" }, diff --git a/packages/astro/package.json b/packages/astro/package.json index 2d4bd8331c5f..59f02a58234d 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/astro", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Astro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/astro", @@ -56,10 +56,10 @@ "astro": ">=3.x || >=4.0.0-beta || >=7.0.0-beta" }, "dependencies": { - "@sentry/browser": "10.67.0", - "@sentry/core": "10.67.0", + "@sentry/browser": "10.73.0", + "@sentry/core": "10.73.0", "@sentry/conventions": "^0.16.0", - "@sentry/node": "10.67.0", + "@sentry/node": "10.73.0", "@sentry/vite-plugin": "^5.3.0" }, "devDependencies": { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 789097ac44cb..567c5c24dc91 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -160,6 +160,8 @@ export { instrumentAnthropicAiClient, instrumentGoogleGenAIClient, instrumentLangChainEmbeddings, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentStateGraphCompile, zodErrorsIntegration, diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index fc606654e389..2e833d86f6ef 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import type { Span, SpanAttributes } from '@sentry/core'; import { addNonEnumerableProperty, @@ -218,6 +219,8 @@ async function instrumentRequestStartHttpServerSpan( [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: method, // This is here for backwards compatibility, we used to set this here before method, + [URL_FULL]: ctx.url.href, + [URL_PATH]: ctx.url.pathname, url: stripUrlQueryAndFragment(ctx.url.href), ...httpHeadersToSpanAttributes( winterCGHeadersToDict(request.headers), @@ -406,7 +409,8 @@ function checkIsDynamicPageRequest(context: Parameters[0] & { routePattern?: string }, ): string | undefined { try { - // `routePattern` is available after Astro 5 + // `routePattern` is available from Astro 5 on. const contextWithRoutePattern = ctx; const rawRoutePattern = contextWithRoutePattern.routePattern; @@ -441,9 +445,12 @@ function getParametrizedRoute( )?.routeData?.segments; return ( - // Astro v5+ - Joining the segments to get the correct casing of the parametrized route + // Astro v5 and v6 - Joining the segments to get the correct casing of the parametrized route (matchedRouteSegmentsFromManifest && joinRouteSegments(matchedRouteSegmentsFromManifest)) || - // Fallback (Astro v4 and earlier) + // Astro v7 - the manifest is no longer reachable from the context, but + // `routePattern` keeps the author's casing, so it needs no correction. + rawRoutePattern || + // Fallback (Astro v4 and earlier, which has no `routePattern`) interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params) ); } catch { diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 205cfb7e757f..e571c8ae55d0 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -1,3 +1,4 @@ +import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import type { Client, Span } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import * as SentryCore from '@sentry/core'; @@ -117,6 +118,8 @@ describe('sentryMiddleware', () => { 'sentry.origin': 'auto.http.astro', method: 'GET', url: 'https://mydomain.io/users/123/details', + [URL_FULL]: 'https://mydomain.io/users/123/details', + [URL_PATH]: '/users/123/details', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SentryCore.SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: 'GET', 'http.route': '/users/[id]/details', @@ -154,6 +157,8 @@ describe('sentryMiddleware', () => { 'sentry.origin': 'auto.http.astro', method: 'GET', url: 'http://localhost:1234/a%xx', + [URL_FULL]: 'http://localhost:1234/a%xx', + [URL_PATH]: 'a%xx', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', [SentryCore.SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: 'GET', }, @@ -575,6 +580,107 @@ describe('sentryMiddleware', () => { }); }); +describe('parametrized route resolution', () => { + const startSpanSpy = vi.spyOn(SentryNode, 'startSpan'); + + beforeEach(() => { + vi.spyOn(SentryNode, 'getCurrentScope').mockImplementation( + () => + ({ + setPropagationContext: vi.fn(), + getSpan: () => undefined, + setSDKProcessingMetadata: vi.fn(), + getPropagationContext: () => ({}), + }) as any, + ); + vi.spyOn(SentryNode, 'getActiveSpan').mockImplementation(() => undefined); + vi.spyOn(SentryNode, 'getClient').mockImplementation( + () => + ({ + getOptions: () => ({}), + getDataCollectionOptions: () => ({ httpHeaders: { request: false, response: false } }), + }) as unknown as Client, + ); + vi.spyOn(SentryNode, 'getTraceMetaTags').mockImplementation(() => ''); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + // Astro lowercases `routePattern`, so the manifest segments are the only way + // to recover the author's casing. Astro 7 dropped the lowercasing and, from + // 7.2.3, the manifest symbols too. + const MANIFEST_SEGMENTS = [ + [{ content: 'catchAll', dynamic: false, spread: false }], + [{ content: '...path', dynamic: true, spread: true }], + ]; + + function runMiddleware(ctx: Record): string | undefined { + const middleware = handleRequest(); + const next = vi.fn(() => Promise.resolve(new Response(null, { status: 200, headers: new Headers() }))); + // @ts-expect-error, a partial ctx object is fine here + middleware({ ...DYNAMIC_REQUEST_CONTEXT, ...ctx }, next); + return startSpanSpy.mock.lastCall?.[0]?.name; + } + + const CATCH_ALL_CTX = { + request: { method: 'GET', url: '/catchAll/a/b', headers: new Headers() }, + url: new URL('https://myDomain.io/catchAll/a/b'), + params: { path: 'a/b' }, + routePattern: '/catchAll/[...path]', + }; + + it.each([ + ['Astro 5', Symbol.for('context.routes')], + ['Astro 6', Symbol.for('astro.pipeline')], + ])('reads the route from the %s manifest, preserving casing', (_label, symbol) => { + const name = runMiddleware({ + ...CATCH_ALL_CTX, + // Astro lowercases `routePattern`, so this differs from the manifest casing. + routePattern: '/catchall/[...path]', + [symbol]: { + manifest: { routes: [{ routeData: { route: '/catchall/[...path]', segments: MANIFEST_SEGMENTS } }] }, + }, + }); + + expect(name).toBe('GET /catchAll/[...path]'); + }); + + // Astro 7.2.3 removed the last manifest symbol. `routePattern` keeps the + // author's casing there, so it is the correct source once the manifest is gone. + it('falls back to `routePattern` when no manifest is reachable', () => { + expect(runMiddleware(CATCH_ALL_CTX)).toBe('GET /catchAll/[...path]'); + }); + + it('prefers `routePattern` over interpolating a rest param out of the URL', () => { + // Interpolation reverse-maps param values found in the URL, so it cannot + // recover the `...` of a rest param. + expect(interpolateRouteFromUrlAndParams('/catchAll/a/b', { path: 'a/b' })).toBe('/catchAll/[path]'); + expect(runMiddleware(CATCH_ALL_CTX)).toBe('GET /catchAll/[...path]'); + }); + + it('falls back to `routePattern` when the manifest holds no matching route', () => { + const name = runMiddleware({ + ...CATCH_ALL_CTX, + [Symbol.for('astro.pipeline')]: { manifest: { routes: [{ routeData: { route: '/other', segments: [] } }] } }, + }); + + expect(name).toBe('GET /catchAll/[...path]'); + }); + + // Astro 4 has no `routePattern`, so interpolation stays the last resort. + it('interpolates from the URL when `routePattern` is absent', () => { + const name = runMiddleware({ + request: { method: 'GET', url: '/users/123/details', headers: new Headers() }, + url: new URL('https://myDomain.io/users/123/details'), + params: { id: '123' }, + }); + + expect(name).toBe('GET /users/[id]/details'); + }); +}); + describe('interpolateRouteFromUrlAndParams', () => { it.each([ ['/', {}, '/'], diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 4bda1912820a..db44ddac9e14 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/aws-serverless", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for AWS Lambda and AWS Serverless Environments", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless", @@ -72,9 +72,9 @@ "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/node-core": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/node-core": "10.73.0", "@types/aws-lambda": "^8.10.161" }, "devDependencies": { diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 2078044d64a8..6fd1f496adc7 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -142,6 +142,8 @@ export { instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentStateGraphCompile, zodErrorsIntegration, diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index abb431cb38ca..d579cc4618e6 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser-utils", - "version": "10.67.0", + "version": "10.73.0", "description": "Browser Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser-utils", @@ -40,7 +40,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.67.0", + "@sentry/core": "10.73.0", "@sentry/conventions": "^0.16.0" }, "scripts": { diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 60dbe88f2fa7..4f6f48f00cd2 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -8,7 +8,6 @@ import { isPrimitive, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_URL_FULL, setMeasurement, spanToJSON, stringMatchesSomePattern, @@ -30,6 +29,7 @@ import { getActivationStart } from './web-vitals/lib/getActivationStart'; import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry'; import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher'; import { DEBUG_BUILD } from '../debug-build'; +import { URL_FULL } from '@sentry/conventions/attributes'; interface NavigatorNetworkInformation { readonly connection?: NetworkInformation; } @@ -775,7 +775,7 @@ export function _addResourceSpans( attributes['url.same_origin'] = resourceUrl.includes(WINDOW.location.origin); - attributes[SEMANTIC_ATTRIBUTE_URL_FULL] = resourceUrl; + attributes[URL_FULL] = resourceUrl; _setResourceRequestAttributes(entry, attributes, [ // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming/responseStatus diff --git a/packages/browser/package.json b/packages/browser/package.json index 51b889e369b4..737a44e11942 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for browsers", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser", @@ -45,15 +45,15 @@ "access": "public" }, "dependencies": { - "@sentry/browser-utils": "10.67.0", + "@sentry/browser-utils": "10.73.0", "@sentry/conventions": "^0.16.0", - "@sentry/feedback": "10.67.0", - "@sentry/replay": "10.67.0", - "@sentry/replay-canvas": "10.67.0", - "@sentry/core": "10.67.0" + "@sentry/feedback": "10.73.0", + "@sentry/replay": "10.73.0", + "@sentry/replay-canvas": "10.73.0", + "@sentry/core": "10.73.0" }, "devDependencies": { - "@sentry-internal/integration-shims": "10.67.0", + "@sentry-internal/integration-shims": "10.73.0", "fake-indexeddb": "^6.2.4" }, "scripts": { diff --git a/packages/browser/rollup.bundle.config.mjs b/packages/browser/rollup.bundle.config.mjs index 2a70d25dac77..a3b7761f5c35 100644 --- a/packages/browser/rollup.bundle.config.mjs +++ b/packages/browser/rollup.bundle.config.mjs @@ -16,6 +16,7 @@ const reexportedPluggableIntegrationFiles = [ 'instrumentanthropicaiclient', 'instrumentopenaiclient', 'instrumentgooglegenaiclient', + 'instrumentstategraph', 'instrumentlanggraph', 'createlangchaincallbackhandler', 'instrumentlangchainembeddings', diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index d91cd0e9bc76..c7fb7c44cdd5 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -410,5 +410,5 @@ function getObjectClassName(obj: unknown): string | undefined | void { /** If a plain object has a property that is an `Error`, return this error. */ function getErrorPropertyFromObject(obj: Record): Error | undefined { - return Object.values(obj).find((v): v is Error => v instanceof Error); + return Object.values(obj).find(isError); } diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index a9e7b568ea97..b349c9c8c8d8 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -75,6 +75,8 @@ export { instrumentAnthropicAiClient, instrumentOpenAiClient, instrumentGoogleGenAIClient, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentCreateReactAgent, createLangChainCallbackHandler, diff --git a/packages/browser/src/integrations-bundle/index.instrumentlanggraph.ts b/packages/browser/src/integrations-bundle/index.instrumentlanggraph.ts index e54333eed24a..f3322ba3f08a 100644 --- a/packages/browser/src/integrations-bundle/index.instrumentlanggraph.ts +++ b/packages/browser/src/integrations-bundle/index.instrumentlanggraph.ts @@ -1 +1,2 @@ +// eslint-disable-next-line typescript/no-deprecated export { instrumentLangGraph } from '@sentry/core/browser'; diff --git a/packages/browser/src/integrations-bundle/index.instrumentstategraph.ts b/packages/browser/src/integrations-bundle/index.instrumentstategraph.ts new file mode 100644 index 000000000000..4cfa5da9697a --- /dev/null +++ b/packages/browser/src/integrations-bundle/index.instrumentstategraph.ts @@ -0,0 +1 @@ +export { instrumentStateGraph } from '@sentry/core/browser'; diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index f2d298027f3d..71c05d8381a3 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -5,13 +5,12 @@ import { isString, SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_URL_FULL, spanToJSON, stringMatchesSomePattern, } from '@sentry/core/browser'; import type { FetchHint, XhrHint } from '@sentry/browser-utils'; import { getBodyString, getFetchRequestArgBody, SENTRY_XHR_DATA_KEY } from '@sentry/browser-utils'; -import { GRAPHQL_DOCUMENT } from '@sentry/conventions/attributes'; +import { GRAPHQL_DOCUMENT, URL_FULL } from '@sentry/conventions/attributes'; interface GraphQLClientOptions { endpoints: Array; @@ -71,7 +70,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption // Fall back to `url` because fetch instrumentation only sets `http.url` for absolute URLs; // relative URLs end up only in `url` (see `getFetchSpanAttributes` in packages/core/src/fetch.ts). - const httpUrl = spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL] || spanAttributes['http.url'] || spanAttributes['url']; + const httpUrl = spanAttributes[URL_FULL] || spanAttributes['http.url'] || spanAttributes['url']; const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes['http.method']; if (!isString(httpUrl) || !isString(httpMethod)) { diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index c013a5939d7b..92234b2e9631 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -1,5 +1,6 @@ import { defineIntegration, safeSetSpanJSONAttributes, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core/browser'; import { getHttpRequestData, WINDOW } from '../helpers'; +import { URL_FULL } from '@sentry/conventions/attributes'; /** * Collects information about HTTP request headers and @@ -39,7 +40,7 @@ export const httpContextIntegration = defineIntegration(() => { safeSetSpanJSONAttributes(span, { // Coerce empty string to undefined so the helper's nullish check drops it, // rather than writing an empty `url.full` attribute onto the span. - 'url.full': spanOp !== 'http.client' ? reqData.url : undefined, + [URL_FULL]: spanOp !== 'http.client' ? reqData.url : undefined, 'http.request.header.user_agent': reqData.headers['User-Agent'], 'http.request.header.referer': reqData.headers['Referer'], }); diff --git a/packages/browser/src/tracing/request.ts b/packages/browser/src/tracing/request.ts index 38cb73d085cc..1c33ceb1f8af 100644 --- a/packages/browser/src/tracing/request.ts +++ b/packages/browser/src/tracing/request.ts @@ -40,6 +40,7 @@ import { } from '@sentry/browser-utils'; import type { BrowserClient } from '../client'; import { baggageHeaderHasSentryValues, createHeadersSafely, getFullURL, isPerformanceResourceTiming } from './utils'; +import { HTTP_URL, URL_FULL } from '@sentry/conventions/attributes'; /** Options for Request Instrumentation */ export interface RequestInstrumentationOptions { @@ -172,10 +173,11 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { }); }); + it('handles object with error prop created in another realm', () => { + const error = runInNewContext(`new Error('Some error')`) as Error; + expect(error).not.toBeInstanceOf(Error); + + const event = eventFromUnknownInput(defaultStackParser, { + err: error, + }); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + type: 'Error', + value: 'Some error', + }), + ); + }); + it('handles class with error prop', () => { const error = new Error('Some error'); diff --git a/packages/browser/test/integrations/graphqlClient.test.ts b/packages/browser/test/integrations/graphqlClient.test.ts index ee926e812d01..26d4cd639100 100644 --- a/packages/browser/test/integrations/graphqlClient.test.ts +++ b/packages/browser/test/integrations/graphqlClient.test.ts @@ -6,6 +6,7 @@ import type { Client } from '@sentry/core/browser'; import { SentrySpan, spanToJSON } from '@sentry/core/browser'; import type { FetchHint, XhrHint } from '@sentry/browser-utils'; import { SENTRY_XHR_DATA_KEY } from '@sentry/browser-utils'; +import { URL_FULL } from '@sentry/conventions/attributes'; import { describe, expect, test } from 'vitest'; import { _getGraphQLOperation, @@ -352,7 +353,7 @@ describe('GraphqlClient', () => { extensions: {}, }; - test('enriches http.client span for absolute URLs (http.url attribute)', () => { + test('enriches http.client span for absolute URLs', () => { const handler = setupHandler([/\/graphql$/]); const span = new SentrySpan({ name: 'POST http://localhost:4000/graphql', @@ -360,6 +361,7 @@ describe('GraphqlClient', () => { attributes: { 'http.method': 'POST', 'http.url': 'http://localhost:4000/graphql', + [URL_FULL]: 'http://localhost:4000/graphql', url: 'http://localhost:4000/graphql', }, }); @@ -371,9 +373,27 @@ describe('GraphqlClient', () => { expect(json.data['graphql.document']).toBe(requestBody.query); }); + test('enriches http.client span when only url.full is present', () => { + const handler = setupHandler([/\/graphql$/]); + const span = new SentrySpan({ + name: 'POST http://localhost:4000/graphql', + op: 'http.client', + attributes: { + 'http.method': 'POST', + [URL_FULL]: 'http://localhost:4000/graphql', + }, + }); + + handler(span, makeFetchHint('http://localhost:4000/graphql', requestBody)); + + const json = spanToJSON(span); + expect(json.description).toBe('POST http://localhost:4000/graphql (query GetHello)'); + expect(json.data['graphql.document']).toBe(requestBody.query); + }); + test('enriches http.client span for relative URLs (only url attribute)', () => { const handler = setupHandler([/\/graphql$/]); - // Fetch instrumentation does NOT set http.url for relative URLs — only `url`. + // Fetch instrumentation does not set `http.url` or `url.full` for relative URLs. const span = new SentrySpan({ name: 'POST /graphql', op: 'http.client', @@ -433,6 +453,7 @@ describe('GraphqlClient', () => { attributes: { 'http.method': 'POST', 'http.url': 'http://localhost:4000/graphql', + [URL_FULL]: 'http://localhost:4000/graphql', url: 'http://localhost:4000/graphql', }, }); diff --git a/packages/bun/package.json b/packages/bun/package.json index df7c6404f38c..cede1c6d7516 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bun", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for bun", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", @@ -49,10 +49,11 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/server-utils": "10.67.0" + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", + "@sentry/core": "10.73.0", + "@sentry/conventions": "^0.16.0", + "@sentry/node": "10.73.0", + "@sentry/server-utils": "10.73.0" }, "devDependencies": { "bun-types": "^1.2.9" diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index ae98ae2aecad..04b62615b557 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -160,6 +160,8 @@ export { instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentStateGraphCompile, zodErrorsIntegration, diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 4a67ba93c029..6ff72e9eb754 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -15,6 +15,7 @@ import { withIsolationScope, } from '@sentry/core'; import type { ServeOptions } from 'bun'; +import { URL_FULL } from '@sentry/conventions/attributes'; const INTEGRATION_NAME = 'BunServer' as const; @@ -282,7 +283,7 @@ function getSpanAttributesFromParsedUrl( attributes['url.path'] = parsedUrl.pathname; } if (!isURLObjectRelative(parsedUrl)) { - attributes['url.full'] = parsedUrl.href; + attributes[URL_FULL] = parsedUrl.href; if (parsedUrl.port) { attributes['url.port'] = parsedUrl.port; } diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index 8312bfee4fa8..26d6eb90625f 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bundler-plugins", - "version": "10.67.0", + "version": "10.73.0", "description": "Sentry Bundler Plugins", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/main/packages/bundler-plugins", @@ -112,7 +112,7 @@ "dependencies": { "@babel/core": "^7.18.5", "@sentry/cli": "^2.58.6", - "@sentry/core": "10.67.0", + "@sentry/core": "10.73.0", "dotenv": "^17.4.2", "find-up": "^5.0.0", "glob": "^13.0.6", diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index a72054a86115..961b6be4d14c 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -117,6 +117,7 @@ export function createComponentNameAnnotateHooks(ignoredComponents: string[], in const result = await transformAsync(code, { plugins: [[plugin, { ignoredComponents }]], filename: id, + sourceFileName: idWithoutQueryAndHash, parserOpts: { sourceType: 'module', allowAwaitOutsideFunction: true, diff --git a/packages/bundler-plugins/test/core/index.test.ts b/packages/bundler-plugins/test/core/index.test.ts index cc6e6a2eb189..a3362759be60 100644 --- a/packages/bundler-plugins/test/core/index.test.ts +++ b/packages/bundler-plugins/test/core/index.test.ts @@ -1,7 +1,21 @@ -import { getDebugIdSnippet } from '../../src/core'; +import { createComponentNameAnnotateHooks, getDebugIdSnippet } from '../../src/core'; import { containsOnlyImports } from '../../src/core/utils'; import { describe, it, expect } from 'vitest'; +describe('createComponentNameAnnotateHooks', () => { + it.each([ + ['.tsx', '/project/src/shared/providers/AppProviders.tsx'], + ['.jsx', '/project/src/shared/providers/AppProviders.jsx'], + ])('preserves the full file path in the emitted source map (%s)', async (_ext, id) => { + const { transform } = createComponentNameAnnotateHooks([], false); + const code = 'export function AppProviders() {\n return
hello
;\n}\n'; + + const result = await transform(code, id); + + expect(result?.map?.sources).toEqual([id]); + }); +}); + describe('getDebugIdSnippet', () => { it('returns the debugId injection snippet for a passed debugId', () => { const snippet = getDebugIdSnippet('1234'); diff --git a/packages/cloudflare/.oxlintrc.json b/packages/cloudflare/.oxlintrc.json index 401c478775ee..3ec49c6228c2 100644 --- a/packages/cloudflare/.oxlintrc.json +++ b/packages/cloudflare/.oxlintrc.json @@ -30,11 +30,11 @@ ], "patterns": [ { - "group": ["@sentry/node/*"], + "group": ["@sentry/node/**"], "message": "Do not import from `@sentry/node` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points." }, { - "group": ["@sentry/server-utils/*"], + "group": ["@sentry/server-utils/**"], "message": "Do not import from `@sentry/server-utils` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points." } ] @@ -43,7 +43,7 @@ } }, { - "files": ["**/src/nodejs_compat/**"], + "files": ["**/src/nodejs_compat/**", "**/src/vite/**"], "rules": { "no-restricted-imports": "off" } diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 7e6d3b478e60..748710e947ed 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/cloudflare", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Cloudflare Workers and Pages", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare", @@ -70,16 +70,20 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/server-utils": "10.67.0" + "@sentry/core": "10.73.0", + "@sentry/server-utils": "10.73.0", + "magic-string": "~0.30.21" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.x || ^5.x" + "@cloudflare/workers-types": "^4.x || ^5.x", + "wrangler": "^4.x" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { "optional": true + }, + "wrangler": { + "optional": true } }, "devDependencies": { diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 087c1ad720d9..ce42b8416a93 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,5 +1,11 @@ -import type { ClientOptions, Options, ServerRuntimeClientOptions } from '@sentry/core'; -import { applySdkMetadata, debug, ServerRuntimeClient, spanIsSampled } from '@sentry/core'; +import type { ClientOptions, Options, ServerRuntimeClientOptions, TracePropagationTargets } from '@sentry/core'; +import { + _INTERNAL_clearAiProviderSkips, + applySdkMetadata, + debug, + ServerRuntimeClient, + spanIsSampled, +} from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import type { ExecutionContextCompat } from './executionContext'; import type { makeFlushLock } from './flush'; @@ -140,6 +146,16 @@ export class CloudflareClient extends ServerRuntimeClient { (this as unknown as { _flushLock: ReturnType | void })._flushLock = undefined; } + /** @inheritDoc */ + protected override _setupIntegrations(): void { + // Clear AI provider skip registrations before setting up integrations. + // The registry is module-global and Cloudflare calls `init()` per request, so without this a + // single `ai` SDK call would suppress direct `env.AI.run` spans for the rest of the isolate's + // life. Mirrors the same reset in the Node client. + _INTERNAL_clearAiProviderSkips(); + super._setupIntegrations(); + } + /** * Resets the span completion promise and resolve function. */ @@ -172,47 +188,58 @@ interface BaseCloudflareOptions { skipOpenTelemetrySetup?: boolean; /** - * Enable trace propagation for RPC calls between Workers, Durable Objects, and Service Bindings. + * The bindings on `env` that outgoing RPC calls propagate trace context to. * - * When enabled, trace context (sentry-trace + baggage) is propagated across: - * - `stub.fetch()` calls to Durable Objects (via HTTP headers) - * - Service binding `fetch()` calls (via HTTP headers) - * - RPC method calls to Durable Objects and WorkerEntrypoints (via trailing argument) + * Strings match a binding name exactly, regular expressions match by pattern. An empty array + * (the default) propagates to nothing. * - * When enabled on the **receiver side** (DurableObject or WorkerEntrypoint), the SDK will also: - * - Extract and continue traces from incoming RPC calls - * - Create spans for each RPC method invocation - * - Capture errors thrown by RPC methods + * RPC has no headers to carry trace context, so the SDK appends it as a trailing argument to + * every RPC method call on a matching binding. Only a Sentry-instrumented receiver strips that + * argument again. Anywhere else it arrives as a real argument and changes what the method was + * called with, so list only the bindings whose receiver you know runs Sentry. * - * **Important:** This option should be enabled on **both sides** for full trace propagation. + * Propagation over `stub.fetch()` and service binding `fetch()` uses HTTP headers and is not + * affected by this option. * - * @default false + * When you build with the Sentry Cloudflare Vite plugin, bindings that resolve to *this* worker + * (its own Durable Objects, its self service bindings) are added for you, because the plugin + * instruments those receivers itself. Whatever you list here is added on top of them. + * + * Setting this takes precedence over `enableRpcTracePropagation`: an allow list is the more + * precise statement, so a worker that sets both propagates only to the listed bindings. An empty + * array propagates to nothing, which is how a receiver keeps `enableRpcTracePropagation: true` + * without propagating to anything itself. + * + * The receiver still needs `enableRpcTracePropagation: true` to continue the trace it is sent. + * + * @default [] * @example * ```ts - * // Worker side (caller) + * // Propagate to `env.ORDERS` and every `env.SVC_*` binding * export default Sentry.withSentry( - * (env) => ({ + * env => ({ * dsn: env.SENTRY_DSN, - * enableRpcTracePropagation: true, + * rpcTracePropagationBindings: ['ORDERS', /^SVC_/], * }), * handler, * ); + * ``` + */ + rpcTracePropagationBindings?: TracePropagationTargets; + + /** + * Whether trace context is propagated over RPC calls between Workers, Durable Objects, and + * Service Bindings. * - * // Durable Object side (receiver) - * export const MyDO = Sentry.instrumentDurableObjectWithSentry( - * (env) => ({ - * dsn: env.SENTRY_DSN, - * enableRpcTracePropagation: true, - * }), - * MyDOBase, - * ); + * On the caller side, `true` appends the trace context as a trailing argument to every RPC method + * call on `env`, including bindings whose receiver does not run Sentry and therefore never strips + * that argument again. On the receiver side, `true` continues an incoming trace, creates a span + * per RPC method invocation, and captures errors thrown by RPC methods. * - * // WorkerEntrypoint side (receiver) - * export const MyEntrypoint = Sentry.withSentry( - * env => ({ dsn: env.SENTRY_DSN, enableRpcTracePropagation: true }), - * MyEntrypointBase, - * ); - * ``` + * @deprecated Use `rpcTracePropagationBindings` to name the bindings you call. This option will + * be removed in a future major version. Receivers keep using it until then. + * + * @default false */ enableRpcTracePropagation?: boolean; @@ -240,6 +267,31 @@ interface BaseCloudflareOptions { */ durableObjectSqlSpanAllowlist?: Array; + /** + * KV keys that should stay instrumented even though they match a reserved prefix used by Durable + * Object frameworks (`agents`, `partyserver`, ...) for their internal storage entries. + * + * By default, KV reads/writes (`get`, `put`, `delete`, `list`) of `cf_`- or `__ps_`-prefixed keys + * are treated as framework noise and no `durable_object_storage_*` span is created for them, + * mirroring how `cf_`-prefixed SQL tables are handled (see {@link durableObjectSqlSpanAllowlist}). + * If one of your own keys happens to use such a prefix, add it here to opt it back into + * instrumentation. Strings must match exactly, while regular expressions give you prefix/pattern + * matching. + * + * @default [] + * @example + * ```ts + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * durableObjectStorageSpanAllowlist: ['cf_my_key', /^cf_reports_/], + * }), + * handler, + * ); + * ``` + */ + durableObjectStorageSpanAllowlist?: Array; + /** * @deprecated Use `enableRpcTracePropagation` instead. This option will be removed in a future major version. * @@ -253,6 +305,18 @@ interface BaseCloudflareOptions { * @default false */ instrumentPrototypeMethods?: boolean | string[]; + + /** + * If you use Spotlight by Sentry during development, use + * this option to forward captured Sentry events to Spotlight. + * + * Either set it to true, or provide a specific Spotlight Sidecar URL. + * + * More details: https://spotlightjs.com/ + * + * IMPORTANT: Only set this option to `true` while developing, not in production! + */ + spotlight?: boolean | string; } /** diff --git a/packages/cloudflare/src/defineCloudflareOptions.ts b/packages/cloudflare/src/defineCloudflareOptions.ts new file mode 100644 index 000000000000..6e905dad7fa1 --- /dev/null +++ b/packages/cloudflare/src/defineCloudflareOptions.ts @@ -0,0 +1,46 @@ +import type { env as cloudflareEnv } from 'cloudflare:workers'; +import type { CloudflareOptions } from './client'; + +/** + * Define the Sentry options for a Cloudflare Worker in a dedicated module. + * + * This is the recommended way to configure the SDK when using the Vite plugin's + * auto-instrumentation: place an `instrument.server.{ts,js,mjs}` file next to + * the worker entry whose **default export** is the result of this function. The + * plugin picks it up automatically and hands it to `withSentry`. + * + * Unlike Node's `Sentry.init(...)`, the options cannot be applied at module + * load time on Cloudflare: the DSN and other settings typically come from the + * per-request `env`, which only exists inside the handler. Pass a callback to + * read from `env`, or a static object when no `env` access is needed — either + * way you get full type-checking and autocomplete on {@link CloudflareOptions}. + * + * At runtime this is a thin pass-through; it only normalizes a static object + * into a callback so the plugin always imports a `(env) => options` function. + * + * @example + * ```ts + * // src/instrument.server.ts + * import { defineCloudflareOptions } from '@sentry/cloudflare'; + * + * export default defineCloudflareOptions((env) => ({ + * dsn: env.SENTRY_DSN, + * tracesSampleRate: 1.0, + * })); + * ``` + * + * @example + * ```ts + * // Static options — no `env` access needed + * export default defineCloudflareOptions({ tracesSampleRate: 1.0 }); + * ``` + */ +export function defineCloudflareOptions( + optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined), +): (env: Env) => CloudflareOptions | undefined { + if (typeof optionsOrCallback === 'function') { + return optionsOrCallback as (env: Env) => CloudflareOptions | undefined; + } + + return () => optionsOrCallback; +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 42bfcab834cf..6077b1881235 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -1,17 +1,344 @@ /* eslint-disable @typescript-eslint/unbound-method */ -import { captureException } from '@sentry/core'; +import { isObjectLike } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; -import { ensureInstrumented } from './instrument'; +import { getInstrumented, markAsInstrumented } from './instrument'; +import { instrumentDurableObjectHandlers } from './instrumentations/instrumentDurableObjectHandlers'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; -import { wrapRequestHandler } from './request'; import { instrumentContext } from './utils/instrumentContext'; -import { extractRpcMeta } from './utils/rpcMeta'; +import { hasRpcMeta } from './utils/rpcMeta'; import { getEffectiveRpcPropagation } from './utils/rpcOptions'; +import { instrumentCloudflareAgent } from './instrumentations/agents'; import { type UncheckedMethod, wrapMethodWithSentry } from './wrapMethodWithSentry'; +/** + * The instrumented context passed between the shared construction helpers. + * + * This is intentionally `any` rather than `ReturnType>`. + * A concrete `DurableObjectState` here forces `tsc` to structurally relate its `storage: SqlStorage` + * graph against the `ExecutionContext | InstrumentedDurableObjectState` parameter of + * `wrapMethodWithSentry`, while the RPC-branded `DurableObject` from `cloudflare:workers` is also in + * scope. That union comparison explodes (1296×1296) and hangs the type build. The original inline + * implementation avoided this only incidentally, because `ctx` reached it as `any` through the Proxy + * `construct` trap. Keeping the shared context `any` preserves that behavior. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type InstrumentedDurableObjectContext = any; + +/** + * Constructs a Durable Object instance and instruments its built-in handler methods + * (`fetch`, `alarm`, `webSocketMessage`, `webSocketClose`, `webSocketError`). + * + * This is the shared construction path used by both {@link instrumentDurableObjectWithSentry} + * and {@link instrumentAgentWithSentry}. It intentionally does NOT apply the RPC prototype-method + * instrumentation — callers apply that last via {@link finalizeWithRpcInstrumentation}, after any + * additional per-instance instrumentation has been layered onto the returned object. + * + * @internal + */ +export function constructInstrumentedDurableObject>( + target: new (state: DurableObjectState, env: E) => T, + ctx: DurableObjectState, + env: E, + newTarget: NewableFunction, + optionsCallback: (env: E) => CloudflareOptions, +): { + obj: T; + options: CloudflareOptions; + context: InstrumentedDurableObjectContext; + frameworkManagedMethods: ReadonlySet; +} { + setAsyncLocalStorageAsyncContextStrategy(); + const options = getFinalOptions(optionsCallback(env), env); + // See InstrumentedDurableObjectContext — `ctx` is widened to `any` so the concrete + // `DurableObjectState` type never enters the checker's relation graph in this module. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const context = instrumentContext(ctx as any); + const instrumentedEnv = instrumentEnv(env as Record, options); + + const prototype = (newTarget as unknown as { prototype?: object }).prototype ?? target.prototype; + const cachedFrameworkManagedMethods = frameworkManagedMethodsCache.get(prototype); + const methodsBeforeConstruction = cachedFrameworkManagedMethods ? undefined : resolvePrototypeMethods(prototype); + + // Pass `newTarget` so that subclasses of the instrumented class (e.g. the wrapper classes + // created by wrangler's local dev tooling or `@cloudflare/vitest-pool-workers`) keep their + // own prototype — otherwise subclass methods disappear and `instanceof` checks break. + const obj = Reflect.construct(target, [context, instrumentedEnv], newTarget) as T; + + const frameworkManagedMethods = resolveFrameworkManagedMethods( + prototype, + obj, + methodsBeforeConstruction, + cachedFrameworkManagedMethods, + ); + + instrumentDurableObjectHandlers(obj, options, context); + + return { obj, options, context, frameworkManagedMethods }; +} + +const frameworkManagedMethodsCache = new WeakMap>(); + +/** + * Collects the methods visible from a prototype, using normal property lookup precedence. + * Methods inherited from `Object.prototype` are excluded because they cannot be Durable Object RPC methods. + */ +function resolvePrototypeMethods(prototype: object | null): Map { + const methods = new Map(); + + for (let current = prototype; current && current !== Object.prototype; current = Object.getPrototypeOf(current)) { + for (const name of Object.getOwnPropertyNames(current)) { + // The first occurrence wins, mirroring what a property lookup on the instance would find + if (name === 'constructor' || methods.has(name)) { + continue; + } + + const descriptor = Object.getOwnPropertyDescriptor(current, name); + + if (descriptor && typeof descriptor.value === 'function') { + methods.set(name, descriptor.value); + } + } + } + + return methods; +} + +/** + * Finds methods that a framework replaced while constructing the first instance. + * + * Some frameworks register methods by function identity, so replacing one of their wrappers would + * break dispatch. The result is cached because frameworks commonly install their wrappers only + * once; later constructions would no longer reveal which methods they manage. + */ +function resolveFrameworkManagedMethods( + prototype: object, + obj: object, + methodsBeforeConstruction: Map | undefined, + cached: ReadonlySet | undefined, +): ReadonlySet { + if (cached) { + return cached; + } + + const methodsAfterConstruction = resolvePrototypeMethods(Object.getPrototypeOf(obj) as object); + const managed = new Set(); + + for (const [name, method] of methodsAfterConstruction) { + const before = methodsBeforeConstruction?.get(name); + + if (before && before !== method) { + managed.add(name); + } + } + + frameworkManagedMethodsCache.set(prototype, managed); + + return managed; +} + +type RpcInstanceState = { + options: CloudflareOptions; + context: InstrumentedDurableObjectContext; + /** + * Whether to trace calls that carry no RPC metadata. Only the deprecated + * `instrumentPrototypeMethods` option does, because it predates metadata propagation. + */ + alwaysTrace: boolean; + /** Per-instance cache of the traced method wrappers, keyed by method name. Created on first use. */ + tracedMethods?: Map; +}; + +/** + * Method names the runtime never dispatches over RPC, so wrapping them buys no tracing. + * + * Mirrors `isReservedName` in workerd (`src/workerd/api/worker-rpc.c++`): the runtime rejects these + * before any property lookup happens. `fetch`, `alarm` and the `webSocket*` handlers are also + * instrumented per-instance as own properties, and `constructor` is on every prototype. + */ +const RESERVED_RPC_METHOD_NAMES: ReadonlySet = new Set([ + 'constructor', + 'fetch', + 'connect', + 'alarm', + 'webSocketMessage', + 'webSocketClose', + 'webSocketError', + 'dup', +]); + +// Prototype wrappers are shared by all instances, while SDK options and traced method caches are not. +const rpcInstanceStates = new WeakMap(); + +/** + * Adds trace propagation to 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 + * visible to Cloudflare's RPC dispatcher. Built-in handlers, Agent handlers, and methods managed by + * another framework are left untouched. + * + * Call this after all per-instance instrumentation has been applied. If RPC trace propagation is + * disabled, the object is returned unchanged. + * + * @param obj The constructed Durable Object instance. + * @param options The resolved SDK options for this instance. + * @param context The instrumented execution context for this instance. + * @param excludedMethods Method names owned by another framework and therefore not safe to wrap. + * @returns The same Durable Object instance, with eligible prototype methods instrumented. + * @internal + */ +export function finalizeWithRpcInstrumentation( + obj: T, + options: CloudflareOptions, + context: InstrumentedDurableObjectContext, + excludedMethods?: ReadonlySet, +): T { + // Get effective RPC propagation setting (handles deprecation of instrumentPrototypeMethods) + const rpcPropagation = getEffectiveRpcPropagation(options); + + // Skip RPC instrumentation if not enabled + if (!rpcPropagation) { + return obj; + } + + // If `instrumentPrototypeMethods` was passed as an array (deprecated), + // only the listed method names should be instrumented. + // eslint-disable-next-line typescript/no-deprecated + const allowedMethods = Array.isArray(options.instrumentPrototypeMethods) + ? // eslint-disable-next-line typescript/no-deprecated + new Set(options.instrumentPrototypeMethods) + : undefined; + + // When using the deprecated `instrumentPrototypeMethods` option, always create spans. + // When using the new `enableRpcTracePropagation`, only create spans when RPC metadata is present. + // eslint-disable-next-line typescript/no-deprecated + const alwaysTrace = options.enableRpcTracePropagation === undefined; + + rpcInstanceStates.set(obj, { options, context, alwaysTrace }); + + instrumentPrototypeRpcMethods(obj, excludedMethods, allowedMethods); + + return obj; +} + +/** + * Returns a prototype method when it is eligible for RPC instrumentation. + */ +function getRpcMethodDescriptor( + obj: object, + prototype: object, + methodName: string, + excludedMethods?: ReadonlySet, + allowedMethods?: ReadonlySet, +): PropertyDescriptor | undefined { + if ( + RESERVED_RPC_METHOD_NAMES.has(methodName) || + Object.prototype.hasOwnProperty.call(obj, methodName) || + excludedMethods?.has(methodName) || + (allowedMethods && !allowedMethods.has(methodName)) + ) { + return undefined; + } + + const descriptor = Object.getOwnPropertyDescriptor(prototype, methodName); + + if (!descriptor || typeof descriptor.value !== 'function' || getInstrumented(descriptor.value)) { + return undefined; + } + + return descriptor; +} + +/** + * Wraps eligible methods on the instance's prototype chain once per class. + * + * Because the wrappers live on the prototype, the allow-list of the first constructed instance + * decides which methods carry a wrapper for every later instance of that class. This only affects + * the deprecated array form of `instrumentPrototypeMethods`, where differing options per instance + * of the same class were never a supported configuration. + */ +function instrumentPrototypeRpcMethods( + obj: object, + excludedMethods?: ReadonlySet, + allowedMethods?: ReadonlySet, +): void { + let prototype: object | null = Object.getPrototypeOf(obj); + + while (prototype && prototype !== Object.prototype) { + for (const methodName of Object.getOwnPropertyNames(prototype)) { + const descriptor = getRpcMethodDescriptor(obj, prototype, methodName, excludedMethods, allowedMethods); + + if (!descriptor) { + continue; + } + + const wrapped = createRpcPrototypeWrapper(methodName, descriptor.value as UncheckedMethod); + + try { + Object.defineProperty(prototype, methodName, { ...descriptor, value: wrapped }); + } catch {} + + // Only the wrapper is marked, not the original method: `wrapMethodWithSentry` resolves + // through the same global map and must not resolve the original to this wrapper, + // which would recurse. + markAsInstrumented(wrapped); + } + + prototype = Object.getPrototypeOf(prototype); + } +} + +/** + * Creates a prototype wrapper that traces RPC calls carrying Sentry metadata. + * + * The wrapper looks up SDK state from its receiver, allowing one prototype function to serve every + * instance. Calls without instance state use the original method directly, as do calls without RPC + * metadata unless the instance opted into unconditional tracing. 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 { + const state = isObjectLike(this) ? rpcInstanceStates.get(this) : undefined; + + // Untraced calls are the common case — every internal call the instance makes to one of its + // own methods lands here too, and those never carry RPC metadata. + if (!state || (!state.alwaysTrace && !hasRpcMeta(args))) { + 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); + } + + return Reflect.apply(traced, this, args); + }; + + Object.defineProperties(wrapper, { + name: { value: originalMethod.name, configurable: true }, + length: { value: originalMethod.length, configurable: true }, + }); + + return wrapper as UncheckedMethod; +} + /** * Instruments a Durable Object class to capture errors and performance data. * @@ -52,168 +379,81 @@ export function instrumentDurableObjectWithSentry< >(optionsCallback: (env: E) => CloudflareOptions, DurableObjectClass: C): C { return new Proxy(DurableObjectClass, { construct(target, [ctx, env], newTarget) { - setAsyncLocalStorageAsyncContextStrategy(); - const context = instrumentContext(ctx); - const options = getFinalOptions(optionsCallback(env), env); - const instrumentedEnv = instrumentEnv(env, options); - - // Pass `newTarget` so that subclasses of the instrumented class (e.g. the wrapper classes - // created by wrangler's local dev tooling or `@cloudflare/vitest-pool-workers`) keep their - // own prototype — otherwise subclass methods disappear and `instanceof` checks break. - const obj = Reflect.construct(target, [context, instrumentedEnv], newTarget) as T; - - // These are the methods that are available on a Durable Object - // ref: https://developers.cloudflare.com/durable-objects/api/base/ - // obj.alarm - // obj.fetch - // obj.webSocketError - // obj.webSocketClose - // obj.webSocketMessage - - // Any other public methods on the Durable Object instance are RPC calls. - - // Bind each built-in handler to this instance before wrapping. - // See https://github.com/getsentry/sentry-javascript/issues/22328 - if (obj.fetch && typeof obj.fetch === 'function') { - obj.fetch = ensureInstrumented( - obj.fetch.bind(obj), - original => - new Proxy(original, { - apply(target, thisArg, args) { - return wrapRequestHandler({ options, request: args[0], context }, () => { - return Reflect.apply(target, thisArg, args); - }); - }, - }), - ); - } - - if (obj.alarm && typeof obj.alarm === 'function') { - // Alarms are independent invocations, so we start a new trace and link to the previous alarm - obj.alarm = wrapMethodWithSentry( - { - options, - context, - spanName: 'alarm', - spanOp: 'function', - startNewTrace: true, - origin: 'auto.faas.cloudflare.durable_object', - }, - obj.alarm.bind(obj), - ); - } - - if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { - obj.webSocketMessage = wrapMethodWithSentry( - { options, context, spanName: 'webSocketMessage', origin: 'auto.faas.cloudflare.durable_object' }, - obj.webSocketMessage.bind(obj), - ); - } - - if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { - obj.webSocketClose = wrapMethodWithSentry( - { options, context, spanName: 'webSocketClose', origin: 'auto.faas.cloudflare.durable_object' }, - obj.webSocketClose.bind(obj), - ); - } + const { obj, options, context, frameworkManagedMethods } = constructInstrumentedDurableObject( + target, + ctx, + env, + newTarget, + optionsCallback, + ); - if (obj.webSocketError && typeof obj.webSocketError === 'function') { - obj.webSocketError = wrapMethodWithSentry( - { options, context, spanName: 'webSocketError', origin: 'auto.faas.cloudflare.durable_object' }, - obj.webSocketError.bind(obj), - (_, error) => - captureException(error, { - mechanism: { - type: 'auto.faas.cloudflare.durable_object_websocket', - handled: false, - }, - }), - ); - } + return finalizeWithRpcInstrumentation(obj, options, context, frameworkManagedMethods); + }, + }); +} - // Get effective RPC propagation setting (handles deprecation of instrumentPrototypeMethods) - const rpcPropagation = getEffectiveRpcPropagation(options); +/** + * Instruments a Cloudflare [`agents`](https://www.npmjs.com/package/agents) Agent class with Sentry. + * + * An `Agent` is a Durable Object under the hood, so this applies the same instrumentation as + * {@link instrumentDurableObjectWithSentry} (request transactions, `alarm`, WebSocket handlers, RPC + * trace propagation, SQL spans) and additionally captures Agent-specific telemetry via + * `instrumentCloudflareAgent`: + * + * - **Callable RPC spans** — a span (op `rpc`) for each `@callable()` method invoked over WebSocket. + * - **Breadcrumbs** — for every Agent observability event (`rpc`, `state:update`, `connect`, + * `disconnect`, `schedule:*`, `queue:*`, `workflow:*`, `email:*`, `mcp:*`, ...). + * + * Cloudflare Workers cannot auto-instrument, so the Agent class must be wrapped manually. + * + * @param optionsCallback Function that returns the options for the SDK initialization. + * @param AgentClass The Agent class to instrument. + * @returns The instrumented Agent class. + * + * @example + * ```ts + * import { Agent, callable, routeAgentRequest } from 'agents'; + * import * as Sentry from '@sentry/cloudflare'; + * + * class MyAgentBase extends Agent { + * @callable() + * async greet(name: string): Promise { + * return `Hello, ${name}!`; + * } + * } + * + * export const MyAgent = Sentry.instrumentAgentWithSentry( + * env => ({ + * dsn: env.SENTRY_DSN, + * tracesSampleRate: 1.0, + * enableRpcTracePropagation: true, + * }), + * MyAgentBase, + * ); + * ``` + */ +export function instrumentAgentWithSentry< + E, + T extends DurableObject, + C extends new (state: DurableObjectState, env: E) => T, +>(optionsCallback: (env: E) => CloudflareOptions, AgentClass: C): C { + return new Proxy(AgentClass, { + construct(target, [ctx, env], newTarget) { + const { obj, options, context, frameworkManagedMethods } = constructInstrumentedDurableObject( + target, + ctx, + env, + newTarget, + optionsCallback, + ); - // Skip RPC instrumentation if not enabled - if (!rpcPropagation) { - return obj; - } + instrumentCloudflareAgent(obj); - // If `instrumentPrototypeMethods` was passed as an array (deprecated), - // only the listed method names should be instrumented. - // eslint-disable-next-line typescript/no-deprecated - const instrumentPrototypeMethods = Array.isArray(options.instrumentPrototypeMethods) - ? // eslint-disable-next-line typescript/no-deprecated - options.instrumentPrototypeMethods - : undefined; - const allowSet = instrumentPrototypeMethods ? new Set(instrumentPrototypeMethods) : null; - - // When using the deprecated `instrumentPrototypeMethods` option, always create spans. - // When using the new `enableRpcTracePropagation`, only create spans when RPC metadata is present. - const alwaysTrace = options.enableRpcTracePropagation === undefined; - - // Return a Proxy that binds all methods to the original object and creates spans - // for RPC calls that have Sentry trace context propagated. - // Binding is required because frameworks may use private fields (babel WeakMap pattern), - // which fail if `this` is the Proxy instead of the original object. - const methodCache = new Map(); - - return new Proxy(obj, { - get(proxyTarget, prop, receiver) { - const value = Reflect.get(proxyTarget, prop, receiver); - - if (typeof prop !== 'string' || typeof value !== 'function' || prop === 'constructor') { - return value; - } - - const cached = methodCache.get(prop); - - if (cached) { - return cached; - } - - const boundMethod = (value as UncheckedMethod).bind(proxyTarget); - - if ( - prop in Object.prototype || - Object.prototype.hasOwnProperty.call(proxyTarget, prop) || - (allowSet && !allowSet.has(prop)) - ) { - methodCache.set(prop, boundMethod); - - return boundMethod; - } - - // Pre-create the traced version - const tracedMethod = wrapMethodWithSentry( - { options, context, spanName: prop, spanOp: 'rpc', origin: 'auto.faas.cloudflare.durable_object' }, - boundMethod, - undefined, - true, - ); - - // For deprecated `instrumentPrototypeMethods`, always trace. - // For new `enableRpcTracePropagation`, only trace when RPC metadata is present. - if (alwaysTrace) { - methodCache.set(prop, tracedMethod); - - return tracedMethod; - } - - // Wrapper that checks for Sentry RPC metadata at call time - const wrappedMethod = ((...args: unknown[]) => { - const { rpcMeta } = extractRpcMeta(args); - - // If Sentry RPC metadata is present, use the traced version (creates span) - // Otherwise, call the bound method directly (no span) - return rpcMeta ? tracedMethod(...args) : boundMethod(...args); - }) as UncheckedMethod; - - methodCache.set(prop, wrappedMethod); - - return wrappedMethod; - }, - }); + // Apply RPC prototype-method instrumentation last, so the Agent-specific own-property + // handlers we just installed are excluded from RPC method tracing. Methods the Agent + // framework installed itself are excluded too — `instrumentCloudflareAgent` traces those by + // wrapping the dispatch instead of the method. + return finalizeWithRpcInstrumentation(obj, options, context, frameworkManagedMethods); }, }); } diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts index 77911cd8752c..fe86e21dbd62 100644 --- a/packages/cloudflare/src/flush.ts +++ b/packages/cloudflare/src/flush.ts @@ -35,7 +35,7 @@ const flushLockRegistries = new WeakMap(agent: T): T { + const internals = agent as T & AgentInternals; + + instrumentAgentCallableRpc(internals); + instrumentChatAgentConversation(internals); + instrumentAgentRequestConversation(internals); + + return agent; +} diff --git a/packages/cloudflare/src/instrumentations/agents/instrumentAgentCallableRpc.ts b/packages/cloudflare/src/instrumentations/agents/instrumentAgentCallableRpc.ts new file mode 100644 index 000000000000..4cc1ef4acfea --- /dev/null +++ b/packages/cloudflare/src/instrumentations/agents/instrumentAgentCallableRpc.ts @@ -0,0 +1,72 @@ +import { debug, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { AGENT_SPAN_ORIGIN, type AgentInternals, getAgentAttributes, setAgentConversationId } from './types'; + +/** + * Wraps the Agent's `onMessage` handler to create a span for each `@callable()` RPC invocation. + * RPC requests arrive as WebSocket messages, so this span nests under the active transaction for + * the WebSocket message (on Cloudflare, the instrumented Durable Object `webSocketMessage` hook). + * + * Also sets the conversation id on the scope for the duration of the call: callable methods are the + * unit of work for plain (non-chat) agents, which run LLM calls just like chat turns do. It is + * awaited so the id is on the scope before the method body creates any span; the `agents` + * `onMessage` own property this wraps is already `async`, so the promise return is nothing new to + * the WebSocket dispatch upstream. + */ +export function instrumentAgentCallableRpc(obj: AgentInternals): void { + const original = obj.onMessage; + if (typeof original !== 'function') { + DEBUG_BUILD && debug.log('[Sentry] Agent `onMessage` not found — callable RPC span instrumentation skipped.'); + return; + } + + obj.onMessage = new Proxy(original, { + apply(target, thisArg: AgentInternals, args: unknown[]): unknown { + const method = extractCallableMethod(args[1]); + + if (!method) { + return Reflect.apply(target, thisArg, args); + } + + return startSpan( + { + name: method, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'rpc', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: AGENT_SPAN_ORIGIN, + ...getAgentAttributes(thisArg), + }, + }, + async () => { + await setAgentConversationId(thisArg); + return Reflect.apply(target, thisArg, args); + }, + ); + }, + }); +} + +/** Extracts the RPC method name from a WebSocket message, mirroring the SDK's `isRPCRequest`. */ +function extractCallableMethod(message: unknown): string | undefined { + const text = + typeof message === 'string' + ? message + : message instanceof ArrayBuffer + ? new TextDecoder().decode(message) + : undefined; + + if (!text) { + return undefined; + } + + try { + const parsed = JSON.parse(text) as { type?: unknown; method?: unknown; args?: unknown }; + if (parsed.type === 'rpc' && typeof parsed.method === 'string' && Array.isArray(parsed.args)) { + return parsed.method; + } + } catch { + // Not JSON, or not an RPC request — no span. + } + + return undefined; +} diff --git a/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts b/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts new file mode 100644 index 000000000000..59cc31d08523 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/agents/instrumentAgentRequestConversation.ts @@ -0,0 +1,30 @@ +import { type AgentInternals, setAgentConversationId } from './types'; + +/** + * Correlates the AI spans of an HTTP-driven agent turn with a conversation id on the active scope. + * + * `onRequest` is the third unit of agent work, alongside chat turns and `@callable()` RPC: the + * `agents` router sends every non-WebSocket request to it, which is how REST endpoints and webhooks + * reach an agent. + * + * `agents` installs `onRequest` as an own property in the `Agent` constructor (as it does + * `onMessage`), and we instrument after construction, so wrapping the own property is what the + * router ends up calling. That own property is already `async`, and partyserver's `fetch` awaits it, + * so returning a promise from this wrapper — to get the conversation id onto the scope before the + * request handler creates any span — keeps the existing contract. + */ +export function instrumentAgentRequestConversation(obj: AgentInternals): void { + const original = obj.onRequest; + + if (typeof original !== 'function') { + return; + } + + obj.onRequest = new Proxy(original, { + async apply(target, thisArg: AgentInternals, args: unknown[]): Promise { + await setAgentConversationId(thisArg); + + return Reflect.apply(target, thisArg, args); + }, + }); +} diff --git a/packages/cloudflare/src/instrumentations/agents/instrumentChatAgentConversation.ts b/packages/cloudflare/src/instrumentations/agents/instrumentChatAgentConversation.ts new file mode 100644 index 000000000000..be796c7ec901 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/agents/instrumentChatAgentConversation.ts @@ -0,0 +1,56 @@ +import { uuid4 } from '@sentry/core'; +import { type AgentInternals, setAgentConversationId, storeAgentConversationId } from './types'; + +/** + * For chat agents (`AIChatAgent` from `@cloudflare/ai-chat`), correlates each chat turn's AI spans + * with a conversation id on the active scope. + * + * In the Agents model one agent instance is one long-lived conversation, so the conversation id is + * minted once per instance and persisted to Durable Object storage, which is what carries it across + * hibernation (that destroys the in-memory instance). When the user clears the chat, they expect a + * fresh conversation — but recreating the Durable Object for that would also drop the MCP/OAuth + * state stored per instance (GitHub/Sentry sign-in). To get a fresh conversation id *without* + * losing that state, we rotate the persisted id when the SDK reports a cleared chat. + * + * The id itself is not attached to spans here — `setAgentConversationId` resolves it, and the SDK's + * `conversationIdIntegration` picks it off the scope at `spanStart` to stamp + * `gen_ai.conversation.id` onto the AI spans created inside the turn (e.g. by the Workers AI + * instrumentation), which correlates a turn's model and tool calls. + * + * Plain (non-chat) `Agent`s do not define `onChatMessage`, so they are skipped here — their unit + * of work is the callable RPC method, where `instrumentAgentCallableRpc` sets the conversation id + * instead. + */ +export function instrumentChatAgentConversation(obj: AgentInternals): void { + // Rotate the conversation id when the chat is cleared. `_emit` is the central choke-point through + // which all `agents:*` observability events are published, and it already exists on the base + // `Agent` class — so this hook composes with the RPC instrumentation, which keys off the same + // surface. We shadow it with an own property so the original stays reachable on the prototype. + const originalEmit = obj._emit; + + if (typeof originalEmit === 'function') { + obj._emit = new Proxy(originalEmit, { + apply(target, thisArg: AgentInternals, args: [string, Record?]) { + if (args[0] === 'message:clear') { + storeAgentConversationId(thisArg, uuid4()); + } + + return Reflect.apply(target, thisArg, args); + }, + }); + } + + const original = obj.onChatMessage; + + if (typeof original !== 'function') { + return; + } + + obj.onChatMessage = new Proxy(original, { + async apply(target, thisArg: AgentInternals, args: unknown[]): Promise { + await setAgentConversationId(thisArg); + + return Reflect.apply(target, thisArg, args); + }, + }); +} diff --git a/packages/cloudflare/src/instrumentations/agents/types.ts b/packages/cloudflare/src/instrumentations/agents/types.ts new file mode 100644 index 000000000000..b8c232a23dd2 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/agents/types.ts @@ -0,0 +1,196 @@ +import type { DurableObjectStorage } from '@cloudflare/workers-types'; +import { debug, getCurrentScope, getIsolationScope, uuid4 } from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { InstrumentedDurableObjectState } from '../../wrapMethodWithSentry'; + +// v10 predates `@sentry/conventions` and doesn't expose this from `@sentry/core`, so keep the +// standard semantic attribute name locally. +const GEN_AI_AGENT_NAME_ATTRIBUTE = 'gen_ai.agent.name'; + +export const AGENT_SPAN_ORIGIN = 'auto.faas.cloudflare.agents'; + +/** DO storage key under which the conversation id is persisted so it survives hibernation. */ +export const AGENT_CONVERSATION_ID_STORAGE_KEY = '__SENTRY_AGENT_CONVERSATION_ID__'; + +/** + * Instance keys for our conversation-id bookkeeping, keyed by symbol so the state stays invisible + * to anything enumerating the user-owned agent instance (`Object.keys`, `JSON.stringify`, spread). + * Exported because the exported `AgentInternals` interface references them. + */ +export const AGENT_CONVERSATION_ID_SYMBOL: unique symbol = Symbol('sentryAgentConversationId'); +export const AGENT_APPLIED_CONVERSATION_ID_SYMBOL: unique symbol = Symbol('sentryAgentAppliedConversationId'); + +/** + * The subset of the `agents` `Agent` instance internals that we instrument. These are runtime + * implementation details of the `agents` package (v0.13.x) rather than part of its public type + * surface, so every access is guarded and wrapping degrades gracefully if a name changes upstream. + */ +export interface AgentInternals { + /** Central choke-point through which all `agents:*` observability events are published. */ + _emit?: (type: string, payload?: Record) => void; + /** WebSocket message handler; dispatches `@callable()` RPC requests. */ + onMessage?: (...args: unknown[]) => unknown; + /** + * Chat-turn handler. Only `AIChatAgent` (from `@cloudflare/ai-chat`) defines this; a plain `Agent` + * does not, so its presence discriminates a chat agent. + */ + onChatMessage?: (...args: unknown[]) => unknown; + /** HTTP request handler; the router sends every non-WebSocket request here. */ + onRequest?: (...args: unknown[]) => unknown; + /** The user's Agent class (used by the SDK for the observability event `agent` field). */ + _ParentClass?: { name?: string }; + /** The Agent instance name, reported as the `cloudflare.agent.name` span attribute. */ + name?: string; + /** + * The Durable Object state of the instance. Present on every real Agent; guarded like the other + * internals so partially-mocked instances keep working. + */ + ctx?: InstrumentedDurableObjectState; + /** + * The current conversation id for this agent instance. It is cached in memory after being loaded + * from Durable Object storage and updated when the conversation rotates. `undefined` means storage + * has not been read yet. + */ + [AGENT_CONVERSATION_ID_SYMBOL]?: string; + /** + * The conversation id this instrumentation most recently applied to the isolation scope. It lets + * subsequent units of work distinguish an SDK-applied id, which may be replaced after rotation, + * from an id explicitly set by the user, which must be preserved. + */ + [AGENT_APPLIED_CONVERSATION_ID_SYMBOL]?: string; +} + +/** Reads best-effort agent identity attributes from the instance, tolerating missing internals. */ +export function getAgentAttributes(instance: AgentInternals): Record { + const attributes: Record = {}; + + const agentClass = instance._ParentClass?.name; + if (typeof agentClass === 'string' && agentClass) { + attributes[GEN_AI_AGENT_NAME_ATTRIBUTE] = agentClass; + } + + return attributes; +} + +/** + * Persists the conversation id to Durable Object storage, so it survives hibernation, and updates + * the in-memory cache synchronously so the current wake is immediately consistent even if the + * write fails. Uses the async storage API, which exists on KV- and SQLite-backed DOs alike. + * The write is fire-and-forget: DO storage writes are tracked by the runtime and land without + * awaiting (`waitUntil` is a no-op in Durable Objects), and the catch keeps a rejection from + * surfacing as unhandled — a failed write just means the next wake starts a new conversation, + * which must never throw into user code. Uses the original uninstrumented storage so internal + * bookkeeping doesn't create spans. + */ +export function storeAgentConversationId(instance: AgentInternals, conversationId: string): void { + instance[AGENT_CONVERSATION_ID_SYMBOL] = conversationId; + + const storage = resolveStorage(instance); + if (!storage) { + return; + } + + try { + storage + .put(AGENT_CONVERSATION_ID_STORAGE_KEY, conversationId) + .catch(error => DEBUG_BUILD && debug.log('[Sentry] Failed to persist agent conversation id', error)); + } catch (error) { + DEBUG_BUILD && debug.log('[Sentry] Failed to persist agent conversation id', error); + } +} + +/** + * Sets the agent instance's conversation id on the active scope for the duration of the + * surrounding unit of work (chat turn, callable RPC call, HTTP request). In the Agents model one + * instance is one long-lived conversation, so the id is minted once per instance and persisted to + * DO storage — for chat and plain agents alike, since plain agents run LLM calls too (e.g. inside + * `@callable()` methods). The instance `name` is deliberately not used: it is caller-chosen and can + * be a stable, guessable, or shared value, whereas a conversation id should identify exactly one + * conversation. Clearing the chat rotates it (see `storeAgentConversationId`) so subsequent LLM + * calls group under the fresh conversation. + * + * Every handler wrapper awaits this before invoking the original, so the id is on the scope before + * the unit of work — and any `gen_ai` span it creates — starts. Only the first unit of work per wake + * pays the storage read; the rest resolve from the in-memory cache. Awaiting is safe on all three + * paths because the `agents` `Agent` constructor already replaces `onMessage` and `onRequest` with + * `async` wrappers of its own, and `onChatMessage` is async by contract, so every caller upstream + * already handles a promise. + * + * An id the user set explicitly outranks this inferred one, in either order: a `setConversationId()` + * call that already happened is detected here and left alone, and one made inside the handler lands + * on the same scope afterwards and therefore wins. That is why the write targets the isolation scope + * — it is the scope the public `Sentry.setConversationId()` writes to, and `conversationIdIntegration` + * prefers the current scope over it, so writing there would make a user's call unoverridable. + * + * `conversationIdIntegration` reads the id off the scope at `spanStart` and stamps + * `gen_ai.conversation.id` onto AI spans created within the unit of work, correlating its model and + * tool calls. + */ +export async function setAgentConversationId(instance: AgentInternals): Promise { + const isolationScope = getIsolationScope(); + const existing = isolationScope.getScopeData().conversationId ?? getCurrentScope().getScopeData().conversationId; + + // An id that isn't the one we put there ourselves came from the user — never override it. + if (existing && existing !== instance[AGENT_APPLIED_CONVERSATION_ID_SYMBOL]) { + return; + } + + const conversationId = instance[AGENT_CONVERSATION_ID_SYMBOL] ?? (await loadAgentConversationId(instance)); + + instance[AGENT_APPLIED_CONVERSATION_ID_SYMBOL] = conversationId; + isolationScope.setConversationId(conversationId); +} + +/** + * Reads the persisted id into the instance cache, minting and persisting a fresh one when storage + * holds none, so subsequent units of work resolve from memory. The async `get` exists on KV- and + * SQLite-backed DOs alike, so this one path covers both. + * + * Anything already cached by the time the read lands wins over the read: that covers a rotation + * (cleared chat) racing the read, and a second concurrent unit of work that must not mint and + * persist a competing id for the same conversation. + */ +async function loadAgentConversationId(instance: AgentInternals): Promise { + let stored: unknown; + let readFailed = false; + + try { + stored = await resolveStorage(instance)?.get(AGENT_CONVERSATION_ID_STORAGE_KEY); + } catch (error) { + readFailed = true; + DEBUG_BUILD && debug.log('[Sentry] Failed to read agent conversation id from storage', error); + } + + const cached = instance[AGENT_CONVERSATION_ID_SYMBOL]; + + if (cached !== undefined) { + return cached; + } + + if (typeof stored === 'string' && stored) { + instance[AGENT_CONVERSATION_ID_SYMBOL] = stored; + + return stored; + } + + const conversationId = uuid4(); + + if (readFailed) { + // Cache without persisting: a read that failed may still have an id behind it, and overwriting + // it would split the conversation for good instead of just for this wake. + instance[AGENT_CONVERSATION_ID_SYMBOL] = conversationId; + } else { + storeAgentConversationId(instance, conversationId); + } + + return conversationId; +} + +/** + * Resolves the uninstrumented DO storage for internal reads/writes, so they don't create spans of + * their own. Falls back to the instance's regular storage when the uninstrumented one isn't + * exposed (e.g. when `instrumentCloudflareAgent` is used directly, without `instrumentContext`). + */ +function resolveStorage(instance: AgentInternals): DurableObjectStorage | undefined { + return instance.ctx?.originalStorage ?? instance.ctx?.storage; +} diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectHandlers.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectHandlers.ts new file mode 100644 index 000000000000..3cf434559fda --- /dev/null +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectHandlers.ts @@ -0,0 +1,161 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { captureException, debug } from '@sentry/core'; +import type { DurableObject } from 'cloudflare:workers'; +import type { CloudflareOptions } from '../client'; +import { DEBUG_BUILD } from '../debug-build'; +import { ensureInstrumented } from '../instrument'; +import { wrapRequestHandler } from '../request'; +import { wrapMethodWithSentry } from '../wrapMethodWithSentry'; + +/** + * The instrumented context of the Durable Object being wrapped. + * + * Kept as `any` for the same reason as in `durableobject.ts`: a concrete `DurableObjectState` here + * makes `tsc` relate its `SqlStorage` graph against the parameter union of `wrapMethodWithSentry`, + * which hangs the type build. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type InstrumentedDurableObjectContext = any; + +/** + * Instruments the built-in Durable Object handler methods on a constructed instance. + * + * These are the methods that are available on a Durable Object + * ref: https://developers.cloudflare.com/durable-objects/api/base/ + * - obj.alarm + * - obj.fetch + * - obj.webSocketError + * - obj.webSocketClose + * - obj.webSocketMessage + * + * Any other public methods on the Durable Object instance are RPC calls. + * + * @internal + */ +export function instrumentDurableObjectHandlers>( + obj: T, + options: CloudflareOptions, + context: InstrumentedDurableObjectContext, +): void { + // Bind each built-in handler to this instance before wrapping. + // See https://github.com/getsentry/sentry-javascript/issues/22328 + if (obj.fetch && typeof obj.fetch === 'function') { + setInstanceHandler( + obj, + 'fetch', + ensureInstrumented( + obj.fetch.bind(obj), + original => + new Proxy(original, { + apply(target, thisArg, args) { + return wrapRequestHandler({ options, request: args[0], context }, () => { + return Reflect.apply(target, thisArg, args); + }); + }, + }), + ), + ); + } + + if (obj.alarm && typeof obj.alarm === 'function') { + // Alarms are independent invocations, so we start a new trace and link to the previous alarm + setInstanceHandler( + obj, + 'alarm', + wrapMethodWithSentry( + { + options, + context, + spanName: 'alarm', + spanOp: 'function', + startNewTrace: true, + origin: 'auto.faas.cloudflare.durable_object', + }, + obj.alarm.bind(obj), + ), + ); + } + + if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { + setInstanceHandler( + obj, + 'webSocketMessage', + wrapMethodWithSentry( + { + options, + context, + spanName: 'webSocketMessage', + origin: 'auto.faas.cloudflare.durable_object', + }, + obj.webSocketMessage.bind(obj), + ), + ); + } + + if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { + setInstanceHandler( + obj, + 'webSocketClose', + wrapMethodWithSentry( + { + options, + context, + spanName: 'webSocketClose', + origin: 'auto.faas.cloudflare.durable_object', + }, + obj.webSocketClose.bind(obj), + ), + ); + } + + if (obj.webSocketError && typeof obj.webSocketError === 'function') { + setInstanceHandler( + obj, + 'webSocketError', + wrapMethodWithSentry( + { + options, + context, + spanName: 'webSocketError', + origin: 'auto.faas.cloudflare.durable_object', + }, + obj.webSocketError.bind(obj), + (_, error) => + captureException(error, { + mechanism: { + type: 'auto.faas.cloudflare.durable_object_websocket', + handled: false, + }, + }), + ), + ); + } +} + +/** + * Installs an instrumented handler as an own property of the Durable Object instance. + * + * A plain assignment is not always possible. The `agents` package installs its handlers with + * `Object.defineProperty(instance, name, { value, configurable: true })`, and `defineProperty` + * leaves `writable` at `false`. Assigning to such a property throws a `TypeError` in strict mode, + * so a read-only property is redefined instead. When the property can be neither assigned nor + * redefined, the handler stays uninstrumented rather than breaking the object. + */ +function setInstanceHandler(obj: object, name: string, handler: unknown): void { + const descriptor = Object.getOwnPropertyDescriptor(obj, name); + + try { + if (descriptor?.writable === false) { + Object.defineProperty(obj, name, { + value: handler, + writable: true, + enumerable: descriptor.enumerable, + configurable: descriptor.configurable, + }); + } else { + (obj as Record)[name] = handler; + } + } catch (error) { + DEBUG_BUILD && debug.warn(`Failed to instrument Durable Object handler "${name}"`, error); + } +} diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts index 4c29f6e9595e..321a90dd3c4e 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -13,8 +13,12 @@ export const STUB_NON_RPC_METHODS = new Set(['fetch', 'connect', 'dup']); * - `namespace.idFromName(name)` / `namespace.idFromString(id)` / `namespace.newUniqueId()` with breadcrumbs * * @param namespace - The DurableObjectNamespace to instrument + * @param propagateRpcTrace - Whether RPC method calls on the returned stubs carry trace context */ -export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespace): DurableObjectNamespace { +export function instrumentDurableObjectNamespace( + namespace: DurableObjectNamespace, + propagateRpcTrace = false, +): DurableObjectNamespace { return new Proxy(namespace, { get(target, prop, _receiver) { const value = Reflect.get(target, prop) as unknown; @@ -27,7 +31,7 @@ export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespa return function (this: unknown, ...args: unknown[]) { const stub = Reflect.apply(value, target, args); - return instrumentDurableObjectStub(stub); + return instrumentDurableObjectStub(stub, propagateRpcTrace); }; } @@ -41,8 +45,9 @@ export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespa * and propagate trace context across RPC calls. * * @param stub - The DurableObjectStub to instrument + * @param propagateRpcTrace - Whether RPC method calls carry trace context */ -function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub { +function instrumentDurableObjectStub(stub: DurableObjectStub, propagateRpcTrace: boolean): DurableObjectStub { return new Proxy(stub, { get(target, prop) { const value = Reflect.get(target, prop); @@ -51,7 +56,12 @@ function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub return instrumentFetcher((...args) => Reflect.apply(value, target, args)); } - if (typeof value === 'function' && typeof prop === 'string' && !STUB_NON_RPC_METHODS.has(prop)) { + if ( + propagateRpcTrace && + typeof value === 'function' && + typeof prop === 'string' && + !STUB_NON_RPC_METHODS.has(prop) + ) { return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts index 9413b313b4b0..e468512dd722 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectStorage.ts @@ -1,5 +1,7 @@ import type { DurableObjectStorage, SyncKvStorage, SqlStorage } from '@cloudflare/workers-types'; -import { isThenable, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { getClient, isThenable, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import type { CloudflareClientOptions } from '../client'; +import { getStorageKeys, targetsCloudflareInternalKey } from '../utils/internalStorageKey'; import { storeSpanContext } from '../utils/traceLinks'; import { instrumentDurableObjectSyncKvStorage } from './instrumentDurableObjectSyncKvStorage'; import { instrumentSqlStorage } from './instrumentSqlStorage'; @@ -55,6 +57,15 @@ export function instrumentDurableObjectStorage( } return function (this: unknown, ...args: unknown[]) { + // KV entries managed by the DO framework itself (agents/partyserver state) are bookkeeping + // rather than user work — skip the span, mirroring how `cf_` SQL tables are treated. + const allowlist = (getClient()?.getOptions() as CloudflareClientOptions | undefined) + ?.durableObjectStorageSpanAllowlist; + const keys = getStorageKeys(methodName, args); + if (keys && keys.length > 0 && keys.every(key => targetsCloudflareInternalKey(key, allowlist))) { + return (original as (...a: unknown[]) => unknown).apply(target, args); + } + return startSpan( { // Use underscore naming to match Cloudflare's native instrumentation (e.g., "durable_object_storage_get") diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts index 739a0a7ef14a..ce5a92d7764c 100644 --- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -33,7 +33,7 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { const allowlist = (getClient()?.getOptions() as CloudflareClientOptions | undefined) ?.durableObjectSqlSpanAllowlist; - if (targetsCloudflareInternalTable(querySummary, allowlist)) { + if (targetsCloudflareInternalTable(querySummary, allowlist, sanitizedQuery)) { return (original as (...a: unknown[]) => ReturnType).apply(target, args); } diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index bb53e124568e..bfb215d2e034 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -93,6 +93,7 @@ function instrumentMethod( true, ); + // eslint-disable-next-line typescript/no-deprecated if (!options.enableRpcTracePropagation) { return captureMethod; } diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 474fbf831b86..e25cc52ba21e 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -12,7 +12,7 @@ import { } from '../../utils/isBinding'; import { instrumentD1 } from './instrumentD1'; import { appendRpcMeta } from '../../utils/rpcMeta'; -import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; +import { createRpcPropagationResolver } from '../../utils/rpcPropagation'; import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; import { instrumentQueueProducer } from './instrumentQueueProducer'; @@ -45,7 +45,7 @@ export function instrumentEnv>(env: Env, opt return env; } - const rpcPropagation = options ? getEffectiveRpcPropagation(options) : false; + const shouldPropagateRpcTrace = createRpcPropagationResolver(options); return new Proxy(env, { get(target, prop, receiver) { @@ -94,12 +94,10 @@ export function instrumentEnv>(env: Env, opt return instrumented; } - if (!rpcPropagation) { - return item; - } + const propagateRpcTrace = shouldPropagateRpcTrace(String(prop)); if (isDurableObjectNamespace(item)) { - const instrumented = instrumentDurableObjectNamespace(item); + const instrumented = instrumentDurableObjectNamespace(item, propagateRpcTrace); instrumentedBindings.set(item, instrumented); return instrumented; } @@ -113,7 +111,12 @@ export function instrumentEnv>(env: Env, opt return instrumentFetcher((...args) => Reflect.apply(value, target, args)); } - if (typeof value === 'function' && typeof p === 'string' && !STUB_NON_RPC_METHODS.has(p)) { + if ( + propagateRpcTrace && + typeof value === 'function' && + typeof p === 'string' && + !STUB_NON_RPC_METHODS.has(p) + ) { return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } diff --git a/packages/cloudflare/src/integrations/spotlight.ts b/packages/cloudflare/src/integrations/spotlight.ts new file mode 100644 index 000000000000..ead0e8f552c3 --- /dev/null +++ b/packages/cloudflare/src/integrations/spotlight.ts @@ -0,0 +1,89 @@ +import type { Client, Envelope, IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, serializeEnvelope, suppressTracing } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +type SpotlightConnectionOptions = { + /** + * Set this if the Spotlight Sidecar is not running on localhost:8969. + * By default, the URL is set to http://localhost:8969/stream + */ + sidecarUrl?: string; +}; + +export const INTEGRATION_NAME = 'Spotlight' as const; + +const _spotlightIntegration = ((options: Partial = {}) => { + const sidecarUrl = options.sidecarUrl || 'http://localhost:8969/stream'; + + return { + name: INTEGRATION_NAME, + setup(client) { + DEBUG_BUILD && debug.log('[Spotlight] Using Sidecar URL', sidecarUrl); + setupSidecarForwarding(client, sidecarUrl); + }, + }; +}) satisfies IntegrationFn; + +/** + * Use this integration to send errors and transactions to Spotlight. + * + * Learn more about spotlight at https://spotlightjs.com + * + * Important: This integration is intended for local development only. + * Each forwarded envelope counts as a Worker subrequest (50 free / 1000 paid + * per invocation), so it should not be enabled in production. + */ +export const spotlightIntegration = defineIntegration(_spotlightIntegration); + +function setupSidecarForwarding(client: Client, sidecarUrl: string): void { + const parsedUrl = parseSidecarUrl(sidecarUrl); + if (!parsedUrl) { + return; + } + + let failCount = 0; + + client.on('beforeEnvelope', (envelope: Envelope) => { + if (failCount > 3) { + DEBUG_BUILD && debug.warn('[Spotlight] Disabled Sentry -> Spotlight forwarding due to too many failed requests'); + return; + } + + const body = serializeEnvelope(envelope); + + suppressTracing(() => { + fetch(parsedUrl.href, { + method: 'POST', + body, + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + }).then( + res => { + // Consume the response body to satisfy Cloudflare Workers' requirement + // that all fetch response bodies are read or cancelled. + res.text().catch(() => { + // no-op + }); + + if (res.status >= 200 && res.status < 400) { + failCount = 0; + } + }, + () => { + failCount++; + DEBUG_BUILD && debug.warn('[Spotlight] Failed to send envelope to Spotlight Sidecar'); + }, + ); + }); + }); +} + +function parseSidecarUrl(url: string): URL | undefined { + try { + return new URL(url); + } catch { + DEBUG_BUILD && debug.warn(`[Spotlight] Invalid sidecar URL: ${url}`); + return undefined; + } +} diff --git a/packages/cloudflare/src/nodejs_compat/index.ts b/packages/cloudflare/src/nodejs_compat/index.ts index ed85f22806aa..3c259e10e7e9 100644 --- a/packages/cloudflare/src/nodejs_compat/index.ts +++ b/packages/cloudflare/src/nodejs_compat/index.ts @@ -1,3 +1,3 @@ export * from '../index'; -export { prismaIntegration } from '@sentry/node'; +export { prismaIntegration } from '@sentry/server-utils'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; diff --git a/packages/cloudflare/src/options.ts b/packages/cloudflare/src/options.ts index 7506dd34468e..28e5f911d8b0 100644 --- a/packages/cloudflare/src/options.ts +++ b/packages/cloudflare/src/options.ts @@ -54,6 +54,12 @@ export function getFinalOptions(userOptions: CloudflareOptions = {}, env: unknow const tracesSampleRate = userOptions.tracesSampleRate ?? parseFloat(getEnvVar(env, 'SENTRY_TRACES_SAMPLE_RATE') ?? ''); + // Spotlight precedence (mirrors node-core's getSpotlightConfig): + // - false or explicit string from options: use as-is + // - true: enable, but prefer a custom URL from the env var if set + // - undefined: defer entirely to the env var (bool or URL) + const spotlight = getSpotlightFromEnv(userOptions.spotlight, getEnvVar(env, 'SENTRY_SPOTLIGHT')); + return { release, ...userOptions, @@ -62,5 +68,33 @@ export function getFinalOptions(userOptions: CloudflareOptions = {}, env: unknow tracesSampleRate: isFinite(tracesSampleRate) ? tracesSampleRate : undefined, debug: userOptions.debug ?? envToBool(getEnvVar(env, 'SENTRY_DEBUG')), tunnel: userOptions.tunnel ?? getEnvVar(env, 'SENTRY_TUNNEL'), + spotlight, }; } + +/** + * Resolve the spotlight option from a user-supplied value and an env binding string. + * Mirrors node-core's `getSpotlightConfig` precedence: + * - `false` or explicit string from options → use as-is + * - `true` → enable, but prefer a custom URL from the env var if set + * - `undefined` → defer entirely to the env var (bool or URL) + */ +function getSpotlightFromEnv( + optionsSpotlight: boolean | string | undefined, + envVar: string | undefined, +): boolean | string | undefined { + if (optionsSpotlight === false) { + return false; + } + if (typeof optionsSpotlight === 'string') { + return optionsSpotlight; + } + + // optionsSpotlight is true or undefined + const envBool = envToBool(envVar, { strict: true }); + const envUrl = envBool === null && envVar ? envVar : undefined; + + return optionsSpotlight === true + ? (envUrl ?? true) // true: use env URL if present, otherwise true + : (envBool ?? envUrl); // undefined: use env var (bool or URL) +} diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index 2adf4cbf2c24..e77559df6d8c 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -10,7 +10,6 @@ import { setHttpStatus, startSpanManual, winterCGHeadersToDict, - withIsolationScope, } from '@sentry/core'; import { captureIncomingRequestBody } from './integrations/httpServer'; import type { CloudflareOptions } from './client'; @@ -18,6 +17,7 @@ import type { ExecutionContextCompat } from './executionContext'; import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils'; import { init } from './sdk'; +import { withInvocationIsolationScope } from './utils/invocationScope'; import { classifyResponseStreaming } from './utils/streaming'; function getRequestErrorMechanismType(context: ExecutionContextCompat | undefined): string { @@ -49,7 +49,7 @@ export function wrapRequestHandler( wrapperOptions: RequestHandlerWrapperOptions, handler: (...args: unknown[]) => Response | Promise, ): Promise { - return withIsolationScope(async isolationScope => { + return withInvocationIsolationScope(async isolationScope => { const { options, request, captureErrors = true } = wrapperOptions; const context = wrapperOptions.context; @@ -58,7 +58,7 @@ export function wrapRequestHandler( // to track pending tasks. If we use the instrumented version for flushAndDispose, // it acquires the lock, then flushAndDispose tries to wait for the same lock, // creating a deadlock. - const waitUntil = context ? getOriginalWaitUntil(context)?.bind(context) : undefined; + const waitUntil = context ? getOriginalWaitUntil(context).bind(context) : undefined; const errorMechanismType = getRequestErrorMechanismType(context); const client = init({ ...options, ctx: context }); diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 2bbd704e6004..7cb21103987c 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -18,6 +18,7 @@ import { makeFlushLock } from './flush'; import { httpServerIntegration } from './integrations/httpServer'; import { fetchIntegration } from './integrations/fetch'; import { honoIntegration } from './integrations/hono'; +import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from './integrations/spotlight'; import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; @@ -91,6 +92,14 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined { flushLock, }; + if (options.spotlight && !clientOptions.integrations.some(({ name }) => name === SPOTLIGHT_INTEGRATION_NAME)) { + clientOptions.integrations.push( + spotlightIntegration({ + sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined, + }), + ); + } + /** * The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility * via a custom trace provider. diff --git a/packages/cloudflare/src/utils/internalSqlQuery.ts b/packages/cloudflare/src/utils/internalSqlQuery.ts index 977a5a9c05a1..7c67415d4da9 100644 --- a/packages/cloudflare/src/utils/internalSqlQuery.ts +++ b/packages/cloudflare/src/utils/internalSqlQuery.ts @@ -14,25 +14,40 @@ import { stringMatchesSomePattern } from '@sentry/core'; * * The check operates on the query summary produced by `getSqlQuerySummary` (`{operation} {table} ...`, * the same value used as the span name), so table targets are already isolated from the rest of the - * query. + * query. The one exception is `CREATE INDEX`: its summary carries the index name, not the indexed + * table (upstream OTel convention), so the `cf_` target only appears in the ON clause of the full + * statement, which `queryText` is needed for. */ export function targetsCloudflareInternalTable( querySummary: string | undefined, allowlist?: Array, + queryText?: string, ): boolean { if (!querySummary) { return false; } + const indexedTable = queryText ? CREATE_INDEX_TABLE_RE.exec(queryText)?.groups?.['table'] : undefined; + if (indexedTable) { + return isCloudflareInternalTable(indexedTable, allowlist); + } + const [, ...tables] = querySummary.split(' '); - return tables.some(table => { - if (!table.toLowerCase().startsWith('cf_')) { - return false; - } + return tables.some(table => isCloudflareInternalTable(table, allowlist)); +} + +// `CREATE [UNIQUE] INDEX [IF NOT EXISTS] ON ` — the IF EXISTS shape mirrors DDL_RE +// in @sentry/core. +const CREATE_INDEX_TABLE_RE = + /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+[^\s(,;)]+\s+ON\s+(?
[^\s(,;)]+)/i; + +function isCloudflareInternalTable(table: string, allowlist?: Array): boolean { + if (!table.toLowerCase().startsWith('cf_')) { + return false; + } - // A table on the allowlist is treated as a user table and stays instrumented, even though it - // matches the reserved prefix. - return !allowlist?.length || !stringMatchesSomePattern(table, allowlist, true); - }); + // A table on the allowlist is treated as a user table and stays instrumented, even though it + // matches the reserved prefix. + return !allowlist?.length || !stringMatchesSomePattern(table, allowlist, true); } diff --git a/packages/cloudflare/src/utils/internalStorageKey.ts b/packages/cloudflare/src/utils/internalStorageKey.ts new file mode 100644 index 000000000000..12c40bada805 --- /dev/null +++ b/packages/cloudflare/src/utils/internalStorageKey.ts @@ -0,0 +1,78 @@ +import { stringMatchesSomePattern } from '@sentry/core'; + +/** + * Cloudflare frameworks that build on Durable Objects (`agents`, `partyserver`, ...) also manage + * their own internal KV entries alongside their internal SQLite tables, namespaced with a reserved + * prefix — e.g. `cf_agents_state`, `cf_agents_mcp_servers`, `__ps_name`. Reads/writes of these + * (message persistence, MCP connection bookkeeping, name hydration) are framework implementation + * details that otherwise flood traces with dozens of zero-signal `durable_object_storage_*` spans + * per request, so we match the reserved prefixes rather than an enumerated list. This mirrors the + * `cf_` convention used for internal SQL tables (see `targetsCloudflareInternalTable`). + * + * The prefixes are a reserved convention for framework-managed entries, so user keys should not use + * them. In case a user key does collide, the `durableObjectStorageSpanAllowlist` option lets them + * opt those keys back into instrumentation. + */ +export function targetsCloudflareInternalKey(key: string | undefined, allowlist?: Array): boolean { + if (!key) { + return false; + } + + // Framework-managed KV namespaces: + // - `cf_` — agents / ai-chat internal state (mirrors the internal SQL table convention) + // - `cf:` — agents chat-recovery entries (`cf:chat-recovery:*`, `cf:chat:*`); the same reserved + // `cf` namespace, but colon-separated instead of underscore-separated + // - `__ps_` — partyserver internals (e.g. `__ps_name`) + // - `/` — MCP OAuth client state (`///{token,client_info,state,...}`), + // read on every MCP tool call. User keys on an Agent rarely use a leading slash; if one does, + // the allowlist opts it back in. + const isFrameworkKey = + key.startsWith('cf_') || key.startsWith('cf:') || key.startsWith('__ps_') || key.startsWith('/'); + if (!isFrameworkKey) { + return false; + } + + // A key on the allowlist is treated as a user key and stays instrumented, even though it matches + // a reserved prefix. + return !allowlist?.length || !stringMatchesSomePattern(key, allowlist, true); +} + +/** + * Extracts the KV keys a Durable Object storage call targets, so the caller can decide whether the + * operation only touches framework-internal entries. Returns `undefined` when the keys can't be + * determined from the arguments (e.g. `list()` without a prefix), in which case the call is treated + * as user work and stays instrumented. + */ +export function getStorageKeys(methodName: string, args: unknown[]): string[] | undefined { + const [first] = args; + + if (methodName === 'get' || methodName === 'delete') { + // get(key) / get(keys[]) / delete(key) / delete(keys[]) + if (typeof first === 'string') { + return [first]; + } + if (Array.isArray(first)) { + return first.filter((k): k is string => typeof k === 'string'); + } + return undefined; + } + + if (methodName === 'put') { + // put(key, value) or put({ key: value, ... }) + if (typeof first === 'string') { + return [first]; + } + if (first && typeof first === 'object' && !Array.isArray(first)) { + return Object.keys(first); + } + return undefined; + } + + if (methodName === 'list') { + // list({ prefix }) + const prefix = first && typeof first === 'object' ? (first as { prefix?: unknown }).prefix : undefined; + return typeof prefix === 'string' ? [prefix] : undefined; + } + + return undefined; +} diff --git a/packages/cloudflare/src/utils/invocationScope.ts b/packages/cloudflare/src/utils/invocationScope.ts new file mode 100644 index 000000000000..3591bac64eb2 --- /dev/null +++ b/packages/cloudflare/src/utils/invocationScope.ts @@ -0,0 +1,30 @@ +import { getDefaultIsolationScope, getIsolationScope, type Scope, withIsolationScope } from '@sentry/core'; + +/** + * Runs `callback` on the isolation scope for the current invocation. + * + * An instrumented handler is either the entry point of an invocation or reentrant — reached from + * another instrumented handler already serving the same invocation (a Durable Object method calling + * its own `fetch`, an RPC method reaching a sibling method). Only the entry point may fork: + * + * - Forking at the entry point is mandatory. `setUser`/`setTag` write to the isolation scope, and a + * Durable Object's isolation scope outlives the invocation that touched it, so without a fork one + * invocation's user and tags reappear on the next invocation's events in the same isolate. + * Forking clones, so request data set by an enclosing wrapper is still inherited. + * - Forking again when reentrant would be wrong. Everything below the entry point is one logical + * unit of work: a nested call must see what the caller set and be able to add to it, the way it + * would if the SDK were not wrapping it at all. + * + * The AsyncLocalStorage strategy hands the default isolation scope back whenever no invocation is in + * flight, and a forked one while inside `withIsolationScope`. Reference-comparing against the default + * is therefore enough to tell the two cases apart. The stack fallback does not fork, so it reports the + * default scope even inside an invocation; there the fork degrades to a no-op, which the stack strategy + * tolerates. This matches the approach used by `patchEventHandler` in Nuxt. + */ +export function withInvocationIsolationScope(callback: (scope: Scope) => T): T { + const isolationScope = getIsolationScope(); + + const newIsolationScope = isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; + + return withIsolationScope(newIsolationScope, () => callback(newIsolationScope)); +} diff --git a/packages/cloudflare/src/utils/rpcMeta.ts b/packages/cloudflare/src/utils/rpcMeta.ts index b12ab5e74235..ad0e4b5ebb33 100644 --- a/packages/cloudflare/src/utils/rpcMeta.ts +++ b/packages/cloudflare/src/utils/rpcMeta.ts @@ -32,6 +32,17 @@ export function appendRpcMeta(args: unknown[]): unknown[] { return [...args, { [SENTRY_RPC_META_KEY]: traceData }]; } +/** + * Whether the trailing argument carries Sentry RPC metadata. + * + * Separate from {@link extractRpcMeta} because the RPC method wrappers run this check on every + * call — including the instance's own internal method calls, which never carry metadata — and + * must not allocate on that path. + */ +export function hasRpcMeta(args: unknown[]): boolean { + return args.length > 0 && isSentryRpcMeta(args[args.length - 1]); +} + /** * Extracts Sentry RPC metadata from the trailing argument of an args array. * Returns cleaned args (without meta) and the extracted trace data if found. diff --git a/packages/cloudflare/src/utils/rpcPropagation.ts b/packages/cloudflare/src/utils/rpcPropagation.ts new file mode 100644 index 000000000000..0e1c8557bbbc --- /dev/null +++ b/packages/cloudflare/src/utils/rpcPropagation.ts @@ -0,0 +1,34 @@ +import { stringMatchesSomePattern } from '@sentry/core'; +import type { CloudflareOptions } from '../client'; +import { getEffectiveRpcPropagation } from './rpcOptions'; + +const PROPAGATE_TO_NONE = (): boolean => false; +const PROPAGATE_TO_ALL = (): boolean => true; + +/** + * Builds the per-binding predicate that decides whether a binding takes part in RPC trace + * propagation. + * + * `rpcTracePropagationBindings` wins over `enableRpcTracePropagation` as soon as it is set, an + * allow list is the more precise statement. An empty list therefore propagates to nothing, even + * next to `enableRpcTracePropagation: true`, which is how a receiver opts out of being a caller. + * Only leaving it unset falls back to the boolean. + * + * Callers only. Receivers continue an incoming trace whenever `enableRpcTracePropagation` is on, + * so they have nothing to match against. + */ +export function createRpcPropagationResolver(options: CloudflareOptions | undefined): (bindingName: string) => boolean { + const bindings = options?.rpcTracePropagationBindings; + + if (bindings === undefined) { + return options && getEffectiveRpcPropagation(options) ? PROPAGATE_TO_ALL : PROPAGATE_TO_NONE; + } + + if (!bindings.length) { + return PROPAGATE_TO_NONE; + } + + // Strings must match a binding name exactly, without this, an entry of `DB` would also enable + // propagation for a binding named `MY_DB`. Regular expressions still give pattern matching. + return (bindingName: string) => stringMatchesSomePattern(bindingName, bindings, true); +} diff --git a/packages/cloudflare/src/vite/agentClass.ts b/packages/cloudflare/src/vite/agentClass.ts new file mode 100644 index 000000000000..e19118d29280 --- /dev/null +++ b/packages/cloudflare/src/vite/agentClass.ts @@ -0,0 +1,232 @@ +import { readFileSync } from 'node:fs'; +import { DEFAULT_EXPORT, type ModuleShape, shapeFromAst, shapeFromSource, type SuperRef } from './moduleShape'; +import type { ProgramBody } from './transform'; + +/** + * Agent base classes, keyed by the module specifier they are imported from. A class extending any + * of these — directly or through a chain of local/imported subclasses — is an `agents` Agent rather + * than a plain Durable Object. + * + * `McpAgent`, `AIChatAgent` and `Think` all extend `Agent` themselves, but they live in packages + * whose sources we never walk into (see {@link followImport}), so each needs its own entry. + */ +const AGENT_BASE_CLASSES: Record> = { + agents: new Set(['Agent']), + 'agents/mcp': new Set(['McpAgent']), + '@cloudflare/ai-chat': new Set(['AIChatAgent']), + '@cloudflare/think': new Set(['Think']), +}; + +/** + * How many modules deep the base-class chain is followed. Guards against pathological module graphs; + * real Agent hierarchies are only a few levels deep. + */ +const MAX_DEPTH = 8; + +/** + * The subset of the Vite/Rollup plugin context needed to walk the module graph. + * + * `resolve` is optional: without it detection still works, but only for base-class chains declared + * inside the entry module itself. `readFile` is injectable for tests and defaults to reading disk. + * + * Deliberately **no `load`**: awaiting the plugin context's `load()` from inside a `transform` hook + * deadlocks the build — the module cannot finish loading while its own transform is still pending + * (Rollup documents this hazard, and Vite's Rolldown pipeline is stricter still). Sibling modules + * are therefore read straight off disk; see {@link getModuleShape}. + */ +export interface ModuleResolver { + parse(code: string): ProgramBody; + resolve?(source: string, importer: string): Promise<{ id: string } | null | undefined>; + readFile?(id: string): string | undefined; +} + +interface DetectContext { + resolver: ModuleResolver; + modules: Map; +} + +/** + * Find which of `candidates` (top-level class names in the entry module) are Cloudflare + * [`agents`](https://www.npmjs.com/package/agents) Agents. + * + * An Agent is a Durable Object under the hood, so wrangler configures it as one and the transform + * would otherwise reach for `instrumentDurableObjectWithSentry`. Detecting the Agent base chain lets + * it pick `instrumentAgentWithSentry` instead, which adds the Agent-specific telemetry on top. + * + * Unlike `WorkerEntrypoint` detection, the chain is followed *across* modules: base classes commonly + * live in their own file (`import { MyBase } from './base'`), and there is no runtime fallback that + * would catch a missed one. Resolution stops at package boundaries — a base class imported from + * `node_modules` is only recognized when it is one of {@link AGENT_BASE_CLASSES}. + */ +export async function detectAgentClasses( + ast: ProgramBody, + entryId: string, + candidates: Iterable, + resolver: ModuleResolver, +): Promise> { + // The entry's shape comes from the AST Vite already handed us (TypeScript stripped, full + // fidelity); only sibling modules fall back to source scanning. + const ctx: DetectContext = { resolver, modules: new Map([[entryId, shapeFromAst(ast)]]) }; + + const detected = new Set(); + for (const name of candidates) { + // A fresh cycle guard per candidate: a shared one would record "not an Agent" for bindings + // visited while resolving an earlier candidate that turned out to be unrelated. + if (await isAgentBinding(ctx, entryId, name, 0, new Set())) { + detected.add(name); + } + } + return detected; +} + +/** + * The local class names in the entry that a configured class name could refer to — either declared + * under that name directly, or aliased to it by an `export { Local as Configured }` specifier. + * + * Keeps detection (which reads and scans other modules) off classes that no binding points at. + */ +export function collectAgentCandidates(ast: ProgramBody, configuredNames: Iterable): Set { + const shape = shapeFromAst(ast); + const candidates = new Set(); + + for (const configured of configuredNames) { + if (shape.classes.has(configured)) { + candidates.add(configured); + } + const local = shape.localExports.get(configured); + if (local && shape.classes.has(local)) { + candidates.add(local); + } + } + + return candidates; +} + +async function isAgentBinding( + ctx: DetectContext, + moduleId: string, + name: string, + depth: number, + visited: Set, +): Promise { + if (depth > MAX_DEPTH) return false; + + const key = `${moduleId} ${name}`; + if (visited.has(key)) return false; + visited.add(key); + + const shape = await getModuleShape(ctx, moduleId); + if (!shape) return false; + + if (shape.classes.has(name)) { + return extendsAgent(ctx, shape, moduleId, shape.classes.get(name), depth, visited); + } + + const imported = shape.imports.get(name); + if (imported) { + return followImport(ctx, moduleId, imported.source, imported.imported, depth, visited); + } + + if (name === DEFAULT_EXPORT) { + if (shape.defaultExportIsClass) { + return extendsAgent(ctx, shape, moduleId, shape.defaultExportSuper, depth, visited); + } + if (shape.defaultExportName) { + return isAgentBinding(ctx, moduleId, shape.defaultExportName, depth + 1, visited); + } + } + + // `export { Local as Requested }` — the caller asked by exported name. + const localName = shape.localExports.get(name); + if (localName) { + return isAgentBinding(ctx, moduleId, localName, depth + 1, visited); + } + + const reexport = shape.reexports.get(name); + if (reexport) { + return followImport(ctx, moduleId, reexport.source, reexport.imported, depth, visited); + } + + // Barrel modules (`export * from './base'`) don't name the binding, so every star source is a + // candidate home for it. + for (const source of shape.starExports) { + if (await followImport(ctx, moduleId, source, name, depth, visited)) { + return true; + } + } + + return false; +} + +/** + * Whether a superclass reference resolves to an Agent base — a bare identifier (imported, or a + * subclass declared in the same module) or `ns.Agent` off a namespace import. + */ +async function extendsAgent( + ctx: DetectContext, + shape: ModuleShape, + moduleId: string, + superClass: SuperRef | undefined, + depth: number, + visited: Set, +): Promise { + if (!superClass) return false; + + if (superClass.kind === 'identifier') { + return isAgentBinding(ctx, moduleId, superClass.name, depth + 1, visited); + } + + const source = shape.namespaces.get(superClass.object); + if (source) { + return followImport(ctx, moduleId, source, superClass.property, depth, visited); + } + + return false; +} + +/** Resolve an import to its module and continue the search there, stopping at package boundaries. */ +async function followImport( + ctx: DetectContext, + importerId: string, + source: string, + importedName: string, + depth: number, + visited: Set, +): Promise { + if (AGENT_BASE_CLASSES[source]?.has(importedName)) return true; + + if (!ctx.resolver.resolve) return false; + + let resolved: { id: string } | null | undefined; + try { + resolved = await ctx.resolver.resolve(source, importerId); + } catch { + return false; + } + if (!resolved?.id) return false; + + // Third-party sources are not walked: they are large, may not be plain JS by the time we see + // them, and any Agent base worth knowing about is listed in AGENT_BASE_CLASSES. + if (resolved.id.includes('/node_modules/') || resolved.id.includes('\\node_modules\\')) return false; + + return isAgentBinding(ctx, resolved.id, importedName, depth + 1, visited); +} + +async function getModuleShape(ctx: DetectContext, moduleId: string): Promise { + if (ctx.modules.has(moduleId)) return ctx.modules.get(moduleId); + + let shape: ModuleShape | undefined; + try { + const code = ctx.resolver.readFile + ? ctx.resolver.readFile(moduleId) + : readFileSync(moduleId.replace(/[?#].*$/, ''), 'utf8'); + if (typeof code === 'string') { + shape = shapeFromSource(code); + } + } catch { + // Unreadable or not a source file — treat as "not an Agent" rather than failing the build. + } + + ctx.modules.set(moduleId, shape); + return shape; +} diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts new file mode 100644 index 000000000000..707eed54375e --- /dev/null +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -0,0 +1,147 @@ +import { basename } from 'node:path'; +import { collectAgentCandidates, detectAgentClasses, type ModuleResolver } from './agentClass'; +import { buildOptionsImport, ENV_FALLBACK_OPTIONS_FN, resolveInstrumentFile } from './instrumentFile'; +import { applyAutoInstrumentTransforms, type ClassWrapperKind, type ProgramBody } from './transform'; +import { resolveWranglerConfig, type WranglerConfig } from './wranglerConfig'; + +// Vite normalizes module IDs to posix separators even on Windows, while +// `path.resolve` yields backslashes there — normalize before comparing. +function normalizePath(path: string): string { + return path.replace(/\\/g, '/'); +} + +// Extensions the entry-module match may tolerate swapping (e.g. wrangler's +// `main` says `.ts` but the served module is `.js`). Anything else — `.css`, +// `.html`, … — sharing the entry's basename must never be treated as the entry. +const JS_EXTENSION_REGEX = /\.[cm]?[jt]sx?$/; + +export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPath?: string } = {}) { + let wranglerConfig: WranglerConfig | undefined; + let entryFilePath: string | undefined; + + let optionsFn = ENV_FALLBACK_OPTIONS_FN; + let optionsImport: string | undefined; + + return { + name: 'sentry-cloudflare-auto-instrument', + + configResolved(config: { root: string; logger?: { warn(msg: string): void } }): void { + const result = resolveWranglerConfig(config.root, options.wranglerConfigPath); + if (!result) { + // An explicit path that fails is a misconfiguration worth naming; + // without one, hint at the option so custom-named configs (e.g. a + // `configPath` handed to @cloudflare/vite-plugin) are discoverable. + config.logger?.warn( + options.wranglerConfigPath + ? `[sentry] Could not find or parse the wrangler config "${basename(options.wranglerConfigPath)}" ` + + '(resolved against the Vite root) — auto-instrumentation disabled.' + : '[sentry] No parseable wrangler config found — auto-instrumentation disabled. ' + + 'Set `wranglerConfigPath` if your config uses a custom name.', + ); + return; + } + + wranglerConfig = result.config; + if (wranglerConfig.main) { + // `main` is already absolute (wrangler resolves it); just normalize + // separators so the entry-module comparison holds on Windows. + entryFilePath = normalizePath(wranglerConfig.main); + } + + if (entryFilePath) { + const instrumentFilePath = resolveInstrumentFile(entryFilePath); + if (instrumentFilePath) { + const built = buildOptionsImport(entryFilePath, instrumentFilePath); + optionsFn = built.optionsFn; + optionsImport = built.importStmt; + } + } + }, + + async transform( + this: ModuleResolver & { warn?(msg: string): void; environment?: { name?: string } }, + code: string, + id: string, + ): Promise<{ code: string; map: unknown } | undefined> { + if (!wranglerConfig || !entryFilePath) return undefined; + + // The worker entry never belongs to the client (browser) environment. + // Skipping it keeps a same-basename sibling (e.g. a `src/index.tsx` + // client entry next to a `src/index.ts` worker) out of the browser bundle. + if (this.environment?.name === 'client') return undefined; + + // Vite may append query/hash params to the module ID. + const normalizedId = normalizePath(id.replace(/[?#].*$/, '')); + if (normalizedId !== entryFilePath) { + // Tolerate a differing JS-flavored extension (e.g. `.js` vs `.ts`). + if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryFilePath)) return undefined; + if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryFilePath.replace(JS_EXTENSION_REGEX, '')) { + return undefined; + } + } + + let ast: ProgramBody; + try { + ast = this.parse(code); + } catch { + // Raw TypeScript or syntax error — esbuild hasn't run yet (unlikely) + // or the file is genuinely broken. Either way, skip silently. + return undefined; + } + + const classWrappers = new Map(); + for (const { className } of wranglerConfig.durableObjects) { + classWrappers.set(className, 'durableObject'); + } + for (const { className } of wranglerConfig.workflows) { + classWrappers.set(className, 'workflow'); + } + for (const className of wranglerConfig.workerEntrypoints) { + classWrappers.set(className, 'workerEntrypoint'); + } + + // An `agents` Agent is a Durable Object, so wrangler lists it among the DO bindings and only + // its base-class chain tells the two apart. Detection walks the module graph (base classes + // usually live in their own file), so it is limited to the configured DO classes. + // + // Only `resolve` is handed over — never `load`: awaiting `load()` from inside a transform + // hook deadlocks the build, since the module can't finish loading while this transform is + // still pending. `agentClass` reads sibling modules off disk instead. + const agentCandidates = collectAgentCandidates( + ast, + [...classWrappers].filter(([, kind]) => kind === 'durableObject').map(([name]) => name), + ); + const agentClasses = + agentCandidates.size > 0 + ? await detectAgentClasses(ast, normalizedId, agentCandidates, { + parse: code => this.parse(code), + resolve: this.resolve ? (source, importer) => this.resolve!(source, importer) : undefined, + }) + : undefined; + + // No registration import is injected here: the orchestrion plugin's + // subscribe-injection makes each bundled package self-register its channel + // subscriber on the global marker, so wrapping the entry with `withSentry` + // is all this plugin needs to do. + const result = applyAutoInstrumentTransforms(code, ast, { + classWrappers, + agentClasses, + optionsFn, + optionsImport, + sameWorkerBindings: wranglerConfig.sameWorkerBindings, + }); + + const wrappedClasses = result?.wrappedClasses ?? new Set(); + const missing = [...classWrappers.keys()].filter(name => !wrappedClasses.has(name)); + if (missing.length > 0) { + this.warn?.( + `[sentry] Could not auto-instrument class(es) ${missing.join(', ')}: no matching exported class ` + + 'declaration found in the worker entry (re-exports from other modules cannot be wrapped ' + + 'automatically). Wrap them manually with the matching `instrument*WithSentry` helper.', + ); + } + + return result ?? undefined; + }, + }; +} diff --git a/packages/cloudflare/src/vite/bindings.ts b/packages/cloudflare/src/vite/bindings.ts new file mode 100644 index 000000000000..1efd402b7ee8 --- /dev/null +++ b/packages/cloudflare/src/vite/bindings.ts @@ -0,0 +1,17 @@ +/** + * Binding vocabulary shared by the wrangler config reader and the AST transform. + * + * It lives apart from `wranglerConfig.ts` so the transform never has to import that module, which + * pulls in `wrangler` and with it a Node 20+ runtime. + */ + +/** Stands in for a class name where a binding targets the module's default export. */ +export const DEFAULT_EXPORT = Symbol('defaultExport'); + +/** The name a binding or an export is known by, either an exported class name or the default export. */ +export type ExportName = string | typeof DEFAULT_EXPORT; + +export interface SameWorkerBinding { + bindingName: string; + className: ExportName; +} diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index 113d18193257..01f8c6c578e6 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -4,11 +4,23 @@ // The CJS rollup variant still emits this file, but `package.json` doesn't // expose it — same setup as `@sentry/server-utils/orchestrion/vite` itself. import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; +import { sentryCloudflareAutoInstrumentPlugin } from './autoInstrument'; /** * Options for {@link sentryCloudflareVitePlugin}. */ export interface SentryCloudflareVitePluginOptions { + /** + * Path to the wrangler config, relative to the Vite root (or absolute). + * Set this when your config doesn't use a default name — e.g. when you + * pass `configPath: './wrangler.agent.jsonc'` to `@cloudflare/vite-plugin`, + * which the Sentry plugin cannot see. When set, only this file is read + * (no default-name probing), and a warning is emitted if it is missing or + * unparseable. + * + * @default undefined (probes `wrangler.json`, `wrangler.jsonc`, `wrangler.toml` at the Vite root) + */ + wranglerConfigPath?: string; /** * Experimental options that may change or be removed without notice. */ @@ -28,6 +40,22 @@ export interface SentryCloudflareVitePluginOptions { * @experimental May change or be removed in any release. */ useDiagnosticsChannelInjection?: boolean; + /** + * Automatically wraps your Worker at build time so you don't have to edit + * your entry: the plugin reads your wrangler config, wraps the default + * export with `Sentry.withSentry()` (sourcing options from a co-located + * `instrument.*` file, falling back to env), and wraps any configured + * Durable Object class with `instrumentDurableObjectWithSentry`. Both + * `vite build` and `vite dev` are instrumented. + * + * The plugin also adds the bindings that resolve to the wrapped classes (this worker's own + * Durable Objects and self service bindings) to `rpcTracePropagationBindings`. Bindings to + * other workers stay opt-in, their receivers may not run Sentry. + * + * @default false + * @experimental May change or be removed in any release. + */ + autoInstrumentation?: boolean; }; } @@ -63,9 +91,12 @@ export interface SentryCloudflareVitePluginOptions { * ``` */ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOptions = {}) { - if (!options._experimental?.useDiagnosticsChannelInjection) { - return []; - } - - return sentryOrchestrionPlugin({ injectChannelSubscribers: true }); + return [ + ...(options._experimental?.useDiagnosticsChannelInjection + ? [sentryOrchestrionPlugin({ injectChannelSubscribers: true })] + : []), + ...(options._experimental?.autoInstrumentation + ? [sentryCloudflareAutoInstrumentPlugin({ wranglerConfigPath: options.wranglerConfigPath })] + : []), + ]; } diff --git a/packages/cloudflare/src/vite/instrumentFile.ts b/packages/cloudflare/src/vite/instrumentFile.ts new file mode 100644 index 000000000000..ffd79ca0104c --- /dev/null +++ b/packages/cloudflare/src/vite/instrumentFile.ts @@ -0,0 +1,53 @@ +import { existsSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; + +// Fallback options callback used when no instrument file is present. Returning +// `undefined` makes the SDK read all configuration (DSN, release, environment, +// sample rate, …) from the worker's `env` at runtime. +export const ENV_FALLBACK_OPTIONS_FN = '() => undefined'; + +// Identifier the generated import binds the user's options module to. +const OPTIONS_IMPORT_IDENTIFIER = '__SENTRY_OPTIONS_CALLBACK__'; + +// Conventional, non-configurable name of the Sentry options module. It is +// looked up next to the worker entry file; its default export is the options +// callback `(env) => CloudflareOptions`. +const INSTRUMENT_FILE_BASENAME = 'instrument.server'; +const INSTRUMENT_FILE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs', 'cjs']; + +/** + * Locate the conventional `instrument.server.*` module sitting next to the + * worker entry file. Returns its absolute path, or `undefined` when absent. + */ +export function resolveInstrumentFile(entryFilePath: string): string | undefined { + const dir = dirname(entryFilePath); + for (const ext of INSTRUMENT_FILE_EXTENSIONS) { + const candidate = resolve(dir, `${INSTRUMENT_FILE_BASENAME}.${ext}`); + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +/** + * Build the `optionsFn` reference and `import` statement for the instrument + * module whose **default export** is the options callback + * `(env) => CloudflareOptions`. + * + * The import is emitted relative to `entryFilePath` because it is injected into + * the entry file's source. The file extension is kept: extensionless specifiers + * only resolve for extensions in Vite's default `resolve.extensions` (which + * excludes `.cjs`), and keeping it makes our probe order authoritative when + * several `instrument.server.*` files coexist. + */ +export function buildOptionsImport( + entryFilePath: string, + instrumentFilePath: string, +): { optionsFn: string; importStmt: string } { + let relativePath = relative(dirname(entryFilePath), instrumentFilePath).replace(/\\/g, '/'); + if (!relativePath.startsWith('.')) relativePath = `./${relativePath}`; + + return { + optionsFn: OPTIONS_IMPORT_IDENTIFIER, + importStmt: `import ${OPTIONS_IMPORT_IDENTIFIER} from '${relativePath}';\n`, + }; +} diff --git a/packages/cloudflare/src/vite/moduleShape.ts b/packages/cloudflare/src/vite/moduleShape.ts new file mode 100644 index 000000000000..93c78c4810eb --- /dev/null +++ b/packages/cloudflare/src/vite/moduleShape.ts @@ -0,0 +1,389 @@ +import type { BaseNode, ProgramBody } from './transform'; + +// --------------------------------------------------------------------------- +// Minimal ESTree node shapes for structural Agent detection. +// --------------------------------------------------------------------------- + +interface IdentifierNode extends BaseNode { + name: string; +} + +interface ClassNode extends BaseNode { + id?: IdentifierNode | null; + superClass?: BaseNode | null; +} + +interface MemberExpressionNode extends BaseNode { + object?: { type: string; name?: string }; + property?: { type: string; name?: string }; +} + +interface ImportSpecifierNode { + type: string; + imported?: { type: string; name?: string }; + local?: { type: string; name?: string }; +} + +interface ImportDeclarationNode extends BaseNode { + source?: { value?: unknown }; + specifiers?: ImportSpecifierNode[]; +} + +interface ExportSpecifierNode { + type: string; + local?: { type: string; name?: string }; + exported?: { type: string; name?: string }; +} + +interface ExportNamedNode extends BaseNode { + declaration?: BaseNode | null; + source?: { value?: unknown } | null; + specifiers?: ExportSpecifierNode[]; +} + +interface ExportDefaultNode extends BaseNode { + declaration: BaseNode; +} + +interface ExportAllNode extends BaseNode { + source?: { value?: unknown }; + exported?: { type: string; name?: string } | null; +} + +/** Binding name used for a module's default export. */ +export const DEFAULT_EXPORT = 'default'; + +/** A resolved superclass reference: a bare identifier or a `ns.Member` expression. */ +export type SuperRef = { kind: 'identifier'; name: string } | { kind: 'member'; object: string; property: string }; + +/** The declarations of a single module that binding resolution needs. */ +export interface ModuleShape { + /** Top-level class names → their superclass reference (absent when the class has no `extends`). */ + classes: Map; + /** Local binding name → the import it came from (`imported` is `default` for a default import). */ + imports: Map; + /** Local binding name → module specifier, for `import * as ns from '...'`. */ + namespaces: Map; + /** Exported name → the re-export it came from, for `export { x as y } from '...'`. */ + reexports: Map; + /** Exported name → local binding name, for `export { x as y }` without a source. */ + localExports: Map; + /** Module specifiers of bare `export * from '...'` declarations. */ + starExports: string[]; + /** `export default ` — the local name it refers to. */ + defaultExportName?: string; + /** True when the module's default export is a class declaration/expression. */ + defaultExportIsClass?: boolean; + /** Superclass of an anonymous `export default class extends X`. */ + defaultExportSuper?: SuperRef; +} + +function emptyShape(): ModuleShape { + return { + classes: new Map(), + imports: new Map(), + namespaces: new Map(), + reexports: new Map(), + localExports: new Map(), + starExports: [], + }; +} + +// --------------------------------------------------------------------------- +// Shape from a parsed AST (used for the entry module) +// --------------------------------------------------------------------------- + +/** Collect the shape of a module from its parsed AST — used for the already-parsed entry module. */ +export function shapeFromAst(ast: ProgramBody): ModuleShape { + const shape = emptyShape(); + + for (const node of ast.body) { + switch (node.type) { + case 'ClassDeclaration': + addAstClass(shape, node as ClassNode); + break; + case 'ImportDeclaration': + addAstImports(shape, node as ImportDeclarationNode); + break; + case 'ExportNamedDeclaration': + addAstNamedExport(shape, node as ExportNamedNode); + break; + case 'ExportDefaultDeclaration': { + const decl = (node as ExportDefaultNode).declaration; + if (decl.type === 'ClassDeclaration' || decl.type === 'ClassExpression') { + shape.defaultExportIsClass = true; + shape.defaultExportSuper = superRefFromNode((decl as ClassNode).superClass); + // `export default class Foo {}` also binds `Foo` locally. + addAstClass(shape, decl as ClassNode); + } else if (decl.type === 'Identifier') { + shape.defaultExportName = (decl as IdentifierNode).name; + } + break; + } + case 'ExportAllDeclaration': { + const starNode = node as ExportAllNode; + // `export * as ns from '...'` binds a namespace object, not the individual exports. + const source = starNode.exported ? undefined : asString(starNode.source?.value); + if (source) shape.starExports.push(source); + break; + } + } + } + + return shape; +} + +function superRefFromNode(superClass: BaseNode | null | undefined): SuperRef | undefined { + if (!superClass) return undefined; + + if (superClass.type === 'Identifier') { + return { kind: 'identifier', name: (superClass as IdentifierNode).name }; + } + + if (superClass.type === 'MemberExpression') { + const member = superClass as MemberExpressionNode; + const object = member.object?.type === 'Identifier' ? member.object.name : undefined; + const property = member.property?.name; + if (object && property) return { kind: 'member', object, property }; + } + + return undefined; +} + +function addAstClass(shape: ModuleShape, classNode: ClassNode): void { + if (classNode.id?.name) shape.classes.set(classNode.id.name, superRefFromNode(classNode.superClass)); +} + +function addAstImports(shape: ModuleShape, node: ImportDeclarationNode): void { + const source = asString(node.source?.value); + if (!source) return; + + for (const specifier of node.specifiers ?? []) { + const local = specifier.local?.name; + if (!local) continue; + + if (specifier.type === 'ImportSpecifier' && specifier.imported?.name) { + shape.imports.set(local, { source, imported: specifier.imported.name }); + } else if (specifier.type === 'ImportDefaultSpecifier') { + shape.imports.set(local, { source, imported: DEFAULT_EXPORT }); + } else if (specifier.type === 'ImportNamespaceSpecifier') { + shape.namespaces.set(local, source); + } + } +} + +function addAstNamedExport(shape: ModuleShape, node: ExportNamedNode): void { + if (node.declaration?.type === 'ClassDeclaration') { + addAstClass(shape, node.declaration as ClassNode); + return; + } + + const source = asString(node.source?.value); + for (const specifier of node.specifiers ?? []) { + const exported = specifier.exported?.name; + const local = specifier.local?.name; + if (!exported || !local) continue; + + if (source) { + shape.reexports.set(exported, { source, imported: local }); + } else { + shape.localExports.set(exported, local); + } + } +} + +// --------------------------------------------------------------------------- +// Shape from raw source (used for sibling modules read off disk) +// --------------------------------------------------------------------------- + +/** + * Extract the module shape from **unprocessed source**. + * + * Sibling modules are read straight from disk (the plugin context's `load()` would deadlock inside a + * transform hook), so they may still be TypeScript — which no JS parser will accept. Only + * import/export statements and `class X extends Y` headers matter here, and those are + * declaration-level syntax a scan can pick out reliably; class bodies, type annotations and generics + * are irrelevant and simply skipped. + */ +export function shapeFromSource(rawCode: string): ModuleShape { + const code = stripComments(rawCode); + const shape = emptyShape(); + + // `class X extends Y {` / `class X extends Y {`, optionally exported and/or abstract. + const CLASS_RE = /\b(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)([^{]*)\{/g; + for (const match of code.matchAll(CLASS_RE)) { + const className = match[1]!; + const isDefault = /\bexport\s+default\b/.test(match[0]); + const superRef = superRefFromHeader(match[2] ?? ''); + + shape.classes.set(className, superRef); + if (isDefault) { + shape.defaultExportIsClass = true; + shape.defaultExportSuper = superRef; + } + } + + // `export default Identifier;` + const defaultExprMatch = /\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;/.exec(code); + if (defaultExprMatch && !shape.defaultExportIsClass) { + shape.defaultExportName = defaultExprMatch[1]; + } + + // `export * from '...'` (but not `export * as ns from '...'`). + for (const match of code.matchAll(/\bexport\s*\*\s*from\s*['"]([^'"]+)['"]/g)) { + if (match[1]) shape.starExports.push(match[1]); + } + + // `export { a, b as c } from '...'` and `export { a, b as c }`. + for (const match of code.matchAll(/\bexport\s*\{([^}]*)\}\s*(?:from\s*['"]([^'"]+)['"])?/g)) { + const source = match[2]; + for (const { imported, local } of parseSpecifierList(match[1] ?? '')) { + // In an export clause the first name is local and the alias is the exported name. + if (source) { + shape.reexports.set(local, { source, imported }); + } else { + shape.localExports.set(local, imported); + } + } + } + + // `import Default, { a as b }, * as ns from '...'` in its various shapes. + for (const match of code.matchAll(/\bimport\s+([^'";]+?)\s+from\s*['"]([^'"]+)['"]/g)) { + addSourceImports(shape, match[1] ?? '', match[2]!); + } + + return shape; +} + +/** Pull the superclass out of the text between a class name and its body. */ +function superRefFromHeader(header: string): SuperRef | undefined { + // The header still carries the class's own generic parameter list (``), whose + // constraint `extends` would otherwise be mistaken for the superclass clause. Strip a leading + // balanced `<...>` first, then read the (possibly dotted) base name. Trailing generic args on the + // base (`extends Agent`) stop the match naturally. + const withoutParams = stripLeadingGenerics(header); + const match = /\bextends\s+([A-Za-z_$][\w$]*)\s*(?:\.\s*([A-Za-z_$][\w$]*))?/.exec(withoutParams); + if (!match) return undefined; + + return match[2] ? { kind: 'member', object: match[1]!, property: match[2] } : { kind: 'identifier', name: match[1]! }; +} + +/** + * Remove a leading `<...>` generic parameter list, honoring nesting (`>`). + * Anything after the balanced close — the superclass clause — is returned unchanged. When the + * header doesn't start with `<` (no generics), it is returned as-is. + */ +function stripLeadingGenerics(header: string): string { + const start = header.indexOf('<'); + // Only treat it as a parameter list when `<` is the first meaningful token — a `<` appearing + // later is part of the superclass's own generic args and must be left alone. + if (start === -1 || header.slice(0, start).trim() !== '') return header; + + let depth = 0; + for (let i = start; i < header.length; i++) { + const char = header[i]; + if (char === '<') depth++; + else if (char === '>') { + depth--; + if (depth === 0) return header.slice(i + 1); + } + } + // Unbalanced `<` — fall back to the untouched header rather than dropping the superclass. + return header; +} + +/** Parse `a, b as c` into `{ imported: 'b', local: 'c' }` pairs (type-only specifiers dropped). */ +function parseSpecifierList(clause: string): Array<{ imported: string; local: string }> { + const specifiers: Array<{ imported: string; local: string }> = []; + + for (const rawPart of clause.split(',')) { + const part = rawPart.trim().replace(/^type\s+/, ''); + if (!part) continue; + + const aliased = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(part); + if (aliased) { + specifiers.push({ imported: aliased[1]!, local: aliased[2]! }); + } else if (/^[A-Za-z_$][\w$]*$/.test(part)) { + specifiers.push({ imported: part, local: part }); + } + } + + return specifiers; +} + +function addSourceImports(shape: ModuleShape, clause: string, source: string): void { + // Type-only imports never contribute a runtime base class. + if (/^type\s/.test(clause.trim())) return; + + const namespaceMatch = /\*\s*as\s+([A-Za-z_$][\w$]*)/.exec(clause); + if (namespaceMatch) { + shape.namespaces.set(namespaceMatch[1]!, source); + } + + const bracesMatch = /\{([^}]*)\}/.exec(clause); + if (bracesMatch) { + for (const { imported, local } of parseSpecifierList(bracesMatch[1] ?? '')) { + shape.imports.set(local, { source, imported }); + } + } + + // A leading bare identifier (before any brace/star) is the default import. + const defaultMatch = /^\s*([A-Za-z_$][\w$]*)\s*(?:,|$)/.exec(clause); + if (defaultMatch) { + shape.imports.set(defaultMatch[1]!, { source, imported: DEFAULT_EXPORT }); + } +} + +/** Remove line and block comments, leaving string literals (module specifiers) intact. */ +function stripComments(code: string): string { + let result = ''; + let index = 0; + + while (index < code.length) { + const char = code[index]!; + const next = code[index + 1]; + + if (char === '/' && next === '/') { + while (index < code.length && code[index] !== '\n') index++; + continue; + } + + if (char === '/' && next === '*') { + index += 2; + while (index < code.length && !(code[index] === '*' && code[index + 1] === '/')) index++; + index += 2; + // Keep a separator so `*/class` doesn't fuse into one token. + result += ' '; + continue; + } + + if (char === '"' || char === "'" || char === '`') { + const quote = char; + result += char; + index++; + while (index < code.length) { + const inner = code[index]!; + result += inner; + index++; + if (inner === '\\') { + if (index < code.length) { + result += code[index]!; + index++; + } + continue; + } + if (inner === quote) break; + } + continue; + } + + result += char; + index++; + } + + return result; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined; +} diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts new file mode 100644 index 000000000000..9d44b7413799 --- /dev/null +++ b/packages/cloudflare/src/vite/transform.ts @@ -0,0 +1,430 @@ +import MagicString from 'magic-string'; +import { DEFAULT_EXPORT, type ExportName, type SameWorkerBinding } from './bindings'; +import { detectWorkerEntrypointClasses } from './workerEntrypoint'; + +const MERGED_OPTIONS_IDENTIFIER = '__SENTRY_OPTIONS__'; + +// --------------------------------------------------------------------------- +// Minimal ESTree node types for the AST nodes we inspect. +// --------------------------------------------------------------------------- + +export interface BaseNode { + type: string; + start: number; + end: number; +} + +export interface ProgramBody { + body: BaseNode[]; +} + +interface IdentifierNode extends BaseNode { + name: string; +} + +interface CalleeNode { + type: string; + name?: string; + property?: { type: string; name?: string }; +} + +interface CallExpressionNode extends BaseNode { + callee?: CalleeNode; +} + +interface ClassDeclarationNode extends BaseNode { + id?: IdentifierNode | null; +} + +interface ExportDefaultNode extends BaseNode { + declaration: BaseNode; +} + +interface ExportSpecifierNode { + type: string; + local?: { type: string; name?: string }; + exported?: { type: string; name?: string }; +} + +interface ExportNamedNode extends BaseNode { + declaration?: BaseNode | null; + source?: unknown; + specifiers?: ExportSpecifierNode[]; +} + +interface VariableDeclaratorNode { + id?: { type: string; name?: string }; + init?: BaseNode | null; +} + +interface VariableDeclarationNode extends BaseNode { + declarations?: VariableDeclaratorNode[]; +} + +function isCallToMethod(node: BaseNode, methodName: string): boolean { + if (node.type !== 'CallExpression') return false; + const callee = (node as CallExpressionNode).callee; + if (!callee) return false; + if (callee.type === 'Identifier' && callee.name === methodName) return true; + return ( + callee.type === 'MemberExpression' && callee.property?.type === 'Identifier' && callee.property.name === methodName + ); +} + +/** + * The kind of Sentry wrapper to apply to an exported class. + * + * `durableObject` and `workflow` are keyed by name from the wrangler config + * (`durable_objects.bindings`, `workflows`), since those class names are + * authoritative there. `workerEntrypoint` is different: a worker's own + * entrypoints aren't enumerated in its config, so they're detected structurally + * (a class extending `WorkerEntrypoint` from `cloudflare:workers`), with the + * config providing only a fallback for self-bound entrypoints whose base class + * lives in another module. + */ +export type ClassWrapperKind = 'durableObject' | 'agent' | 'workflow' | 'workerEntrypoint'; + +/** + * The `@sentry/cloudflare` helper each wrapper kind emits. All share the same + * `(optionsCallback, Class)` signature. `WorkerEntrypoint` classes use + * `withSentry`, which runtime-detects the class type and routes accordingly. + */ +const WRAPPER_METHODS: Record = { + durableObject: 'instrumentDurableObjectWithSentry', + agent: 'instrumentAgentWithSentry', + workflow: 'instrumentWorkflowWithSentry', + workerEntrypoint: 'withSentry', +}; + +export interface TransformContext { + /** + * Exported class name → the kind of Sentry wrapper to apply. Populated from + * the wrangler config, so the transform can wrap by name without resolving + * each class's base type. + */ + classWrappers: Map; + /** + * Local class names detected as `agents` Agents. An Agent is configured as a Durable Object in + * wrangler, so this upgrades those entries from `durableObject` to `agent`. + */ + agentClasses?: ReadonlySet; + optionsFn: string; + /** Import statement prepended when `optionsFn` references a separate module. */ + optionsImport?: string; + /** @see {@link import('./wranglerConfig').WranglerConfig.sameWorkerBindings} */ + sameWorkerBindings?: readonly SameWorkerBinding[]; +} + +export interface TransformResult { + code: string; + map: ReturnType; + /** + * The configured class names that were actually wrapped. Lets the plugin warn + * about configured classes it could not find, instead of silently leaving + * them uninstrumented. + */ + wrappedClasses: Set; +} + +/** + * Rewrite the worker entry source to wrap its default export with `withSentry` + * and any configured class export with its matching Sentry wrapper (see + * {@link TransformContext.classWrappers}, e.g. Durable Object classes with + * `instrumentDurableObjectWithSentry`). + * + * Handles both `export class MyDO {}` and the specifier form + * (`class MyDO {}` … `export { MyDO }` / `export { Foo as MyDO }`). + * Re-exports from other modules (`export { MyDO } from './do'`) cannot be + * wrapped here and are left alone — the plugin warns about them via + * {@link TransformResult.wrappedClasses}. + * + * Exported (rather than inlined into the plugin) so it can be unit-tested with a + * plain AST and no Vite context. Returns `undefined` when nothing was wrapped and + * there are no already-manually-wrapped classes to report. + */ +export function applyAutoInstrumentTransforms( + code: string, + ast: ProgramBody, + ctx: TransformContext, +): TransformResult | undefined { + const ms = new MagicString(code); + const topLevelClasses = collectTopLevelClasses(ast); + const sameWorkerBindings = ctx.sameWorkerBindings ?? []; + const state: TransformState = { + ms, + needsImport: false, + wrappedClasses: new Set(), + topLevelClasses, + renamedLocals: new Set(), + classWrappers: ctx.classWrappers, + agentClasses: ctx.agentClasses ?? new Set(), + workerEntrypointClasses: detectWorkerEntrypointClasses(ast), + // The identifier must be chosen before wrapping, which bindings survive is only known after. + optionsFn: sameWorkerBindings.length > 0 ? MERGED_OPTIONS_IDENTIFIER : ctx.optionsFn, + autoWrapped: new Set(), + }; + const { wrappedClasses } = state; + + // Named exports first, regardless of source order: the default-export handler + // needs to know which local bindings a named export already wrapped, so it can + // skip a class that is both exported by name and re-exported as default (which + // would otherwise wrap it twice). + for (const node of ast.body) { + if (node.type === 'ExportNamedDeclaration') { + handleNamedExport(node as ExportNamedNode, ctx, state); + } + } + for (const node of ast.body) { + if (node.type === 'ExportDefaultDeclaration') { + wrapDefaultExport(node as ExportDefaultNode, ctx, state); + } + } + + if (!state.needsImport) { + // Nothing was rewritten. Still surface any classes found already wrapped + // manually (via `wrappedClasses`) so the caller doesn't warn about them; + // return undefined only when there was nothing to report either. + if (wrappedClasses.size === 0) return undefined; + return { code, map: ms.generateMap({ hires: true }), wrappedClasses }; + } + + // `prepend` inserts before earlier prepends, yielding: Sentry import, options import, declaration. + if (sameWorkerBindings.length > 0) { + ms.prepend(buildMergedOptionsDeclaration(sameWorkerBindings, ctx.optionsFn, state)); + } + if (ctx.optionsImport) ms.prepend(ctx.optionsImport); + ms.prepend("import * as __SENTRY__ from '@sentry/cloudflare';\n"); + + return { + code: ms.toString(), + map: ms.generateMap({ hires: true }), + wrappedClasses, + }; +} + +interface TransformState { + ms: MagicString; + needsImport: boolean; + wrappedClasses: Set; + /** + * Top-level (non-exported) class declarations, so specifier exports like + * `export { MyDO }` can locate the class they refer to. + */ + topLevelClasses: Map; + /** + * Local class names already renamed + wrapped, so two specifiers pointing at + * the same class don't produce duplicate bindings. + */ + renamedLocals: Set; + /** Class name → wrapper kind, keyed by the *exported* name (from config). */ + classWrappers: Map; + /** Local class names detected as `agents` Agents (see {@link TransformContext.agentClasses}). */ + agentClasses: ReadonlySet; + /** + * Local class names detected as `WorkerEntrypoint` subclasses in this module, + * so they can be wrapped without a config entry. + */ + workerEntrypointClasses: Set; + optionsFn: string; + /** + * Export names wrapped by this transform, so their options can be extended with + * `rpcTracePropagationBindings`. Hand-wrapped exports stay out, they keep the options they were + * wrapped with. `wrappedClasses` counts both. + */ + autoWrapped: Set; +} + +/** + * Builds the callback that merges same-worker binding names into `rpcTracePropagationBindings` at + * runtime, the options object only exists once the callback runs with `env`. Only bindings whose + * class this transform wrapped survive, a hand-wrapped class runs on its own options. + * + * The same callback also turns `enableRpcTracePropagation` on, because it is what the wrapped + * receivers read to continue an incoming trace. An explicit value in the user's options wins. + */ +function buildMergedOptionsDeclaration( + sameWorkerBindings: readonly SameWorkerBinding[], + optionsFn: string, + state: TransformState, +): string { + const bindingNames = sameWorkerBindings + .filter(({ className }) => state.autoWrapped.has(className)) + .map(({ bindingName }) => bindingName); + + if (!bindingNames.length) { + return `const ${MERGED_OPTIONS_IDENTIFIER} = ${optionsFn};\n`; + } + + const names = bindingNames.map(name => JSON.stringify(name)).join(', '); + return ( + `const ${MERGED_OPTIONS_IDENTIFIER} = (env) => { ` + + `const opts = (${optionsFn})(env); ` + + 'return { ...opts, ' + + 'enableRpcTracePropagation: opts?.enableRpcTracePropagation ?? true, ' + + `rpcTracePropagationBindings: [${names}, ...(opts?.rpcTracePropagationBindings ?? [])] }; };\n` + ); +} + +/** + * Resolve the wrapper kind for a class export. + * + * Config (`classWrappers`) wins — it's authoritative for Durable Objects and + * Workflows, and provides the self-binding fallback for WorkerEntrypoints whose + * base class this module can't see. Otherwise a structurally-detected + * `WorkerEntrypoint` subclass (matched by its *local* name) gets wrapped with + * `withSentry`. + * + * The one case where config is refined rather than obeyed is an `agents` Agent: + * it *is* a Durable Object, so wrangler can only ever describe it as one, and + * only the detected base chain distinguishes the two. + */ +function resolveWrapperKind( + exportedName: string, + localName: string | undefined, + state: TransformState, +): ClassWrapperKind | undefined { + const configured = state.classWrappers.get(exportedName); + if (configured === 'durableObject' && localName && state.agentClasses.has(localName)) return 'agent'; + if (configured) return configured; + if (localName && state.workerEntrypointClasses.has(localName)) return 'workerEntrypoint'; + return undefined; +} + +function collectTopLevelClasses(ast: ProgramBody): Map { + const classes = new Map(); + for (const node of ast.body) { + if (node.type !== 'ClassDeclaration') continue; + const classNode = node as ClassDeclarationNode; + if (classNode.id?.name) classes.set(classNode.id.name, classNode); + } + return classes; +} + +function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state: TransformState): void { + const decl = node.declaration; + + // Already wrapped — leave it alone + if (isCallToMethod(decl, 'withSentry')) return; + + // `export default Foo` where `Foo` is a local class already wrapped by a named + // export (e.g. a self-bound WorkerEntrypoint also used as the default handler). + // Wrapping again would produce `withSentry(withSentry(...))`. The binding still + // points at the wrapped class, so the default export counts as auto-wrapped. + if (decl.type === 'Identifier' && state.renamedLocals.has((decl as IdentifierNode).name)) { + state.autoWrapped.add(DEFAULT_EXPORT); + return; + } + + // `export default ` → `const __SENTRY_DEFAULT_EXPORT__ = ` + // MagicString positions are always relative to the original source. + state.ms.overwrite(node.start, decl.start, 'const __SENTRY_DEFAULT_EXPORT__ = '); + state.ms.append(`\nexport default __SENTRY__.withSentry(${state.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`); + state.needsImport = true; + state.autoWrapped.add(DEFAULT_EXPORT); +} + +function handleNamedExport(node: ExportNamedNode, ctx: TransformContext, state: TransformState): void { + const decl = node.declaration; + + // ---- Manually wrapped class export ---- + // `export const MyDO = instrumentDurableObjectWithSentry(...)` — count it + // as wrapped so the plugin doesn't warn about it, but leave it alone. + if (decl?.type === 'VariableDeclaration') { + collectManuallyWrappedClassExports(decl as VariableDeclarationNode, ctx, state); + return; + } + + // ---- Named class export matching a configured binding ---- + if (decl?.type === 'ClassDeclaration') { + wrapInlineClassExport(node, decl as ClassDeclarationNode, ctx, state); + return; + } + + // ---- Specifier export of a local class (`export { Foo as MyDO }`) ---- + // Re-exports from another module carry a `source` — nothing local to wrap. + if (node.source) return; + for (const specifier of node.specifiers ?? []) { + wrapSpecifierExport(specifier, ctx, state); + } +} + +function collectManuallyWrappedClassExports( + varDecl: VariableDeclarationNode, + ctx: TransformContext, + state: TransformState, +): void { + for (const declarator of varDecl.declarations ?? []) { + const name = declarator.id?.type === 'Identifier' ? declarator.id.name : undefined; + const kind = name ? ctx.classWrappers.get(name) : undefined; + if (!name || !kind || !declarator.init) continue; + + // A hand-wrapped Agent is configured as a Durable Object, so accept either helper there — + // otherwise an already-instrumented Agent would be reported as unwrapped. + const accepted = + kind === 'durableObject' ? [WRAPPER_METHODS.durableObject, WRAPPER_METHODS.agent] : [WRAPPER_METHODS[kind]]; + + if (accepted.some(method => isCallToMethod(declarator.init as BaseNode, method))) { + state.wrappedClasses.add(name); + } + } +} + +function wrapInlineClassExport( + exportNode: ExportNamedNode, + classDecl: ClassDeclarationNode, + ctx: TransformContext, + state: TransformState, +): void { + const classId = classDecl.id; + // Inline export: the exported name and the local class name are the same. + const kind = classId ? resolveWrapperKind(classId.name, classId.name, state) : undefined; + if (!classId || !kind) return; + + const className = classId.name; + const renamedClass = `__SENTRY_ORIGINAL_${className}__`; + + // Strip the `export ` keyword + state.ms.overwrite(exportNode.start, classDecl.start, ''); + + // Rename the class to avoid a duplicate binding + state.ms.overwrite(classId.start, classId.end, renamedClass); + + // Insert the wrapped re-export after the class body + state.ms.appendLeft( + exportNode.end, + `\nexport const ${className} = __SENTRY__.${WRAPPER_METHODS[kind]}(${state.optionsFn}, ${renamedClass});\n`, + ); + + state.wrappedClasses.add(className); + state.autoWrapped.add(className); + state.renamedLocals.add(className); + state.needsImport = true; +} + +function wrapSpecifierExport(specifier: ExportSpecifierNode, ctx: TransformContext, state: TransformState): void { + if (specifier.type !== 'ExportSpecifier' || specifier.exported?.type !== 'Identifier') return; + const exportedName = specifier.exported.name; + if (!exportedName) return; + + const localName = specifier.local?.type === 'Identifier' ? specifier.local.name : undefined; + const kind = resolveWrapperKind(exportedName, localName, state); + if (!kind) return; + + const localClass = localName ? state.topLevelClasses.get(localName) : undefined; + if (!localName || !localClass?.id) return; + + state.wrappedClasses.add(exportedName); + state.autoWrapped.add(exportedName); + state.needsImport = true; + if (state.renamedLocals.has(localName)) return; + state.renamedLocals.add(localName); + + const renamedClass = `__SENTRY_ORIGINAL_${localName}__`; + state.ms.overwrite(localClass.id.start, localClass.id.end, renamedClass); + // The existing `export { ... }` statement keeps exporting the (now + // wrapped) `localName` binding, so the wrapper is NOT exported here. + state.ms.appendLeft( + localClass.end, + `\nconst ${localName} = __SENTRY__.${WRAPPER_METHODS[kind]}(${state.optionsFn}, ${renamedClass});\n`, + ); +} diff --git a/packages/cloudflare/src/vite/workerEntrypoint.ts b/packages/cloudflare/src/vite/workerEntrypoint.ts new file mode 100644 index 000000000000..0baf2720a4fd --- /dev/null +++ b/packages/cloudflare/src/vite/workerEntrypoint.ts @@ -0,0 +1,141 @@ +import type { BaseNode, ProgramBody } from './transform'; + +// --------------------------------------------------------------------------- +// Minimal ESTree node shapes for structural WorkerEntrypoint detection. +// --------------------------------------------------------------------------- + +interface IdentifierNode extends BaseNode { + name: string; +} + +interface MemberExpressionNode extends BaseNode { + object?: { type: string; name?: string }; + property?: { type: string; name?: string }; +} + +interface ClassDeclarationNode extends BaseNode { + id?: IdentifierNode | null; + superClass?: BaseNode | null; +} + +interface ExportNamedDeclNode extends BaseNode { + declaration?: BaseNode | null; +} + +interface ImportSpecifierNode { + type: string; + imported?: { type: string; name?: string }; + local?: { type: string; name?: string }; +} + +interface ImportDeclarationNode extends BaseNode { + source?: { value?: unknown }; + specifiers?: ImportSpecifierNode[]; +} + +interface WorkerEntrypointBases { + /** Local identifiers bound to the named `WorkerEntrypoint` import. */ + named: Set; + /** Local identifiers bound to a `* as ns` import of `cloudflare:workers`. */ + namespaces: Set; +} + +/** + * Find top-level classes that (transitively, within this module) extend + * `WorkerEntrypoint` imported from `cloudflare:workers`. esbuild has already + * stripped TypeScript by transform time, so a superclass is a plain identifier + * (`extends WorkerEntrypoint`) or member access (`extends cf.WorkerEntrypoint`). + * + * Only same-file base chains are resolvable here; a base class imported from + * another module is invisible and relies on the config self-binding fallback. + */ +export function detectWorkerEntrypointClasses(ast: ProgramBody): Set { + const bases = collectWorkerEntrypointImports(ast); + if (bases.named.size === 0 && bases.namespaces.size === 0) { + return new Set(); + } + + // Every top-level class, including the `export class Foo {}` form (where the + // class is nested inside an ExportNamedDeclaration) so directly-exported + // entrypoints are seen too. + const classes = new Map(); + for (const node of ast.body) { + const classNode = asClassDeclaration(node); + if (classNode?.id?.name) classes.set(classNode.id.name, classNode); + } + + const entrypoints = new Set(); + // Iterate to a fixed point so an indirect chain (A extends B extends WE) is + // fully resolved regardless of declaration order. + let changed = true; + while (changed) { + changed = false; + for (const [name, classNode] of classes) { + if (entrypoints.has(name)) continue; + if (extendsWorkerEntrypoint(classNode.superClass, bases, entrypoints)) { + entrypoints.add(name); + changed = true; + } + } + } + return entrypoints; +} + +/** Unwrap `export class Foo {}` to its ClassDeclaration; pass bare classes through. */ +function asClassDeclaration(node: BaseNode): ClassDeclarationNode | undefined { + if (node.type === 'ClassDeclaration') return node as ClassDeclarationNode; + if (node.type === 'ExportNamedDeclaration') { + const decl = (node as ExportNamedDeclNode).declaration; + if (decl?.type === 'ClassDeclaration') return decl as ClassDeclarationNode; + } + return undefined; +} + +function collectWorkerEntrypointImports(ast: ProgramBody): WorkerEntrypointBases { + const named = new Set(); + const namespaces = new Set(); + for (const node of ast.body) { + if (node.type !== 'ImportDeclaration') continue; + const importNode = node as ImportDeclarationNode; + if (importNode.source?.value !== 'cloudflare:workers') continue; + for (const specifier of importNode.specifiers ?? []) { + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported?.name === 'WorkerEntrypoint' && + specifier.local?.name + ) { + named.add(specifier.local.name); + } else if (specifier.type === 'ImportNamespaceSpecifier' && specifier.local?.name) { + namespaces.add(specifier.local.name); + } + } + } + return { named, namespaces }; +} + +/** + * Whether a superclass expression resolves to `WorkerEntrypoint` — either a bare + * identifier from the named import (or an already-detected local subclass), or a + * `ns.WorkerEntrypoint` member access off a namespace import. + */ +function extendsWorkerEntrypoint( + superClass: BaseNode | null | undefined, + bases: WorkerEntrypointBases, + detected: Set, +): boolean { + if (!superClass) return false; + if (superClass.type === 'Identifier') { + const name = (superClass as IdentifierNode).name; + return bases.named.has(name) || detected.has(name); + } + if (superClass.type === 'MemberExpression') { + const member = superClass as MemberExpressionNode; + return ( + member.object?.type === 'Identifier' && + !!member.object.name && + bases.namespaces.has(member.object.name) && + member.property?.name === 'WorkerEntrypoint' + ); + } + return false; +} diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts new file mode 100644 index 000000000000..174000b1af29 --- /dev/null +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -0,0 +1,143 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { type Unstable_Config, unstable_readConfig } from 'wrangler'; +import { DEFAULT_EXPORT, type ExportName, type SameWorkerBinding } from './bindings'; + +export { DEFAULT_EXPORT, type ExportName, type SameWorkerBinding }; + +/** + * The slice of the wrangler configuration the auto-instrument plugin cares + * about. `main` is an absolute path (wrangler resolves it against the config + * file's directory). + */ +export interface WranglerConfig { + main?: string; + durableObjects: Array<{ name: string; className: string }>; + workflows: Array<{ name: string; className: string }>; + /** + * Named `WorkerEntrypoint` exports this worker binds to itself via a service + * binding (`services[]` whose `service` is this worker's own `name`). Only + * self-bindings appear here: a service binding's `entrypoint` otherwise names + * an export on a *different* worker, which this build can't wrap. + */ + workerEntrypoints: string[]; + /** + * Bindings whose RPC receiver lives in this worker, so this build instruments it. Not deduped by + * class, two bindings may point at the same class and both names have to be listed. + */ + sameWorkerBindings: SameWorkerBinding[]; +} + +/** + * Locate and resolve the wrangler configuration via wrangler's own + * `unstable_readConfig` — the API `@cloudflare/vite-plugin` uses. + * + * We only locate the file (probing `wrangler.json`, `.jsonc`, `.toml` inside + * `root` with wrangler's own precedence, since it discovers from `cwd` rather + * than an arbitrary root); wrangler then parses it, flattens the active + * environment (honoring `CLOUDFLARE_ENV`), and resolves `main` to an absolute + * path. Durable Object and Workflow bindings are the active environment's, + * matching what the deployed Worker actually binds. + * + * Returns `undefined` when no config file is found or it can't be read/parsed + * (the caller warns and disables auto-instrumentation rather than failing the + * whole build). + */ +export function resolveWranglerConfig( + root: string, + explicitPath?: string, +): { config: WranglerConfig; configDir: string } | undefined { + const configPath = explicitPath + ? resolve(root, explicitPath) + : ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'].map(name => resolve(root, name)).find(existsSync); + + if (!configPath || !existsSync(configPath)) { + return undefined; + } + + let raw: Unstable_Config; + try { + // `hideWarnings` keeps wrangler's config diagnostics (e.g. missing DO + // migrations) out of the Vite build output. + raw = unstable_readConfig({ config: configPath }, { hideWarnings: true }); + } catch { + return undefined; + } + + return { + config: { + main: raw.main, + durableObjects: collectClassBindings(raw.durable_objects?.bindings), + workflows: collectClassBindings(raw.workflows), + workerEntrypoints: collectSelfBoundEntrypoints(raw), + sameWorkerBindings: collectSameWorkerBindings(raw), + }, + configDir: dirname(raw.configPath ?? configPath), + }; +} + +/** + * Collect named `WorkerEntrypoint` exports the worker binds to itself. A service + * binding's `entrypoint` normally names an export on the *target* worker, so it + * is only ours when `service` equals this worker's own `name`. Without a `name` + * there is nothing to match against, so no entrypoints are derivable. + */ +function collectSelfBoundEntrypoints(raw: Unstable_Config): string[] { + if (!raw.name) { + return []; + } + const entrypoints = new Set(); + for (const binding of raw.services ?? []) { + if (binding?.service === raw.name && typeof binding.entrypoint === 'string') { + entrypoints.add(binding.entrypoint); + } + } + return [...entrypoints]; +} + +/** + * Bindings with a `script_name` or naming another worker target a class this build does not wrap, + * so they are excluded and stay opt-in. + */ +function collectSameWorkerBindings(raw: Unstable_Config): SameWorkerBinding[] { + const bindings: SameWorkerBinding[] = []; + + for (const binding of raw.durable_objects?.bindings ?? []) { + if (typeof binding?.name === 'string' && typeof binding.class_name === 'string' && !binding.script_name) { + bindings.push({ bindingName: binding.name, className: binding.class_name }); + } + } + + for (const binding of raw.services ?? []) { + // A service binding is only ours when `service` names this worker; without a `name` none can be. + if (raw.name && binding?.service === raw.name && typeof binding.binding === 'string') { + bindings.push({ + bindingName: binding.binding, + className: typeof binding.entrypoint === 'string' ? binding.entrypoint : DEFAULT_EXPORT, + }); + } + } + + return bindings; +} + +/** + * Map wrangler class bindings (Durable Objects, Workflows — same shape) to the + * `{ name, className }` the transform needs, skipping duplicates and bindings + * with a `script_name` (those reference a class exported by a *different* + * worker, so there is nothing to wrap in this worker's entry file). + */ +function collectClassBindings( + bindings: ReadonlyArray<{ name: string; class_name?: string; script_name?: string }> | undefined, +): Array<{ name: string; className: string }> { + const result: Array<{ name: string; className: string }> = []; + const seenClassNames = new Set(); + for (const binding of bindings ?? []) { + if (typeof binding?.class_name !== 'string' || binding.script_name || seenClassNames.has(binding.class_name)) { + continue; + } + seenClassNames.add(binding.class_name); + result.push({ name: binding.name, className: binding.class_name }); + } + return result; +} diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index beccbcfa882c..db55e96b872b 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -23,7 +23,7 @@ import type { } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; -import { flushAndDispose } from './flush'; +import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; @@ -214,7 +214,7 @@ export function instrumentWorkflowWithSentry< setAsyncLocalStorageAsyncContextStrategy(); return withIsolationScope(async isolationScope => { - const waitUntil = context.waitUntil.bind(context); + const waitUntil = getOriginalWaitUntil(context).bind(context); const client = init({ ...options, ctx: context, enableDedupe: false }); isolationScope.setClient(client); diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 4723887dbace..290c9947b5e0 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -4,26 +4,24 @@ import { isObjectLike, captureException, continueTrace, - getClient, isThenable, type Scope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startNewTrace as startNewTraceCore, startSpan, - withIsolationScope, - withScope, } from '@sentry/core'; import type { CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { ensureInstrumented } from './instrument'; import { init } from './sdk'; +import { withInvocationIsolationScope } from './utils/invocationScope'; import { extractRpcMeta } from './utils/rpcMeta'; import { buildSpanLinks, getStoredSpanContext, storeSpanContext } from './utils/traceLinks'; /** Extended DurableObjectState with originalStorage exposed by instrumentContext */ -interface InstrumentedDurableObjectState extends DurableObjectState { +export interface InstrumentedDurableObjectState extends DurableObjectState { originalStorage?: DurableObjectStorage; } @@ -112,11 +110,6 @@ export function wrapMethodWithSentry( rpcMeta = extracted.rpcMeta; } - // For startNewTrace, always use withIsolationScope to ensure a fresh scope - // Otherwise, use existing client's scope or isolation scope - const currentClient = getClient(); - const sentryWithScope = startNewTrace ? withIsolationScope : currentClient ? withScope : withIsolationScope; - const wrappedFunction = (scope: Scope): unknown | Promise => { // In certain situations, the passed context can become undefined. // For example, for Astro while prerendering pages at build time. @@ -241,7 +234,7 @@ export function wrapMethodWithSentry( return executeSpan(); }; - return sentryWithScope(wrappedFunction); + return withInvocationIsolationScope(wrappedFunction); }, }), noMark, diff --git a/packages/cloudflare/test/agents.test.ts b/packages/cloudflare/test/agents.test.ts new file mode 100644 index 000000000000..93c6abda81f0 --- /dev/null +++ b/packages/cloudflare/test/agents.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { instrumentAgentWithSentry } from '../src'; +import { getInstrumented } from '../src/instrument'; + +describe('instrumentAgentWithSentry', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('instruments the built-in Durable Object handlers (Agent extends DurableObject)', () => { + const testClass = class { + fetch() {} + alarm() {} + webSocketMessage() {} + webSocketClose() {} + webSocketError() {} + }; + + const instrumented = instrumentAgentWithSentry(vi.fn().mockReturnValue({}), testClass as any); + const obj = Reflect.construct(instrumented, []); + + for (const methodName of ['fetch', 'alarm', 'webSocketMessage', 'webSocketClose', 'webSocketError']) { + expect(getInstrumented((obj as any)[methodName]), `Method ${methodName} is instrumented`).toBeTruthy(); + } + }); + + it('wraps the Agent-specific handlers as own-properties on the constructed instance', () => { + const testClass = class { + fetch() {} + onMessage() {} + }; + const proto = testClass.prototype as any; + + const instrumented = instrumentAgentWithSentry(vi.fn().mockReturnValue({}), testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + // `instrumentCloudflareAgent` replaces each handler with a wrapper stored as an own-property, + // so the instance's copy is a distinct function from the untouched prototype method. + for (const methodName of ['onMessage']) { + expect(Object.prototype.hasOwnProperty.call(obj, methodName), `${methodName} is an own-property`).toBe(true); + expect(obj[methodName], `${methodName} differs from the prototype original`).not.toBe(proto[methodName]); + } + }); + + it('keeps RPC methods on the prototype callable while wrapping Agent handlers as own-properties', () => { + const testClass = class { + fetch() {} + onMessage() {} + onChatMessage() {} + rpcMethod() { + return 'rpc'; + } + }; + + const instrumented = instrumentAgentWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // Agent-specific handlers become own properties, so they are excluded from RPC method tracing. + expect(Object.prototype.hasOwnProperty.call(obj, 'onMessage')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(obj, 'onChatMessage')).toBe(true); + + // RPC methods remain on the prototype and still work through the proxy. + expect(Object.prototype.hasOwnProperty.call(obj, 'rpcMethod')).toBe(false); + expect((obj as any).rpcMethod()).toBe('rpc'); + }); +}); diff --git a/packages/cloudflare/test/defineCloudflareOptions.test.ts b/packages/cloudflare/test/defineCloudflareOptions.test.ts new file mode 100644 index 000000000000..08a6a8233024 --- /dev/null +++ b/packages/cloudflare/test/defineCloudflareOptions.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { defineCloudflareOptions } from '../src/defineCloudflareOptions'; + +describe('defineCloudflareOptions', () => { + it('returns the callback unchanged', () => { + const callback = (env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN }); + expect(defineCloudflareOptions(callback)).toBe(callback); + }); + + it('passes env through to the callback', () => { + const callback = defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + })); + + expect(callback({ SENTRY_DSN: 'https://example' })).toEqual({ + dsn: 'https://example', + tracesSampleRate: 1.0, + }); + }); + + it('normalizes a static options object into a callback', () => { + const callback = defineCloudflareOptions({ tracesSampleRate: 0.5 }); + + expect(typeof callback).toBe('function'); + expect(callback({} as never)).toEqual({ tracesSampleRate: 0.5 }); + }); +}); diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index ec0c9e8ec708..111d12fa1054 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -1,12 +1,15 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'; -import { instrumentDurableObjectWithSentry } from '../src'; +import { instrumentAgentWithSentry, instrumentDurableObjectWithSentry } from '../src'; import { getInstrumented } from '../src/instrument'; +import { resetSdk } from './testUtils'; describe('instrumentDurableObjectWithSentry', () => { afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); it('Generic functionality', () => { @@ -176,7 +179,7 @@ describe('instrumentDurableObjectWithSentry', () => { expect(startSpanSpy).toHaveBeenCalled(); }); - it('Binds prototype methods to original object when enableRpcTracePropagation is true', () => { + it('Invokes prototype methods with the instance as receiver when enableRpcTracePropagation is true', () => { const testClass = class { method() { return this; @@ -188,15 +191,76 @@ describe('instrumentDurableObjectWithSentry', () => { ); const obj = Reflect.construct(instrumented, []); - // Method should be callable and return the original object (not the proxy) + // The instance is not proxied, so the receiver is the instance itself — this is what keeps + // native private fields working (#23040) const result = obj.method(); - expect(result).not.toBe(obj); // result is original object, obj is proxy - expect(typeof result.method).toBe('function'); // original object still has method + expect(result).toBe(obj); + expect(typeof result.method).toBe('function'); // Methods should be cached (same reference on repeated access) expect(obj.method).toBe(obj.method); }); + // Hibernation-woken WebSocket messages and alarms arrive as their own invocations with no + // enclosing instrumented handler, so each must open a fresh isolation scope. The Durable Object + // instance outlives them, so a leak here would follow the isolate for its remaining lifetime. + it('Runtime-invoked built-in handlers each get their own isolation scope', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const mockContext = { + waitUntil: vi.fn((promise: Promise) => { + waits.push(promise); + }), + } as any; + + const testClass = class { + webSocketMessage(_ws: unknown, message: string) { + if (message === 'seed') { + SentryCore.setTag('seeded_tag', 'from-seeding-message'); + SentryCore.setUser({ id: 'user-from-seeding-message' }); + } + + SentryCore.captureMessage(message); + } + + alarm() { + SentryCore.captureMessage('alarm'); + } + }; + const obj = Reflect.construct( + instrumentDurableObjectWithSentry( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event: Event) { + events.push(event); + return null; + }, + }), + testClass as any, + ), + [mockContext, {} as any], + ); + + await obj.webSocketMessage({}, 'seed'); + await Promise.all(waits.splice(0)); + await obj.webSocketMessage({}, 'probe'); + await Promise.all(waits.splice(0)); + await obj.alarm(); + await Promise.all(waits); + + // Guards the assertions below against passing vacuously. + expect(events[0]?.tags).toEqual(expect.objectContaining({ seeded_tag: 'from-seeding-message' })); + expect(events[0]?.user).toEqual({ id: 'user-from-seeding-message' }); + + expect(events[1]?.message).toBe('probe'); + expect(events[1]?.tags?.seeded_tag).toBeUndefined(); + expect(events[1]?.user).toBeUndefined(); + + expect(events[2]?.message).toBe('alarm'); + expect(events[2]?.tags?.seeded_tag).toBeUndefined(); + expect(events[2]?.user).toBeUndefined(); + }); + it('Built-in durable object methods are always instrumented', () => { const testClass = class { fetch() {} @@ -248,7 +312,7 @@ describe('instrumentDurableObjectWithSentry', () => { expect(obj.rpcMethod()).toBe('rpc'); }); - it('preserves constructor identity on the proxy', () => { + it('preserves constructor identity', () => { const testClass = class MyDO { rpcMethod() { return 'result'; @@ -307,6 +371,120 @@ describe('instrumentDurableObjectWithSentry', () => { expect(obj.rpcMethod()).toBe('result'); }); + it('skips non-configurable prototype methods instead of failing construction', () => { + const testClass = class { + sealedMethod() { + return 'sealed-result'; + } + + rpcMethod() { + return 'rpc-result'; + } + }; + Object.defineProperty(testClass.prototype, 'sealedMethod', { + value: testClass.prototype.sealedMethod, + writable: false, + enumerable: false, + configurable: false, + }); + const originalSealedMethod = testClass.prototype.sealedMethod; + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + + let obj: any; + expect(() => { + obj = Reflect.construct(instrumented, []); + }).not.toThrow(); + + // The non-configurable method keeps its original (unwrapped) implementation + expect(testClass.prototype.sealedMethod).toBe(originalSealedMethod); + expect(obj.sealedMethod()).toBe('sealed-result'); + + // Other methods on the same prototype are still wrapped + expect(getInstrumented(obj.rpcMethod)).toBeTruthy(); + expect(obj.rpcMethod()).toBe('rpc-result'); + }); + + it('instruments built-in handlers installed as read-only own properties', () => { + // Shape installed by `agents` >= 0.22: `defineProperty` without `writable`, so the handlers + // are read-only and a plain assignment would throw in strict mode. + const testClass = class { + constructor() { + for (const name of ['fetch', 'alarm', 'webSocketMessage', 'webSocketClose', 'webSocketError']) { + Object.defineProperty(this, name, { + value: () => name, + configurable: true, + }); + } + } + }; + + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); + + let obj: any; + expect(() => { + obj = Reflect.construct(instrumented, [{ waitUntil: vi.fn() }, {}]); + }).not.toThrow(); + + for (const name of ['fetch', 'alarm', 'webSocketMessage', 'webSocketClose', 'webSocketError']) { + expect(getInstrumented(obj[name]), `Handler ${name} is instrumented`).toBeTruthy(); + } + + expect(obj.webSocketMessage()).toBe('webSocketMessage'); + }); + + it('leaves sealed own-property handlers untouched instead of failing construction', () => { + const originalHandler = (): string => 'sealed-result'; + const testClass = class { + constructor() { + Object.defineProperty(this, 'webSocketMessage', { + value: originalHandler, + writable: false, + configurable: false, + }); + } + }; + + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); + + let obj: any; + expect(() => { + obj = Reflect.construct(instrumented, [{ waitUntil: vi.fn() }, {}]); + }).not.toThrow(); + + expect(obj.webSocketMessage).toBe(originalHandler); + expect(obj.webSocketMessage()).toBe('sealed-result'); + }); + + it('does not wrap Object.prototype methods as RPC methods', () => { + const testClass = class { + rpcMethod() { + return 'rpc-result'; + } + }; + // Capture the original before construction wraps the prototype + const originalRpcMethod = testClass.prototype.rpcMethod; + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // Object.prototype methods should NOT be wrapped with Sentry tracing. + expect(obj.toString()).toBe('[object Object]'); + expect(obj.hasOwnProperty('rpcMethod')).toBe(false); // It's on prototype, not own + // The instance is not proxied, so valueOf returns the instance itself + expect(obj.valueOf()).toBe(obj); + + // Meanwhile, actual RPC methods SHOULD be wrapped on the prototype + expect(obj.rpcMethod).not.toBe(originalRpcMethod); + expect(obj.rpcMethod()).toBe('rpc-result'); + }); + describe('instrumentPrototypeMethods option', () => { it('instruments all RPC methods when option is true', () => { const testClass = class { @@ -317,17 +495,18 @@ describe('instrumentDurableObjectWithSentry', () => { return 'two'; } }; + // Capture the originals before construction wraps the prototype + const originalMethodOne = testClass.prototype.rpcMethodOne; + const originalMethodTwo = testClass.prototype.rpcMethodTwo; + const instrumented = instrumentDurableObjectWithSentry( vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }), testClass as any, ); const obj = Reflect.construct(instrumented, []); - // RPC methods (prototype methods) are wrapped via Proxy - verify they are callable and cached - expect(typeof obj.rpcMethodOne).toBe('function'); - expect(typeof obj.rpcMethodTwo).toBe('function'); - expect(obj.rpcMethodOne).toBe(obj.rpcMethodOne); // Cached wrapper - expect(obj.rpcMethodTwo).toBe(obj.rpcMethodTwo); // Cached wrapper + expect(obj.rpcMethodOne).not.toBe(originalMethodOne); + expect(obj.rpcMethodTwo).not.toBe(originalMethodTwo); expect(obj.rpcMethodOne()).toBe('one'); expect(obj.rpcMethodTwo()).toBe('two'); }); @@ -344,19 +523,22 @@ describe('instrumentDurableObjectWithSentry', () => { return 'three'; } }; + const originalMethodOne = testClass.prototype.methodOne; + const originalMethodTwo = testClass.prototype.methodTwo; + const originalMethodThree = testClass.prototype.methodThree; + const instrumented = instrumentDurableObjectWithSentry( vi.fn().mockReturnValue({ instrumentPrototypeMethods: ['methodOne', 'methodThree'] }), testClass as any, ); const obj = Reflect.construct(instrumented, []); - // methodOne and methodThree should be wrapped — i.e. they should NOT be - // identical to the underlying prototype method. - expect(obj.methodOne).not.toBe(testClass.prototype.methodOne); - expect(obj.methodThree).not.toBe(testClass.prototype.methodThree); + expect(obj.methodOne).not.toBe(originalMethodOne); + expect(obj.methodThree).not.toBe(originalMethodThree); + + // methodTwo is not in the allow-list — it keeps the original prototype method. + expect(obj.methodTwo).toBe(originalMethodTwo); - // methodTwo is not in the allow-list — it's bound but not wrapped with Sentry tracing. - // All methods should still be callable and behave correctly. expect(obj.methodOne()).toBe('one'); expect(obj.methodTwo()).toBe('two'); expect(obj.methodThree()).toBe('three'); @@ -371,6 +553,9 @@ describe('instrumentDurableObjectWithSentry', () => { return 'two'; } }; + const originalMethodOne = testClass.prototype.methodOne; + const originalMethodTwo = testClass.prototype.methodTwo; + const instrumented = instrumentDurableObjectWithSentry( vi.fn().mockReturnValue({ instrumentPrototypeMethods: [] }), testClass as any, @@ -378,8 +563,8 @@ describe('instrumentDurableObjectWithSentry', () => { const obj = Reflect.construct(instrumented, []); // Empty array means no methods are allowed → none should be wrapped. - expect(obj.methodOne).toBe(testClass.prototype.methodOne); - expect(obj.methodTwo).toBe(testClass.prototype.methodTwo); + expect(obj.methodOne).toBe(originalMethodOne); + expect(obj.methodTwo).toBe(originalMethodTwo); expect(obj.methodOne()).toBe('one'); expect(obj.methodTwo()).toBe('two'); }); @@ -400,29 +585,208 @@ describe('instrumentDurableObjectWithSentry', () => { expect(getInstrumented(obj.rpcMethod)).toBeFalsy(); expect(obj.rpcMethod()).toBe('result'); }); + }); + + // Frameworks that dispatch methods themselves (the `agents` `@callable()` registry, for example) + // install their own function during construction and resolve the dispatch through that exact + // function instance. Replacing it makes the framework no longer recognize the method, so those + // methods must keep the function the framework installed. + describe('framework-managed methods', () => { + it('does not wrap methods a framework replaced during construction, but wraps the rest', () => { + const frameworkDispatch = new WeakSet(); + + class FrameworkLike { + constructor() { + const original = FrameworkLike.prototype.greet; + + if (!frameworkDispatch.has(original)) { + const dispatched = function (this: FrameworkLike, name: string): string { + return original.call(this, name); + }; + frameworkDispatch.add(dispatched); + FrameworkLike.prototype.greet = dispatched; + } + } + + greet(name: string): string { + return `Hello, ${name}!`; + } + + fetchData(): string { + return 'data'; + } + } + + const originalFetchData = FrameworkLike.prototype.fetchData; + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + FrameworkLike as any, + ); + const obj = Reflect.construct(instrumented, []) as FrameworkLike; + + // Left as the framework installed it, so its identity-keyed dispatch keeps resolving + expect(frameworkDispatch.has(FrameworkLike.prototype.greet)).toBe(true); + expect(obj.greet('World')).toBe('Hello, World!'); + + // Every other RPC method is still wrapped on the prototype + expect(FrameworkLike.prototype.fetchData).not.toBe(originalFetchData); + expect(obj.fetchData()).toBe('data'); + }); - it('does not wrap Object.prototype methods as RPC methods', () => { - const testClass = class { - rpcMethod() { - return 'rpc-result'; + it('keeps excluding a framework-managed method for instances constructed later', () => { + const frameworkDispatch = new WeakSet(); + + class FrameworkLike { + constructor() { + const original = FrameworkLike.prototype.greet; + + // Frameworks typically install their dispatch once, for the first instance + if (!frameworkDispatch.has(original)) { + const dispatched = function (this: FrameworkLike, name: string): string { + return original.call(this, name); + }; + frameworkDispatch.add(dispatched); + FrameworkLike.prototype.greet = dispatched; + } } - }; + + greet(name: string): string { + return `Hello, ${name}!`; + } + } + const instrumented = instrumentDurableObjectWithSentry( vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - testClass as any, + FrameworkLike as any, ); - const obj = Reflect.construct(instrumented, []); - // Object.prototype methods should NOT be wrapped with Sentry tracing. - // They are bound to the original object but still work correctly. - expect(obj.toString()).toBe('[object Object]'); - expect(obj.hasOwnProperty('rpcMethod')).toBe(false); // It's on prototype, not own - // valueOf returns the original object, not the proxy - expect(obj.valueOf()).not.toBe(obj); + Reflect.construct(instrumented, []); + const second = Reflect.construct(instrumented, []) as FrameworkLike; + + expect(frameworkDispatch.has(FrameworkLike.prototype.greet)).toBe(true); + expect(second.greet('World')).toBe('Hello, World!'); + }); + }); + + // The wrapper replaces a method on a class the user owns, so it has to keep the parts of the + // function that are observable from the outside. + it('preserves the name and arity of the methods it wraps', () => { + const testClass = class { + rpcMethod(_a: string, _b: number): string { + return 'rpc-result'; + } + }; + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + Reflect.construct(instrumented, []); + + expect(testClass.prototype.rpcMethod.name).toBe('rpcMethod'); + expect(testClass.prototype.rpcMethod.length).toBe(2); + }); + + // The runtime rejects these before any property lookup (`isReservedName` in workerd's + // `worker-rpc.c++`), so wrapping them would mutate the user's class for no tracing. + it('leaves methods the runtime never dispatches over RPC untouched', () => { + const testClass = class { + connect(): string { + return 'connect'; + } + dup(): string { + return 'dup'; + } + webSocketClose(): string { + return 'closed'; + } + rpcMethod(): string { + return 'rpc-result'; + } + }; + + const originals = { + connect: testClass.prototype.connect, + dup: testClass.prototype.dup, + webSocketClose: testClass.prototype.webSocketClose, + rpcMethod: testClass.prototype.rpcMethod, + }; + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + Reflect.construct(instrumented, []); + + expect(testClass.prototype.connect).toBe(originals.connect); + expect(testClass.prototype.dup).toBe(originals.dup); + expect(testClass.prototype.webSocketClose).toBe(originals.webSocketClose); + + // A regular RPC method is still wrapped + expect(testClass.prototype.rpcMethod).not.toBe(originals.rpcMethod); + }); + + // Regression for #23040 — workerd's native RPC dispatch (Durable Object facets, the Agents + // SDK bootstrap calling `setName()` via `getAgentByName`/`subAgent`) resolves the method on + // the prototype and invokes it with the stored Durable Object instance as the receiver. When + // the instrumented constructor returned a Proxy of the instance, native private field access + // failed because a Proxy never carries the target's private brand. + describe('native private fields', () => { + it('invokes prototype RPC methods with the instance as receiver so native private fields work', () => { + class PartyServerLike { + #name?: string; + + setName(name: string): void { + this.#name = name; + } + + getName(): string | undefined { + return this.#name; + } + } + + const instrumented = instrumentAgentWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + PartyServerLike as any, + ); + const obj = Reflect.construct(instrumented, []) as PartyServerLike; + + // This is how native RPC invokes the method: resolved on the prototype, called with the + // instance as `this` — not fetched through a property access on the instance. + const prototypeSetName = Object.getPrototypeOf(obj).setName as PartyServerLike['setName']; + expect(() => Reflect.apply(prototypeSetName, obj, ['agent-1'])).not.toThrow(); + expect(obj.getName()).toBe('agent-1'); + }); + + it('preserves the instance receiver on the traced RPC path so native private fields work', () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan').mockImplementation((_, callback) => callback({} as any)); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + + class WithSecret { + #secret = 42; + + getSecret(): number { + return this.#secret; + } + } + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + WithSecret as any, + ); + const obj = Reflect.construct(instrumented, []) as WithSecret; + + const rpcMeta = { + __sentry_rpc_meta__: { + 'sentry-trace': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-1', + baggage: '', + }, + }; - // Meanwhile, actual RPC methods SHOULD be wrapped (not equal to prototype method) - expect(obj.rpcMethod).not.toBe(testClass.prototype.rpcMethod); - expect(obj.rpcMethod()).toBe('rpc-result'); + const prototypeGetSecret = Object.getPrototypeOf(obj).getSecret as WithSecret['getSecret']; + expect(Reflect.apply(prototypeGetSecret, obj, [rpcMeta])).toBe(42); + expect(startSpanSpy).toHaveBeenCalled(); }); }); diff --git a/packages/cloudflare/test/flush.test.ts b/packages/cloudflare/test/flush.test.ts index 49ce15dc5153..bcef56a8c101 100644 --- a/packages/cloudflare/test/flush.test.ts +++ b/packages/cloudflare/test/flush.test.ts @@ -165,7 +165,7 @@ describe('getOriginalWaitUntil', () => { expect(result).not.toBe(context.waitUntil); expect(result).toBeDefined(); - result!(Promise.resolve()); + result(Promise.resolve()); expect(originalWaitUntil).toHaveBeenCalled(); }); @@ -183,7 +183,7 @@ describe('getOriginalWaitUntil', () => { const result = getOriginalWaitUntil(context); expect(result).not.toBe(context.waitUntil); - result!(Promise.resolve()); + result(Promise.resolve()); expect(originalWaitUntil).toHaveBeenCalled(); }); @@ -207,7 +207,7 @@ describe('getOriginalWaitUntil', () => { } as unknown as Client; const originalWaitUntil = getOriginalWaitUntil(context); - originalWaitUntil!.call(context, flushAndDispose(mockClient)); + originalWaitUntil.call(context, flushAndDispose(mockClient)); await vi.waitFor(() => Promise.all(waitUntilPromises)); expect(mockClient.flush).toHaveBeenCalled(); diff --git a/packages/cloudflare/test/instrumentChatAgentConversation.test.ts b/packages/cloudflare/test/instrumentChatAgentConversation.test.ts new file mode 100644 index 000000000000..a5d446ea610c --- /dev/null +++ b/packages/cloudflare/test/instrumentChatAgentConversation.test.ts @@ -0,0 +1,486 @@ +import type { DurableObjectStorage } from '@cloudflare/workers-types'; +import { getIsolationScope } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { instrumentChatAgentConversation } from '../src/instrumentations/agents/instrumentChatAgentConversation'; +import { + AGENT_CONVERSATION_ID_STORAGE_KEY, + type AgentInternals, + setAgentConversationId, +} from '../src/instrumentations/agents/types'; + +/** `uuid4()` from `@sentry/core` returns 32 hex characters, without dashes. */ +const UUID_PATTERN = /^[0-9a-f]{32}$/; + +function mockStorage(stored?: string): { + get: ReturnType; + put: ReturnType; + /** The currently persisted value, for asserting against an id the SDK minted itself. */ + peek: () => string | undefined; +} { + let value = stored; + return { + get: vi.fn(async () => value), + put: vi.fn(async (_key: string, newValue: string) => { + value = newValue; + }), + peek: () => value, + }; +} + +function mockCtx(stored?: string): { + ctx: NonNullable; + storage: ReturnType; +} { + const storage = mockStorage(stored); + const ctx = { + originalStorage: storage as unknown as DurableObjectStorage, + } as unknown as NonNullable; + + return { ctx, storage }; +} + +/** + * The conversation id is written to the isolation scope — the scope the public + * `Sentry.setConversationId()` targets — so that an explicit user call can override it. + */ +function spyOnSetConversationId(): ReturnType { + const setConversationId = vi.fn(); + vi.spyOn(getIsolationScope(), 'setConversationId').mockImplementation(setConversationId); + return setConversationId; +} + +describe('instrumentChatAgentConversation', () => { + afterEach(() => { + vi.restoreAllMocks(); + getIsolationScope().clear(); + }); + + it('sets a generated conversation id during a chat turn', async () => { + const setConversationId = spyOnSetConversationId(); + + const obj: AgentInternals = { + name: 'conversation-42', + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await obj.onChatMessage!(() => {}, {}); + + expect(setConversationId).toHaveBeenCalledTimes(1); + expect(setConversationId).toHaveBeenCalledWith(expect.stringMatching(UUID_PATTERN)); + }); + + it('keeps the same conversation id across chat turns, reading storage only once', async () => { + const setConversationId = spyOnSetConversationId(); + + const { ctx, storage } = mockCtx('persisted-id'); + const obj: AgentInternals = { + ctx, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await obj.onChatMessage!(() => {}, {}); + await obj.onChatMessage!(() => {}, {}); + + expect(setConversationId).toHaveBeenCalledTimes(2); + expect(setConversationId).toHaveBeenNthCalledWith(2, 'persisted-id'); + expect(storage.get).toHaveBeenCalledTimes(1); + }); + + it('mints a single conversation id when two units of work start concurrently', async () => { + const setConversationId = spyOnSetConversationId(); + + const { ctx, storage } = mockCtx(undefined); + const obj: AgentInternals = { + ctx, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await Promise.all([obj.onChatMessage!(() => {}, {}), obj.onChatMessage!(() => {}, {})]); + + // The second turn must adopt the id the first one minted instead of persisting a competing one. + expect(storage.put).toHaveBeenCalledTimes(1); + expect(setConversationId).toHaveBeenCalledTimes(2); + expect(new Set(setConversationId.mock.calls.flat()).size).toBe(1); + }); + + it('leaves a conversation id the user set before the turn alone', async () => { + const { ctx, storage } = mockCtx('persisted-id'); + const obj: AgentInternals = { + ctx, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + getIsolationScope().setConversationId('user-chosen-id'); + await obj.onChatMessage!(() => {}, {}); + + expect(getIsolationScope().getScopeData().conversationId).toBe('user-chosen-id'); + // An explicit id means the instance's own id is never needed, so storage is not touched either. + expect(storage.get).not.toHaveBeenCalled(); + }); + + it('replaces its own id from an earlier turn, so a rotation still takes effect', async () => { + const { ctx } = mockCtx('persisted-id'); + const obj: AgentInternals = { + ctx, + _emit() { + return undefined; + }, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + // The isolation scope outlives a unit of work, so the second turn finds the first turn's id + // already there — ours, and therefore replaceable, unlike a user's. + await obj.onChatMessage!(() => {}, {}); + expect(getIsolationScope().getScopeData().conversationId).toBe('persisted-id'); + + obj._emit!('message:clear'); + await obj.onChatMessage!(() => {}, {}); + + expect(getIsolationScope().getScopeData().conversationId).toMatch(UUID_PATTERN); + }); + + it('forwards the return value from the original onChatMessage', async () => { + const obj: AgentInternals = { + onChatMessage() { + return { output: 'hello' }; + }, + }; + + instrumentChatAgentConversation(obj); + + await expect(obj.onChatMessage!(() => {}, {})).resolves.toEqual({ output: 'hello' }); + }); + + it('leaves the agent untouched when onChatMessage is not defined', () => { + const obj: AgentInternals = { name: 'agent-1' }; + + instrumentChatAgentConversation(obj); + + expect('onChatMessage' in obj).toBe(false); + }); + + it('rotates the conversation id on the message:clear observability event', async () => { + const setConversationId = spyOnSetConversationId(); + + const { ctx } = mockCtx('persisted-id'); + const obj: AgentInternals = { + ctx, + _emit() { + return undefined; + }, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await obj.onChatMessage!(() => {}, {}); + + // Simulate the SDK emitting the chat-clear observability event. + obj._emit!('message:clear'); + + await obj.onChatMessage!(() => {}, {}); + + expect(setConversationId).toHaveBeenNthCalledWith(1, 'persisted-id'); + expect(setConversationId).toHaveBeenNthCalledWith(2, expect.stringMatching(UUID_PATTERN)); + }); + + it('forwards message:clear to the original _emit', () => { + const received: Array<{ type: string; payload: unknown }> = []; + const obj: AgentInternals = { + _emit(type: string, payload?: Record) { + received.push({ type, payload }); + return undefined; + }, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + obj._emit!('message:clear', { source: 'user' }); + expect(received).toEqual([{ type: 'message:clear', payload: { source: 'user' } }]); + }); + + it('does not rotate the conversation id for other observability events', async () => { + const setConversationId = spyOnSetConversationId(); + + const { ctx, storage } = mockCtx('persisted-id'); + const obj: AgentInternals = { + ctx, + _emit() { + return undefined; + }, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + obj._emit!('message:request'); + obj._emit!('rpc', { method: 'greet' }); + + await obj.onChatMessage!(() => {}, {}); + + expect(setConversationId).toHaveBeenCalledWith('persisted-id'); + expect(storage.put).not.toHaveBeenCalled(); + }); + + it('persists the rotated conversation id to DO storage on message:clear', () => { + const { ctx, storage } = mockCtx('persisted-id'); + const obj: AgentInternals = { + ctx, + _emit() { + return undefined; + }, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + obj._emit!('message:clear'); + + expect(storage.put).toHaveBeenCalledTimes(1); + expect(storage.put).toHaveBeenCalledWith(AGENT_CONVERSATION_ID_STORAGE_KEY, expect.stringMatching(UUID_PATTERN)); + expect(storage.peek()).not.toBe('persisted-id'); + }); + + it('reads a persisted conversation id from DO storage (survives hibernation)', async () => { + const setConversationId = spyOnSetConversationId(); + + const { ctx, storage } = mockCtx('persisted-id'); + const obj: AgentInternals = { + ctx, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await obj.onChatMessage!(() => {}, {}); + + expect(setConversationId).toHaveBeenCalledWith('persisted-id'); + expect(storage.put).not.toHaveBeenCalled(); + }); + + it('resolves the id for an instance that was never routed through a handler wrapper', async () => { + const setConversationId = spyOnSetConversationId(); + + const { ctx } = mockCtx('late-id'); + const obj: AgentInternals = { ctx }; + + await setAgentConversationId(obj); + + expect(setConversationId).toHaveBeenCalledWith('late-id'); + }); + + it('generates and persists a conversation id when storage has none', async () => { + const setConversationId = spyOnSetConversationId(); + + const { ctx, storage } = mockCtx(undefined); + const obj: AgentInternals = { + name: 'session-7', + ctx, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await obj.onChatMessage!(() => {}, {}); + + expect(storage.put).toHaveBeenCalledTimes(1); + expect(storage.put).toHaveBeenCalledWith(AGENT_CONVERSATION_ID_STORAGE_KEY, expect.stringMatching(UUID_PATTERN)); + expect(setConversationId).toHaveBeenCalledWith(storage.peek()); + }); + + it('generates a conversation id when the instance has no DO storage at all', async () => { + const setConversationId = spyOnSetConversationId(); + + const obj: AgentInternals = { + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await obj.onChatMessage!(() => {}, {}); + await obj.onChatMessage!(() => {}, {}); + + expect(setConversationId).toHaveBeenCalledTimes(2); + expect(setConversationId).toHaveBeenCalledWith(expect.stringMatching(UUID_PATTERN)); + expect(new Set(setConversationId.mock.calls.flat()).size).toBe(1); + }); + + it('prefers the uninstrumented originalStorage over the instrumented storage', async () => { + const original = mockStorage(); + const instrumented = mockStorage(); + const ctx = { + originalStorage: original as unknown as DurableObjectStorage, + storage: instrumented as unknown as DurableObjectStorage, + } as unknown as NonNullable; + + const obj: AgentInternals = { + ctx, + _emit() { + return undefined; + }, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await obj.onChatMessage!(() => {}, {}); + obj._emit!('message:clear'); + + expect(original.get).toHaveBeenCalled(); + expect(original.put).toHaveBeenCalled(); + expect(instrumented.get).not.toHaveBeenCalled(); + expect(instrumented.put).not.toHaveBeenCalled(); + }); + + it('falls back to the instrumented storage when originalStorage is not exposed', () => { + const storage = mockStorage(); + const ctx = { + storage: storage as unknown as DurableObjectStorage, + } as unknown as NonNullable; + + const obj: AgentInternals = { + ctx, + _emit() { + return undefined; + }, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + obj._emit!('message:clear'); + + expect(storage.put).toHaveBeenCalledTimes(1); + expect(storage.put).toHaveBeenCalledWith(AGENT_CONVERSATION_ID_STORAGE_KEY, expect.stringMatching(UUID_PATTERN)); + }); + + it('swallows storage read errors and keeps an in-memory id without overwriting storage', async () => { + const setConversationId = spyOnSetConversationId(); + + const storage = { + get: vi.fn(async () => { + throw new Error('storage unavailable'); + }), + put: vi.fn(async () => undefined), + }; + const ctx = { + originalStorage: storage as unknown as DurableObjectStorage, + } as unknown as NonNullable; + + const obj: AgentInternals = { + name: 'session-7', + ctx, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await expect(obj.onChatMessage!(() => {}, {})).resolves.toBe('response'); + + expect(setConversationId).toHaveBeenCalledWith(expect.stringMatching(UUID_PATTERN)); + // A read that failed may still have an id behind it — don't destroy it. + expect(storage.put).not.toHaveBeenCalled(); + }); + + it('swallows a storage write that throws synchronously', async () => { + const setConversationId = spyOnSetConversationId(); + + const storage = { + get: vi.fn(async () => undefined), + put: vi.fn(() => { + throw new Error('storage unavailable'); + }), + }; + const ctx = { + originalStorage: storage as unknown as DurableObjectStorage, + } as unknown as NonNullable; + + const obj: AgentInternals = { + ctx, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + await expect(obj.onChatMessage!(() => {}, {})).resolves.toBe('response'); + + expect(storage.put).toHaveBeenCalledTimes(1); + expect(setConversationId).toHaveBeenCalledWith(expect.stringMatching(UUID_PATTERN)); + }); + + it('swallows storage write errors and keeps the rotation for the current wake', async () => { + const setConversationId = spyOnSetConversationId(); + + const storage = { + get: vi.fn(async () => 'persisted-id'), + put: vi.fn(async () => { + throw new Error('storage unavailable'); + }), + }; + const ctx = { + originalStorage: storage as unknown as DurableObjectStorage, + } as unknown as NonNullable; + + const obj: AgentInternals = { + ctx, + _emit() { + return undefined; + }, + onChatMessage() { + return 'response'; + }, + }; + + instrumentChatAgentConversation(obj); + + expect(() => obj._emit!('message:clear')).not.toThrow(); + + await obj.onChatMessage!(() => {}, {}); + + expect(setConversationId).toHaveBeenCalledWith(expect.stringMatching(UUID_PATTERN)); + expect(setConversationId).not.toHaveBeenCalledWith('persisted-id'); + }); +}); diff --git a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts new file mode 100644 index 000000000000..a77050169671 --- /dev/null +++ b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts @@ -0,0 +1,323 @@ +import type { Event, SpanJSON } from '@sentry/core'; +import { + conversationIdIntegration, + getCurrentScope, + getIsolationScope, + setConversationId, + setCurrentClient, + startSpan, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { CloudflareClient, type CloudflareClientOptions } from '../src/client'; +import { instrumentCloudflareAgent } from '../src/instrumentations/agents'; +import { resetSdk } from './testUtils'; + +const dsn = 'https://123@sentry.io/42'; + +/** Resolves the conversation id the way `conversationIdIntegration` does at `spanStart`. */ +function effectiveConversationId(): string | undefined { + return getCurrentScope().getScopeData().conversationId || getIsolationScope().getScopeData().conversationId; +} + +/** Minimal stand-in for an `agents` Agent instance exposing the internals we hook. */ +function createFakeAgent(overrides: Record = {}): Record { + return { + _ParentClass: { name: 'MyAgent' }, + name: 'instance-1', + messages: [] as unknown[], + onMessage(this: any, _connection: unknown, message: unknown) { + this.messages.push(message); + return 'handled'; + }, + ...overrides, + }; +} + +describe('instrumentCloudflareAgent', () => { + let transactions: Event[]; + let childSpans: SpanJSON[]; + let client: CloudflareClient; + + beforeEach(() => { + resetSdk(); + setAsyncLocalStorageAsyncContextStrategy(); + + transactions = []; + childSpans = []; + + const options: CloudflareClientOptions = { + dsn, + tracesSampleRate: 1, + traceLifecycle: 'static', + stackParser: () => [], + // The integration that turns the scope's conversation id into `gen_ai.conversation.id`, so the + // tests below assert the attribute the way it actually reaches Sentry. + integrations: [conversationIdIntegration()], + transport: () => ({ + send: vi.fn().mockResolvedValue({}), + flush: vi.fn().mockResolvedValue(true), + }), + beforeSendTransaction: event => { + transactions.push(event); + return event; + }, + beforeSendSpan: span => { + childSpans.push(span); + return span; + }, + }; + + client = new CloudflareClient(options); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** + * The `gen_ai.conversation.id` that reached the transport, read off the sent span rather than the + * enclosing transaction: a gen_ai span started inside a callable RPC method is a child of the `rpc` + * span, so it is only ever sent as a span. + */ + function genAiConversationId(): unknown { + return childSpans.find(span => span.op === 'gen_ai.chat')?.data['gen_ai.conversation.id']; + } + + it('returns the same instance', () => { + const agent = createFakeAgent(); + expect(instrumentCloudflareAgent(agent)).toBe(agent); + }); + + it('does not throw when the Agent internals are missing', () => { + const agent = { name: 'nope' } as Record; + expect(() => instrumentCloudflareAgent(agent)).not.toThrow(); + }); + + describe('onMessage → callable RPC spans', () => { + it('creates an rpc span named after the method for RPC messages', async () => { + const agent = createFakeAgent(); + instrumentCloudflareAgent(agent); + + const result = await agent.onMessage( + {}, + JSON.stringify({ type: 'rpc', id: '1', method: 'greet', args: ['World'] }), + ); + + expect(result).toBe('handled'); + expect(agent.messages).toHaveLength(1); + + await client.flush(); + + expect(transactions).toHaveLength(1); + expect(transactions[0]?.transaction).toBe('greet'); + expect(transactions[0]?.contexts?.trace).toEqual( + expect.objectContaining({ + op: 'rpc', + origin: 'auto.faas.cloudflare.agents', + data: expect.objectContaining({ + 'gen_ai.agent.name': 'MyAgent', + }), + }), + ); + }); + + it('does not create a span for non-RPC messages', async () => { + const agent = createFakeAgent(); + instrumentCloudflareAgent(agent); + + agent.onMessage({}, JSON.stringify({ type: 'cf_agent_state', state: {} })); + agent.onMessage({}, 'not json'); + + await client.flush(); + + expect(agent.messages).toHaveLength(2); + expect(transactions).toHaveLength(0); + }); + + it('does not set the conversation id for non-RPC messages', () => { + const agent = createFakeAgent({ + onMessage(this: any, _connection: unknown, message: unknown) { + this.seenConversationId = effectiveConversationId(); + this.messages.push(message); + return 'handled'; + }, + }); + instrumentCloudflareAgent(agent); + + agent.onMessage({}, JSON.stringify({ type: 'cf_agent_state', state: {} })); + + expect(agent.seenConversationId).toBeUndefined(); + }); + }); + + describe('conversation id', () => { + /** `uuid4()` from `@sentry/core` returns 32 hex characters, without dashes. */ + const UUID_PATTERN = /^[0-9a-f]{32}$/; + + it('sets a generated conversation id during a chat turn', async () => { + const agent = createFakeAgent({ + name: 'thread-abc', + onChatMessage(this: any) { + // Capture what the scope sees while the turn is running. + this.seenConversationId = effectiveConversationId(); + return 'response'; + }, + }); + instrumentCloudflareAgent(agent); + + const result = await agent.onChatMessage(() => {}, {}); + + expect(result).toBe('response'); + expect(agent.seenConversationId).toMatch(UUID_PATTERN); + }); + + it('sets the conversation id during callable RPC execution on plain (non-chat) agents', async () => { + const agent = createFakeAgent({ + onMessage(this: any, _connection: unknown, message: unknown) { + // Capture what the scope sees while the RPC method is running. + this.seenConversationId = effectiveConversationId(); + this.messages.push(message); + return 'handled'; + }, + }); + instrumentCloudflareAgent(agent); + + // A plain Agent has no `onChatMessage`; the RPC call is its unit of work. + await agent.onMessage({}, JSON.stringify({ type: 'rpc', id: '1', method: 'greet', args: [] })); + + expect(agent.seenConversationId).toMatch(UUID_PATTERN); + expect('onChatMessage' in agent).toBe(false); + }); + + it('sets the conversation id during an HTTP request', async () => { + const agent = createFakeAgent({ + onRequest(this: any) { + this.seenConversationId = effectiveConversationId(); + return 'response'; + }, + }); + instrumentCloudflareAgent(agent); + + const result = await agent.onRequest(new Request('https://example.com/agents/my-agent/instance-1')); + + expect(result).toBe('response'); + expect(agent.seenConversationId).toMatch(UUID_PATTERN); + }); + + it('never uses the instance name as the conversation id', async () => { + const agent = createFakeAgent({ + name: 'thread-abc', + onRequest(this: any) { + this.seenConversationId = effectiveConversationId(); + return 'response'; + }, + }); + instrumentCloudflareAgent(agent); + + await agent.onRequest(new Request('https://example.com/agents/my-agent/thread-abc')); + + expect(agent.seenConversationId).not.toBe('thread-abc'); + }); + + it('uses the persisted conversation id on the HTTP path', async () => { + const agent = createFakeAgent({ + // Simulates a hibernation wake: only storage carries the conversation id over. + ctx: { + originalStorage: { get: async () => 'persisted-id', put: async () => undefined }, + }, + onRequest(this: any) { + this.seenConversationId = effectiveConversationId(); + return 'response'; + }, + }); + instrumentCloudflareAgent(agent); + + await agent.onRequest(new Request('https://example.com/agents/my-agent/instance-1')); + + expect(agent.seenConversationId).toBe('persisted-id'); + }); + + it('does not throw when the agent has no onRequest handler', () => { + const agent = createFakeAgent(); + + expect(() => instrumentCloudflareAgent(agent)).not.toThrow(); + expect('onRequest' in agent).toBe(false); + }); + + it('stamps the agent conversation id on gen_ai spans created inside a chat turn', async () => { + const agent = createFakeAgent({ + onChatMessage() { + return startSpan({ name: 'chat gpt-4', op: 'gen_ai.chat' }, () => 'response'); + }, + }); + instrumentCloudflareAgent(agent); + + await agent.onChatMessage(() => {}, {}); + await client.flush(); + + expect(genAiConversationId()).toMatch(UUID_PATTERN); + }); + + it('lets a conversation id set manually inside onRequest take over the request', async () => { + const agent = createFakeAgent({ + onRequest(this: any) { + this.seenConversationId = effectiveConversationId(); + + setConversationId('user-chosen-id'); + + return startSpan({ name: 'chat gpt-4', op: 'gen_ai.chat' }, () => 'response'); + }, + }); + instrumentCloudflareAgent(agent); + + await agent.onRequest(new Request('https://example.com/agents/my-agent/instance-1')); + await client.flush(); + + // The wrapper sets its id before invoking the handler, so the manual call lands afterwards. + expect(agent.seenConversationId).toMatch(UUID_PATTERN); + expect(genAiConversationId()).toBe('user-chosen-id'); + }); + + it('lets a conversation id set manually inside a callable RPC method take over the call', async () => { + const agent = createFakeAgent({ + onMessage(this: any) { + setConversationId('user-chosen-id'); + + return startSpan({ name: 'chat gpt-4', op: 'gen_ai.chat' }, () => 'handled'); + }, + }); + instrumentCloudflareAgent(agent); + + await agent.onMessage({}, JSON.stringify({ type: 'rpc', id: '1', method: 'greet', args: [] })); + await client.flush(); + + expect(genAiConversationId()).toBe('user-chosen-id'); + }); + + it('lets a conversation id set manually inside onChatMessage take over the turn', async () => { + const agent = createFakeAgent({ + onChatMessage(this: any) { + // The id we put on the scope is already there when the user's handler runs. + this.seenConversationId = effectiveConversationId(); + + // A user may prefer to key the conversation on an id from their own domain. + setConversationId('user-chosen-id'); + + return startSpan({ name: 'chat gpt-4', op: 'gen_ai.chat' }, () => 'response'); + }, + }); + instrumentCloudflareAgent(agent); + + const result = await agent.onChatMessage(() => {}, {}); + await client.flush(); + + expect(result).toBe('response'); + expect(agent.seenConversationId).toMatch(UUID_PATTERN); + + expect(genAiConversationId()).toBe('user-chosen-id'); + }); + }); +}); diff --git a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts index ee4cdc66360b..1c8fc20f2605 100644 --- a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts +++ b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts @@ -324,6 +324,91 @@ describe('instrumentDurableObjectStorage', () => { ); }); + describe('framework-internal KV keys', () => { + it('does not create a span for a cf_-prefixed get', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const instrumented = instrumentDurableObjectStorage(createMockStorage()); + + await instrumented.get('cf_agents_state'); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('does not create a span for a __ps_-prefixed get', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const instrumented = instrumentDurableObjectStorage(createMockStorage()); + + await instrumented.get('__ps_name'); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('does not create a span for cf:-prefixed chat-recovery keys', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const instrumented = instrumentDurableObjectStorage(createMockStorage()); + + await instrumented.put('cf:chat-recovery:progress', 1); + await instrumented.get('cf:chat-recovery:incident:abc'); + await instrumented.list({ prefix: 'cf:chat-recovery:incident:' }); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('does not create a span for a cf_-prefixed put with object entries', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const instrumented = instrumentDurableObjectStorage(createMockStorage()); + + await instrumented.put({ cf_agents_a: 1, cf_agents_b: 2 }); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('does not create a span for a cf_-prefixed delete with an array of keys', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const instrumented = instrumentDurableObjectStorage(createMockStorage()); + + await instrumented.delete(['cf_agents_a', 'cf_agents_b']); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('does not create a span for a list with a cf_ prefix', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const instrumented = instrumentDurableObjectStorage(createMockStorage()); + + await instrumented.list({ prefix: 'cf_agents_' }); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('still creates a span when a batch mixes framework and user keys', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const instrumented = instrumentDurableObjectStorage(createMockStorage()); + + await instrumented.get(['cf_agents_state', 'myKey']); + + expect(startSpanSpy).toHaveBeenCalled(); + }); + + it('still creates a span for a list without a prefix', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const instrumented = instrumentDurableObjectStorage(createMockStorage()); + + await instrumented.list(); + + expect(startSpanSpy).toHaveBeenCalled(); + }); + + it('still creates a span for a user key', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const instrumented = instrumentDurableObjectStorage(createMockStorage()); + + await instrumented.get('myKey'); + + expect(startSpanSpy).toHaveBeenCalled(); + }); + }); + describe('non-instrumented methods', () => { it('does not instrument deleteAll, sync, transaction', async () => { const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); diff --git a/packages/cloudflare/test/instrumentSqlStorage.test.ts b/packages/cloudflare/test/instrumentSqlStorage.test.ts index e9fdb9f5d2ff..52af1863400d 100644 --- a/packages/cloudflare/test/instrumentSqlStorage.test.ts +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -159,31 +159,129 @@ describe('instrumentSqlStorage', () => { expect(result).toBe(mockCursor); }); - it('still creates a span for user queries', () => { - const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); - const mockSql = createMockSqlStorage(); - const instrumented = instrumentSqlStorage(mockSql); + describe('internal tables (cf_ prefix) are skipped', () => { + it.each([ + ['SELECT', 'SELECT * FROM cf_agents_state WHERE id = ?'], + ['INSERT', 'INSERT INTO cf_agents_fibers (id, callback) VALUES (?, ?)'], + ['DELETE', 'DELETE FROM cf_agents_schedules WHERE id = ?'], + ['UPDATE', 'UPDATE cf_agent_tool_runs SET output_json = ? WHERE id = ?'], + ['CREATE TABLE', 'CREATE TABLE IF NOT EXISTS cf_agents_workflows (id TEXT PRIMARY KEY NOT NULL)'], + ['ALTER TABLE', 'ALTER TABLE cf_agents_queues ADD COLUMN retry_options TEXT'], + ['DROP TABLE', 'DROP TABLE cf_agents_state'], + ['cf_agent_ prefix', 'SELECT * FROM cf_agent_identity'], + ['cf_ai_ prefix', 'INSERT INTO cf_ai_chat_stream_chunks (id) VALUES (?)'], + ['cf_mcp_ prefix', 'SELECT * FROM cf_mcp_agent_event'], + ['schema version', 'SELECT version FROM cf_schema_version'], + // SQLite upsert forms used by the agents framework for state/schedule/MCP persistence + ['INSERT OR REPLACE', 'INSERT OR REPLACE INTO cf_agents_state (id, state) VALUES (?, ?)'], + [ + 'INSERT OR REPLACE with column list', + `INSERT OR REPLACE INTO cf_agents_mcp_servers ( id, name, server_url, client_id, auth_url, + callback_url, server_options ) + VALUES ( ?, ?, ?, ?, ?, ?, ? )`, + ], + ['INSERT OR IGNORE', 'INSERT OR IGNORE INTO cf_agents_sub_agents (class, name) VALUES (?, ?)'], + ['REPLACE INTO', 'REPLACE INTO cf_agents_queues (id, payload) VALUES (?, ?)'], + ['UPDATE OR REPLACE', 'UPDATE OR REPLACE cf_agents_state SET state = ? WHERE id = ?'], + // The summary of a CREATE INDEX carries the index name, not the indexed table — the cf_ + // target only exists in the ON clause of the full statement. + [ + 'CREATE INDEX (framework statement)', + `create index if not exists idx_ai_chat_agent_tool_request_id + on cf_ai_chat_agent_tool_runs(request_id)`, + ], + ['CREATE INDEX (uppercase)', 'CREATE INDEX idx_agents_state_id ON cf_agents_state (id)'], + ['CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX idx_agents_state_id ON cf_agents_state (id)'], + [ + 'CREATE INDEX (without IF NOT EXISTS)', + 'CREATE INDEX idx_chunks_stream ON cf_ai_chat_stream_chunks (stream_id)', + ], + [ + 'JOIN between internal tables', + `SELECT f.fiber_id, f.status + FROM cf_agents_fibers f + LEFT JOIN cf_agents_runs r ON r.id = f.fiber_id + WHERE f.status IN ('pending', 'running')`, + ], + // `.some()` — any internal table present means the query is framework-driven noise. + ['JOIN with a user table', 'SELECT * FROM cf_agents_state s JOIN users u ON u.id = s.id'], + ['lowercase keywords and prefix', 'select * from CF_AGENTS_STATE'], + ])('skips %s', (_label, query) => { + expect(execCreatesSpan(query)).toBe(false); + }); + }); - instrumented.exec('SELECT * FROM users WHERE id = ?', 1); + describe('user queries stay instrumented', () => { + it.each([ + ['SELECT', 'SELECT * FROM users WHERE id = ?'], + ['INSERT', 'INSERT INTO orders (id, total) VALUES (?, ?)'], + ['UPDATE', 'UPDATE products SET price = ? WHERE id = ?'], + ['DELETE', 'DELETE FROM sessions WHERE expired = 1'], + ['CREATE TABLE', 'CREATE TABLE users (id TEXT PRIMARY KEY)'], + ['CREATE INDEX', 'CREATE INDEX idx_name ON users (name)'], + ['table with cf in the middle', 'SELECT * FROM my_cf_table'], + ['table starting with cfg', 'SELECT * FROM cfg_settings'], + ['INSERT OR REPLACE', 'INSERT OR REPLACE INTO users (id, name) VALUES (?, ?)'], + ['REPLACE INTO', 'REPLACE INTO sessions (id, token) VALUES (?, ?)'], + ['UPDATE OR IGNORE', 'UPDATE OR IGNORE products SET price = ? WHERE id = ?'], + // No resolvable table target — safe default is to instrument. + ['no-table SELECT', 'SELECT 1'], + ['PRAGMA', 'PRAGMA foreign_keys = ON'], + ['bare operation', 'BEGIN'], + ['empty query', ''], + ])('instruments %s', (_label, query) => { + expect(execCreatesSpan(query)).toBe(true); + }); + }); - expect(startSpanSpy).toHaveBeenCalledTimes(1); + describe('durableObjectSqlSpanAllowlist (opt a cf_ table back into instrumentation)', () => { + it.each([ + ['exact string', 'SELECT * FROM cf_my_table', ['cf_my_table']], + ['regex', 'SELECT * FROM cf_reports_daily', [/^cf_reports_/]], + ['upsert target', 'INSERT OR REPLACE INTO cf_my_table (id) VALUES (?)', ['cf_my_table']], + ['CREATE INDEX target', 'CREATE INDEX idx_mine ON cf_my_table (id)', ['cf_my_table']], + ])('instruments an allowlisted table matched by %s', (_label, query, allowlist) => { + expect(execCreatesSpan(query, allowlist)).toBe(true); + }); + + it.each([ + // Substring matches must not opt a table back in, otherwise `cf_` would allowlist everything. + ['a string entry only matches exactly', 'SELECT * FROM cf_agents_state', ['cf_agents']], + ['a non-matching entry leaves internal tables skipped', 'SELECT * FROM cf_agents_state', ['cf_my_table']], + [ + 'an internal table joined with an allowlisted table is still skipped', + 'SELECT * FROM cf_my_table t JOIN cf_agents_state s ON s.id = t.id', + ['cf_my_table'], + ], + ['an empty allowlist is ignored', 'SELECT * FROM cf_agents_state', []], + ])('%s', (_label, query, allowlist) => { + expect(execCreatesSpan(query, allowlist)).toBe(false); + }); }); + }); +}); - it('creates a span for a cf_ table on the durableObjectSqlSpanAllowlist', () => { - const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); - vi.spyOn(sentryCore, 'getClient').mockReturnValue({ - getOptions: () => ({ durableObjectSqlSpanAllowlist: ['cf_my_table'] }), - } as unknown as ReturnType); +/** + * Runs a query through the real `instrumentSqlStorage` proxy and reports whether it produced a + * `db.query` span, so the filtering matrix exercises the actual code path rather than a + * reimplementation of it. + */ +function execCreatesSpan(query: string, allowlist?: Array): boolean { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); - const mockSql = createMockSqlStorage(); - const instrumented = instrumentSqlStorage(mockSql); + if (allowlist) { + vi.spyOn(sentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ durableObjectSqlSpanAllowlist: allowlist }), + } as unknown as ReturnType); + } - instrumented.exec('SELECT * FROM cf_my_table WHERE id = ?', 1); + const mockSql = createMockSqlStorage(); + instrumentSqlStorage(mockSql).exec(query); - expect(startSpanSpy).toHaveBeenCalledTimes(1); - }); - }); -}); + expect(mockSql.exec).toHaveBeenCalledWith(query); + + return startSpanSpy.mock.calls.length > 0; +} function createMockCursor() { return { diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts index 67c6420147ac..05c78ae40089 100644 --- a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -195,7 +195,7 @@ describe('instrumentDurableObjectNamespace', () => { myRpcMethod: rpcMethod, }), }; - const instrumented = instrumentDurableObjectNamespace(namespace); + const instrumented = instrumentDurableObjectNamespace(namespace, true); const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); (stub as any).myRpcMethod('arg1', 42); @@ -221,7 +221,7 @@ describe('instrumentDurableObjectNamespace', () => { myRpcMethod: rpcMethod, }), }; - const instrumented = instrumentDurableObjectNamespace(namespace); + const instrumented = instrumentDurableObjectNamespace(namespace, true); const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); (stub as any).myRpcMethod('arg1'); @@ -229,6 +229,29 @@ describe('instrumentDurableObjectNamespace', () => { expect(rpcMethod).toHaveBeenCalledWith('arg1'); }); + it('does not inject meta when RPC trace propagation is off for the binding', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + }); + + const rpcMethod = vi.fn(); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1', 42); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); + }); + it('does not wrap built-in stub methods (connect, dup)', () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': 'abc-def-1', @@ -246,7 +269,7 @@ describe('instrumentDurableObjectNamespace', () => { dup: dupFn, }), }; - const instrumented = instrumentDurableObjectNamespace(namespace); + const instrumented = instrumentDurableObjectNamespace(namespace, true); const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); @@ -263,7 +286,7 @@ describe('instrumentDurableObjectNamespace', () => { ...originalNamespace, someProperty: 'value', }; - const instrumented = instrumentDurableObjectNamespace(namespace); + const instrumented = instrumentDurableObjectNamespace(namespace, true); expect((instrumented as any).someProperty).toBe('value'); }); diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 72f9d0774507..f0a0380db3aa 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -3,9 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentEnv } from '../../src/instrumentations/worker/instrumentEnv'; vi.mock('../../src/instrumentations/instrumentDurableObjectNamespace', () => ({ - instrumentDurableObjectNamespace: vi.fn((namespace: unknown) => ({ + instrumentDurableObjectNamespace: vi.fn((namespace: unknown, propagateRpcTrace: boolean) => ({ __instrumented: true, __original: namespace, + __propagateRpcTrace: propagateRpcTrace, })), STUB_NON_RPC_METHODS: new Set(['fetch', 'connect', 'dup']), })); @@ -74,7 +75,7 @@ describe('instrumentEnv', () => { expect(instrumented.UNKNOWN).toBe(unknownBinding); }); - it('does not instrument DurableObjectNamespace when enableRpcTracePropagation is disabled', () => { + it('instruments DurableObjectNamespace bindings without RPC propagation when the allowlist is empty', () => { const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), @@ -84,24 +85,36 @@ describe('instrumentEnv', () => { const env = { COUNTER: doNamespace }; const instrumented = instrumentEnv(env); - // DO bindings pass through untouched when RPC propagation is disabled - expect(instrumented.COUNTER).toBe(doNamespace); - expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); + expect((instrumented.COUNTER as any).__instrumented).toBe(true); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace, false); }); - it('detects and instruments DurableObjectNamespace bindings when enableRpcTracePropagation is enabled', () => { - const doNamespace = { - idFromName: vi.fn(), - idFromString: vi.fn(), - get: vi.fn(), - newUniqueId: vi.fn(), - }; - const env = { COUNTER: doNamespace }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + it('enables RPC propagation only for the DurableObjectNamespace bindings named in the allowlist', () => { + const allowed = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() }; + const denied = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() }; + const env = { COUNTER: allowed, SESSIONS: denied }; + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: ['COUNTER'] }); - const result = instrumented.COUNTER; - expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); - expect((result as any).__instrumented).toBe(true); + expect((instrumented.COUNTER as any).__instrumented).toBe(true); + expect((instrumented.SESSIONS as any).__instrumented).toBe(true); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(allowed, true); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(denied, false); + }); + + it('matches allowlisted binding names exactly rather than as substrings', () => { + const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() }; + const env = { MY_COUNTER: doNamespace }; + instrumentEnv(env, { rpcTracePropagationBindings: ['COUNTER'] }).MY_COUNTER; + + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace, false); + }); + + it('supports regular expressions in the allowlist', () => { + const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() }; + const env = { SVC_ORDERS: doNamespace }; + instrumentEnv(env, { rpcTracePropagationBindings: [/^SVC_/] }).SVC_ORDERS; + + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace, true); }); it('caches instrumented bindings across repeated access', () => { @@ -112,7 +125,7 @@ describe('instrumentEnv', () => { newUniqueId: vi.fn(), }; const env = { COUNTER: doNamespace }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); const first = instrumented.COUNTER; const second = instrumented.COUNTER; @@ -135,20 +148,20 @@ describe('instrumentEnv', () => { newUniqueId: vi.fn(), }; const env = { COUNTER: doNamespace1, SESSIONS: doNamespace2 }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); instrumented.COUNTER; instrumented.SESSIONS; expect(instrumentDurableObjectNamespace).toHaveBeenCalledTimes(2); - expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace1); - expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace2); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace1, true); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace2, true); }); - it('does not wrap JSRPC proxy when enableRpcTracePropagation is disabled', () => { - const mockFetch = vi.fn(); + it('wraps JSRPC bindings for fetch instrumentation even when the allowlist is empty', () => { + const rpcMethod = vi.fn(); const jsrpcProxy = new Proxy( - { fetch: mockFetch }, + { fetch: vi.fn(), myRpcMethod: rpcMethod }, { get(target, prop) { if (prop in target) { @@ -162,33 +175,11 @@ describe('instrumentEnv', () => { const env = { SERVICE: jsrpcProxy }; const instrumented = instrumentEnv(env); - const result = instrumented.SERVICE; - // Should be the same reference — not wrapped when propagation is disabled - expect(result).toBe(jsrpcProxy); - expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); - }); - - it('wraps JSRPC proxy with a Proxy that instruments fetch when enableRpcTracePropagation is enabled', () => { - const mockFetch = vi.fn(); - const jsrpcProxy = new Proxy( - { fetch: mockFetch }, - { - get(target, prop) { - if (prop in target) { - return Reflect.get(target, prop); - } - // JSRPC behavior: return truthy for any property - return () => {}; - }, - }, - ); - const env = { SERVICE: jsrpcProxy }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); - - const result = instrumented.SERVICE; - // Should NOT be the same reference — it's wrapped in a Proxy + const result = instrumented.SERVICE as { myRpcMethod: (arg: string) => void }; expect(result).not.toBe(jsrpcProxy); - expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); + + result.myRpcMethod('arg1'); + expect(rpcMethod).toHaveBeenCalledWith('arg1'); }); it('does not instrument JSRPC proxies as DurableObjectNamespace', () => { @@ -248,12 +239,12 @@ describe('instrumentEnv', () => { newUniqueId: vi.fn(), }; const env = { MY_QUEUE: queue, COUNTER: doNamespace }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); // Access both — DO instrumentation only fires on property access expect(instrumented.MY_QUEUE).not.toBe(queue); instrumented.COUNTER; - expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace, true); }); it('wraps RateLimit bindings in a proxy and forwards calls', async () => { @@ -342,13 +333,24 @@ describe('instrumentEnv', () => { ); } - it('does not instrument mTLS Fetcher when enableRpcTracePropagation is disabled', () => { - const mockFetch = vi.fn(); + it('instruments mTLS Fetcher fetch when rpcTracePropagationBindings is empty', async () => { + vi.spyOn(SentryCore, '_INTERNAL_getTracingHeadersForFetchRequest').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); const mtlsFetcher = createMtlsFetcherProxy(mockFetch); const env = { MY_CERT: mtlsFetcher }; const instrumented = instrumentEnv(env); - expect(instrumented.MY_CERT).toBe(mtlsFetcher); + expect(instrumented.MY_CERT).not.toBe(mtlsFetcher); + + await instrumented.MY_CERT.fetch('https://example.com/api'); + + const [, init] = mockFetch.mock.calls[0]!; + expect(new Headers(init?.headers).get('sentry-trace')).toBe( + '12345678901234567890123456789012-1234567890123456-1', + ); }); it('preserves existing headers and response on mTLS Fetcher fetch', async () => { @@ -361,7 +363,7 @@ describe('instrumentEnv', () => { const mockFetch = vi.fn().mockResolvedValue(new Response('mtls-response')); const mtlsFetcher = createMtlsFetcherProxy(mockFetch); const env = { MY_CERT: mtlsFetcher }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); const response = await instrumented.MY_CERT.fetch('https://example.com/api', { headers: { Authorization: 'Bearer client-cert-token' }, @@ -378,7 +380,7 @@ describe('instrumentEnv', () => { }); describe('JSRPC RPC method instrumentation', () => { - it('does not inject Sentry RPC meta by default (enableRpcTracePropagation not set)', () => { + it('does not inject Sentry RPC meta by default (rpcTracePropagationBindings not set)', () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -401,11 +403,11 @@ describe('instrumentEnv', () => { instrumented.SERVICE.myRpcMethod('arg1', 42); - // Without enableRpcTracePropagation, no metadata should be injected + // Without rpcTracePropagationBindings, no metadata should be injected expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); }); - it('injects Sentry RPC meta when enableRpcTracePropagation is true', () => { + it('injects Sentry RPC meta when rpcTracePropagationBindings matches', () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -424,7 +426,7 @@ describe('instrumentEnv', () => { }, ); const env = { SERVICE: jsrpcProxy }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); instrumented.SERVICE.myRpcMethod('arg1', 42); @@ -455,7 +457,7 @@ describe('instrumentEnv', () => { }, ); const env = { SERVICE: jsrpcProxy }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); instrumented.SERVICE.fetch('https://example.com'); @@ -480,11 +482,50 @@ describe('instrumentEnv', () => { }, ); const env = { SERVICE: jsrpcProxy }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); instrumented.SERVICE.myRpcMethod('arg1'); expect(rpcMethod).toHaveBeenCalledWith('arg1'); }); + + // A receiver without Sentry never strips the trailing metadata argument, so a caller has to be + // able to limit propagation to the bindings it knows are instrumented. + // See https://github.com/getsentry/sentry-javascript/issues/23233. + it('injects meta only into JSRPC calls on allowlisted bindings', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const allowedMethod = vi.fn(); + const deniedMethod = vi.fn(); + const createJsrpcBinding = (rpcMethod: ReturnType) => + new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + + const env = { ORDERS: createJsrpcBinding(allowedMethod), EXTERNAL: createJsrpcBinding(deniedMethod) }; + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: ['ORDERS'] }); + + instrumented.ORDERS.myRpcMethod('first'); + instrumented.EXTERNAL.myRpcMethod('first'); + + expect(allowedMethod).toHaveBeenCalledWith('first', { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + expect(deniedMethod).toHaveBeenCalledWith('first'); + }); }); }); diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 353eabebe474..54988800899c 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -410,6 +410,96 @@ describe('instrumentWorkerEntrypoint', () => { expect(events).toHaveLength(2); }); + it('shares the isolation scope with directly called instrumented methods', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = createMockExecutionContext(); + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); + const TestClass = class extends WorkerEntrypoint { + async outer() { + SentryCore.setTag('outer_tag', 'from-outer'); + + await this.inner(); + + SentryCore.captureMessage('outer message'); + } + + async inner() { + SentryCore.setTag('inner_tag', 'from-inner'); + SentryCore.setUser({ id: 'user-from-inner' }); + } + }; + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); + + await obj.outer(); + await Promise.all(waits); + + // `inner` is instrumented too, but it is reached from within `outer`'s invocation, so it must + // write to the scope `outer` already opened rather than fork one of its own. + expect(events[0]?.tags).toEqual(expect.objectContaining({ outer_tag: 'from-outer', inner_tag: 'from-inner' })); + expect(events[0]?.user).toEqual({ id: 'user-from-inner' }); + }); + + it('does not leak isolation scope data between consecutive invocations', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = createMockExecutionContext(); + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); + const TestClass = class extends WorkerEntrypoint { + async seed() { + SentryCore.setTag('seeded_tag', 'from-seeding-invocation'); + SentryCore.setUser({ id: 'user-from-seeding-invocation' }); + SentryCore.captureMessage('seed'); + } + + async probe() { + SentryCore.captureMessage('probe'); + } + }; + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); + + await obj.seed(); + await Promise.all(waits.splice(0)); + await obj.probe(); + await Promise.all(waits); + + // Guards the probe assertions against passing vacuously. + expect(events[0]?.tags).toEqual(expect.objectContaining({ seeded_tag: 'from-seeding-invocation' })); + expect(events[0]?.user).toEqual({ id: 'user-from-seeding-invocation' }); + + expect(events[1]?.message).toBe('probe'); + expect(events[1]?.tags?.seeded_tag).toBeUndefined(); + expect(events[1]?.user).toBeUndefined(); + }); + it('only excludes WorkerEntrypoint lifecycle methods from RPC instrumentation', async () => { const initAndBind = vi.spyOn(SentryCore, 'initAndBind'); const TestClass = class extends WorkerEntrypoint { diff --git a/packages/cloudflare/test/integrations/spotlight.test.ts b/packages/cloudflare/test/integrations/spotlight.test.ts new file mode 100644 index 000000000000..e485715b6e6a --- /dev/null +++ b/packages/cloudflare/test/integrations/spotlight.test.ts @@ -0,0 +1,234 @@ +import type { Envelope, EventEnvelope } from '@sentry/core'; +import { createEnvelope, debug } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CloudflareClient } from '../../src/client'; +import { INTEGRATION_NAME, spotlightIntegration } from '../../src/integrations/spotlight'; +import { createStackParser } from '@sentry/core'; + +function createTestClient(): CloudflareClient { + return new CloudflareClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [], + transport: () => ({ + send: () => Promise.resolve({}), + flush: () => Promise.resolve(true), + }), + stackParser: createStackParser(), + }); +} + +function createTestEnvelope(): EventEnvelope { + return createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }], + ]); +} + +describe('Spotlight (Cloudflare)', () => { + const debugWarnSpy = vi.spyOn(debug, 'warn'); + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + fetchSpy = vi.fn().mockResolvedValue({ + status: 200, + text: () => Promise.resolve(''), + }); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('has integration name "Spotlight"', () => { + const integration = spotlightIntegration(); + expect(integration.name).toEqual(INTEGRATION_NAME); + expect(integration.name).toEqual('Spotlight'); + }); + + it('registers a callback on the beforeEnvelope hook', () => { + const client = createTestClient(); + const onSpy = vi.spyOn(client, 'on'); + + const integration = spotlightIntegration(); + integration.setup!(client); + + expect(onSpy).toHaveBeenCalledWith('beforeEnvelope', expect.any(Function)); + }); + + it('sends an envelope POST request to the default sidecar URL', () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + const integration = spotlightIntegration(); + integration.setup!(client); + + callback(createTestEnvelope()); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://localhost:8969/stream', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }), + ); + }); + + it('sends an envelope POST request to a custom sidecar URL', () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + const integration = spotlightIntegration({ sidecarUrl: 'http://mylocalhost:8888/abcd' }); + integration.setup!(client); + + callback(createTestEnvelope()); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://mylocalhost:8888/abcd', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-sentry-envelope' }, + }), + ); + }); + + it('serializes the envelope body', () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + const integration = spotlightIntegration(); + integration.setup!(client); + + callback(createTestEnvelope()); + + const body = fetchSpy.mock.calls[0]![1].body as string; + expect(body).toContain('aa3ff046696b4bc6b609ce6d28fde9e2'); + expect(typeof body).toBe('string'); + }); + + it('stops forwarding after more than 3 failed requests', async () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + fetchSpy.mockRejectedValue(new Error('connection refused')); + + const integration = spotlightIntegration(); + integration.setup!(client); + + const envelope = createTestEnvelope(); + + // 4 failed requests should trigger the disable + for (let i = 0; i < 4; i++) { + callback(envelope); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(i + 1)); + } + + fetchSpy.mockClear(); + callback(envelope); + + // Wait a tick to ensure any async handling is done + await new Promise(resolve => setTimeout(resolve, 10)); + + // The 5th call should not reach fetch + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('resets fail count on successful request', async () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + // Fail 3 times, then succeed + fetchSpy + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce({ status: 200, text: () => Promise.resolve('') }); + + const integration = spotlightIntegration(); + integration.setup!(client); + + const envelope = createTestEnvelope(); + + for (let i = 0; i < 4; i++) { + callback(envelope); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(i + 1)); + } + + // After the success, fail count should be reset, so the next call should go through + fetchSpy.mockResolvedValueOnce({ status: 200, text: () => Promise.resolve('') }); + callback(envelope); + + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(5)); + }); + + it('warns on invalid sidecar URL', () => { + const client = createTestClient(); + + const integration = spotlightIntegration({ sidecarUrl: 'not-a-valid-url' }); + integration.setup!(client); + + expect(debugWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid sidecar URL: not-a-valid-url')); + }); + + it('does not call fetch for invalid sidecar URL', () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + const integration = spotlightIntegration({ sidecarUrl: 'not-a-valid-url' }); + integration.setup!(client); + + // If the URL is invalid, the beforeEnvelope hook is never registered + // so callback is never replaced — it's still the no-op default + callback(createTestEnvelope()); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does not increment fail count on 4xx/5xx responses', async () => { + let callback: (envelope: Envelope) => void = () => {}; + const client = createTestClient(); + vi.spyOn(client, 'on').mockImplementationOnce((_hook: string, cb: (envelope: Envelope) => void) => { + callback = cb; + return () => {}; + }); + + fetchSpy.mockResolvedValue({ status: 500, text: () => Promise.resolve('') }); + + const integration = spotlightIntegration(); + integration.setup!(client); + + const envelope = createTestEnvelope(); + + // 5 calls with 500 status — fail count is NOT incremented for HTTP errors, + // only for network rejections, so fetch should still be called each time + for (let i = 0; i < 5; i++) { + callback(envelope); + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(i + 1)); + } + }); +}); diff --git a/packages/cloudflare/test/options.test.ts b/packages/cloudflare/test/options.test.ts index 9dc21606b445..e999df4b7970 100644 --- a/packages/cloudflare/test/options.test.ts +++ b/packages/cloudflare/test/options.test.ts @@ -189,4 +189,54 @@ describe('getFinalOptions', () => { expect(result).toEqual(expect.objectContaining({ dsn: 'test-dsn', release: undefined })); }); }); + + describe('SENTRY_SPOTLIGHT', () => { + it('reads SENTRY_SPOTLIGHT boolean "true" from env', () => { + const result = getFinalOptions({}, { SENTRY_SPOTLIGHT: 'true' }); + expect(result.spotlight).toBe(true); + }); + + it('reads SENTRY_SPOTLIGHT boolean "false" from env', () => { + const result = getFinalOptions({}, { SENTRY_SPOTLIGHT: 'false' }); + expect(result.spotlight).toBe(false); + }); + + it('reads SENTRY_SPOTLIGHT URL string from env', () => { + const result = getFinalOptions({}, { SENTRY_SPOTLIGHT: 'http://localhost:9999/stream' }); + expect(result.spotlight).toBe('http://localhost:9999/stream'); + }); + + it('user option takes precedence over env', () => { + const result = getFinalOptions({ spotlight: false }, { SENTRY_SPOTLIGHT: 'true' }); + expect(result.spotlight).toBe(false); + }); + + it('user option string takes precedence over env', () => { + const result = getFinalOptions( + { spotlight: 'http://custom:1234/stream' }, + { SENTRY_SPOTLIGHT: 'http://other:5678/stream' }, + ); + expect(result.spotlight).toBe('http://custom:1234/stream'); + }); + + it('returns undefined when SENTRY_SPOTLIGHT is not set', () => { + const result = getFinalOptions({}, { SENTRY_DSN: 'test-dsn' }); + expect(result.spotlight).toBeUndefined(); + }); + + it('spotlight: true prefers env URL over boolean true', () => { + const result = getFinalOptions({ spotlight: true }, { SENTRY_SPOTLIGHT: 'http://custom:1234/stream' }); + expect(result.spotlight).toBe('http://custom:1234/stream'); + }); + + it('spotlight: true stays true when env is boolean "true"', () => { + const result = getFinalOptions({ spotlight: true }, { SENTRY_SPOTLIGHT: 'true' }); + expect(result.spotlight).toBe(true); + }); + + it('spotlight: true stays true when env is not set', () => { + const result = getFinalOptions({ spotlight: true }, {}); + expect(result.spotlight).toBe(true); + }); + }); }); diff --git a/packages/cloudflare/test/utils/internalSqlQuery.test.ts b/packages/cloudflare/test/utils/internalSqlQuery.test.ts index 7d119eb8a98f..72d01d58ad9e 100644 --- a/packages/cloudflare/test/utils/internalSqlQuery.test.ts +++ b/packages/cloudflare/test/utils/internalSqlQuery.test.ts @@ -1,106 +1,25 @@ -import { _INTERNAL_getSqlQuerySummary } from '@sentry/core'; import { describe, expect, it } from 'vitest'; import { targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery'; -// Builds the summary the same way `instrumentSqlStorage` does, so the test exercises the real -// operation -> summary -> detection path rather than hand-written summaries. -const summarize = (query: string): string | undefined => _INTERNAL_getSqlQuerySummary(query); - +// Behavioural coverage of the filter lives in `instrumentSqlStorage.test.ts`, which drives real +// queries through the instrumented `exec`. What remains here are the signature-level contracts that +// call path cannot reach: an absent summary, and an absent `queryText`. describe('targetsCloudflareInternalTable', () => { - describe('internal queries (cf_ tables)', () => { - it.each([ - ['SELECT', 'SELECT * FROM cf_agents_state WHERE id = ?'], - ['INSERT', 'INSERT INTO cf_agents_fibers (id, callback) VALUES (?, ?)'], - ['DELETE', 'DELETE FROM cf_agents_schedules WHERE id = ?'], - ['UPDATE', 'UPDATE cf_agent_tool_runs SET output_json = ? WHERE id = ?'], - ['CREATE TABLE', 'CREATE TABLE IF NOT EXISTS cf_agents_workflows (id TEXT PRIMARY KEY NOT NULL)'], - ['ALTER TABLE', 'ALTER TABLE cf_agents_queues ADD COLUMN retry_options TEXT'], - ['DROP TABLE', 'DROP TABLE cf_agents_state'], - ['cf_agent_ prefix', 'SELECT * FROM cf_agent_identity'], - ['cf_ai_ prefix', 'INSERT INTO cf_ai_chat_stream_chunks (id) VALUES (?)'], - ['cf_mcp_ prefix', 'SELECT * FROM cf_mcp_agent_event'], - ['schema version', 'SELECT version FROM cf_schema_version'], - ])('returns true for %s on internal tables', (_label, query) => { - expect(targetsCloudflareInternalTable(summarize(query))).toBe(true); - }); - - it('returns true for an internal JOIN', () => { - const query = ` - SELECT f.fiber_id, f.status - FROM cf_agents_fibers f - LEFT JOIN cf_agents_runs r ON r.id = f.fiber_id - WHERE f.status IN ('pending', 'running') - `; - expect(targetsCloudflareInternalTable(summarize(query))).toBe(true); - }); - - it('returns true when an internal table is joined with a user table', () => { - // `.some()` — any internal table present means the query is framework-driven noise. - expect( - targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state s JOIN users u ON u.id = s.id')), - ).toBe(true); - }); - - it('handles case-insensitive keywords and prefixes', () => { - expect(targetsCloudflareInternalTable(summarize('select * from CF_AGENTS_STATE'))).toBe(true); - }); - }); - - describe('user queries (must be instrumented)', () => { - it.each([ - ['SELECT', 'SELECT * FROM users WHERE id = ?'], - ['INSERT', 'INSERT INTO orders (id, total) VALUES (?, ?)'], - ['UPDATE', 'UPDATE products SET price = ? WHERE id = ?'], - ['DELETE', 'DELETE FROM sessions WHERE expired = 1'], - ['CREATE TABLE', 'CREATE TABLE users (id TEXT PRIMARY KEY)'], - ['table with cf in the middle', 'SELECT * FROM my_cf_table'], - ['table starting with cfg', 'SELECT * FROM cfg_settings'], - ])('returns false for %s on user tables', (_label, query) => { - expect(targetsCloudflareInternalTable(summarize(query))).toBe(false); - }); + it.each([ + ['undefined', undefined], + ['empty', ''], + ])('returns false for a %s summary', (_label, summary) => { + expect(targetsCloudflareInternalTable(summary)).toBe(false); }); - describe('allowlist (opt a cf_ table back into instrumentation)', () => { - it('returns false for an allowlisted table matched by exact string', () => { - expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table'), ['cf_my_table'])).toBe(false); - }); - - it('returns false for an allowlisted table matched by regex', () => { - expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_reports_daily'), [/^cf_reports_/])).toBe(false); - }); - - it('requires an exact match for string entries', () => { - // Substring matches must not opt a table back in, otherwise `cf_` would allowlist everything. - expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_agents'])).toBe(true); - }); - - it('still skips genuine internal tables that are not allowlisted', () => { - expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_my_table'])).toBe(true); - }); - - it('still skips when an internal table is joined with an allowlisted table', () => { - expect( - targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table t JOIN cf_agents_state s ON s.id = t.id'), [ - 'cf_my_table', - ]), - ).toBe(true); - }); - - it('ignores an empty allowlist', () => { - expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), [])).toBe(true); - }); + it('falls back to the summary when no queryText is passed', () => { + expect(targetsCloudflareInternalTable('SELECT cf_agents_state')).toBe(true); + expect(targetsCloudflareInternalTable('SELECT users')).toBe(false); }); - describe('summaries without a resolvable table target (safe default: instrument)', () => { - it.each([ - ['undefined', undefined], - ['empty', ''], - ['no-table SELECT', 'SELECT 1'], - ['PRAGMA', 'PRAGMA foreign_keys = ON'], - ['bare operation', 'BEGIN'], - ])('returns false for %s', (_label, value) => { - const summary = typeof value === 'string' ? summarize(value) : value; - expect(targetsCloudflareInternalTable(summary)).toBe(false); - }); + // Without queryText a CREATE INDEX summary carries the index name, so the cf_ table in the ON + // clause is invisible and the query is instrumented — the caller must pass queryText to filter it. + it('cannot resolve a CREATE INDEX target from the summary alone', () => { + expect(targetsCloudflareInternalTable('CREATE INDEX idx_agents_state_id')).toBe(false); }); }); diff --git a/packages/cloudflare/test/utils/internalStorageKey.test.ts b/packages/cloudflare/test/utils/internalStorageKey.test.ts new file mode 100644 index 000000000000..3f00d1438ab9 --- /dev/null +++ b/packages/cloudflare/test/utils/internalStorageKey.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { getStorageKeys, targetsCloudflareInternalKey } from '../../src/utils/internalStorageKey'; + +describe('targetsCloudflareInternalKey', () => { + it('matches cf_-prefixed keys', () => { + expect(targetsCloudflareInternalKey('cf_agents_state')).toBe(true); + expect(targetsCloudflareInternalKey('cf_mcp_servers')).toBe(true); + }); + + it('matches cf:-prefixed keys (agents chat-recovery namespace)', () => { + expect(targetsCloudflareInternalKey('cf:chat-recovery:incident:abc')).toBe(true); + expect(targetsCloudflareInternalKey('cf:chat-recovery:progress')).toBe(true); + expect(targetsCloudflareInternalKey('cf:chat:recovering')).toBe(true); + }); + + it('matches __ps_-prefixed keys', () => { + expect(targetsCloudflareInternalKey('__ps_name')).toBe(true); + }); + + it('matches MCP OAuth client-state keys', () => { + expect(targetsCloudflareInternalKey('/sentry/abc123/def456/token')).toBe(true); + expect(targetsCloudflareInternalKey('/github-inspector/abc123/state/nonce')).toBe(true); + expect(targetsCloudflareInternalKey('/sentry/abc123/def456/client_info/')).toBe(true); + }); + + it('does not match user keys', () => { + expect(targetsCloudflareInternalKey('myKey')).toBe(false); + expect(targetsCloudflareInternalKey('user_settings')).toBe(false); + }); + + it('does not match keys that merely contain a reserved substring', () => { + expect(targetsCloudflareInternalKey('my_cf_key')).toBe(false); + }); + + it('returns false for undefined or empty keys', () => { + expect(targetsCloudflareInternalKey(undefined)).toBe(false); + expect(targetsCloudflareInternalKey('')).toBe(false); + }); + + it('respects an exact-string allowlist entry', () => { + expect(targetsCloudflareInternalKey('cf_my_key', ['cf_my_key'])).toBe(false); + expect(targetsCloudflareInternalKey('cf_other', ['cf_my_key'])).toBe(true); + }); + + it('respects a regex allowlist entry', () => { + expect(targetsCloudflareInternalKey('cf_reports_daily', [/^cf_reports_/])).toBe(false); + expect(targetsCloudflareInternalKey('cf_agents_state', [/^cf_reports_/])).toBe(true); + }); +}); + +describe('getStorageKeys', () => { + it('extracts a single string key for get/delete', () => { + expect(getStorageKeys('get', ['myKey'])).toEqual(['myKey']); + expect(getStorageKeys('delete', ['myKey'])).toEqual(['myKey']); + }); + + it('extracts an array of keys for get/delete', () => { + expect(getStorageKeys('get', [['a', 'b']])).toEqual(['a', 'b']); + expect(getStorageKeys('delete', [['a', 'b']])).toEqual(['a', 'b']); + }); + + it('filters non-string entries from key arrays', () => { + expect(getStorageKeys('get', [['a', 1, 'b']])).toEqual(['a', 'b']); + }); + + it('extracts a single key for put(key, value)', () => { + expect(getStorageKeys('put', ['myKey', 'myValue'])).toEqual(['myKey']); + }); + + it('extracts all keys for put(entries)', () => { + expect(getStorageKeys('put', [{ a: 1, b: 2 }])).toEqual(['a', 'b']); + }); + + it('extracts the prefix for list({ prefix })', () => { + expect(getStorageKeys('list', [{ prefix: 'cf_agents_' }])).toEqual(['cf_agents_']); + }); + + it('returns undefined for list() without a prefix', () => { + expect(getStorageKeys('list', [])).toBeUndefined(); + expect(getStorageKeys('list', [{}])).toBeUndefined(); + }); + + it('returns undefined for alarm methods', () => { + expect(getStorageKeys('setAlarm', [Date.now()])).toBeUndefined(); + expect(getStorageKeys('deleteAlarm', [])).toBeUndefined(); + expect(getStorageKeys('getAlarm', [])).toBeUndefined(); + }); + + it('returns undefined for unknown methods', () => { + expect(getStorageKeys('deleteAll', [])).toBeUndefined(); + }); +}); diff --git a/packages/cloudflare/test/utils/invocationScope.test.ts b/packages/cloudflare/test/utils/invocationScope.test.ts new file mode 100644 index 000000000000..0c2bf6dd8389 --- /dev/null +++ b/packages/cloudflare/test/utils/invocationScope.test.ts @@ -0,0 +1,119 @@ +import { + getCurrentScope, + getGlobalScope, + getIsolationScope, + GLOBAL_OBJ, + type Scope, + setAsyncContextStrategy, +} from '@sentry/core'; +import { AsyncLocalStorage } from 'async_hooks'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../../src/async'; +import { withInvocationIsolationScope } from '../../src/utils/invocationScope'; + +describe('withInvocationIsolationScope()', () => { + beforeEach(() => { + getIsolationScope().clear(); + getCurrentScope().clear(); + getGlobalScope().clear(); + + (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; + setAsyncLocalStorageAsyncContextStrategy(); + }); + + it('forks the isolation scope at the entry point', () => { + const outerScope = getIsolationScope(); + + withInvocationIsolationScope(scope => { + expect(scope).not.toBe(outerScope); + expect(getIsolationScope()).toBe(scope); + }); + }); + + it('inherits data from the enclosing isolation scope', () => { + getIsolationScope().setTag('from-outer', 'yes'); + + withInvocationIsolationScope(scope => { + expect(scope.getScopeData().tags).toEqual({ 'from-outer': 'yes' }); + }); + }); + + it('does not leak data written inside the invocation to the enclosing scope', () => { + const outerScope = getIsolationScope(); + + withInvocationIsolationScope(scope => { + scope.setTag('from-invocation', 'yes'); + scope.setUser({ id: 'user-1' }); + }); + + expect(outerScope.getScopeData().tags).toEqual({}); + expect(outerScope.getScopeData().user).toEqual({}); + }); + + it('gives two sibling invocations independent scopes', () => { + const scopes: Scope[] = []; + + withInvocationIsolationScope(scope => { + scope.setTag('first', 'yes'); + scopes.push(scope); + }); + + withInvocationIsolationScope(scope => { + scopes.push(scope); + expect(scope.getScopeData().tags).toEqual({}); + }); + + expect(scopes[0]).not.toBe(scopes[1]); + }); + + it('reuses the invocation scope when reentrant', () => { + withInvocationIsolationScope(outer => { + withInvocationIsolationScope(inner => { + expect(inner).toBe(outer); + }); + }); + }); + + it('lets a reentrant call add to what the entry point set, and vice versa', () => { + withInvocationIsolationScope(outer => { + outer.setTag('outer', 'yes'); + + withInvocationIsolationScope(inner => { + expect(inner.getScopeData().tags).toEqual({ outer: 'yes' }); + inner.setTag('inner', 'yes'); + }); + + expect(outer.getScopeData().tags).toEqual({ outer: 'yes', inner: 'yes' }); + }); + }); + + it('treats an invocation following a reentrant one as a fresh entry point', () => { + withInvocationIsolationScope(outer => { + withInvocationIsolationScope(inner => { + inner.setTag('nested', 'yes'); + }); + + expect(outer.getScopeData().tags).toEqual({ nested: 'yes' }); + }); + + withInvocationIsolationScope(scope => { + expect(scope.getScopeData().tags).toEqual({}); + }); + }); + + it('degrades to a no-op fork under a non-forking strategy', () => { + // The core stack fallback never forks: `getIsolationScope()` always reports the shared default + // scope, and `withIsolationScope` reuses it. So the active isolation scope stays the shared one + // even inside the invocation — the computed clone is silently dropped by the strategy. Cloudflare + // always installs the AsyncLocalStorage strategy, so this branch is not hit in production. + setAsyncContextStrategy(undefined); + + const outerScope = getIsolationScope(); + + withInvocationIsolationScope(() => { + expect(getIsolationScope()).toBe(outerScope); + }); + + expect(getIsolationScope()).toBe(outerScope); + }); +}); diff --git a/packages/cloudflare/test/utils/rpcPropagation.test.ts b/packages/cloudflare/test/utils/rpcPropagation.test.ts new file mode 100644 index 000000000000..e806eab26f0b --- /dev/null +++ b/packages/cloudflare/test/utils/rpcPropagation.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { createRpcPropagationResolver } from '../../src/utils/rpcPropagation'; + +describe('createRpcPropagationResolver', () => { + it('propagates to nothing when no options are available', () => { + const shouldPropagate = createRpcPropagationResolver(undefined); + + expect(shouldPropagate('MY_DO')).toBe(false); + }); + + it('propagates to nothing when the option is unset', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: undefined }); + + expect(shouldPropagate('MY_DO')).toBe(false); + expect(shouldPropagate('EXTERNAL')).toBe(false); + }); + + it('falls back to enableRpcTracePropagation when no bindings are listed', () => { + // eslint-disable-next-line typescript/no-deprecated + const shouldPropagate = createRpcPropagationResolver({ enableRpcTracePropagation: true }); + + expect(shouldPropagate('MY_DO')).toBe(true); + expect(shouldPropagate('EXTERNAL')).toBe(true); + }); + + it('falls back to instrumentPrototypeMethods when no bindings are listed', () => { + // eslint-disable-next-line typescript/no-deprecated + const shouldPropagate = createRpcPropagationResolver({ instrumentPrototypeMethods: true }); + + expect(shouldPropagate('MY_DO')).toBe(true); + }); + + it('lets the binding list win over enableRpcTracePropagation', () => { + const shouldPropagate = createRpcPropagationResolver({ + // eslint-disable-next-line typescript/no-deprecated + enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DO'], + }); + + expect(shouldPropagate('MY_DO')).toBe(true); + expect(shouldPropagate('EXTERNAL')).toBe(false); + }); + + it('propagates to nothing for an empty target list', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: [] }); + + expect(shouldPropagate('MY_DO')).toBe(false); + }); + + it('propagates to nothing for an empty target list next to enableRpcTracePropagation', () => { + const shouldPropagate = createRpcPropagationResolver({ + // eslint-disable-next-line typescript/no-deprecated + enableRpcTracePropagation: true, + rpcTracePropagationBindings: [], + }); + + expect(shouldPropagate('MY_DO')).toBe(false); + expect(shouldPropagate('EXTERNAL')).toBe(false); + }); + + it('propagates only to the targeted binding names', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: ['MY_DO', 'EXTERNAL'] }); + + expect(shouldPropagate('MY_DO')).toBe(true); + expect(shouldPropagate('EXTERNAL')).toBe(true); + expect(shouldPropagate('OTHER')).toBe(false); + }); + + it('matches binding names exactly, never as a substring', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: ['DB'] }); + + expect(shouldPropagate('DB')).toBe(true); + expect(shouldPropagate('MY_DB')).toBe(false); + expect(shouldPropagate('DB_REPLICA')).toBe(false); + }); + + it('supports regular expressions for pattern matching', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: [/^SVC_/] }); + + expect(shouldPropagate('SVC_ORDERS')).toBe(true); + expect(shouldPropagate('SVC_USERS')).toBe(true); + expect(shouldPropagate('ORDERS')).toBe(false); + expect(shouldPropagate('PREFIXED_SVC_ORDERS')).toBe(false); + }); +}); diff --git a/packages/cloudflare/test/vite/agentClass.test.ts b/packages/cloudflare/test/vite/agentClass.test.ts new file mode 100644 index 000000000000..ccc25e83287b --- /dev/null +++ b/packages/cloudflare/test/vite/agentClass.test.ts @@ -0,0 +1,287 @@ +import { parse } from 'acorn'; +import { describe, expect, it } from 'vitest'; +import { collectAgentCandidates, detectAgentClasses, type ModuleResolver } from '../../src/vite/agentClass'; + +function parseJS(code: string) { + return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; +} + +const ENTRY = '/app/src/index.js'; + +/** + * A resolver over an in-memory module graph. Relative specifiers resolve against `/app/src`, bare + * ones into `/app/node_modules`, mirroring how Vite reports third-party ids. + */ +function createResolver(modules: Record): ModuleResolver & { loaded: string[] } { + const loaded: string[] = []; + return { + loaded, + parse: (code: string) => parseJS(code), + async resolve(source: string, _importer: string) { + if (source.startsWith('.')) { + const id = `/app/src/${source.replace(/^\.\//, '')}.js`; + return { id }; + } + return { id: `/app/node_modules/${source}/dist/index.js` }; + }, + readFile(id: string) { + loaded.push(id); + return modules[id]; + }, + }; +} + +async function detect(entryCode: string, modules: Record = {}, resolver?: ModuleResolver) { + const ast = parseJS(entryCode); + const candidates = collectAgentCandidates(ast, extractClassNames(entryCode)); + return detectAgentClasses(ast, ENTRY, candidates, resolver ?? createResolver(modules)); +} + +/** Every class name mentioned in the entry, so tests don't have to restate the wrangler config. */ +function extractClassNames(code: string): string[] { + return [...code.matchAll(/class\s+(\w+)/g)].map(match => match[1]!); +} + +describe('detectAgentClasses', () => { + it('detects a class extending `Agent` from `agents`', async () => { + const code = ["import { Agent } from 'agents';", 'export class MyAgent extends Agent {}'].join('\n'); + expect(await detect(code)).toEqual(new Set(['MyAgent'])); + }); + + it('does not detect a plain Durable Object', async () => { + const code = ["import { DurableObject } from 'cloudflare:workers';", 'export class MyDO extends DurableObject {}']; + expect(await detect(code.join('\n'))).toEqual(new Set()); + }); + + it('detects `AIChatAgent` from `@cloudflare/ai-chat`', async () => { + const code = [ + "import { AIChatAgent } from '@cloudflare/ai-chat';", + 'export class Chat extends AIChatAgent {}', + ].join('\n'); + expect(await detect(code)).toEqual(new Set(['Chat'])); + }); + + it('detects `McpAgent` from `agents/mcp`', async () => { + const code = ["import { McpAgent } from 'agents/mcp';", 'export class MyMCP extends McpAgent {}'].join('\n'); + expect(await detect(code)).toEqual(new Set(['MyMCP'])); + }); + + it('detects `Think` from `@cloudflare/think`', async () => { + const code = ["import { Think } from '@cloudflare/think';", 'export class Thinker extends Think {}'].join('\n'); + expect(await detect(code)).toEqual(new Set(['Thinker'])); + }); + + it('resolves a chain of subclasses declared in the entry', async () => { + const code = [ + "import { Agent } from 'agents';", + 'class Base extends Agent {}', + 'class Middle extends Base {}', + 'export class Leaf extends Middle {}', + ].join('\n'); + expect(await detect(code)).toContain('Leaf'); + }); + + it('resolves a base class imported from another module', async () => { + const code = ["import { MyBase } from './base';", 'export class MyAgent extends MyBase {}'].join('\n'); + const modules = { + '/app/src/base.js': ["import { Agent } from 'agents';", 'export class MyBase extends Agent {}'].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set(['MyAgent'])); + }); + + it('resolves a base class several modules deep', async () => { + const code = ["import { Level1 } from './l1';", 'export class MyAgent extends Level1 {}'].join('\n'); + const modules = { + '/app/src/l1.js': ["import { Level2 } from './l2';", 'export class Level1 extends Level2 {}'].join('\n'), + '/app/src/l2.js': ["import { Agent } from 'agents';", 'export class Level2 extends Agent {}'].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set(['MyAgent'])); + }); + + it('resolves a base class through a barrel re-export', async () => { + const code = ["import { MyBase } from './barrel';", 'export class MyAgent extends MyBase {}'].join('\n'); + const modules = { + '/app/src/barrel.js': "export { MyBase } from './base';", + '/app/src/base.js': ["import { Agent } from 'agents';", 'export class MyBase extends Agent {}'].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set(['MyAgent'])); + }); + + it('resolves a base class through a star re-export', async () => { + const code = ["import { MyBase } from './barrel';", 'export class MyAgent extends MyBase {}'].join('\n'); + const modules = { + '/app/src/barrel.js': "export * from './base';", + '/app/src/base.js': ["import { Agent } from 'agents';", 'export class MyBase extends Agent {}'].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set(['MyAgent'])); + }); + + it('resolves a renamed re-export', async () => { + const code = ["import { Renamed } from './barrel';", 'export class MyAgent extends Renamed {}'].join('\n'); + const modules = { + '/app/src/barrel.js': "export { MyBase as Renamed } from './base';", + '/app/src/base.js': ["import { Agent } from 'agents';", 'export class MyBase extends Agent {}'].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set(['MyAgent'])); + }); + + it('resolves a default-exported base class', async () => { + const code = ["import MyBase from './base';", 'export class MyAgent extends MyBase {}'].join('\n'); + const modules = { + '/app/src/base.js': ["import { Agent } from 'agents';", 'export default class MyBase extends Agent {}'].join( + '\n', + ), + }; + expect(await detect(code, modules)).toEqual(new Set(['MyAgent'])); + }); + + it('resolves a namespace-imported Agent base', async () => { + const code = ["import * as agents from 'agents';", 'export class MyAgent extends agents.Agent {}'].join('\n'); + expect(await detect(code)).toEqual(new Set(['MyAgent'])); + }); + + it('does not detect a non-Agent base class from another module', async () => { + const code = ["import { MyBase } from './base';", 'export class MyDO extends MyBase {}'].join('\n'); + const modules = { + '/app/src/base.js': [ + "import { DurableObject } from 'cloudflare:workers';", + 'export class MyBase extends DurableObject {}', + ].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set()); + }); + + it('does not walk into node_modules for unknown packages', async () => { + const code = ["import { Base } from 'some-pkg';", 'export class MyDO extends Base {}'].join('\n'); + const resolver = createResolver({ + '/app/node_modules/some-pkg/dist/index.js': [ + "import { Agent } from 'agents';", + 'export class Base extends Agent {}', + ].join('\n'), + }); + + expect(await detect(code, {}, resolver)).toEqual(new Set()); + expect(resolver.loaded).not.toContain('/app/node_modules/some-pkg/dist/index.js'); + }); + + it('survives a circular module graph', async () => { + const code = ["import { A } from './a';", 'export class MyAgent extends A {}'].join('\n'); + const modules = { + '/app/src/a.js': ["import { B } from './b';", 'export class A extends B {}'].join('\n'), + '/app/src/b.js': ["import { A } from './a';", 'export class B extends A {}'].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set()); + }); + + it('tolerates an unresolvable module', async () => { + const code = ["import { Missing } from './missing';", 'export class MyDO extends Missing {}'].join('\n'); + expect(await detect(code, {})).toEqual(new Set()); + }); + + it('tolerates an unparseable module', async () => { + const code = ["import { Broken } from './broken';", 'export class MyDO extends Broken {}'].join('\n'); + expect(await detect(code, { '/app/src/broken.js': 'this is ) not ( javascript' })).toEqual(new Set()); + }); + + // Sibling modules are read raw off disk, so they are usually still TypeScript. A JS parser would + // choke on all of this; the source scan only needs the declaration-level syntax. + it('resolves a base class through unstripped TypeScript', async () => { + const code = ["import { MyBase } from './base';", 'export class MyAgent extends MyBase {}'].join('\n'); + const modules = { + '/app/src/base.js': [ + "import { Agent } from 'agents';", + "import type { Something } from './types';", + '', + 'interface Props { name: string }', + '', + 'export abstract class MyBase extends Agent {', + ' private readonly field: Map = new Map();', + ' protected async method(arg: string): Promise {}', + '}', + ].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set(['MyAgent'])); + }); + + // A constrained generic (``) puts an `extends` before the superclass clause; the + // source scan must skip the parameter list rather than latch onto the constraint. + it('resolves a base class past a constrained generic parameter', async () => { + const code = ["import { MyBase } from './base';", 'export class MyAgent extends MyBase {}'].join('\n'); + const modules = { + '/app/src/base.js': [ + "import { Agent } from 'agents';", + 'interface Constraint { name: string }', + 'export class MyBase extends Agent {', + ' private field?: T;', + '}', + ].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set(['MyAgent'])); + }); + + it('resolves a base class past a nested generic constraint', async () => { + const code = ["import { MyBase } from './base';", 'export class MyAgent extends MyBase {}'].join('\n'); + const modules = { + '/app/src/base.js': [ + "import { Agent } from 'agents';", + 'export class MyBase> extends Agent {}', + ].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set(['MyAgent'])); + }); + + it('ignores an `extends` that only appears inside a comment or string', async () => { + const code = ["import { MyBase } from './base';", 'export class MyDO extends MyBase {}'].join('\n'); + const modules = { + '/app/src/base.js': [ + "import { DurableObject } from 'cloudflare:workers';", + "import { Agent } from 'agents';", + '// export class MyBase extends Agent {}', + '/* class MyBase extends Agent {} */', + 'export class MyBase extends DurableObject {', + ' hint = "class Other extends Agent {}";', + '}', + ].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set()); + }); + + it('ignores a type-only import of an Agent base', async () => { + const code = ["import { MyBase } from './base';", 'export class MyDO extends MyBase {}'].join('\n'); + const modules = { + // `Agent` is imported for types only, so `MyBase` genuinely extends the DO base at runtime. + '/app/src/base.js': [ + "import type { Agent } from 'agents';", + "import { DurableObject } from 'cloudflare:workers';", + 'export class MyBase extends DurableObject {}', + ].join('\n'), + }; + expect(await detect(code, modules)).toEqual(new Set()); + }); + + it('works without resolve/readFile (entry-local chains only)', async () => { + const resolver: ModuleResolver = { parse: (c: string) => parseJS(c) }; + const local = ["import { Agent } from 'agents';", 'export class MyAgent extends Agent {}'].join('\n'); + expect(await detect(local, {}, resolver)).toEqual(new Set(['MyAgent'])); + + const crossModule = ["import { MyBase } from './base';", 'export class MyAgent extends MyBase {}'].join('\n'); + expect(await detect(crossModule, {}, resolver)).toEqual(new Set()); + }); +}); + +describe('collectAgentCandidates', () => { + it('only returns configured names that are classes in this module', async () => { + const code = ['class MyAgent {}', 'class Unrelated {}'].join('\n'); + expect(collectAgentCandidates(parseJS(code), ['MyAgent', 'Elsewhere'])).toEqual(new Set(['MyAgent'])); + }); + + it('maps a configured name back to its aliased local class', async () => { + const code = ['class LocalAgent {}', 'export { LocalAgent as ConfiguredAgent };'].join('\n'); + expect(collectAgentCandidates(parseJS(code), ['ConfiguredAgent'])).toEqual(new Set(['LocalAgent'])); + }); + + it('returns nothing when no configured class is declared here', async () => { + const code = "export { MyAgent } from './agent';"; + expect(collectAgentCandidates(parseJS(code), ['MyAgent'])).toEqual(new Set()); + }); +}); diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts new file mode 100644 index 000000000000..e98023d28405 --- /dev/null +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -0,0 +1,499 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parse } from 'acorn'; +import { afterEach, describe, expect, it } from 'vitest'; +import { sentryCloudflareAutoInstrumentPlugin } from '../../src/vite/autoInstrument'; + +function parseJS(code: string) { + return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; +} + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + +function writeTempDir(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-')); + tempDirs.push(dir); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +// --------------------------------------------------------------------------- +// Plugin integration (transform hook with mock this.parse) +// --------------------------------------------------------------------------- + +describe('sentryCloudflareAutoInstrumentPlugin', () => { + function createPlugin(wranglerToml: string) { + const dir = writeTempDir({ 'wrangler.toml': wranglerToml }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const mainMatch = wranglerToml.match(/main\s*=\s*"([^"]+)"/); + const entryPath = join(dir, mainMatch?.[1] ?? 'src/index.ts'); + + // Bind a mock `this.parse` that delegates to acorn. + const boundTransform = (code: string, id: string) => + plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, id); + + return { transform: boundTransform, entryPath, plugin }; + } + + it('transforms the entry file', async () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = await tx(code, entryPath); + expect(result).toBeDefined(); + expect(result.code).toContain('__SENTRY__.withSentry('); + }); + + it('leaves an already-manually-wrapped entry untouched', async () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export default withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + // No DO classes configured and nothing to wrap → no transform result. + expect(await tx(code, entryPath)).toBeUndefined(); + }); + + it('skips non-entry files', async () => { + const { transform: tx } = createPlugin('main = "src/index.ts"'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + expect(await tx(code, '/some/other/file.ts')).toBeUndefined(); + }); + + it('tolerates query params in module IDs', async () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = tx(code, `${entryPath}?worker_file`); + expect(result).toBeDefined(); + }); + + it('tolerates JS-flavored extension mismatches', async () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + const jsPath = entryPath.replace(/\.ts$/, '.js'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = tx(code, jsPath); + expect(result).toBeDefined(); + }); + + it('does not match a non-JS sibling sharing the entry basename', async () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + const cssPath = entryPath.replace(/\.ts$/, '.css'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + expect(await tx(code, cssPath)).toBeUndefined(); + }); + + it('matches Windows-style module IDs against the entry path', async () => { + const { transform: tx, entryPath } = createPlugin('main = "src/index.ts"'); + const windowsId = entryPath.replace(/\//g, '\\'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + expect(await tx(code, windowsId)).toBeDefined(); + }); + + it('skips modules served to the client environment', async () => { + const { entryPath, plugin } = createPlugin('main = "src/index.ts"'); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = await plugin.transform.call( + { parse: (c: string) => parseJS(c), environment: { name: 'client' } }, + code, + entryPath, + ); + expect(result).toBeUndefined(); + }); + + it('wraps a configured workflow class in the entry', async () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "index.ts"', + '', + '[[workflows]]', + 'name = "my-workflow"', + 'binding = "MY_WF"', + 'class_name = "MyWorkflow"', + ].join('\n'), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const code = ['class WorkflowEntrypoint {}', 'export class MyWorkflow extends WorkflowEntrypoint {}'].join('\n'); + const result = await plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, join(dir, 'index.ts')); + + expect(result).toBeDefined(); + expect(result.code).toContain('__SENTRY__.instrumentWorkflowWithSentry('); + }); + + it('wraps a directly-exported WorkerEntrypoint class (structural, no config)', async () => { + const { transform: tx, entryPath } = createPlugin('main = "index.ts"'); + + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'export class AdminEntry extends WorkerEntrypoint {', + ' fetch() { return new Response("admin"); }', + '}', + ].join('\n'); + const result = await tx(code, entryPath); + + expect(result).toBeDefined(); + expect(result.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class __SENTRY_ORIGINAL_AdminEntry__ extends WorkerEntrypoint {', + ' fetch() { return new Response("admin"); }', + '}', + 'export const AdminEntry = __SENTRY__.withSentry(() => undefined, __SENTRY_ORIGINAL_AdminEntry__);', + '', + ].join('\n'), + ); + }); + + it('wraps a self-bound WorkerEntrypoint whose base class lives in another module (config fallback)', async () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'index.ts', + services: [{ binding: 'SELF', service: 'worker-self', entrypoint: 'AdminEntry' }], + }), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + // Base class is imported, so structural detection can't see it — the config + // self-binding supplies the name instead. + const code = ["import { BaseEntry } from './base';", 'export class AdminEntry extends BaseEntry {}'].join('\n'); + const result = await plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, join(dir, 'index.ts')); + + expect(result).toBeDefined(); + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + }); + + it('wraps a WorkerEntrypoint named via a services[].entrypoint self-binding (jsonc config)', async () => { + // Mirrors the `worker-workerentrypoint-rpc` integration test, which declares + // its entrypoints through `services[].entrypoint` in a wrangler.jsonc. + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' "name": "my-worker",', + ' "main": "index.ts",', + ' "services": [', + ' { "binding": "SELF", "service": "my-worker", "entrypoint": "BindingEntrypoint" },', + ' ],', + '}', + ].join('\n'), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + // Base class imported from another module, so only the config self-binding + // identifies `BindingEntrypoint` as an entrypoint to wrap. + const code = [ + "import { BaseEntrypoint } from './base';", + 'export class BindingEntrypoint extends BaseEntrypoint {}', + ].join('\n'); + const result = await plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, join(dir, 'index.ts')); + + expect(result).toBeDefined(); + expect(result.code).toContain('export const BindingEntrypoint = __SENTRY__.withSentry('); + }); + + it('does not wrap an entrypoint that is neither detected nor self-bound', async () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'index.ts', + services: [{ binding: 'OTHER', service: 'worker-x', entrypoint: 'RemoteEntry' }], + }), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + // Base class imported (structural blind), and the only service binding is + // outward (names `worker-x`'s export), so there is nothing to wrap here. + const code = ["import { BaseEntry } from './base';", 'export class RemoteEntry extends BaseEntry {}'].join('\n'); + const result = await plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, join(dir, 'index.ts')); + + expect(result).toBeUndefined(); + }); + + // An Agent is a Durable Object, so wrangler can only ever list it under + // `durable_objects.bindings` — the base class is what tells the two apart. + describe('agent classes', () => { + const AGENT_WRANGLER = [ + 'main = "index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "MY_AGENT"', + 'class_name = "MyAgent"', + ].join('\n'); + + /** + * Plugin bound to a real temp directory. Sibling modules are written to disk because that is + * how detection reads them — the plugin context intentionally exposes no `load`, since awaiting + * it inside a transform hook deadlocks the build. + */ + function createAgentPlugin(files: Record, wrangler = AGENT_WRANGLER) { + const dir = writeTempDir({ 'wrangler.toml': wrangler, ...files }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const ctx = { + parse: (c: string) => parseJS(c), + resolve: async (source: string) => ({ id: join(dir, `${source.replace(/^\.\//, '')}.ts`) }), + }; + + return (code: string) => plugin.transform.call(ctx, code, join(dir, 'index.ts')); + } + + it('wraps an Agent declared in the entry with instrumentAgentWithSentry', async () => { + const tx = createAgentPlugin({}); + const code = ["import { Agent } from 'agents';", 'export class MyAgent extends Agent {}'].join('\n'); + + const result = await tx(code); + expect(result.code).toContain('export const MyAgent = __SENTRY__.instrumentAgentWithSentry('); + expect(result.code).not.toContain('instrumentDurableObjectWithSentry'); + }); + + it('wraps an AIChatAgent subclass with instrumentAgentWithSentry', async () => { + const tx = createAgentPlugin({}); + const code = [ + "import { AIChatAgent } from '@cloudflare/ai-chat';", + 'export class MyAgent extends AIChatAgent {}', + ].join('\n'); + + const result = await tx(code); + expect(result.code).toContain('export const MyAgent = __SENTRY__.instrumentAgentWithSentry('); + }); + + it('wraps an Agent whose base class lives in another module', async () => { + const tx = createAgentPlugin({ + 'base.ts': ["import { Agent } from 'agents';", 'export class MyBase extends Agent {}'].join('\n'), + }); + const code = ["import { MyBase } from './base';", 'export class MyAgent extends MyBase {}'].join('\n'); + + const result = await tx(code); + expect(result.code).toContain('export const MyAgent = __SENTRY__.instrumentAgentWithSentry('); + }); + + it('keeps the Durable Object helper for a DO whose base class lives in another module', async () => { + const tx = createAgentPlugin({ + 'base.ts': [ + "import { DurableObject } from 'cloudflare:workers';", + 'export class MyBase extends DurableObject {}', + ].join('\n'), + }); + const code = ["import { MyBase } from './base';", 'export class MyAgent extends MyBase {}'].join('\n'); + + const result = await tx(code); + expect(result.code).toContain('export const MyAgent = __SENTRY__.instrumentDurableObjectWithSentry('); + expect(result.code).not.toContain('instrumentAgentWithSentry'); + }); + + it('does not warn about an Agent that was wrapped manually', async () => { + const dir = writeTempDir({ 'wrangler.toml': AGENT_WRANGLER }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const warnings: string[] = []; + const code = [ + "import * as Sentry from '@sentry/cloudflare';", + "import { Agent } from 'agents';", + 'class MyAgentBase extends Agent {}', + 'export const MyAgent = Sentry.instrumentAgentWithSentry((env) => ({}), MyAgentBase);', + ].join('\n'); + + await plugin.transform.call( + { parse: (c: string) => parseJS(c), warn: (msg: string) => warnings.push(msg) }, + code, + join(dir, 'index.ts'), + ); + + expect(warnings).toEqual([]); + }); + }); + + it('warns when a configured DO class cannot be wrapped', async () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "MY_DO"', + 'class_name = "MyDO"', + ].join('\n'), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const warnings: string[] = []; + const code = "export { MyDO } from './do';"; + await plugin.transform.call( + { parse: (c: string) => parseJS(c), warn: (msg: string) => warnings.push(msg) }, + code, + join(dir, 'index.ts'), + ); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('MyDO'); + }); +}); + +describe('wranglerConfigPath option', () => { + it('reads a custom-named wrangler config (e.g. wrangler.agent.jsonc)', async () => { + const dir = writeTempDir({ 'wrangler.agent.jsonc': '{ "main": "src/agent.ts" }' }); + const plugin = sentryCloudflareAutoInstrumentPlugin({ wranglerConfigPath: './wrangler.agent.jsonc' }); + plugin.configResolved({ root: dir }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = await plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, join(dir, 'src/agent.ts')); + + expect(result).toBeDefined(); + expect(result.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };', + 'export default __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', + '', + ].join('\n'), + ); + }); + + it('prefers the explicit path over default-name configs', async () => { + const dir = writeTempDir({ + 'wrangler.toml': 'main = "src/default.ts"', + 'wrangler.agent.jsonc': '{ "main": "src/agent.ts" }', + }); + const plugin = sentryCloudflareAutoInstrumentPlugin({ wranglerConfigPath: 'wrangler.agent.jsonc' }); + plugin.configResolved({ root: dir }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const tx = (id: string) => plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, id); + + // The probed default config's entry must not be treated as the worker entry… + expect(await tx(join(dir, 'src/default.ts'))).toBeUndefined(); + // …while the explicit config's entry is. + expect(await tx(join(dir, 'src/agent.ts'))).toBeDefined(); + }); + + it('warns with only the basename when the explicit path cannot be read', () => { + const dir = writeTempDir({}); + const warnings: string[] = []; + const plugin = sentryCloudflareAutoInstrumentPlugin({ wranglerConfigPath: 'nested/dir/wrangler.agent.jsonc' }); + plugin.configResolved({ root: dir, logger: { warn: msg => warnings.push(msg) } }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('wrangler.agent.jsonc'); + // The full path may leak a location the user doesn't want in build logs. + expect(warnings[0]).not.toContain('nested/dir'); + }); + + it('hints at the option when no default-named config is found', () => { + const dir = writeTempDir({}); + const warnings: string[] = []; + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir, logger: { warn: msg => warnings.push(msg) } }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('`wranglerConfigPath`'); + }); +}); + +// --------------------------------------------------------------------------- +// instrument.server.* auto-detection (config from a conventional module) +// --------------------------------------------------------------------------- + +describe('instrument file auto-detection', () => { + function createPluginWithDir(files: Record) { + const dir = writeTempDir(files); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const mainMatch = files['wrangler.toml']?.match(/main\s*=\s*"([^"]+)"/); + const entryPath = join(dir, mainMatch?.[1] ?? 'index.ts'); + + const boundTransform = (code: string, id: string) => + plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, id); + + return { transform: boundTransform, entryPath, dir }; + } + + it('imports the callback from an instrument.server file next to the entry', async () => { + const { transform: tx, entryPath } = createPluginWithDir({ + 'wrangler.toml': 'main = "index.ts"', + 'instrument.server.ts': 'export default (env) => ({ dsn: env.SENTRY_DSN });', + }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = await tx(code, entryPath)!; + expect(result).toBeDefined(); + expect(result.code).toContain("import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.ts';"); + expect(result.code).toContain('__SENTRY__.withSentry(__SENTRY_OPTIONS_CALLBACK__,'); + }); + + it('detects alternative extensions (e.g. .mjs)', async () => { + const { transform: tx, entryPath } = createPluginWithDir({ + 'wrangler.toml': 'main = "index.ts"', + 'instrument.server.mjs': 'export default () => ({ dsn: "x" });', + }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = await tx(code, entryPath)!; + expect(result.code).toContain("import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.mjs';"); + }); + + it('emits a resolvable import for .cjs instrument files', async () => { + const { transform: tx, entryPath } = createPluginWithDir({ + 'wrangler.toml': 'main = "index.ts"', + 'instrument.server.cjs': 'module.exports = () => ({ dsn: "x" });', + }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = await tx(code, entryPath)!; + expect(result.code).toContain("import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.cjs';"); + }); + + it('falls back to an env-based callback when no instrument file exists', async () => { + const { transform: tx, entryPath } = createPluginWithDir({ 'wrangler.toml': 'main = "index.ts"' }); + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = await tx(code, entryPath)!; + expect(result.code).not.toContain('__SENTRY_OPTIONS_CALLBACK__'); + expect(result.code).toContain('__SENTRY__.withSentry(() => undefined,'); + }); + + it('applies the detected callback to Durable Object classes too', async () => { + const { transform: tx, entryPath } = createPluginWithDir({ + 'wrangler.toml': [ + 'main = "index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "MY_DO"', + 'class_name = "MyDO"', + ].join('\n'), + 'instrument.server.ts': 'export default (env) => ({ dsn: env.SENTRY_DSN });', + }); + + const code = ['class DurableObject {}', 'export class MyDO extends DurableObject {}'].join('\n'); + const result = await tx(code, entryPath)!; + expect(result.code).toContain( + 'const __SENTRY_OPTIONS__ = (env) => { const opts = (__SENTRY_OPTIONS_CALLBACK__)(env); return { ...opts, enableRpcTracePropagation: opts?.enableRpcTracePropagation ?? true, rpcTracePropagationBindings: ["MY_DO", ...(opts?.rpcTracePropagationBindings ?? [])] }; };', + ); + expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS__,'); + }); +}); diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts new file mode 100644 index 000000000000..8dcc594c25f8 --- /dev/null +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -0,0 +1,782 @@ +import { parse } from 'acorn'; +import { describe, expect, it } from 'vitest'; +import { applyAutoInstrumentTransforms, type ClassWrapperKind, type TransformContext } from '../../src/vite/transform'; +import { DEFAULT_EXPORT } from '../../src/vite/bindings'; + +function parseJS(code: string) { + return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; +} + +/** Build a `classWrappers` map with every given class name marked as a DO. */ +function doWrappers(...names: string[]): Map { + return new Map(names.map(name => [name, 'durableObject'])); +} + +/** Build a `classWrappers` map with every given class name marked as a Workflow. */ +function workflowWrappers(...names: string[]): Map { + return new Map(names.map(name => [name, 'workflow'])); +} + +/** Build a `classWrappers` map with every given class name marked as a WorkerEntrypoint. */ +function entrypointWrappers(...names: string[]): Map { + return new Map(names.map(name => [name, 'workerEntrypoint'])); +} + +function transform(code: string, ctx: TransformContext) { + return applyAutoInstrumentTransforms(code, parseJS(code), ctx); +} + +// --------------------------------------------------------------------------- +// Default export wrapping +// --------------------------------------------------------------------------- + +describe('default export wrapping', () => { + const ctx: TransformContext = { classWrappers: doWrappers(), optionsFn: '(env) => ({})' }; + + it('wraps an object-literal default export', () => { + const code = [ + 'const handler = {', + ' fetch() { return new Response("ok"); }', + '};', + 'export default handler;', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain("import * as __SENTRY__ from '@sentry/cloudflare'"); + expect(result.code).toContain('const __SENTRY_DEFAULT_EXPORT__ = handler'); + expect(result.code).toContain('__SENTRY__.withSentry((env) => ({}), __SENTRY_DEFAULT_EXPORT__)'); + expect(result.code).not.toContain('export default handler'); + expect(result.map).toBeDefined(); + }); + + it('wraps an inline object default export', () => { + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('const __SENTRY_DEFAULT_EXPORT__ ='); + expect(result.code).toContain('__SENTRY__.withSentry('); + }); + + it('wraps a class default export', () => { + const code = [ + 'class Worker {', + ' fetch(request) { return new Response("ok"); }', + '}', + 'export default Worker;', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('__SENTRY__.withSentry('); + }); + + it('uses custom options callback', () => { + const custom: TransformContext = { + classWrappers: doWrappers(), + optionsFn: '(env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 })', + }; + + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = transform(code, custom)!; + expect(result.code).toContain('dsn: env.SENTRY_DSN'); + expect(result.code).toContain('tracesSampleRate: 1.0'); + }); + + it('skips when already wrapped with withSentry', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export default withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('skips when already wrapped with Sentry.withSentry', () => { + const code = [ + "import * as Sentry from '@sentry/cloudflare';", + 'export default Sentry.withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('generates a source map', () => { + const code = 'export default { fetch() { return new Response("ok"); } };'; + const result = transform(code, ctx)!; + expect(result.map).toBeDefined(); + expect(result.map.mappings).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// Durable Object class wrapping +// --------------------------------------------------------------------------- + +describe('Durable Object class wrapping', () => { + const ctx: TransformContext = { + classWrappers: doWrappers('MyDurableObject'), + optionsFn: '(env) => ({})', + }; + + it('wraps an exported DO class', () => { + const code = [ + 'class DurableObject {}', + 'export class MyDurableObject extends DurableObject {', + ' fetch(request) { return new Response("DO ok"); }', + '}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + + expect(result.code).toContain('class __SENTRY_ORIGINAL_MyDurableObject__'); + expect(result.code).not.toContain('export class MyDurableObject'); + expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry('); + expect(result.code).toContain('export const MyDurableObject ='); + expect(result.code).toContain('__SENTRY_ORIGINAL_MyDurableObject__'); + }); + + it('wraps multiple DO classes', () => { + const multi: TransformContext = { + classWrappers: doWrappers('DOA', 'DOB'), + optionsFn: '(env) => ({})', + }; + + const code = [ + 'class DurableObject {}', + 'export class DOA extends DurableObject {}', + 'export class DOB extends DurableObject {}', + ].join('\n'); + + const result = transform(code, multi)!; + expect(result).toBeDefined(); + expect(result.code).toContain('export const DOA ='); + expect(result.code).toContain('export const DOB ='); + expect(result.code).toContain('class __SENTRY_ORIGINAL_DOA__'); + expect(result.code).toContain('class __SENTRY_ORIGINAL_DOB__'); + }); + + it('ignores classes not listed in wrangler config', () => { + const code = ['class DurableObject {}', 'export class SomeOtherClass extends DurableObject {}'].join('\n'); + + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('ignores non-class named exports', () => { + const code = 'export const MyDurableObject = 42;'; + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('wraps a DO class exported via a specifier list', () => { + const code = [ + 'class DurableObject {}', + 'class MyDurableObject extends DurableObject {', + ' fetch(request) { return new Response("DO ok"); }', + '}', + 'export { MyDurableObject };', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('class __SENTRY_ORIGINAL_MyDurableObject__'); + expect(result.code).toContain( + 'const MyDurableObject = __SENTRY__.instrumentDurableObjectWithSentry((env) => ({}), __SENTRY_ORIGINAL_MyDurableObject__);', + ); + // The original specifier export keeps exporting the wrapped binding. + expect(result.code).toContain('export { MyDurableObject };'); + expect(result.wrappedClasses).toEqual(new Set(['MyDurableObject'])); + }); + + it('wraps a DO class exported via an aliased specifier', () => { + const code = [ + 'class DurableObject {}', + 'class Internal extends DurableObject {}', + 'export { Internal as MyDurableObject };', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('class __SENTRY_ORIGINAL_Internal__'); + expect(result.code).toContain( + 'const Internal = __SENTRY__.instrumentDurableObjectWithSentry((env) => ({}), __SENTRY_ORIGINAL_Internal__);', + ); + expect(result.code).toContain('export { Internal as MyDurableObject };'); + expect(result.wrappedClasses).toEqual(new Set(['MyDurableObject'])); + }); + + it('leaves re-exports from other modules alone and reports them unwrapped', () => { + const code = "export { MyDurableObject } from './do';"; + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('reports wrapped DO classes for the inline export form', () => { + const code = ['class DurableObject {}', 'export class MyDurableObject extends DurableObject {}'].join('\n'); + const result = transform(code, ctx)!; + expect(result.wrappedClasses).toEqual(new Set(['MyDurableObject'])); + }); + + it('counts a manually wrapped DO export as wrapped without touching it', () => { + const code = [ + "import { instrumentDurableObjectWithSentry } from '@sentry/cloudflare';", + 'class Impl {}', + 'export const MyDurableObject = instrumentDurableObjectWithSentry((env) => ({}), Impl);', + ].join('\n'); + + // The DO is configured, so its manual wrapping is reported (letting the + // plugin skip the "could not auto-instrument" warning) but the code is left + // untouched — no rewrite, no injected `@sentry/cloudflare` import. + const result = transform(code, { classWrappers: doWrappers('MyDurableObject'), optionsFn: '(env) => ({})' })!; + expect(result.wrappedClasses).toEqual(new Set(['MyDurableObject'])); + expect(result.code).toBe(code); + expect(result.code).not.toContain('__SENTRY_ORIGINAL_'); + expect(result.code).not.toContain("import * as __SENTRY__ from '@sentry/cloudflare'"); + }); + + it('returns undefined when nothing is wrapped and no DO classes are configured', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export default withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + + expect(transform(code, ctx)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Workflow class wrapping +// --------------------------------------------------------------------------- + +describe('Workflow class wrapping', () => { + const ctx: TransformContext = { + classWrappers: workflowWrappers('MyWorkflow'), + optionsFn: '(env) => ({})', + }; + + it('wraps an exported workflow class with instrumentWorkflowWithSentry', () => { + const code = [ + 'class WorkflowEntrypoint {}', + 'export class MyWorkflow extends WorkflowEntrypoint {', + ' async run(event, step) {}', + '}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('class __SENTRY_ORIGINAL_MyWorkflow__'); + expect(result.code).not.toContain('export class MyWorkflow'); + expect(result.code).toContain('export const MyWorkflow = __SENTRY__.instrumentWorkflowWithSentry('); + // A workflow must never be wrapped with the DO helper. + expect(result.code).not.toContain('instrumentDurableObjectWithSentry'); + expect(result.wrappedClasses).toEqual(new Set(['MyWorkflow'])); + }); + + it('wraps a workflow class exported via a specifier', () => { + const code = [ + 'class WorkflowEntrypoint {}', + 'class MyWorkflow extends WorkflowEntrypoint {}', + 'export { MyWorkflow };', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain( + 'const MyWorkflow = __SENTRY__.instrumentWorkflowWithSentry((env) => ({}), __SENTRY_ORIGINAL_MyWorkflow__);', + ); + expect(result.code).toContain('export { MyWorkflow };'); + expect(result.wrappedClasses).toEqual(new Set(['MyWorkflow'])); + }); + + it('counts a manually wrapped workflow export as wrapped without touching it', () => { + const code = [ + "import { instrumentWorkflowWithSentry } from '@sentry/cloudflare';", + 'class Impl {}', + 'export const MyWorkflow = instrumentWorkflowWithSentry((env) => ({}), Impl);', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.wrappedClasses).toEqual(new Set(['MyWorkflow'])); + expect(result.code).toBe(code); + }); + + it('ignores workflow classes not listed in wrangler config', () => { + const code = ['class WorkflowEntrypoint {}', 'export class SomeOtherWorkflow extends WorkflowEntrypoint {}'].join( + '\n', + ); + expect(transform(code, ctx)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// WorkerEntrypoint class wrapping (structural detection) +// +// A worker's own entrypoints aren't listed in its wrangler config, so these are +// detected by their `extends WorkerEntrypoint` clause (the identifier imported +// from `cloudflare:workers`) rather than by a config entry. +// --------------------------------------------------------------------------- + +describe('WorkerEntrypoint class wrapping (structural)', () => { + // No config entry — detection is purely structural. + const ctx: TransformContext = { classWrappers: new Map(), optionsFn: '(env) => ({})' }; + + it('wraps a directly-exported class extending the imported WorkerEntrypoint', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'export class AdminEntry extends WorkerEntrypoint {', + ' fetch(request) { return new Response("admin"); }', + '}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('class __SENTRY_ORIGINAL_AdminEntry__'); + expect(result.code).not.toContain('export class AdminEntry'); + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); + }); + + it('wraps a class extending an aliased WorkerEntrypoint import', () => { + const code = [ + "import { WorkerEntrypoint as WE } from 'cloudflare:workers';", + 'export class AdminEntry extends WE {}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + }); + + it('wraps a class extending a namespace-imported WorkerEntrypoint', () => { + const code = [ + "import * as cf from 'cloudflare:workers';", + 'export class AdminEntry extends cf.WorkerEntrypoint {}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + }); + + it('wraps a class via an indirect same-file base chain', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class Base extends WorkerEntrypoint {}', + 'export class AdminEntry extends Base {}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); + }); + + it('wraps an entrypoint exported via a specifier', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class AdminEntry extends WorkerEntrypoint {}', + 'export { AdminEntry };', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain( + 'const AdminEntry = __SENTRY__.withSentry((env) => ({}), __SENTRY_ORIGINAL_AdminEntry__);', + ); + expect(result.code).toContain('export { AdminEntry };'); + expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); + }); + + it('does not wrap a class extending a same-named local class (not the import)', () => { + // `WorkerEntrypoint` here is a local class, not the `cloudflare:workers` + // import, so it must not be mistaken for an entrypoint. + const code = ['class WorkerEntrypoint {}', 'export class NotAnEntry extends WorkerEntrypoint {}'].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('does not wrap a non-exported entrypoint class', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class Unexported extends WorkerEntrypoint {}', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// WorkerEntrypoint class wrapping (config self-binding fallback) +// +// When the base class lives in another module, structural detection can't see +// it; a self-bound service entrypoint in the config supplies the name instead. +// --------------------------------------------------------------------------- + +describe('WorkerEntrypoint class wrapping (config fallback)', () => { + const ctx: TransformContext = { + classWrappers: entrypointWrappers('AdminEntry'), + optionsFn: '(env) => ({})', + }; + + it('wraps a configured entrypoint whose base class is imported from another module', () => { + const code = ["import { BaseEntry } from './base';", 'export class AdminEntry extends BaseEntry {}'].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); + }); + + it('ignores an entrypoint that is neither structurally detected nor configured', () => { + const other: TransformContext = { classWrappers: new Map(), optionsFn: '(env) => ({})' }; + const code = ["import { BaseEntry } from './base';", 'export class AdminEntry extends BaseEntry {}'].join('\n'); + expect(transform(code, other)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Agent classes (configured as Durable Objects, upgraded by detection) +// --------------------------------------------------------------------------- + +describe('agent class wrapping', () => { + it('wraps a detected Agent with instrumentAgentWithSentry instead of the DO helper', () => { + const code = ["import { Agent } from 'agents';", 'export class MyAgent extends Agent {}'].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyAgent'), + agentClasses: new Set(['MyAgent']), + optionsFn: '(env) => ({})', + })!; + + expect(result.code).toContain('export const MyAgent = __SENTRY__.instrumentAgentWithSentry('); + expect(result.code).not.toContain('instrumentDurableObjectWithSentry'); + expect(result.wrappedClasses).toEqual(new Set(['MyAgent'])); + }); + + it('still wraps an undetected DO with the Durable Object helper', () => { + const code = ["import { DurableObject } from 'cloudflare:workers';", 'export class MyDO extends DurableObject {}']; + + const result = transform(code.join('\n'), { + classWrappers: doWrappers('MyDO'), + agentClasses: new Set(), + optionsFn: '(env) => ({})', + })!; + + expect(result.code).toContain('export const MyDO = __SENTRY__.instrumentDurableObjectWithSentry('); + expect(result.code).not.toContain('instrumentAgentWithSentry'); + }); + + it('wraps an Agent and a plain DO in the same entry with their respective helpers', () => { + const code = [ + "import { Agent } from 'agents';", + "import { DurableObject } from 'cloudflare:workers';", + 'export class MyAgent extends Agent {}', + 'export class MyDO extends DurableObject {}', + ].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyAgent', 'MyDO'), + agentClasses: new Set(['MyAgent']), + optionsFn: '(env) => ({})', + })!; + + expect(result.code).toContain('export const MyAgent = __SENTRY__.instrumentAgentWithSentry('); + expect(result.code).toContain('export const MyDO = __SENTRY__.instrumentDurableObjectWithSentry('); + }); + + it('emits the expected source for a mixed Agent/chat-agent/DO entry', () => { + const code = [ + "import { Agent } from 'agents';", + "import { AIChatAgent } from '@cloudflare/ai-chat';", + "import { DurableObject } from 'cloudflare:workers';", + 'export class MyAgent extends Agent {}', + 'export class MyChat extends AIChatAgent {}', + 'export class MyDO extends DurableObject {}', + 'export default { fetch() {} };', + ].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyAgent', 'MyChat', 'MyDO'), + agentClasses: new Set(['MyAgent', 'MyChat']), + optionsFn: '(env) => ({ dsn: env.SENTRY_DSN })', + })!; + + expect(result.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + "import { Agent } from 'agents';", + "import { AIChatAgent } from '@cloudflare/ai-chat';", + "import { DurableObject } from 'cloudflare:workers';", + 'class __SENTRY_ORIGINAL_MyAgent__ extends Agent {}', + 'export const MyAgent = __SENTRY__.instrumentAgentWithSentry((env) => ({ dsn: env.SENTRY_DSN }), __SENTRY_ORIGINAL_MyAgent__);', + '', + 'class __SENTRY_ORIGINAL_MyChat__ extends AIChatAgent {}', + 'export const MyChat = __SENTRY__.instrumentAgentWithSentry((env) => ({ dsn: env.SENTRY_DSN }), __SENTRY_ORIGINAL_MyChat__);', + '', + 'class __SENTRY_ORIGINAL_MyDO__ extends DurableObject {}', + 'export const MyDO = __SENTRY__.instrumentDurableObjectWithSentry((env) => ({ dsn: env.SENTRY_DSN }), __SENTRY_ORIGINAL_MyDO__);', + '', + 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() {} };', + 'export default __SENTRY__.withSentry((env) => ({ dsn: env.SENTRY_DSN }), __SENTRY_DEFAULT_EXPORT__);', + '', + ].join('\n'), + ); + }); + + it('upgrades a specifier-exported Agent, matching detection on the local name', () => { + const code = [ + "import { Agent } from 'agents';", + 'class LocalAgent extends Agent {}', + 'export { LocalAgent as ConfiguredAgent };', + ].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('ConfiguredAgent'), + agentClasses: new Set(['LocalAgent']), + optionsFn: '(env) => ({})', + })!; + + expect(result.code).toContain('const LocalAgent = __SENTRY__.instrumentAgentWithSentry('); + }); + + it('does not report a manually Agent-wrapped export as unwrapped', () => { + const code = [ + "import * as Sentry from '@sentry/cloudflare';", + "import { Agent } from 'agents';", + 'class MyAgentBase extends Agent {}', + 'export const MyAgent = Sentry.instrumentAgentWithSentry((env) => ({}), MyAgentBase);', + ].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyAgent'), + agentClasses: new Set(), + optionsFn: '(env) => ({})', + })!; + + expect(result.wrappedClasses).toEqual(new Set(['MyAgent'])); + }); + + it('leaves the DO helper accepted for a manually wrapped Durable Object', () => { + const code = [ + "import * as Sentry from '@sentry/cloudflare';", + 'export const MyDO = Sentry.instrumentDurableObjectWithSentry((env) => ({}), class {});', + ].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '(env) => ({})', + })!; + + expect(result.wrappedClasses).toEqual(new Set(['MyDO'])); + }); +}); + +// --------------------------------------------------------------------------- +// Combined transforms (DO + Workflow + default export) +// --------------------------------------------------------------------------- + +describe('combined transforms', () => { + const ctx: TransformContext = { + classWrappers: doWrappers('MyDO'), + optionsFn: '(env) => ({ dsn: env.SENTRY_DSN })', + }; + + it('wraps a DO and a Workflow with their respective helpers', () => { + const mixed: TransformContext = { + classWrappers: new Map([ + ['MyDO', 'durableObject'], + ['MyWorkflow', 'workflow'], + ]), + optionsFn: '(env) => ({})', + }; + + const code = [ + 'class DurableObject {}', + 'class WorkflowEntrypoint {}', + 'export class MyDO extends DurableObject {}', + 'export class MyWorkflow extends WorkflowEntrypoint {}', + ].join('\n'); + + const result = transform(code, mixed)!; + expect(result.code).toContain('export const MyDO = __SENTRY__.instrumentDurableObjectWithSentry('); + expect(result.code).toContain('export const MyWorkflow = __SENTRY__.instrumentWorkflowWithSentry('); + expect(result.wrappedClasses).toEqual(new Set(['MyDO', 'MyWorkflow'])); + const importCount = (result.code.match(/import \* as __SENTRY__/g) ?? []).length; + expect(importCount).toBe(1); + }); + + it('wraps both DO class and default export', () => { + const code = [ + 'class DurableObject {}', + 'export class MyDO extends DurableObject {', + ' fetch(r) { return new Response("do"); }', + '}', + 'export default {', + ' fetch(r) { return new Response("main"); }', + '};', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + + // DO wrapped + expect(result.code).toContain('class __SENTRY_ORIGINAL_MyDO__'); + expect(result.code).toContain('export const MyDO = __SENTRY__.instrumentDurableObjectWithSentry('); + + // Default export wrapped + expect(result.code).toContain('const __SENTRY_DEFAULT_EXPORT__ ='); + expect(result.code).toContain('export default __SENTRY__.withSentry('); + + // Single import + const importCount = (result.code.match(/import \* as __SENTRY__/g) ?? []).length; + expect(importCount).toBe(1); + }); + + it('does not double-wrap a class exported both by name and as default', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class AdminEntry extends WorkerEntrypoint {}', + 'export { AdminEntry };', + 'export default AdminEntry;', + ].join('\n'); + + const result = transform(code, { classWrappers: new Map(), optionsFn: '(env) => ({})' })!; + + // The named export wraps it once; the default re-export must not wrap again. + const wrapCount = (result.code.match(/withSentry\(/g) ?? []).length; + expect(wrapCount).toBe(1); + expect(result.code).toContain('const AdminEntry = __SENTRY__.withSentry('); + expect(result.code).not.toContain('__SENTRY_DEFAULT_EXPORT__'); + // The default export still points at the (single-)wrapped binding. + expect(result.code).toContain('export default AdminEntry;'); + }); + + it('handles the default export appearing before its named wrap in source order', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class AdminEntry extends WorkerEntrypoint {}', + 'export default AdminEntry;', + 'export { AdminEntry };', + ].join('\n'); + + const result = transform(code, { classWrappers: new Map(), optionsFn: '(env) => ({})' })!; + + const wrapCount = (result.code.match(/withSentry\(/g) ?? []).length; + expect(wrapCount).toBe(1); + }); + + it('wraps DO but skips already-wrapped default export', () => { + const code = [ + 'class DurableObject {}', + 'export class MyDO extends DurableObject {}', + "import { withSentry } from '@sentry/cloudflare';", + 'export default withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + // DO still wrapped + expect(result.code).toContain('export const MyDO ='); + // Default not double-wrapped + expect(result.code).not.toContain('__SENTRY_DEFAULT_EXPORT__'); + }); +}); + +describe('same-worker RPC binding floor', () => { + it('declares the merged options callback after both imports and uses it at every wrapper site', () => { + const code = [ + 'export class MyDO {}', + 'export class MyWorkflow {}', + 'export default { fetch() { return new Response("ok"); } };', + ].join('\n'); + + const result = transform(code, { + classWrappers: new Map([ + ['MyDO', 'durableObject'], + ['MyWorkflow', 'workflow'], + ]), + optionsFn: '__SENTRY_OPTIONS_CALLBACK__', + optionsImport: "import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.ts';\n", + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).toContain( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + "import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.ts';", + 'const __SENTRY_OPTIONS__ = (env) => { const opts = (__SENTRY_OPTIONS_CALLBACK__)(env); return { ...opts, enableRpcTracePropagation: opts?.enableRpcTracePropagation ?? true, rpcTracePropagationBindings: ["MY_DO", ...(opts?.rpcTracePropagationBindings ?? [])] }; };', + ].join('\n'), + ); + expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS__,'); + expect(result.code).toContain('__SENTRY__.instrumentWorkflowWithSentry(__SENTRY_OPTIONS__,'); + expect(result.code).toContain('__SENTRY__.withSentry(__SENTRY_OPTIONS__, __SENTRY_DEFAULT_EXPORT__)'); + }); + + it('passes the env fallback callback through when there is no instrument file', () => { + const code = 'export class MyDO {}'; + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).toContain( + 'const __SENTRY_OPTIONS__ = (env) => { const opts = (() => undefined)(env); return { ...opts, enableRpcTracePropagation: opts?.enableRpcTracePropagation ?? true, rpcTracePropagationBindings: ["MY_DO", ...(opts?.rpcTracePropagationBindings ?? [])] }; };', + ); + }); + + it('enables a self service binding without an entrypoint only when it wrapped the default export', () => { + const code = 'export default { fetch() { return new Response("ok"); } };'; + + const result = transform(code, { + classWrappers: doWrappers(), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'SELF', className: DEFAULT_EXPORT }], + })!; + + expect(result.code).toContain('rpcTracePropagationBindings: ["SELF",'); + }); + + it('enables a self service binding when the default export re-exports an already wrapped class', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class AdminEntry extends WorkerEntrypoint {}', + 'export { AdminEntry };', + 'export default AdminEntry;', + ].join('\n'); + + const result = transform(code, { + classWrappers: new Map(), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'SELF', className: DEFAULT_EXPORT }], + })!; + + expect(result.code).toContain('rpcTracePropagationBindings: ["SELF",'); + }); + + it('drops a binding whose class was wrapped by hand', () => { + // A hand-wrapped receiver runs on its own options and would see the trailing argument. + const code = [ + 'export const MyDO = Sentry.instrumentDurableObjectWithSentry(options, class {});', + 'export default { fetch() { return new Response("ok"); } };', + ].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).not.toContain('rpcTracePropagationBindings'); + }); + + it('drops a binding whose class is re-exported from another module', () => { + const code = ['export { MyDO } from "./myDo";', 'export default { fetch() {} };'].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).toContain('const __SENTRY_OPTIONS__ = () => undefined;'); + expect(result.code).not.toContain('rpcTracePropagationBindings'); + }); + + it('leaves the output untouched when there are no same-worker bindings', () => { + const code = 'export class MyDO {}'; + const ctx: TransformContext = { classWrappers: doWrappers('MyDO'), optionsFn: '() => undefined' }; + + expect(transform(code, { ...ctx, sameWorkerBindings: [] })!.code).toBe(transform(code, ctx)!.code); + }); +}); diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts new file mode 100644 index 000000000000..9d8612146b60 --- /dev/null +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -0,0 +1,573 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { unstable_readConfig } from 'wrangler'; +import { DEFAULT_EXPORT, resolveWranglerConfig } from '../../src/vite/wranglerConfig'; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + +function writeTempDir(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-')); + tempDirs.push(dir); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +describe('resolveWranglerConfig', () => { + it('parses wrangler.toml', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "MY_DO"', + 'class_name = "MyDurableObject"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + // wrangler resolves `main` to an absolute path against the config dir. + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'MY_DO', className: 'MyDurableObject' }]); + }); + + it('parses wrangler.json', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/worker.ts', + durable_objects: { + bindings: [{ name: 'DO_A', class_name: 'A' }], + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + expect(result!.config.main).toBe(join(dir, 'src/worker.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'DO_A', className: 'A' }]); + }); + + it('parses wrangler.jsonc (strips comments)', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' // Entry point', + ' "main": "src/index.ts",', + ' /* DO bindings */', + ' "durable_objects": {', + ' "bindings": [', + ' { "name": "DO", "class_name": "MyDO" }', + ' ]', + ' }', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'DO', className: 'MyDO' }]); + }); + + it('parses JSONC with trailing commas', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' "main": "src/index.ts",', + ' "durable_objects": {', + ' "bindings": [', + ' { "name": "DO", "class_name": "MyDO" },', + ' ],', + ' },', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'DO', className: 'MyDO' }]); + }); + + it('parses TOML single-quoted (literal) strings', () => { + const dir = writeTempDir({ 'wrangler.toml': "main = 'src/index.ts'" }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + }); + + it('prefers wrangler.json over wrangler.toml (matching wrangler itself)', () => { + const dir = writeTempDir({ + 'wrangler.toml': 'main = "from-toml.ts"', + 'wrangler.json': '{ "main": "from-json.ts" }', + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'from-json.ts')); + }); + + it('prefers wrangler.jsonc over wrangler.toml (matching wrangler itself)', () => { + const dir = writeTempDir({ + 'wrangler.toml': 'main = "from-toml.ts"', + 'wrangler.jsonc': '{ "main": "from-jsonc.ts" }', + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'from-jsonc.ts')); + }); + + it('handles TOML with commented-out bindings', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '# [[durable_objects.bindings]]', + '# name = "IGNORED"', + '# class_name = "IgnoredDO"', + '', + '[[durable_objects.bindings]]', + 'name = "REAL"', + 'class_name = "RealDO"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'REAL', className: 'RealDO' }]); + }); + + it('handles multiple DO bindings', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "DO_A"', + 'class_name = "A"', + '', + '[[durable_objects.bindings]]', + 'name = "DO_B"', + 'class_name = "B"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toHaveLength(2); + expect(result!.config.durableObjects[0]).toEqual({ name: 'DO_A', className: 'A' }); + expect(result!.config.durableObjects[1]).toEqual({ name: 'DO_B', className: 'B' }); + }); + + it('returns undefined when no config exists', () => { + const dir = writeTempDir({}); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('returns undefined for explicit non-existent path', () => { + expect(resolveWranglerConfig('/tmp', '/tmp/nonexistent.toml')).toBeUndefined(); + }); + + it('resolves a relative explicit path against the root', () => { + const dir = writeTempDir({ 'custom.toml': 'main = "src/index.ts"' }); + + const result = resolveWranglerConfig(dir, 'custom.toml'); + expect(result).toBeDefined(); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.configDir).toBe(dir); + }); + + it('returns undefined for an empty config file instead of crashing', () => { + const dir = writeTempDir({ 'wrangler.json': '' }); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('returns undefined for invalid TOML instead of crashing', () => { + const dir = writeTempDir({ 'wrangler.toml': 'main = [' }); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('skips DO bindings with a script_name (class lives in another worker)', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { + bindings: [ + { name: 'LOCAL', class_name: 'LocalDO' }, + { name: 'EXTERNAL', class_name: 'ExternalDO', script_name: 'other-worker' }, + ], + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'LOCAL', className: 'LocalDO' }]); + }); + + it('uses only the active environment DO bindings (does not union across envs)', () => { + // wrangler flattens to the active environment (top level here, since no + // CLOUDFLARE_ENV), matching what the deployed Worker actually binds. A + // class bound only in a non-active env is intentionally not included. + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'TOP', class_name: 'TopDO' }] }, + env: { + production: { + durable_objects: { + bindings: [{ name: 'PROD_ONLY', class_name: 'ProdDO' }], + }, + }, + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'TOP', className: 'TopDO' }]); + }); + + it('honors CLOUDFLARE_ENV for both main and DO bindings', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'TOP', class_name: 'TopDO' }] }, + env: { + staging: { + main: 'src/staging.ts', + durable_objects: { bindings: [{ name: 'STAGING_DO', class_name: 'StagingDO' }] }, + }, + }, + }), + }); + + const previous = process.env.CLOUDFLARE_ENV; + process.env.CLOUDFLARE_ENV = 'staging'; + try { + const result = resolveWranglerConfig(dir)!; + expect(result.config.main).toBe(join(dir, 'src/staging.ts')); + expect(result.config.durableObjects).toEqual([{ name: 'STAGING_DO', className: 'StagingDO' }]); + } finally { + if (previous === undefined) delete process.env.CLOUDFLARE_ENV; + else process.env.CLOUDFLARE_ENV = previous; + } + }); + + it('parses workflow bindings', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + workflows: [ + { name: 'my-workflow', binding: 'MY_WF', class_name: 'MyWorkflow' }, + { name: 'other', binding: 'OTHER_WF', class_name: 'OtherWorkflow' }, + ], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workflows).toEqual([ + { name: 'my-workflow', className: 'MyWorkflow' }, + { name: 'other', className: 'OtherWorkflow' }, + ]); + }); + + it('parses workflow bindings from TOML', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[workflows]]', + 'name = "my-workflow"', + 'binding = "MY_WF"', + 'class_name = "MyWorkflow"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workflows).toEqual([{ name: 'my-workflow', className: 'MyWorkflow' }]); + }); + + it('skips workflow bindings with a script_name (class lives in another worker)', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + workflows: [ + { name: 'local', binding: 'LOCAL_WF', class_name: 'LocalWorkflow' }, + { name: 'external', binding: 'EXT_WF', class_name: 'ExternalWorkflow', script_name: 'other-worker' }, + ], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workflows).toEqual([{ name: 'local', className: 'LocalWorkflow' }]); + }); + + it('defaults workflows to an empty array when none are configured', () => { + const dir = writeTempDir({ 'wrangler.json': JSON.stringify({ main: 'src/index.ts' }) }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workflows).toEqual([]); + }); + + it('collects a self-bound service entrypoint', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'src/index.ts', + services: [{ binding: 'SELF', service: 'worker-self', entrypoint: 'InternalEntry' }], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual(['InternalEntry']); + }); + + it('collects multiple self-bound entrypoints from a wrangler.jsonc', () => { + // Mirrors the `worker-workerentrypoint-rpc` integration test's config shape: + // several `services[].entrypoint` entries in a JSONC file (comments + + // trailing commas), all self-bound to this worker. + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' // Worker exposing two named entrypoints to itself', + ' "name": "my-worker",', + ' "main": "index.ts",', + ' "services": [', + ' { "binding": "SELF_A", "service": "my-worker", "entrypoint": "BindingEntrypoint" },', + ' { "binding": "SELF_B", "service": "my-worker", "entrypoint": "NoPropagationEntrypoint" },', + ' ],', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual(['BindingEntrypoint', 'NoPropagationEntrypoint']); + }); + + it("ignores outward service entrypoints (they name another worker's export)", () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'src/index.ts', + services: [ + { binding: 'SELF', service: 'worker-self', entrypoint: 'InternalEntry' }, + { binding: 'OTHER', service: 'worker-x', entrypoint: 'RemoteEntry' }, + ], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual(['InternalEntry']); + }); + + it('derives no entrypoints when the worker has no name', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + services: [{ binding: 'S', service: 'x', entrypoint: 'E' }], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual([]); + }); + + it('defaults workerEntrypoints to an empty array when no services are configured', () => { + const dir = writeTempDir({ 'wrangler.json': JSON.stringify({ name: 'w', main: 'src/index.ts' }) }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// What `unstable_readConfig` exposes about service-binding `entrypoint`. +// +// These characterize the wrangler API directly (not our wrapper) to justify a +// design decision: a service binding's `entrypoint` names a *named export on +// the target worker being bound to*, not an entrypoint this worker exposes. +// So it cannot, in general, tell auto-wrap which of *this* worker's exports is +// a handler — with one exception: a self-binding (`service === own name`). +// --------------------------------------------------------------------------- + +describe('unstable_readConfig: service-binding entrypoint semantics', () => { + function readConfig(files: Record) { + const dir = writeTempDir(files); + return unstable_readConfig({ config: join(dir, Object.keys(files)[0]!) }, { hideWarnings: true }); + } + + it('resolves `main` to an absolute path', () => { + const raw = readConfig({ + 'wrangler.json': JSON.stringify({ main: 'src/index.ts', compatibility_date: '2024-01-01' }), + }); + // Not the literal `src/index.ts` from the file — wrangler resolves it. + expect(raw.main).not.toBe('src/index.ts'); + expect(raw.main?.endsWith(join('src', 'index.ts'))).toBe(true); + }); + + it("an outward service binding names the *target* worker's export, not ours", () => { + const raw = readConfig({ + 'wrangler.json': JSON.stringify({ + name: 'worker-a', + main: 'src/index.ts', + compatibility_date: '2024-01-01', + services: [{ binding: 'MY_SVC', service: 'worker-b', entrypoint: 'SomeEntry' }], + }), + }); + + expect(raw.name).toBe('worker-a'); + // `entrypoint` belongs to `worker-b`, a different worker this build isn't + // compiling — nothing in *our* entry file to wrap from this. + expect(raw.services).toEqual([{ binding: 'MY_SVC', service: 'worker-b', entrypoint: 'SomeEntry' }]); + expect(raw.services?.[0]?.service).not.toBe(raw.name); + }); + + it('a self-binding (service === own name) does name one of *our* exports', () => { + const raw = readConfig({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'src/index.ts', + compatibility_date: '2024-01-01', + services: [ + { binding: 'SELF', service: 'worker-self', entrypoint: 'InternalEntry' }, + { binding: 'OTHER', service: 'worker-x', entrypoint: 'RemoteEntry' }, + ], + }), + }); + + // Only the self-bound entrypoint is ours; the other points at `worker-x`. + const ownEntrypoints = (raw.services ?? []).filter(s => s.service === raw.name).map(s => s.entrypoint); + expect(ownEntrypoints).toEqual(['InternalEntry']); + }); + + it('leaves `name` undefined when the config omits it (no self-binding is derivable)', () => { + const raw = readConfig({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + compatibility_date: '2024-01-01', + services: [{ binding: 'S', service: 'x', entrypoint: 'E' }], + }), + }); + + // Without a worker name there is no `service === name` to match against, so + // even self-bindings can't be identified. + expect(raw.name).toBeUndefined(); + expect(raw.topLevelName).toBeUndefined(); + }); +}); + +describe('sameWorkerBindings', () => { + function sameWorkerBindings(files: Record) { + return resolveWranglerConfig(writeTempDir(files))!.config.sameWorkerBindings; + } + + it('includes Durable Object bindings declared by this worker', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'COUNTER', class_name: 'Counter' }] }, + }), + }), + ).toEqual([{ bindingName: 'COUNTER', className: 'Counter' }]); + }); + + it('keeps every binding name pointing at the same Durable Object class', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { + bindings: [ + { name: 'COUNTER', class_name: 'Counter' }, + { name: 'COUNTER_ALIAS', class_name: 'Counter' }, + ], + }, + }), + }), + ).toEqual([ + { bindingName: 'COUNTER', className: 'Counter' }, + { bindingName: 'COUNTER_ALIAS', className: 'Counter' }, + ]); + }); + + it('excludes Durable Object bindings owned by another worker', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { + bindings: [{ name: 'REMOTE_DO', class_name: 'Other', script_name: 'other-worker' }], + }, + }), + }), + ).toEqual([]); + }); + + it('excludes a Durable Object binding carrying this worker as `script_name`', () => { + // A `script_name` binding is never wrapped by this build, propagation would target an uninstrumented receiver. + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { + bindings: [{ name: 'SELF_DO', class_name: 'Counter', script_name: 'my-worker' }], + }, + }), + }), + ).toEqual([]); + }); + + it('includes self service bindings and excludes bindings to other workers', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + services: [ + { binding: 'SELF', service: 'my-worker', entrypoint: 'AdminEntry' }, + { binding: 'DEFAULT_SELF', service: 'my-worker' }, + { binding: 'EXTERNAL', service: 'other-worker' }, + ], + }), + }), + ).toEqual([ + { bindingName: 'SELF', className: 'AdminEntry' }, + { bindingName: 'DEFAULT_SELF', className: DEFAULT_EXPORT }, + ]); + }); + + it('derives nothing from service bindings when the config omits `name`', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + services: [{ binding: 'SELF', service: 'my-worker', entrypoint: 'AdminEntry' }], + }), + }), + ).toEqual([]); + }); + + it('never includes workflow bindings or tail consumers', () => { + // Workflow bindings never reach the RPC instrumentation, tail consumers are not `env` bindings. + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + workflows: [{ name: 'MY_WF', binding: 'MY_WF', class_name: 'MyWorkflow' }], + tail_consumers: [{ service: 'my-worker' }], + }), + }), + ).toEqual([]); + }); +}); diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index a424dbcd956b..a0aea944b2a6 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -208,6 +208,135 @@ describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => { await expect(drainWaitUntilLikeCloudflareVitestPool(waitUntilPromises)).resolves.toBeUndefined(); }); + test('teardown does not deadlock when a workflow instance is reused across runs', async () => { + const waitUntilPromises: Promise[] = []; + const context: ExecutionContext = { + waitUntil: vi.fn((promise: Promise) => { + waitUntilPromises.push(promise); + }), + passThroughOnException: vi.fn(), + props: {}, + }; + + let runCount = 0; + let releaseAppWork: () => void = () => undefined; + + class ReusedWorkflow { + public constructor(private _ctx: ExecutionContext) {} + + public async run(_event: Readonly>, step: WorkflowStep): Promise { + runCount += 1; + await step.do('reused step', async () => { + if (runCount === 2) { + this._ctx.waitUntil( + new Promise(resolve => { + releaseAppWork = resolve; + }), + ); + } + }); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(getSentryOptions, ReusedWorkflow as any); + // Cloudflare reuses a Workflow instance across runs, so the context + // captured at construction is instrumented by the first run's init() + const workflow = new TestWorkflowInstrumented(context, {}) as ReusedWorkflow; + const event = { payload: {}, timestamp: new Date(), instanceId: INSTANCE_ID }; + + await workflow.run(event, mockStep); + await drainWaitUntilLikeCloudflareVitestPool(waitUntilPromises); + + await workflow.run(event, mockStep); + + releaseAppWork(); + + // Both the application work and the teardown promise must settle + await expect(drainWaitUntilLikeCloudflareVitestPool(waitUntilPromises)).resolves.toBeUndefined(); + }); + + test('step errors are still captured when a workflow instance is reused across runs', async () => { + const waitUntilPromises: Promise[] = []; + const context: ExecutionContext = { + waitUntil: vi.fn((promise: Promise) => { + waitUntilPromises.push(promise); + }), + passThroughOnException: vi.fn(), + props: {}, + }; + + let runCount = 0; + + class ReusedErrorWorkflow { + public constructor(private _ctx: ExecutionContext) {} + + public async run(_event: Readonly>, step: WorkflowStep): Promise { + runCount += 1; + await step.do('flaky step', async () => { + if (runCount === 2) { + throw new Error('second run error'); + } + }); + } + } + + // Fails the step through every retry without backoff, so the error is + // captured on the final attempt and surfaces from run() + const alwaysFailStep: WorkflowStep = { + do: vi + .fn() + .mockImplementation( + async ( + _name: string, + configOrCallback: WorkflowStepConfig | ((...args: unknown[]) => Promise), + maybeCallback?: (...args: unknown[]) => Promise, + ) => { + const retryLimit = 2; + const callback = (typeof configOrCallback === 'function' ? configOrCallback : maybeCallback)!; + let lastError: unknown; + for (let attempt = 1; attempt <= retryLimit + 1; attempt++) { + try { + return await callback({ attempt, config: { retries: { limit: retryLimit }, timeout: 60000 } }); + } catch (err) { + lastError = err; + } + } + throw lastError; + }, + ), + sleep: vi.fn(), + sleepUntil: vi.fn(), + waitForEvent: vi.fn(), + }; + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(getSentryOptions, ReusedErrorWorkflow as any); + const workflow = new TestWorkflowInstrumented(context, {}) as ReusedErrorWorkflow; + const event = { payload: {}, timestamp: new Date(), instanceId: INSTANCE_ID }; + + await workflow.run(event, mockStep); + await drainWaitUntilLikeCloudflareVitestPool(waitUntilPromises); + + await expect(workflow.run(event, alwaysFailStep)).rejects.toThrow('second run error'); + await expect(drainWaitUntilLikeCloudflareVitestPool(waitUntilPromises)).resolves.toBeUndefined(); + + const errorEnvelopes = mockTransport.send.mock.calls.filter(call => { + const items = (call[0] as any)[1] as any[]; + return items.some(i => i[0].type === 'event'); + }); + expect(errorEnvelopes).toHaveLength(1); + expect(errorEnvelopes[0]![0][1][0][1]).toMatchObject({ + exception: { + values: [ + expect.objectContaining({ + type: 'Error', + value: 'second run error', + mechanism: { type: 'auto.faas.cloudflare.workflow', handled: true }, + }), + ], + }, + }); + }); + test('Wraps env with instrumentEnv', async () => { class EnvTestWorkflow { constructor(_ctx: ExecutionContext, _env: unknown) {} diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index ea154816da09..45652c6f26e5 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -1,9 +1,11 @@ +import type { ExecutionContext } from '@cloudflare/workers-types'; import * as sentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makeFlushLock } from '../src/flush'; import { getInstrumented } from '../src/instrument'; import * as sdk from '../src/sdk'; import { wrapMethodWithSentry } from '../src/wrapMethodWithSentry'; +import { resetSdk } from './testUtils'; const mocks = vi.hoisted(() => ({ flush: vi.fn().mockResolvedValue(true), @@ -24,31 +26,6 @@ vi.mock('../src/sdk', () => ({ init: vi.fn(() => createMockClient(true)), })); -// Mock sentry/core functions -vi.mock('@sentry/core', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - getClient: vi.fn(), - withIsolationScope: vi.fn((callback: (scope: unknown) => unknown) => callback(createMockScope())), - withScope: vi.fn((callback: (scope: unknown) => unknown) => callback(createMockScope())), - startSpan: vi.fn((opts, callback) => callback(createMockSpan())), - startNewTrace: vi.fn(callback => callback()), - captureException: vi.fn(), - flush: vi.fn().mockResolvedValue(true), - getActiveSpan: vi.fn(), - }; -}); - -const mockedWithIsolationScope = vi.mocked(sentryCore.withIsolationScope); - -function createMockScope() { - return { - getClient: vi.fn(), - setClient: vi.fn(), - }; -} - function createMockSpan() { return { setAttribute: vi.fn(), @@ -88,6 +65,7 @@ describe('wrapMethodWithSentry', () => { afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); describe('basic wrapping', () => { @@ -137,7 +115,6 @@ describe('wrapMethodWithSentry', () => { const wrapped = wrapMethodWithSentry(options, handler); const result = wrapped(); - expect(handler).toHaveBeenCalled(); // Without storage, there's no linkPromise, so sync behavior is preserved expect(result).not.toBeInstanceOf(Promise); expect(result).toBe('sync-result'); @@ -253,6 +230,7 @@ describe('wrapMethodWithSentry', () => { describe('span creation', () => { it('creates span with spanName when provided', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); const handler = vi.fn().mockResolvedValue('result'); const options = { origin: 'auto.faas.cloudflare.durable_object', @@ -265,7 +243,7 @@ describe('wrapMethodWithSentry', () => { const wrapped = wrapMethodWithSentry(options, handler); await wrapped(); - expect(sentryCore.startSpan).toHaveBeenCalledWith( + expect(startSpanSpy).toHaveBeenCalledWith( expect.objectContaining({ name: 'test-span', }), @@ -274,6 +252,7 @@ describe('wrapMethodWithSentry', () => { }); it('does not create span when spanName is not provided', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); const handler = vi.fn().mockResolvedValue('result'); const options = { origin: 'auto.faas.cloudflare.durable_object', @@ -285,12 +264,13 @@ describe('wrapMethodWithSentry', () => { await wrapped(); // startSpan should not be called when no spanName is provided - expect(sentryCore.startSpan).not.toHaveBeenCalled(); + expect(startSpanSpy).not.toHaveBeenCalled(); }); }); describe('error handling', () => { it('captures exceptions from sync methods', async () => { + const exceptionSpy = vi.spyOn(sentryCore, 'captureException'); const error = new Error('Test sync error'); const handler = vi.fn().mockImplementation(() => { throw error; @@ -304,7 +284,7 @@ describe('wrapMethodWithSentry', () => { const wrapped = wrapMethodWithSentry(options, handler); await expect(async () => wrapped()).rejects.toThrow('Test sync error'); - expect(sentryCore.captureException).toHaveBeenCalledWith(error, { + expect(exceptionSpy).toHaveBeenCalledWith(error, { mechanism: { type: 'auto.faas.cloudflare.durable_object', handled: false, @@ -313,6 +293,7 @@ describe('wrapMethodWithSentry', () => { }); it('captures exceptions from async methods', async () => { + const exceptionSpy = vi.spyOn(sentryCore, 'captureException'); const error = new Error('Test async error'); const handler = vi.fn().mockRejectedValue(error); const options = { @@ -324,7 +305,7 @@ describe('wrapMethodWithSentry', () => { const wrapped = wrapMethodWithSentry(options, handler); await expect(wrapped()).rejects.toThrow('Test async error'); - expect(sentryCore.captureException).toHaveBeenCalledWith(error, { + expect(exceptionSpy).toHaveBeenCalledWith(error, { mechanism: { type: 'auto.faas.cloudflare.durable_object', handled: false, @@ -334,23 +315,8 @@ describe('wrapMethodWithSentry', () => { }); describe('startNewTrace option', () => { - it('uses withIsolationScope when startNewTrace is true', async () => { - const handler = vi.fn().mockResolvedValue('result'); - const options = { - origin: 'auto.faas.cloudflare.durable_object', - options: {}, - context: createMockContext(), - startNewTrace: true, - spanName: 'alarm', - }; - - const wrapped = wrapMethodWithSentry(options, handler); - await wrapped(); - - expect(sentryCore.withIsolationScope).toHaveBeenCalled(); - }); - it('uses startNewTrace when startNewTrace is true and spanName is set', async () => { + const startNewTraceSpy = vi.spyOn(sentryCore, 'startNewTrace'); const handler = vi.fn().mockResolvedValue('result'); const options = { origin: 'auto.faas.cloudflare.durable_object', @@ -363,10 +329,11 @@ describe('wrapMethodWithSentry', () => { const wrapped = wrapMethodWithSentry(options, handler); await wrapped(); - expect(sentryCore.startNewTrace).toHaveBeenCalledWith(expect.any(Function)); + expect(startNewTraceSpy).toHaveBeenCalledWith(expect.any(Function)); }); it('does not use startNewTrace when startNewTrace is false', async () => { + const startNewTraceSpy = vi.spyOn(sentryCore, 'startNewTrace'); const handler = vi.fn().mockResolvedValue('result'); const options = { origin: 'auto.faas.cloudflare.durable_object', @@ -379,7 +346,7 @@ describe('wrapMethodWithSentry', () => { const wrapped = wrapMethodWithSentry(options, handler); await wrapped(); - expect(sentryCore.startNewTrace).not.toHaveBeenCalled(); + expect(startNewTraceSpy).not.toHaveBeenCalled(); }); }); @@ -427,7 +394,7 @@ describe('wrapMethodWithSentry', () => { const mockStorage = { kv: mockKv }; const mockSpan = createMockSpan(); - vi.mocked(sentryCore.startSpan).mockImplementation((opts, callback) => callback(mockSpan as any)); + vi.spyOn(sentryCore, 'startSpan').mockImplementation((opts, callback) => callback(mockSpan as any)); const context = { waitUntil: vi.fn(), @@ -461,7 +428,7 @@ describe('wrapMethodWithSentry', () => { }); it('stores span context after execution when startNewTrace is true', async () => { - vi.mocked(sentryCore.getActiveSpan).mockReturnValue({ + vi.spyOn(sentryCore, 'getActiveSpan').mockReturnValue({ spanContext: vi.fn().mockReturnValue({ traceId: 'current-trace-id-123456789012345678', spanId: 'current-span-id', @@ -495,7 +462,7 @@ describe('wrapMethodWithSentry', () => { }); it('does not store span context when startNewTrace is false', async () => { - vi.mocked(sentryCore.getActiveSpan).mockReturnValue({ + vi.spyOn(sentryCore, 'getActiveSpan').mockReturnValue({ spanContext: vi.fn().mockReturnValue({ traceId: 'current-trace-id-123456789012345678', spanId: 'current-span-id', @@ -636,7 +603,7 @@ describe('wrapMethodWithSentry', () => { it('creates a new client when scope has no client', async () => { const scope = new sentryCore.Scope(); - mockedWithIsolationScope.mockImplementation(vi.fn(callback => callback(scope))); + vi.spyOn(sentryCore, 'getIsolationScope').mockReturnValue(scope); const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); @@ -670,7 +637,7 @@ describe('wrapMethodWithSentry', () => { const scope = new sentryCore.Scope(); scope.setClient(disposedClient); - mockedWithIsolationScope.mockImplementation(vi.fn(callback => callback(scope))); + vi.spyOn(sentryCore, 'getIsolationScope').mockReturnValue(scope); const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); @@ -703,7 +670,7 @@ describe('wrapMethodWithSentry', () => { const scope = new sentryCore.Scope(); scope.setClient(validClient); - mockedWithIsolationScope.mockImplementation(vi.fn(callback => callback(scope))); + vi.spyOn(sentryCore, 'getIsolationScope').mockReturnValue(scope); vi.mocked(sdk.init).mockClear(); const spyClient = vi.spyOn(scope, 'setClient'); @@ -731,6 +698,7 @@ describe('wrapMethodWithSentry waitUntil teardown (hibernation regression)', () afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); // Regression for #22328 diff --git a/packages/cloudflare/vite.config.ts b/packages/cloudflare/vite.config.ts index b2150cd225a4..c44d095eb4ee 100644 --- a/packages/cloudflare/vite.config.ts +++ b/packages/cloudflare/vite.config.ts @@ -1,6 +1,19 @@ -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; import baseConfig from '../../vite/vite.config'; +// `wrangler` pulls in `miniflare`'s bundled `undici`, which references the `File` +// global at module load. `File` only exists as a global on Node >=20, so these +// suites throw `ReferenceError: File is not defined` at import time on Node 18. +// The Vite plugin they cover requires Vite 7 (Node >=20.19) to run, so skipping +// them below Node 20 loses no meaningful coverage. +const nodeMajor = Number(process.versions.node.split('.')[0]); +const wranglerDependentTests = + nodeMajor < 20 ? ['**/test/vite/wranglerConfig.test.ts', '**/test/vite/autoInstrument.test.ts'] : []; + export default defineConfig({ ...baseConfig, + test: { + ...baseConfig.test, + exclude: [...configDefaults.exclude, ...wranglerDependentTests], + }, }); diff --git a/packages/core/package.json b/packages/core/package.json index f35b56c8b368..7dbf32e13495 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/core", - "version": "10.67.0", + "version": "10.73.0", "description": "Base implementation for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/core", diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 87b62479d18e..c490ff11e44c 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -52,7 +52,6 @@ import { makePromiseBuffer, type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from import { safeMathRandom } from './utils/randomSafeContext'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning } from './utils/spanUtils'; -import { rejectedSyncPromise } from './utils/syncpromise'; import { safeUnref } from './utils/timer'; import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent'; import { resolveDataCollectionOptions } from './utils/data-collection/resolveDataCollectionOptions'; @@ -256,9 +255,9 @@ export abstract class Client { } // Backfill enableLogs option from _experiments.enableLogs - // TODO(v11): Remove or change default value + // todo(v11): Remove the experimental flag // eslint-disable-next-line typescript/no-deprecated - this._options.enableLogs = this._options.enableLogs ?? this._options._experiments?.enableLogs; + this._options.enableLogs = this._options.enableLogs ?? this._options._experiments?.enableLogs ?? true; // Setup log flushing with weight and timeout tracking if (this._options.enableLogs) { @@ -1438,15 +1437,6 @@ export abstract class Client { // 0.0 === 0% events are sent // Sampling for transaction happens somewhere else const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate); - if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { - this.recordDroppedEvent('sample_rate', 'error'); - return rejectedSyncPromise( - _makeDoNotSendEventError( - `Discarding event because it's not included in the random sample (sampling rate = ${sampleRate})`, - ), - ); - } - const dataCategory = getDataCategoryByType(event.type); return this._prepareEvent(event, hint, currentScope, isolationScope) @@ -1481,6 +1471,13 @@ export abstract class Client { this._updateSessionFromEvent(session, processedEvent); } + if (isError && typeof parsedSampleRate === 'number' && safeMathRandom() > parsedSampleRate) { + this.recordDroppedEvent('sample_rate', 'error'); + throw _makeDoNotSendEventError( + `Discarding event because it's not included in the random sample (sampling rate = ${sampleRate})`, + ); + } + if (isTransaction) { const spanCountBefore = processedEvent.sdkProcessingMetadata?.spanCountBeforeProcessing || 0; const spanCountAfter = processedEvent.spans ? processedEvent.spans.length : 0; diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index bcf181221e9c..cfd70d5c99cb 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -174,7 +174,7 @@ export function setConversationId(conversationId: string | null | undefined): vo * isolation scope. If you call this function after handling a certain error and another error * is captured in between, the last one is returned instead of the one you might expect. * Also, ids of events that were never sent to Sentry (for example because - * they were dropped in `beforeSend`) could be returned. + * they were dropped by sampling or `beforeSend`) could be returned. * * @returns The last event id of the isolation scope. */ diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index bfe78d499260..7887de49c687 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -1,3 +1,4 @@ +import { HTTP_URL, URL_FULL } from '@sentry/conventions/attributes'; import { getClient } from './currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from './semanticAttributes'; import { setHttpStatus, SPAN_STATUS_ERROR, spanIsIgnored, startInactiveSpan } from './tracing'; @@ -222,7 +223,11 @@ export function _INTERNAL_getTracingHeadersForFetchRequest( const originalHeaders = fetchOptionsObj.headers || (isRequest(request) ? request.headers : undefined); if (!originalHeaders) { - return { ...traceHeaders }; + return { + 'sentry-trace': sentryTrace, + ...(baggage && { baggage }), + ...(traceparent && { traceparent }), + }; } else if (isHeaders(originalHeaders)) { const newHeaders = new Headers(originalHeaders); @@ -292,11 +297,11 @@ export function _INTERNAL_getTracingHeadersForFetchRequest( const newHeaders: { 'sentry-trace': string; - baggage: string | undefined; + baggage?: string; traceparent?: string; } = Object.assign({}, originalHeaders, { 'sentry-trace': (existingSentryTraceHeader as string | undefined) ?? sentryTrace, - baggage: newBaggageHeaders.length > 0 ? newBaggageHeaders.join(',') : undefined, + ...(newBaggageHeaders.length > 0 && { baggage: newBaggageHeaders.join(',') }), }); if (propagateTraceparent && traceparent && !existingTraceparentHeader) { @@ -388,7 +393,9 @@ function getFetchSpanAttributes( }; if (parsedUrl) { if (!isURLObjectRelative(parsedUrl)) { - attributes['http.url'] = stripDataUrlContent(parsedUrl.href); + // oxlint-disable-next-line typescript/no-deprecated + attributes[HTTP_URL] = stripDataUrlContent(parsedUrl.href); + attributes[URL_FULL] = stripDataUrlContent(parsedUrl.href); attributes['server.address'] = parsedUrl.host; } if (parsedUrl.search) { diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts index 66821e208581..1940e23b9ff6 100644 --- a/packages/core/src/instrument/fetch.ts +++ b/packages/core/src/instrument/fetch.ts @@ -124,7 +124,8 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat if ( shouldEnhance && - error instanceof TypeError && + isError(error) && + error.name === 'TypeError' && (error.message === 'Failed to fetch' || error.message === 'Load failed' || error.message === 'NetworkError when attempting to fetch resource.') diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index a5b7d44a3b33..6385d330ec37 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -191,7 +191,7 @@ export interface ConsolaLogObject { const DEFAULT_CAPTURED_LEVELS: Array = ['trace', 'debug', 'info', 'warn', 'error', 'fatal']; /** - * Creates a new Sentry reporter for Consola that forwards logs to Sentry. Requires the `enableLogs` option to be enabled. + * Creates a new Sentry reporter for Consola that forwards logs to Sentry. * * **Note: This integration supports Consola v3.x only.** The reporter interface and log object structure * may differ in other versions of Consola. @@ -205,7 +205,7 @@ const DEFAULT_CAPTURED_LEVELS: Array = ['trace', 'debug', 'inf * import { consola } from 'consola'; * * Sentry.init({ - * enableLogs: true, + * dsn: '__DSN__', * }); * * const sentryReporter = Sentry.createConsolaReporter({ diff --git a/packages/core/src/integrations/eventFilters.ts b/packages/core/src/integrations/eventFilters.ts index 8afa97f60ccb..08de4951e641 100644 --- a/packages/core/src/integrations/eventFilters.ts +++ b/packages/core/src/integrations/eventFilters.ts @@ -21,7 +21,8 @@ const DEFAULT_IGNORE_ERRORS = [ /vv\(\)\.getRestrictions is not a function/, // Error thrown by GTM, seemingly not affecting end-users /Can't find variable: _AutofillCallbackHandler/, // Unactionable error in instagram webview https://developers.facebook.com/community/threads/320013549791141/ /Object Not Found Matching Id:\d+, MethodName:simulateEvent/, // unactionable error from CEFSharp, a .NET library that embeds chromium in .NET apps - /^Java exception was raised during method invocation$/, // error from Facebook Mobile browser (https://github.com/getsentry/sentry-javascript/issues/15065) + /Java exception was raised during method invocation$/, // error from Facebook Mobile browser (https://github.com/getsentry/sentry-javascript/issues/15065, https://github.com/getsentry/sentry-javascript/issues/23733) + /Java object is gone$/, // error from Facebook Mobile browser (https://github.com/getsentry/sentry-javascript/issues/15065, https://github.com/getsentry/sentry-javascript/issues/23733) ]; /** Options for the EventFilters integration */ diff --git a/packages/core/src/integrations/express/index.ts b/packages/core/src/integrations/express/index.ts index 0adadff74adc..6cf1787d03de 100644 --- a/packages/core/src/integrations/express/index.ts +++ b/packages/core/src/integrations/express/index.ts @@ -29,11 +29,13 @@ import { debug } from '../../utils/debug-logger'; import { captureException } from '../../exports'; +import { getClient } from '../../currentScopes'; import { DEBUG_BUILD } from '../../debug-build'; import type { ExpressApplication, ExpressErrorMiddleware, ExpressHandlerOptions, + ExpressIntegration, ExpressIntegrationOptions, ExpressLayer, ExpressMiddleware, @@ -43,6 +45,7 @@ import type { ExpressRouter, ExpressRouterv4, ExpressRouterv5, + ExpressShouldHandleError, MiddlewareError, } from './types'; import { @@ -197,6 +200,17 @@ export function patchExpressModule( return express; } +/** + * The `shouldHandleError` configured on the registered Express integration, if any. + * + * The integration is defined per platform (e.g. `expressIntegration()` in `@sentry/node`), so it is + * looked up by name here — the same way `getIntegrationByName` is used for `VercelAI` and + * `ProfilingIntegration`. + */ +function getIntegrationShouldHandleError(): ExpressShouldHandleError | undefined { + return getClient()?.getIntegrationByName('Express')?.getShouldHandleError?.(); +} + /** * An Express-compatible error handler, used by setupExpressErrorHandler */ @@ -209,7 +223,14 @@ export function expressErrorHandler(options?: ExpressHandlerOptions): ExpressErr ): void { // When an error happens, the `expressRequestHandler` middleware does not run, so we set it here too setSDKProcessingMetadata(request); - const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError; + const shouldHandleError = + // oxlint-disable-next-line typescript/no-deprecated + options?.shouldHandleError ?? getIntegrationShouldHandleError() ?? defaultShouldHandleError; + + if (shouldHandleError === false) { + next(error); + return; + } if (shouldHandleError(error)) { const eventId = captureException(error, { diff --git a/packages/core/src/integrations/express/types.ts b/packages/core/src/integrations/express/types.ts index dc5fe252a820..829f0783cef1 100644 --- a/packages/core/src/integrations/express/types.ts +++ b/packages/core/src/integrations/express/types.ts @@ -27,6 +27,7 @@ * limitations under the License. */ +import type { Integration } from '../../types/integration'; import type { RequestEventData } from '../../types/request'; import type { SpanAttributes } from '../../types/span'; @@ -152,6 +153,33 @@ export type ExpressIntegrationOptions = { * resolved route to the underlying transport layer (e.g. OTel RPCMetadata). */ onRouteResolved?: (route: string | undefined) => void; + + /** + * Callback deciding whether an error passed to `next(error)` should be captured + * and sent to Sentry. + * + * By default, 5xx errors (and errors without a resolvable status) are sent, while + * 3xx and 4xx errors are not. Set to `false` to capture no errors at all. + * + * Capturing Express errors still requires `setupExpressErrorHandler(app)`. Passing + * `shouldHandleError` to that call instead is deprecated: it takes precedence over + * this option, but will be removed in v11. + * + * @example + * + * ```javascript + * Sentry.init({ + * integrations: [ + * Sentry.expressIntegration({ + * shouldHandleError(error) { + * return (error.statusCode ?? 500) >= 500; + * }, + * }), + * ], + * }); + * ``` + */ + shouldHandleError?: ExpressShouldHandleError; }; export type LayerMetadata = { @@ -182,10 +210,40 @@ export type ExpressErrorMiddleware = ( next: (error: MiddlewareError) => void, ) => void; +/** Callback deciding whether an error should be captured; `false` disables capture entirely. */ +export type ExpressShouldHandleError = ((error: MiddlewareError) => boolean) | false; + +/** + * The Express integration is defined per platform (e.g. `expressIntegration()` in `@sentry/node`), so + * `expressErrorHandler` reads its `shouldHandleError` back off the registered instance by name. + * `getShouldHandleError` is optional because not every platform's Express integration implements it. + */ +export interface ExpressIntegration extends Integration { + getShouldHandleError?: () => ExpressShouldHandleError | undefined; +} + export interface ExpressHandlerOptions { /** * Callback method deciding whether error should be captured and sent to Sentry + * * @param error Captured middleware error + * + * @deprecated Configure `shouldHandleError` on `expressIntegration()` rather than here. Keep calling + * `setupExpressErrorHandler(app)` as that is what captures the errors. This option will be removed in v11. + * + * @example + * + * ```javascript + * Sentry.init({ + * integrations: [ + * Sentry.expressIntegration({ + * shouldHandleError(error) { + * return (error.statusCode ?? 500) >= 500; + * }, + * }), + * ], + * }); + * ``` */ shouldHandleError?(this: void, error: MiddlewareError): boolean; } diff --git a/packages/core/src/integrations/functiontostring.ts b/packages/core/src/integrations/functiontostring.ts index 86844843664f..c915e55d8c3f 100644 --- a/packages/core/src/integrations/functiontostring.ts +++ b/packages/core/src/integrations/functiontostring.ts @@ -2,6 +2,7 @@ import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { defineIntegration } from '../integration'; import type { IntegrationFn } from '../types/integration'; +import type { WrappedFunction } from '../types/wrappedfunction'; import { getOriginalFunction } from '../utils/object'; const INTEGRATION_NAME = 'FunctionToString' as const; @@ -18,24 +19,22 @@ const _functionToStringIntegration = (() => { // intrinsics (like Function.prototype) might be immutable in some environments // e.g. Node with --frozen-intrinsics, XS (an embedded JavaScript engine) or SES (a JavaScript proposal) try { - Function.prototype.toString = new Proxy(originalFunctionToString, { - apply(target, thisArg, args) { - const originalFunction = getOriginalFunction(thisArg); - let context = thisArg; + Function.prototype.toString = function (this: WrappedFunction, ...args: unknown[]): string { + const originalFunction = getOriginalFunction(this); + let unwrappedFunction: WrappedFunction | undefined; - try { - if (SETUP_CLIENTS.has(getClient()!) && originalFunction) { - context = originalFunction; - } - } catch { - // Reading the Sentry carrier off `getClient()` can throw a `SecurityError` when `this` (or the global - // object) is a `WindowProxy` whose browsing context was navigated cross-origin. The native - // `toString` never throws here, so fall back to it to avoid turning harmless introspection into noise. + try { + if (SETUP_CLIENTS.has(getClient() as Client) && originalFunction !== undefined) { + unwrappedFunction = originalFunction; } + } catch { + // Reading the Sentry carrier off `getClient()` can throw a `SecurityError` when `this` (or the global + // object) is a `WindowProxy` whose browsing context was navigated cross-origin. The native + // `toString` never throws here, so fall back to it to avoid turning harmless introspection into noise. + } - return Reflect.apply(target, context, args); - }, - }); + return originalFunctionToString.apply(unwrappedFunction ?? this, args); + }; } catch { // ignore errors here, just don't patch this } diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts index 86d7359aa119..1e93166c9de4 100644 --- a/packages/core/src/integrations/http/server-subscription.ts +++ b/packages/core/src/integrations/http/server-subscription.ts @@ -1,3 +1,4 @@ +// oxlint-disable max-lines /** * Provide the `http.server.request.start` subscription function that we use * to instrument incoming HTTP requests that use the `node:http` module. @@ -26,7 +27,7 @@ import { getClient, getCurrentScope, getIsolationScope, withIsolationScope } fro import { hasSpansEnabled } from '../../utils/hasSpansEnabled'; import { headersToDict, httpHeadersToSpanAttributes, httpRequestToRequestData } from '../../utils/request'; import { patchRequestToCaptureBody } from './patch-request-to-capture-body'; -import { parseStringToURLObject, stripUrlQueryAndFragment } from '../../utils/url'; +import { isURLObjectRelative, parseStringToURLObject, stripUrlQueryAndFragment } from '../../utils/url'; import { recordRequestSession } from './record-request-session'; import { generateSpanId, generateTraceId } from '../../utils/propagationContext'; import { continueTrace } from '../../tracing/trace'; @@ -40,6 +41,7 @@ import { safeMathRandom } from '../../utils/randomSafeContext'; import { SPAN_KIND } from '../../spanKind'; import type { SpanAttributes } from '../../types/span'; import type { SpanStatus } from '../../types/spanStatus'; +import { HTTP_URL, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -298,7 +300,10 @@ function buildServerSpanWrap( 'net.peer.port': remotePort, 'sentry.http.prefetch': isKnownPrefetchRequest(request) || undefined, // Old Semantic Conventions attributes for compatibility - 'http.url': fullUrl, + [URL_FULL]: urlObj && !isURLObjectRelative(urlObj) ? urlObj.href : undefined, + [URL_PATH]: urlObj?.pathname ?? httpTargetWithoutQueryFragment, + // oxlint-disable-next-line typescript-eslint(no-deprecated) + [HTTP_URL]: fullUrl, 'http.method': method, 'http.target': urlObj ? `${urlObj.pathname}${urlObj.search}` : httpTargetWithoutQueryFragment, 'http.host': host, diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index c527a34cd5a2..c9a7ba4b5c94 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -14,7 +14,12 @@ import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types/span'; import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; -import { buildServerAttributesFromInfo, extractSessionDataFromInitializeResponse } from './sessionExtraction'; +import { + buildServerAttributesFromInfo, + extractSessionDataFromInitializeResponse, + extractSessionDataFromResponse, +} from './sessionExtraction'; +import { updateSessionDataForTransport } from './sessionManagement'; import type { MCPTransport, RequestId, RequestSpanMapValue, ResolvedMcpOptions } from './types'; /** @@ -64,12 +69,20 @@ function getOrCreateSpanMap(transport: MCPTransport): Map = { + ...buildServerAttributesFromInfo(responseSessionData.serverInfo), + }; + if (responseSessionData.protocolVersion) { + responseAttributes[MCP_PROTOCOL_VERSION_ATTRIBUTE] = responseSessionData.protocolVersion; + } + if (Object.keys(responseAttributes).length > 0) { + span.setAttributes(responseAttributes); + } if (hasError) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - } else if (method === 'initialize') { - const sessionData = extractSessionDataFromInitializeResponse(result); - const serverAttributes = buildServerAttributesFromInfo(sessionData.serverInfo); - - const initAttributes: Record = { - ...serverAttributes, - }; - if (sessionData.protocolVersion) { - initAttributes[MCP_PROTOCOL_VERSION_ATTRIBUTE] = sessionData.protocolVersion; - } - - span.setAttributes(initAttributes); } else if (method === 'tools/call') { - const toolAttributes = extractToolResultAttributes(result, options.recordOutputs); + const toolAttributes = extractToolResultAttributes(result, spanData.capturePolicy.recordOutputs); span.setAttributes(toolAttributes); } else if (method === 'prompts/get') { - const promptAttributes = extractPromptResultAttributes(result, options.recordOutputs); + const promptAttributes = extractPromptResultAttributes(result, spanData.capturePolicy.recordOutputs); span.setAttributes(promptAttributes); } diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index df09714e404f..62521def4d01 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -1,8 +1,7 @@ -import { getClient } from '../../currentScopes'; import { fill } from '../../utils/object'; import { wrapAllMCPHandlers, wrapExistingHandlers } from './handlers'; import { wrapTransportError, wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport'; -import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions } from './types'; +import type { MCPServerInstance, McpServerWrapperOptions, MCPTransport } from './types'; import { validateMcpServerInstance } from './validation'; /** @@ -12,10 +11,10 @@ import { validateMcpServerInstance } from './validation'; const wrappedMcpServerInstances = new WeakSet(); /** - * Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation. + * Wraps an MCP Server instance with Sentry instrumentation. * * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package (legacy `tool`/`resource`/`prompt` API) - * and versions that expose the newer `registerTool`/`registerResource`/`registerPrompt` API (introduced in 1.x, sole API in 2.x). + * and `@modelcontextprotocol/server` version 2.x (`registerTool`/`registerResource`/`registerPrompt` API). * Automatically instruments transport methods and handler functions for comprehensive monitoring. * * Both call orderings are supported: wrapping before or after registering tools, resources, @@ -26,8 +25,8 @@ const wrappedMcpServerInstances = new WeakSet(); * @example * ```typescript * import * as Sentry from '@sentry/core'; - * import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; - * import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + * import { McpServer } from '@modelcontextprotocol/server'; + * import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; * * // Wrap first, then register tools — this is the correct order * const server = Sentry.wrapMcpServerWithSentry( @@ -42,7 +41,7 @@ const wrappedMcpServerInstances = new WeakSet(); * { recordInputs: true, recordOutputs: false } * ); * - * const transport = new StreamableHTTPServerTransport(); + * const transport = new NodeStreamableHTTPServerTransport(); * await server.connect(transport); * ``` * @@ -60,13 +59,7 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S, } const serverInstance = mcpServerInstance as MCPServerInstance; - const client = getClient(); - const genAI = client?.getDataCollectionOptions().genAI; - - const resolvedOptions: ResolvedMcpOptions = { - recordInputs: options?.recordInputs ?? genAI?.inputs ?? false, - recordOutputs: options?.recordOutputs ?? genAI?.outputs ?? false, - }; + const captureOptions: McpServerWrapperOptions = { ...options }; fill(serverInstance, 'connect', originalConnect => { return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) { @@ -76,8 +69,8 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S, ...restArgs, ); - wrapTransportOnMessage(transport, resolvedOptions); - wrapTransportSend(transport, resolvedOptions); + wrapTransportOnMessage(transport, captureOptions); + wrapTransportSend(transport, captureOptions); wrapTransportOnClose(transport); wrapTransportError(transport); diff --git a/packages/core/src/integrations/mcp-server/sessionExtraction.ts b/packages/core/src/integrations/mcp-server/sessionExtraction.ts index 7b7878a05644..e487dcad1873 100644 --- a/packages/core/src/integrations/mcp-server/sessionExtraction.ts +++ b/packages/core/src/integrations/mcp-server/sessionExtraction.ts @@ -21,9 +21,20 @@ import { getProtocolVersionForTransport, getSessionDataForTransport, } from './sessionManagement'; -import type { ExtraHandlerData, JsonRpcRequest, MCPTransport, PartyInfo, SessionData } from './types'; +import type { + ExtraHandlerData, + JsonRpcNotification, + JsonRpcRequest, + MCPTransport, + PartyInfo, + SessionData, +} from './types'; import { isValidContentItem } from './validation'; +const MCP_PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; +const MCP_CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo'; +const MCP_SERVER_INFO_META_KEY = 'io.modelcontextprotocol/serverInfo'; + /** * Extracts and validates PartyInfo from an unknown object * @param obj - Unknown object that might contain party info @@ -62,6 +73,29 @@ export function extractSessionDataFromInitializeRequest(request: JsonRpcRequest) sessionData.clientInfo = extractPartyInfo(request.params.clientInfo); } } + + return sessionData; +} + +/** + * Extracts session data from an MCP 2026-07-28 request or notification envelope. + * @param message - JSON-RPC message containing modern request metadata + * @returns Session data extracted from the message + */ +export function extractSessionDataFromMessage(message: JsonRpcRequest | JsonRpcNotification): SessionData { + const sessionData: SessionData = {}; + if (isValidContentItem(message.params)) { + if (isValidContentItem(message.params._meta)) { + const meta = message.params._meta; + if (typeof meta[MCP_PROTOCOL_VERSION_META_KEY] === 'string') { + sessionData.protocolVersion = meta[MCP_PROTOCOL_VERSION_META_KEY]; + } + if (meta[MCP_CLIENT_INFO_META_KEY]) { + sessionData.clientInfo = extractPartyInfo(meta[MCP_CLIENT_INFO_META_KEY]); + } + } + } + return sessionData; } @@ -80,6 +114,22 @@ export function extractSessionDataFromInitializeResponse(result: unknown): Parti sessionData.serverInfo = extractPartyInfo(result.serverInfo); } } + + return sessionData; +} + +/** + * Extracts session data from MCP 2026-07-28 result metadata. + * @param result - JSON-RPC result containing modern response metadata + * @returns Session data extracted from the result + */ +export function extractSessionDataFromResponse(result: unknown): Partial { + const sessionData: Partial = {}; + if (isValidContentItem(result)) { + if (isValidContentItem(result._meta) && result._meta[MCP_SERVER_INFO_META_KEY]) { + sessionData.serverInfo = extractPartyInfo(result._meta[MCP_SERVER_INFO_META_KEY]); + } + } return sessionData; } diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index 8ae9902c2405..23772cbab0ab 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -5,7 +5,7 @@ * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/transports */ -import { getIsolationScope, withIsolationScope } from '../../currentScopes'; +import { getClient, getIsolationScope, withIsolationScope } from '../../currentScopes'; import { startInactiveSpan, withActiveSpan } from '../../tracing'; import { isObjectLike } from '../../utils/is'; import { fill } from '../../utils/object'; @@ -15,68 +15,88 @@ import { captureError } from './errorCapture'; import { buildClientAttributesFromInfo, extractSessionDataFromInitializeRequest, - extractSessionDataFromInitializeResponse, + extractSessionDataFromMessage, } from './sessionExtraction'; -import { - cleanupSessionDataForTransport, - storeSessionDataForTransport, - updateSessionDataForTransport, -} from './sessionManagement'; +import { cleanupSessionDataForTransport, updateSessionDataForTransport } from './sessionManagement'; import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; -import type { ExtraHandlerData, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, isValidContentItem } from './validation'; +import type { ExtraHandlerData, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse } from './validation'; + +function resolveMcpOptions(options: McpServerWrapperOptions): ResolvedMcpOptions { + if (options.recordInputs !== undefined && options.recordOutputs !== undefined) { + return { + recordInputs: options.recordInputs, + recordOutputs: options.recordOutputs, + }; + } + + const genAI = getClient()?.getDataCollectionOptions().genAI; + + return { + recordInputs: options.recordInputs ?? genAI?.inputs ?? false, + recordOutputs: options.recordOutputs ?? genAI?.outputs ?? false, + }; +} /** * Wraps transport.onmessage to create spans for incoming messages. - * For "initialize" requests, extracts and stores client info and protocol version - * in the session data for the transport. + * Extracts and stores client info and protocol version from legacy initialize + * requests and modern message envelopes. * @param transport - MCP transport instance to wrap - * @param options - Resolved MCP options + * @param options - MCP capture overrides */ -export function wrapTransportOnMessage(transport: MCPTransport, options: ResolvedMcpOptions): void { +export function wrapTransportOnMessage(transport: MCPTransport, options: McpServerWrapperOptions): void { if (transport.onmessage) { fill(transport, 'onmessage', originalOnMessage => { return function (this: MCPTransport, message: unknown, extra?: unknown) { - if (isJsonRpcRequest(message)) { - const isInitialize = message.method === 'initialize'; - let initSessionData: SessionData | undefined; - - if (isInitialize) { - try { - initSessionData = extractSessionDataFromInitializeRequest(message); - storeSessionDataForTransport(transport, initSessionData); - } catch { - // noop + const request = isJsonRpcRequest(message) ? message : undefined; + const notification = isJsonRpcNotification(message) ? message : undefined; + const jsonRpcMessage = request || notification; + let messageSessionData: SessionData | undefined; + + if (jsonRpcMessage) { + try { + messageSessionData = + request?.method === 'initialize' + ? extractSessionDataFromInitializeRequest(request) + : extractSessionDataFromMessage(jsonRpcMessage); + if (messageSessionData.protocolVersion || messageSessionData.clientInfo) { + updateSessionDataForTransport(transport, messageSessionData); } + } catch { + // noop } + } + if (request) { + const resolvedOptions = resolveMcpOptions(options); const isolationScope = getIsolationScope().clone(); return withIsolationScope(isolationScope, () => { - const spanConfig = buildMcpServerSpanConfig(message, transport, extra as ExtraHandlerData, options); + const spanConfig = buildMcpServerSpanConfig(request, transport, extra as ExtraHandlerData, resolvedOptions); const span = startInactiveSpan(spanConfig); - // For initialize requests, add client info directly to span (works even for stateless transports) - if (isInitialize && initSessionData) { + if (request.method === 'initialize' && messageSessionData) { span.setAttributes({ - ...buildClientAttributesFromInfo(initSessionData.clientInfo), - ...(initSessionData.protocolVersion && { - [MCP_PROTOCOL_VERSION_ATTRIBUTE]: initSessionData.protocolVersion, + ...buildClientAttributesFromInfo(messageSessionData.clientInfo), + ...(messageSessionData.protocolVersion && { + [MCP_PROTOCOL_VERSION_ATTRIBUTE]: messageSessionData.protocolVersion, }), }); } - storeSpanForRequest(transport, message.id, span, message.method); + storeSpanForRequest(transport, request.id, span, request.method, resolvedOptions); return withActiveSpan(span, () => { - return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, request, extra); }); }); } - if (isJsonRpcNotification(message)) { - return createMcpNotificationSpan(message, transport, extra as ExtraHandlerData, options, () => { - return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); + if (notification) { + const resolvedOptions = resolveMcpOptions(options); + return createMcpNotificationSpan(notification, transport, extra as ExtraHandlerData, resolvedOptions, () => { + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, notification, extra); }); } @@ -88,19 +108,20 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: Resolve /** * Wraps transport.send to handle outgoing messages and response correlation. - * For "initialize" responses, extracts and stores protocol version and server info - * in the session data for the transport. + * Extracts and stores protocol version and server info from legacy initialize + * responses and modern result metadata. * @param transport - MCP transport instance to wrap - * @param options - Resolved MCP options + * @param options - MCP capture overrides */ -export function wrapTransportSend(transport: MCPTransport, options: ResolvedMcpOptions): void { +export function wrapTransportSend(transport: MCPTransport, options: McpServerWrapperOptions): void { if (transport.send) { fill(transport, 'send', originalSend => { return async function (this: MCPTransport, ...args: unknown[]) { const [message] = args; if (isJsonRpcNotification(message)) { - return createMcpOutgoingNotificationSpan(message, transport, options, () => { + const resolvedOptions = resolveMcpOptions(options); + return createMcpOutgoingNotificationSpan(message, transport, resolvedOptions, () => { return (originalSend as (...args: unknown[]) => unknown).call(this, ...args); }); } @@ -111,18 +132,7 @@ export function wrapTransportSend(transport: MCPTransport, options: ResolvedMcpO captureJsonRpcErrorResponse(message.error); } - if (isValidContentItem(message.result)) { - if (message.result.protocolVersion || message.result.serverInfo) { - try { - const serverData = extractSessionDataFromInitializeResponse(message.result); - updateSessionDataForTransport(transport, serverData); - } catch { - // noop - } - } - } - - completeSpanWithResults(transport, message.id, message.result, options, !!message.error); + completeSpanWithResults(transport, message.id, message.result, !!message.error); } } diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 2749e59c1b32..98b771ad9dae 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -176,6 +176,7 @@ export type RequestId = string | number; export type RequestSpanMapValue = { span: Span; method: string; + capturePolicy: ResolvedMcpOptions; startTime: number; }; diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 27990ad2d6cc..8c4f0690073f 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -11,6 +11,7 @@ import { parseCookie } from '../utils/cookie'; import { httpHeadersToSpanAttributes } from '../utils/request'; import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; import { safeSetSpanJSONAttributes } from '../tracing/spans/captureSpan'; +import { URL_FULL, URL_QUERY } from '@sentry/conventions/attributes'; interface RequestDataIncludeOptions { cookies?: boolean; @@ -137,7 +138,7 @@ function addNormalizedRequestDataToSpan( const attributes: Record = {}; if (requestData.url) { - attributes['url.full'] = requestData.url; + attributes[URL_FULL] = requestData.url; } if (requestData.method) { @@ -145,7 +146,7 @@ function addNormalizedRequestDataToSpan( } if (requestData.query_string) { - attributes['url.query'] = normalizeQueryString(requestData.query_string); + attributes[URL_QUERY] = normalizeQueryString(requestData.query_string); } safeSetSpanJSONAttributes(span, attributes); diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 64a75359bcbd..605228376354 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -11,6 +11,7 @@ import { defineIntegration } from '../integration'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import { setHttpStatus, SPAN_STATUS_ERROR, SPAN_STATUS_OK, startSpan } from '../tracing'; import type { IntegrationFn } from '../types/integration'; +import type { WebFetchHeaders } from '../types/webfetchapi'; import { debug } from '../utils/debug-logger'; import { isObjectLike, isPlainObject } from '../utils/is'; import { addExceptionMechanism } from '../utils/misc'; @@ -84,9 +85,15 @@ export interface PostgRESTQueryBuilder { [key: string]: PostgRESTQueryOperationFn; } +/** + * `postgrest-js` stores the request headers as a plain object up to v1.19.x and as a `Headers` + * instance from v2.74.0 on (shipped with `supabase-js` 2.74.0), so we have to handle both shapes. + */ +export type PostgRESTHeaders = Record | WebFetchHeaders; + export interface PostgRESTFilterBuilder { method: string; - headers: Record; + headers: PostgRESTHeaders; url: URL; schema: string; body: any; @@ -168,19 +175,42 @@ function hasMutationBodyForDescription(rawBody: unknown, plainBody: Record; + const lowerCaseName = name.toLowerCase(); + const key = Object.keys(plainHeaders).find(headerName => headerName.toLowerCase() === lowerCaseName); + + return key !== undefined ? plainHeaders[key] : undefined; +} + /** * Extracts the database operation type from the HTTP method and headers * @param method - The HTTP method of the request * @param headers - The request headers * @returns The database operation type ('select', 'insert', 'upsert', 'update', or 'delete') */ -export function extractOperation(method: string, headers: Record = {}): string { +export function extractOperation(method: string, headers: PostgRESTHeaders = {}): string { switch (method) { case 'GET': { return 'select'; } case 'POST': { - if (headers['Prefer']?.includes('resolution=')) { + if (getHeader(headers, 'Prefer')?.includes('resolution=')) { return 'upsert'; } else { return 'insert'; @@ -404,7 +434,7 @@ function instrumentPostgRESTFilterBuilder( 'db.table': table, 'db.schema': typedThis.schema, 'db.url': typedThis.url.origin, - 'db.sdk': typedThis.headers['X-Client-Info'], + 'db.sdk': getHeader(typedThis.headers, 'X-Client-Info'), 'db.system': 'postgresql', 'db.operation': operation, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.supabase', diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index f04f9e540ea9..b31baacaa65e 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -95,7 +95,7 @@ const _consoleLoggingIntegration = ((options: Partial = { }) satisfies IntegrationFn; /** - * Captures calls to the `console` API as logs in Sentry. Requires the `enableLogs` option to be enabled. + * Captures calls to the `console` API as logs in Sentry. * * @experimental This feature is experimental and may be changed or removed in future versions. * @@ -109,7 +109,6 @@ const _consoleLoggingIntegration = ((options: Partial = { * import * as Sentry from '@sentry/browser'; * * Sentry.init({ - * enableLogs: true, * integrations: [Sentry.consoleLoggingIntegration({ levels: ['error', 'warn'] })], * }); * ``` diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index 5987df008547..efbbed137cc2 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -84,7 +84,7 @@ export function _INTERNAL_captureLog( return; } - const { release, environment, enableLogs = false, beforeSendLog } = client.getOptions(); + const { release, environment, enableLogs = true, beforeSendLog } = client.getOptions(); if (!enableLogs) { DEBUG_BUILD && debug.warn('logging option not enabled, log will not be captured.'); return; diff --git a/packages/core/src/logs/public-api.ts b/packages/core/src/logs/public-api.ts index 540ede61da79..1261edc9da75 100644 --- a/packages/core/src/logs/public-api.ts +++ b/packages/core/src/logs/public-api.ts @@ -30,7 +30,7 @@ interface CaptureLogMetadata { } /** - * @summary Capture a log with the `trace` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `trace` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { userId: 100, route: '/dashboard' }. @@ -64,7 +64,7 @@ export function trace( } /** - * @summary Capture a log with the `debug` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `debug` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { component: 'Header', state: 'loading' }. @@ -99,7 +99,7 @@ export function debug( } /** - * @summary Capture a log with the `info` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `info` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { feature: 'checkout', status: 'completed' }. @@ -134,7 +134,7 @@ export function info( } /** - * @summary Capture a log with the `warn` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `warn` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { browser: 'Chrome', version: '91.0' }. @@ -170,7 +170,7 @@ export function warn( } /** - * @summary Capture a log with the `error` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `error` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { error: 'NetworkError', url: '/api/data' }. @@ -207,7 +207,7 @@ export function error( } /** - * @summary Capture a log with the `fatal` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `fatal` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { appState: 'corrupted', sessionId: 'abc-123' }. diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index bde4fdcc405d..ec57bc24eb9c 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -549,6 +549,9 @@ export class Scope { /** * Clears the current scope and resets its properties. * Note: The client will not be cleared. + * + * @deprecated This method will be removed in v11. To reset scope state, re-initialize the SDK or run + * your code in a fresh scope via `withScope` instead. */ public clear(): this { // client is not cleared here on purpose! diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 46f37fa902c2..62a8c10072aa 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -110,6 +110,9 @@ export const SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE = 'cache.item_size'; /** TODO: Remove these once we update to latest semantic conventions */ export const SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD = 'http.request.method'; +/** + * @deprecated Use `URL_FULL` `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full'; /** diff --git a/packages/core/src/server-exports.ts b/packages/core/src/server-exports.ts index 21469232c101..574ece436506 100644 --- a/packages/core/src/server-exports.ts +++ b/packages/core/src/server-exports.ts @@ -18,10 +18,12 @@ export { safeUnref as _INTERNAL_safeUnref } from './utils/timer'; // eslint-disable-next-line typescript/no-deprecated export { patchExpressModule, setupExpressErrorHandler, expressErrorHandler } from './integrations/express/index'; export type { + ExpressIntegration, ExpressIntegrationOptions, ExpressHandlerOptions, ExpressMiddleware, ExpressErrorMiddleware, + ExpressShouldHandleError, } from './integrations/express/types'; export { instrumentPostgresJsSql, diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 983367c882f9..4c1100142fec 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -223,6 +223,8 @@ export type { LangChainOptions, LangChainIntegration } from './tracing/langchain export { instrumentStateGraphCompile, instrumentCreateReactAgent, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentCompiledGraphInvoke, _INTERNAL_getLangGraphCreateAgentSpanOptions, diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index fd762e61fbe3..c617016a3d55 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -1,7 +1,6 @@ /** * Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.) */ -import { captureException } from '../../exports'; import { getClient } from '../../currentScopes'; import { hasSpanStreamingEnabled } from '../spans/hasSpanStreamingEnabled'; import type { Span } from '../../types/span'; @@ -259,22 +258,11 @@ export function extractSystemInstructions(messages: unknown[] | unknown): { async function createWithResponseWrapper( originalWithResponse: Promise, instrumentedPromise: Promise, - mechanismType: string, ): Promise { - // Attach catch handler to originalWithResponse immediately to prevent unhandled rejection - // If instrumentedPromise rejects first, we still need this handled - const safeOriginalWithResponse = originalWithResponse.catch(error => { - captureException(error, { - mechanism: { - handled: false, - type: mechanismType, - }, - }); - throw error; - }); - - const instrumentedResult = await instrumentedPromise; - const originalWrapper = await safeOriginalWithResponse; + // Awaited together rather than in sequence so both promises get a handler attached synchronously. + // Awaiting them one after the other leaves the second unobserved when the first rejects, which + // surfaces as an unhandled rejection. + const [instrumentedResult, originalWrapper] = await Promise.all([instrumentedPromise, originalWithResponse]); // Combine instrumented result with original metadata if (originalWrapper && typeof originalWrapper === 'object' && 'data' in originalWrapper) { @@ -297,7 +285,6 @@ async function createWithResponseWrapper( export function wrapPromiseWithMethods( originalPromiseLike: Promise, instrumentedPromise: Promise, - mechanismType: string, ): Promise { // If the original result is not thenable, return the instrumented promise if (!isThenable(originalPromiseLike)) { @@ -321,7 +308,7 @@ export function wrapPromiseWithMethods( if (prop === 'withResponse' && typeof value === 'function') { return function wrappedWithResponse(this: unknown): unknown { const originalWithResponse = (value as (...args: unknown[]) => unknown).call(target); - return createWithResponseWrapper(originalWithResponse, instrumentedPromise, mechanismType); + return createWithResponseWrapper(originalWithResponse, instrumentedPromise); }; } diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index 64cd105905bc..26e684d46061 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -1,4 +1,3 @@ -import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; @@ -180,11 +179,7 @@ export function addResponseAttributes(span: Span, response: AnthropicAiResponse, /** * Handle common error catching and reporting for streaming requests */ -function handleStreamingError(error: unknown, span: Span, methodPath: string): never { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.anthropic', data: { function: methodPath } }, - }); - +function handleStreamingError(error: unknown, span: Span): never { if (span.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); span.end(); @@ -234,12 +229,12 @@ function handleStreamingRequest( options.recordOutputs ?? false, ) as unknown as R; } catch (error) { - return handleStreamingError(error, span, methodPath); + return handleStreamingError(error, span); } })(); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.anthropic'); + return wrapPromiseWithMethods(originalResult, instrumentedPromise); } else { return startSpanManual(spanConfig, span => { try { @@ -254,7 +249,7 @@ function handleStreamingRequest( return instrumentMessageStream(messageStream, span, options.recordOutputs ?? false); } catch (error) { suppressDelegatedCreate = false; - return handleStreamingError(error, span, methodPath); + return handleStreamingError(error, span); } }); } @@ -326,28 +321,14 @@ function instrumentMethod( addPrivateRequestAttributes(span, params, shouldEnableTruncation(options.enableTruncation)); } - return originalResult.then( - result => { - addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs); - return result; - }, - error => { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic', - data: { - function: methodPath, - }, - }, - }); - throw error; - }, - ); + return originalResult.then(result => { + addResponseAttributes(span, result as AnthropicAiResponse, options.recordOutputs); + return result; + }); }, ); - return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.anthropic'); + return wrapPromiseWithMethods(originalResult, instrumentedPromise); }, }) as (...args: T) => R | Promise; } diff --git a/packages/core/src/tracing/anthropic-ai/streaming.ts b/packages/core/src/tracing/anthropic-ai/streaming.ts index 68bc9a281001..6787a343f633 100644 --- a/packages/core/src/tracing/anthropic-ai/streaming.ts +++ b/packages/core/src/tracing/anthropic-ai/streaming.ts @@ -49,16 +49,10 @@ interface StreamingState { function isErrorEvent(event: AnthropicAiStreamingEvent, span: Span): boolean { if ('type' in event && typeof event.type === 'string') { - // If the event is an error, set the span status and capture the error - // These error events are not rejected by the API by default, but are sent as metadata of the response if (event.type === 'error') { + // The SDK surfaces this error to the caller (the async iterator rejects / their `error` + // listener fires), so we only mark the span failed and do not record it. span.setStatus({ code: SPAN_STATUS_ERROR, message: mapAnthropicErrorToStatusMessage(event.error?.type) }); - captureException(event.error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic.anthropic_error', - }, - }); return true; } } @@ -267,6 +261,8 @@ export function instrumentMessageStream }); stream.on('error', (error: unknown) => { + // Attaching this listener stops the stream error from being raised as an unhandled rejection, so + // we capture it here to avoid swallowing it (e.g. for callers that don't await/iterate the stream). captureException(error, { mechanism: { handled: false, diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index 68e4d414586a..2b146305c74c 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines */ -import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; @@ -309,13 +308,6 @@ function instrumentMethod( return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R; } catch (error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.google_genai', - data: { function: methodPath }, - }, - }); span.end(); throw error; } @@ -334,13 +326,11 @@ function instrumentMethod( addPrivateRequestAttributes(span, params, operationName, shouldEnableTruncation(options.enableTruncation)); } + // `onError` is a no-op because the rejection is rethrown to the caller and `startSpan` already + // marks the span errored; both leading callbacks are positional and only exist to reach `onSuccess`. return handleCallbackErrors( () => target.apply(context, args), - error => { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.google_genai', data: { function: methodPath } }, - }); - }, + () => {}, () => {}, result => { // Only add response attributes for content-producing methods, not for embeddings diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index e07c9f15c457..86105416dc26 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -245,7 +245,7 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti */ function _restartChildSpanTimeout(endTimestamp?: number): void { _cancelChildSpanTimeout(); - _idleTimeoutID = setTimeout(() => { + _childSpanTimeoutID = setTimeout(() => { if (!_finished && _autoFinishAllowed) { _finishReason = FINISH_REASON_HEARTBEAT_FAILED; span.end(endTimestamp); diff --git a/packages/core/src/tracing/langchain/embeddings.ts b/packages/core/src/tracing/langchain/embeddings.ts index f6f70280e2ac..f15c98bb879d 100644 --- a/packages/core/src/tracing/langchain/embeddings.ts +++ b/packages/core/src/tracing/langchain/embeddings.ts @@ -1,4 +1,3 @@ -import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { startSpan } from '../../tracing/trace'; import type { SpanAttributeValue } from '../../types/span'; @@ -93,12 +92,9 @@ export function instrumentEmbeddingMethod( return new Proxy(originalMethod, { apply(target, thisArg, args: unknown[]): Promise { return startSpan(_INTERNAL_getLangChainEmbeddingsSpanOptions(thisArg, args[0], options), () => { - return Reflect.apply(target, thisArg, args).then(undefined, error => { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.langchain' }, - }); - throw error; - }); + // On rejection `startSpan` marks the span failed and rethrows to the caller, so we don't + // record the error ourselves. + return Reflect.apply(target, thisArg, args); }); }, }) as (...args: unknown[]) => Promise; diff --git a/packages/core/src/tracing/langchain/index.ts b/packages/core/src/tracing/langchain/index.ts index 621ae76acdd5..3443c9e55f86 100644 --- a/packages/core/src/tracing/langchain/index.ts +++ b/packages/core/src/tracing/langchain/index.ts @@ -1,5 +1,4 @@ /* eslint-disable max-lines */ -import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpanManual } from '../../tracing/trace'; @@ -183,19 +182,14 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }, // LLM Error Handler - note: handleLLMError with capital LLM - handleLLMError(error: Error, runId: string) { + handleLLMError(_error: Error, runId: string) { + // The error is surfaced to the caller (invoke() rejects), so we only mark the span failed and + // do not record it. const span = spanMap.get(runId); if (span?.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); exitSpan(runId); } - - captureException(error, { - mechanism: { - handled: false, - type: `${LANGCHAIN_ORIGIN}.llm_error_handler`, - }, - }); }, // Chain Start Handler @@ -257,19 +251,14 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }, // Chain Error Handler - handleChainError(error: Error, runId: string) { + handleChainError(_error: Error, runId: string) { + // The error is surfaced to the caller (invoke() rejects), so we only mark the span failed and + // do not record it. const span = spanMap.get(runId); if (span?.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); exitSpan(runId); } - - captureException(error, { - mechanism: { - handled: false, - type: `${LANGCHAIN_ORIGIN}.chain_error_handler`, - }, - }); }, // Tool Start Handler @@ -335,19 +324,14 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): }, // Tool Error Handler - handleToolError(error: Error, runId: string) { + handleToolError(_error: Error, runId: string) { + // The error is surfaced to the caller (invoke() rejects), so we only mark the span failed and + // do not record it. const span = spanMap.get(runId); if (span?.isRecording()) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); exitSpan(runId); } - - captureException(error, { - mechanism: { - handled: false, - type: `${LANGCHAIN_ORIGIN}.tool_error_handler`, - }, - }); }, // LangChain BaseCallbackHandler required methods diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index daf2f55552ea..eaaa9543ea97 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -1,4 +1,3 @@ -import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { @@ -119,13 +118,9 @@ export function instrumentStateGraphCompile( return compiledGraph; } catch (error) { + // The error is rethrown to the caller (compile() throws), so we only mark the span failed + // and do not record it. span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.langgraph.error', - }, - }); throw error; } }); @@ -242,13 +237,9 @@ export function instrumentCompiledGraphInvoke( return result; } catch (error) { + // The error is rethrown to the caller (invoke() rejects), so we only mark the span failed + // and do not record it. span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.langgraph.error', - }, - }); throw error; } }, @@ -328,7 +319,7 @@ export function instrumentCreateReactAgent( * * @example * ```typescript - * import { instrumentLangGraph } from '@sentry/cloudflare'; + * import { instrumentStateGraph } from '@sentry/cloudflare'; * import { StateGraph } from '@langchain/langgraph'; * * const graph = new StateGraph(MessagesAnnotation) @@ -336,12 +327,12 @@ export function instrumentCreateReactAgent( * .addEdge(START, 'agent') * .addEdge('agent', END); * - * instrumentLangGraph(graph, { recordInputs: true, recordOutputs: true }); + * instrumentStateGraph(graph, { recordInputs: true, recordOutputs: true }); * const compiled = graph.compile({ name: 'my_agent' }); * ``` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function instrumentLangGraph any }>( +export function instrumentStateGraph any }>( stateGraph: T, options?: LangGraphOptions, ): T { @@ -349,3 +340,11 @@ export function instrumentLangGraph any return stateGraph; } + +/** + * Directly instruments a StateGraph instance to add tracing spans. + * + * @deprecated This function was renamed and will be removed in a future major version. + * Use `instrumentStateGraph` instead. + */ +export const instrumentLangGraph = instrumentStateGraph; diff --git a/packages/core/src/tracing/langgraph/utils.ts b/packages/core/src/tracing/langgraph/utils.ts index cf37ce18056e..d48683b52ac8 100644 --- a/packages/core/src/tracing/langgraph/utils.ts +++ b/packages/core/src/tracing/langgraph/utils.ts @@ -1,4 +1,3 @@ -import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span, SpanAttributes } from '../../types/span'; @@ -140,13 +139,9 @@ export function wrapToolsWithSpans(tools: unknown[], options: LangGraphOptions, return result; } catch (error) { + // The error is rethrown to the caller (invoke() rejects), so we only mark the span + // failed and do not record it. span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.langgraph.error', - }, - }); throw error; } }, diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index 821e9c68e0ff..60923b4d91cb 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -1,5 +1,4 @@ import { DEBUG_BUILD } from '../../debug-build'; -import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; @@ -185,20 +184,13 @@ function instrumentMethod( ) as unknown as R; } catch (error) { span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.openai.stream', - data: { function: methodPath }, - }, - }); span.end(); throw error; } })(); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.openai'); + return wrapPromiseWithMethods(originalResult, instrumentedPromise); } // Non-streaming @@ -212,25 +204,13 @@ function instrumentMethod( addRequestAttributes(span, params, operationName, shouldEnableTruncation(options.enableTruncation)); } - return originalResult.then( - result => { - addResponseAttributes(span, result, options.recordOutputs); - return result; - }, - error => { - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.openai', - data: { function: methodPath }, - }, - }); - throw error; - }, - ); + return originalResult.then(result => { + addResponseAttributes(span, result, options.recordOutputs); + return result; + }); }); - return wrapPromiseWithMethods(originalResult, instrumentedPromise, 'auto.ai.openai'); + return wrapPromiseWithMethods(originalResult, instrumentedPromise); }; } diff --git a/packages/core/src/tracing/vercel-ai/index.ts b/packages/core/src/tracing/vercel-ai/index.ts index c905ac980614..f9abcb096dc8 100644 --- a/packages/core/src/tracing/vercel-ai/index.ts +++ b/packages/core/src/tracing/vercel-ai/index.ts @@ -5,7 +5,9 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from ' import { shouldEnableTruncation } from '../ai/utils'; import type { Event } from '../../types/event'; import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types/span'; +import { _INTERNAL_skipAiProviderWrapping } from '../../utils/ai/providerSkip'; import { spanToJSON } from '../../utils/spanUtils'; +import { WORKERS_AI_INTEGRATION_NAME } from '../workers-ai/constants'; import { GEN_AI_CONVERSATION_ID_ATTRIBUTE, GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, @@ -86,6 +88,12 @@ function onVercelAiSpanStart(span: Span): void { return; } + // Registered lazily here (not at `setupOnce`) so a direct `env.AI.run` call made before any `ai` + // SDK call still gets its own span. + if (SPAN_TO_OPERATION_NAME.get(name) === 'generate_content') { + _INTERNAL_skipAiProviderWrapping([WORKERS_AI_INTEGRATION_NAME]); + } + const client = getClient(); const integration = client?.getIntegrationByName('VercelAI') as | { options?: { enableTruncation?: boolean } } @@ -505,10 +513,20 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute } } +const CLIENTS_WITH_VERCEL_AI_PROCESSORS = new WeakSet(); + /** * Add event processors to the given client to process Vercel AI spans. + * + * Idempotent: both the integration and the Next.js SDK register these, and duplicate hooks would + * process every span twice. */ export function addVercelAiProcessors(client: Client): void { + if (CLIENTS_WITH_VERCEL_AI_PROCESSORS.has(client)) { + return; + } + CLIENTS_WITH_VERCEL_AI_PROCESSORS.add(client); + client.on('spanStart', onVercelAiSpanStart); // Note: We cannot do this on `spanEnd`, because the span cannot be mutated anymore at this point client.addEventProcessor(Object.assign(vercelAiEventProcessor, { id: 'VercelAiEventProcessor' })); diff --git a/packages/core/src/tracing/workers-ai/constants.ts b/packages/core/src/tracing/workers-ai/constants.ts index 8f532a0a0372..1dfff520b185 100644 --- a/packages/core/src/tracing/workers-ai/constants.ts +++ b/packages/core/src/tracing/workers-ai/constants.ts @@ -8,3 +8,10 @@ export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai'; * The Sentry origin for spans created by the Workers AI instrumentation. */ export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai'; + +/** + * The key used to register this provider in the AI provider skip registry. + * + * @see `_INTERNAL_skipAiProviderWrapping` + */ +export const WORKERS_AI_INTEGRATION_NAME = 'WorkersAI' as const; diff --git a/packages/core/src/tracing/workers-ai/index.ts b/packages/core/src/tracing/workers-ai/index.ts index c6be4824305a..9232b52bd5f1 100644 --- a/packages/core/src/tracing/workers-ai/index.ts +++ b/packages/core/src/tracing/workers-ai/index.ts @@ -1,8 +1,10 @@ import { SPAN_STATUS_ERROR } from '../../tracing'; import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span } from '../../types/span'; +import { _INTERNAL_shouldSkipAiProviderWrapping } from '../../utils/ai/providerSkip'; import { isObjectLike } from '../../utils/is'; import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils'; +import { WORKERS_AI_INTEGRATION_NAME } from './constants'; import { instrumentWorkersAiStream } from './streaming'; import type { WorkersAiOptions } from './types'; import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils'; @@ -27,6 +29,12 @@ function instrumentRun( options: WorkersAiOptions & Required>, ): (...args: unknown[]) => Promise { return function instrumentedRun(...args: unknown[]): Promise { + // When another integration (e.g. Vercel AI via `workers-ai-provider`) is driving this binding, + // it records the spans itself and marks this provider as skipped; skip here to avoid double spans. + if (_INTERNAL_shouldSkipAiProviderWrapping(WORKERS_AI_INTEGRATION_NAME)) { + return originalRun.apply(context, args); + } + const [model, inputs, runOptions] = args as [unknown, unknown, Record | undefined]; const operationName = getOperationName(inputs); diff --git a/packages/core/src/types/datacollection.ts b/packages/core/src/types/datacollection.ts index b160170761d7..8d87b07f9577 100644 --- a/packages/core/src/types/datacollection.ts +++ b/packages/core/src/types/datacollection.ts @@ -91,9 +91,16 @@ export interface DataCollection { /** * Capture local variable values in stack frames. + * + * Accepts a Boolean (`true` collects all variables, `false` collects none) or a `CollectBehavior` to filter which + * variables are sent by name (`{ allow: [...] }` / `{ deny: [...] }`), matching against variable names. + * + * Note: filtering by name requires knowing the variable names **as they appear after bundling**. Minifiers and other + * build-time transforms frequently rename local variables (e.g. `password` becomes `a`), so allow/deny terms + * configured against source names may not match the names captured at runtime. * @default true */ - stackFrameVariables?: boolean; + stackFrameVariables?: boolean | CollectBehavior; /** * Number of source code context lines to capture around stack frames. diff --git a/packages/core/src/types/options.ts b/packages/core/src/types/options.ts index 3d55c5f17498..0a970a64d6e9 100644 --- a/packages/core/src/types/options.ts +++ b/packages/core/src/types/options.ts @@ -581,7 +581,7 @@ export interface ClientOptions { - if (isInstanceOf(childError, Error)) { + if (isError(childError)) { applyExceptionGroupFieldsForParentException(exception, exceptionId, error); - const newException = exceptionFromErrorImplementation(parser, childError as Error); + const newException = exceptionFromErrorImplementation(parser, childError); const newExceptionId = newExceptions.length; applyExceptionGroupFieldsForChildException(newException, `errors[${i}]`, newExceptionId, exceptionId); newExceptions = aggregateExceptionsFromError( diff --git a/packages/core/src/utils/data-collection/filterKeyValueData.ts b/packages/core/src/utils/data-collection/filterKeyValueData.ts index 0d8b00736f87..3cc85ca8eb75 100644 --- a/packages/core/src/utils/data-collection/filterKeyValueData.ts +++ b/packages/core/src/utils/data-collection/filterKeyValueData.ts @@ -13,18 +13,18 @@ function isSensitiveKey(lower: string, denySnippets: string[]): boolean { * * @param additionalDenyTerms - Additional sensitive snippets to check beyond the built-in denylist. */ -export function filterKeyValueData( - data: Record, +export function filterKeyValueData( + data: Record, behavior: CollectBehavior, additionalDenyTerms?: string[], -): Record { +): Record { if (behavior === false) { return {}; } const denySnippets = additionalDenyTerms != null ? [...SENSITIVE_KEY_SNIPPETS, ...additionalDenyTerms] : SENSITIVE_KEY_SNIPPETS; - const result: Record = {}; + const result: Record = {}; if (behavior === true) { for (const key of Object.keys(data)) { diff --git a/packages/core/src/utils/eventbuilder.ts b/packages/core/src/utils/eventbuilder.ts index 8c1525ef7e74..9b300c59c593 100644 --- a/packages/core/src/utils/eventbuilder.ts +++ b/packages/core/src/utils/eventbuilder.ts @@ -63,7 +63,7 @@ function getErrorPropertyFromObject(obj: Record): Error | undef for (const prop in obj) { if (Object.prototype.hasOwnProperty.call(obj, prop)) { const value = obj[prop]; - if (value instanceof Error) { + if (isError(value)) { return value; } } diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 1e9425567e45..64538f670f6c 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -375,6 +375,23 @@ export function addChildSpanToSpan(span: SpanWithPotentialChildren, childSpan: S const rootSpan = span[ROOT_SPAN_FIELD] || span; addNonEnumerableProperty(childSpan as SpanWithPotentialChildren, ROOT_SPAN_FIELD, rootSpan); + // `_sentryChildSpans` exists only so `getSpanDescendants()` can walk the tree when the segment span + // is sent, and that walk stops at an unsampled span without ever visiting its children. So a child + // tracked here would be held for the parent's lifetime and never read. + if (!spanIsSampled(span)) { + return; + } + + // Once the segment span stopped recording, the tree has been read for the last time, and a child + // starting now belongs to whatever segment comes next: it is re-emitted on its own instead. Tracking + // it here would pin it for as long as the parent lives, which for a span left active in an async + // context (e.g. a framework boot span captured by a queue consumer) is the rest of the process. Only + // a parent that is itself still recording keeps tracking, so a late child that outlives its segment + // still collects the subtree it is re-emitted with. + if (!span.isRecording() && !rootSpan.isRecording()) { + return; + } + // We store a list of child spans on the parent span // We need this for `getSpanDescendants()` to work if (span[CHILD_SPANS_FIELD]) { diff --git a/packages/core/src/utils/sql.ts b/packages/core/src/utils/sql.ts index 930cf08cfb5e..45c5c4fd19aa 100644 --- a/packages/core/src/utils/sql.ts +++ b/packages/core/src/utils/sql.ts @@ -8,8 +8,18 @@ const DDL_RE = new RegExp( 'i', ); -const INSERT_RE = new RegExp(`^\\s*(?INSERT)\\s+INTO\\s+(?
${TABLE_NAME})`, 'i'); -const UPDATE_RE = new RegExp(`^\\s*(?UPDATE)\\s+(?
${TABLE_NAME})`, 'i'); +// SQLite upserts insert an optional conflict clause between operation and INTO +// (`INSERT OR REPLACE INTO`, https://sqlite.org/lang_insert.html), with `REPLACE INTO` as the +// standalone shorthand. The clause is filler like INTO — stripping it keeps upserts on the same +// low-cardinality summary as plain inserts. +const INSERT_RE = new RegExp( + `^\\s*(?INSERT|REPLACE)(?:\\s+OR\\s+(?:ROLLBACK|ABORT|FAIL|IGNORE|REPLACE))?\\s+INTO\\s+(?
${TABLE_NAME})`, + 'i', +); +const UPDATE_RE = new RegExp( + `^\\s*(?UPDATE)(?:\\s+OR\\s+(?:ROLLBACK|ABORT|FAIL|IGNORE|REPLACE))?\\s+(?
${TABLE_NAME})`, + 'i', +); const DELETE_RE = new RegExp(`^\\s*(?DELETE)\\s+FROM\\s+(?
${TABLE_NAME})`, 'i'); const SELECT_RE = /^\s*\(?\s*(?SELECT)\b/i; diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index ad56bb846b9b..82d85b5bdc1f 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -1,8 +1,8 @@ +import { URL_FULL } from '@sentry/conventions/attributes'; import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - SEMANTIC_ATTRIBUTE_URL_FULL, } from '../semanticAttributes'; import type { SpanAttributes } from '../types/span'; @@ -188,7 +188,7 @@ export function getHttpSpanDetailsFromUrlObject( } if (!isURLObjectRelative(urlObject)) { - attributes[SEMANTIC_ATTRIBUTE_URL_FULL] = urlObject.href; + attributes[URL_FULL] = urlObject.href; if (urlObject.port) { attributes['url.port'] = urlObject.port; } diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index e37c32dea309..18e2c0e2871f 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -103,7 +103,10 @@ describe('Client', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, test: true }); const client = new TestClient(options); - expect(client.getOptions()).toEqual(options); + expect(client.getOptions()).toEqual({ + ...options, + enableLogs: true, + }); }); }); @@ -302,6 +305,29 @@ describe('Client', () => { expect(eventId).toEqual(lastEventId()); }); + test('sets lastEventId when an error is sampled out', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); + const client = new TestClient(options); + + const eventId = client.captureException(new Error('sampled-out exception')); + + expect(eventId).toEqual(lastEventId()); + expect(TestClient.instance!.event).toBeUndefined(); + }); + + test('(known limitation) replaces lastEventId with a sampled-out error ID', () => { + // After a successfully sent error, a subsequent sampled-out error replaces lastEventId() even though that new ID has no corresponding event in Sentry. + // The `setLastEventId` call in `_prepareEvent` now executes before the `sampleRate` check + const client = new TestClient(getDefaultTestClientOptions({ dsn: PUBLIC_DSN })); + + client.captureException(new Error('sent exception'), { event_id: 'sent-event-id' }); + client.getOptions().sampleRate = 0; + client.captureException(new Error('sampled-out exception'), { event_id: 'sampled-out-event-id' }); + + expect(TestClient.instance!.event?.event_id).toBe('sent-event-id'); + expect(lastEventId()).toBe('sampled-out-event-id'); + }); + test('allows for providing explicit scope', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); @@ -2220,7 +2246,7 @@ describe('Client', () => { .spyOn(logsInternalModule, '_INTERNAL_flushLogsBuffer') .mockImplementation(() => undefined); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); await client.close(); @@ -2533,6 +2559,215 @@ describe('Client', () => { }); }); + describe('session update filtering', () => { + describe('sampleRate drop updates session', () => { + test('marks session as crashed for sampled-out unhandled error', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('crashed'); + }); + + test('marks session as errored for sampled-out handled error', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0 }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'handled capture', mechanism: { type: 'generic', handled: true } }], + }, + }, + {}, + ); + + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('ok'); + }); + }); + + describe('beforeSend drop does not update session', () => { + test('does not update session when beforeSend returns null for unhandled error', () => { + const beforeSend = vi.fn(() => null); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session).toBeUndefined(); + expect(session.errors).toBe(0); + expect(session.status).toBe('ok'); + }); + + test('does not update session when beforeSend returns null for handled error', () => { + const beforeSend = vi.fn(() => null); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'handled capture', mechanism: { type: 'generic', handled: true } }], + }, + }, + {}, + ); + + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session).toBeUndefined(); + expect(session.errors).toBe(0); + expect(session.status).toBe('ok'); + }); + }); + + describe('event processor drop does not update session', () => { + test('does not update session when event processor returns null for unhandled error', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + setCurrentClient(client); + + client.addEventProcessor(() => null); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + expect(client.session).toBeUndefined(); + expect(session.errors).toBe(0); + expect(session.status).toBe('ok'); + }); + }); + + describe('error that passes through beforeSend updates session', () => { + test('updates session when beforeSend passes unhandled error through', () => { + const beforeSend = vi.fn(event => event); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'unhandled crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeDefined(); + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('crashed'); + }); + }); + + describe('sampleRate runs after beforeSend', () => { + test('does not update session when beforeSend drops an error that would be sampled out', () => { + const beforeSend = vi.fn(() => null); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'filtered crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session).toBeUndefined(); + expect(session.errors).toBe(0); + expect(session.status).toBe('ok'); + }); + + test('uses the event returned by beforeSend to update a sampled-out session', () => { + const beforeSend = vi.fn((event: ErrorEvent) => { + const exception = event.exception?.values?.[0]; + if (exception) { + exception.mechanism = { type: 'generic', handled: true }; + } + return event; + }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, sampleRate: 0, beforeSend }); + const client = new TestClient(options); + setCurrentClient(client); + + const session = makeSession(); + getCurrentScope().setSession(session); + + client.captureEvent( + { + exception: { + values: [{ type: 'Error', value: 'reclassified crash', mechanism: { type: 'generic', handled: false } }], + }, + }, + { mechanism: { handled: false } }, + ); + + expect(beforeSend).toHaveBeenCalledOnce(); + expect(TestClient.instance!.event).toBeUndefined(); + expect(client.session?.errors).toBe(1); + expect(client.session?.status).toBe('ok'); + }); + }); + }); + describe('recordDroppedEvent()/_clearOutcomes()', () => { test('records and returns outcomes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); @@ -2902,32 +3137,32 @@ describe('Client', () => { }); describe('enableLogs', () => { - it('defaults to `undefined`', () => { + it('defaults to `true`', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); - expect(client.getOptions().enableLogs).toBeUndefined(); + expect(client.getOptions().enableLogs).toBe(true); }); - it('can be set as a top-level option', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + it('can be disabled via the top-level option', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: false }); const client = new TestClient(options); - expect(client.getOptions().enableLogs).toBe(true); + expect(client.getOptions().enableLogs).toBe(false); }); - it('can be set as an experimental option', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + it('can be disabled via the experimental option', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: false } }); const client = new TestClient(options); - expect(client.getOptions().enableLogs).toBe(true); + expect(client.getOptions().enableLogs).toBe(false); }); test('top-level option takes precedence over experimental option', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, - _experiments: { enableLogs: false }, + enableLogs: false, + _experiments: { enableLogs: true }, }); const client = new TestClient(options); - expect(client.getOptions().enableLogs).toBe(true); + expect(client.getOptions().enableLogs).toBe(false); }); }); @@ -2943,7 +3178,6 @@ describe('Client', () => { it('flushes logs when weight exceeds 800KB', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -2961,7 +3195,6 @@ describe('Client', () => { it('accumulates log weight without flushing when under threshold', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -2979,7 +3212,6 @@ describe('Client', () => { it('flushes logs after idle timeout', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -3001,7 +3233,6 @@ describe('Client', () => { it('does not reset idle timeout when new logs are captured', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -3028,7 +3259,6 @@ describe('Client', () => { it('starts new timer after timeout completes and flushes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -3060,7 +3290,6 @@ describe('Client', () => { it('flushes logs on flush event', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -3081,6 +3310,7 @@ describe('Client', () => { it('does not flush logs when logs are disabled', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, + enableLogs: false, }); const client = new TestClient(options); const scope = new Scope(); @@ -3100,7 +3330,6 @@ describe('Client', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -3118,9 +3347,7 @@ describe('Client', () => { it('flush() drains the log buffer when client has no transport', async () => { // Client without DSN — _transport is undefined - const options = getDefaultTestClientOptions({ - enableLogs: true, - }); + const options = getDefaultTestClientOptions({}); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); diff --git a/packages/core/test/lib/fetch.test.ts b/packages/core/test/lib/fetch.test.ts index 6cfdb74e8a19..5e949439c739 100644 --- a/packages/core/test/lib/fetch.test.ts +++ b/packages/core/test/lib/fetch.test.ts @@ -1,3 +1,4 @@ +import { URL_FULL } from '@sentry/conventions/attributes'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { HandlerDataFetch } from '../../src'; import { _INTERNAL_getTracingHeadersForFetchRequest, instrumentFetchRequest } from '../../src/fetch'; @@ -69,6 +70,21 @@ describe('_INTERNAL_getTracingHeadersForFetchRequest', () => { }); }); + it('omits baggage from headers object when no baggage is available', () => { + vi.mocked(traceData.getTraceData).mockReturnValueOnce({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + }); + + const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { + headers: { 'custom-header': 'custom-value' }, + }); + + expect(returnedHeaders).toStrictEqual({ + 'sentry-trace': DEFAULT_SENTRY_TRACE, + 'custom-header': 'custom-value', + }); + }); + it('attaches sentry headers to a Headers instance', () => { const returnedHeaders = _INTERNAL_getTracingHeadersForFetchRequest('/api/test', { headers: new Headers({ 'custom-header': 'custom-value' }), @@ -444,6 +460,49 @@ describe('_INTERNAL_getTracingHeadersForFetchRequest', () => { }); describe('instrumentFetchRequest', () => { + describe('span attributes', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets url.full for absolute URLs', () => { + const url = 'https://api.example.com/users/42?include=profile#bio'; + const activeSpan = new SentryNonRecordingSpan(); + const fetchSpan = new SentryNonRecordingSpan(); + hasSpansEnabled.mockReturnValue(true); + vi.spyOn(spanUtils, 'getActiveSpan').mockReturnValue(activeSpan); + const startInactiveSpanSpy = vi.spyOn(tracing, 'startInactiveSpan').mockReturnValue(fetchSpan); + + instrumentFetchRequest( + { + fetchData: { url, method: 'GET' }, + args: [url], + startTimestamp: Date.now(), + }, + () => true, + () => false, + {}, + { spanOrigin: 'auto.http.fetch' }, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'GET https://api.example.com/users/42', + attributes: { + url, + type: 'fetch', + 'http.method': 'GET', + 'sentry.origin': 'auto.http.fetch', + 'sentry.op': 'http.client', + 'http.url': url, + [URL_FULL]: url, + 'server.address': 'api.example.com', + 'http.query': '?include=profile', + 'http.fragment': '#bio', + }, + }); + }); + }); + describe('trace header span', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/core/test/lib/instrument/fetch.test.ts b/packages/core/test/lib/instrument/fetch.test.ts index 215b0c513ee5..85d112967a60 100644 --- a/packages/core/test/lib/instrument/fetch.test.ts +++ b/packages/core/test/lib/instrument/fetch.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { runInNewContext } from 'node:vm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parseFetchArgs } from '../../../src/instrument/fetch'; +import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; describe('instrument > parseFetchArgs', () => { it.each([ @@ -53,3 +55,43 @@ describe('instrument > parseFetchArgs', () => { }); }); }); + +describe('instrument > addFetchInstrumentationHandler', () => { + const globalWithFetch = GLOBAL_OBJ as typeof GLOBAL_OBJ & { fetch?: (...args: unknown[]) => unknown }; + const originalFetchDescriptor = Object.getOwnPropertyDescriptor(globalWithFetch, 'fetch'); + + // `maybeInstrument` patches the global `fetch` only once per module instance, so each test needs a + // fresh copy of the instrumentation modules - otherwise only the first one actually wraps `fetch`. + async function loadFetchModule() { + vi.resetModules(); + return import('../../../src/instrument/fetch'); + } + + let addFetchInstrumentationHandler: Awaited>['addFetchInstrumentationHandler']; + + beforeEach(async () => { + ({ addFetchInstrumentationHandler } = await loadFetchModule()); + }); + + afterEach(() => { + if (originalFetchDescriptor) { + Object.defineProperty(globalWithFetch, 'fetch', originalFetchDescriptor); + } else { + Reflect.deleteProperty(globalWithFetch, 'fetch'); + } + + vi.restoreAllMocks(); + }); + + it('enhances a fetch TypeError created in another realm', async () => { + const error = runInNewContext(`new TypeError('Failed to fetch')`) as TypeError; + expect(error).not.toBeInstanceOf(TypeError); + + globalThis.fetch = vi.fn().mockRejectedValue(error); + addFetchInstrumentationHandler(() => undefined); + + await expect(globalThis.fetch('https://example.com/path')).rejects.toBe(error); + + expect(error.message).toBe('Failed to fetch (example.com)'); + }); +}); diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index 0ab7a3cc1e98..6cdaedc64cba 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -32,10 +32,8 @@ describe('createConsolaReporter', () => { beforeEach(() => { vi.clearAllMocks(); - // Create a test client with enableLogs: true mockClient = new TestClient({ ...getDefaultTestClientOptions({ dsn: 'https://username@domain/123' }), - enableLogs: true, normalizeDepth: 3, normalizeMaxBreadth: 1000, }); diff --git a/packages/core/test/lib/integrations/eventFilters.test.ts b/packages/core/test/lib/integrations/eventFilters.test.ts index db3cc4efafbf..5f0a91011e6a 100644 --- a/packages/core/test/lib/integrations/eventFilters.test.ts +++ b/packages/core/test/lib/integrations/eventFilters.test.ts @@ -373,6 +373,28 @@ const FB_MOBILE_BROWSER_EVENT: Event = { }, }; +const FB_MOBILE_BROWSER_POST_MESSAGE_EVENT: Event = { + exception: { + values: [ + { + type: 'Error', + value: 'Error invoking postMessage: Java exception was raised during method invocation', + }, + ], + }, +}; + +const FB_MOBILE_BROWSER_JAVA_OBJECT_GONE_EVENT: Event = { + exception: { + values: [ + { + type: 'Error', + value: 'Error invoking postMessage: Java object is gone', + }, + ], + }, +}; + const MALFORMED_EVENT: Event = { exception: { values: [ @@ -500,6 +522,16 @@ describe.each([ expect(eventProcessor(FB_MOBILE_BROWSER_EVENT, {})).toBe(null); }); + it('uses default filters (FB Mobile Browser, wrapped in postMessage error)', () => { + const eventProcessor = createEventFiltersEventProcessor(integrationFn); + expect(eventProcessor(FB_MOBILE_BROWSER_POST_MESSAGE_EVENT, {})).toBe(null); + }); + + it('uses default filters (FB Mobile Browser, Java object is gone)', () => { + const eventProcessor = createEventFiltersEventProcessor(integrationFn); + expect(eventProcessor(FB_MOBILE_BROWSER_JAVA_OBJECT_GONE_EVENT, {})).toBe(null); + }); + it("uses default filters (undefined is not an object (evaluating 'a.L'))", () => { const eventProcessor = createEventFiltersEventProcessor(integrationFn); expect(eventProcessor(createUndefinedIsNotAnObjectEvent('a.L'), {})).toBe(null); diff --git a/packages/core/test/lib/integrations/express/index.test.ts b/packages/core/test/lib/integrations/express/index.test.ts index 9d4a78cd3244..96106aa4b375 100644 --- a/packages/core/test/lib/integrations/express/index.test.ts +++ b/packages/core/test/lib/integrations/express/index.test.ts @@ -34,10 +34,18 @@ const isolationScope = { }, }; +let integrationShouldHandleError: ExpressIntegrationOptions['shouldHandleError']; vi.mock('../../../../src/currentScopes', () => ({ getIsolationScope() { return isolationScope; }, + getClient() { + return { + getIntegrationByName(name: string) { + return name === 'Express' ? { name, getShouldHandleError: () => integrationShouldHandleError } : undefined; + }, + }; + }, })); const capturedExceptions: [unknown, unknown][] = []; @@ -333,6 +341,66 @@ describe('expressErrorHandler', () => { expect(next).toHaveBeenCalledExactlyOnceWith(err); next.mockReset(); }); + + it('falls back to `shouldHandleError` from the Express integration', () => { + integrationShouldHandleError = () => false; + const errorMiddleware = expressErrorHandler(); + const res = { status: 500 } as unknown as ExpressResponse; + const req = { headers: {} } as unknown as ExpressRequest; + const next = vi.fn(); + const err = new Error('err'); + errorMiddleware(err, req, res, next); + expect(capturedExceptions).toStrictEqual([]); + sdkProcessingMetadata.length = 0; + expect(next).toHaveBeenCalledExactlyOnceWith(err); + }); + + it('prefers the deprecated per-call option over the integration option', () => { + integrationShouldHandleError = () => false; + // oxlint-disable-next-line typescript/no-deprecated + const errorMiddleware = expressErrorHandler({ shouldHandleError: () => true }); + const res = { status: 500 } as unknown as ExpressResponse; + const req = { headers: {} } as unknown as ExpressRequest; + const next = vi.fn(); + const err = new Error('err'); + errorMiddleware(err, req, res, next); + expect(capturedExceptions).toHaveLength(1); + capturedExceptions.length = 0; + sdkProcessingMetadata.length = 0; + expect(next).toHaveBeenCalledExactlyOnceWith(err); + }); + + it('captures nothing when the integration option is `false`', () => { + integrationShouldHandleError = false; + const errorMiddleware = expressErrorHandler(); + const res = { status: 500 } as unknown as ExpressResponse; + const req = { headers: {} } as unknown as ExpressRequest; + const next = vi.fn(); + const err = new Error('err'); + errorMiddleware(err, req, res, next); + expect((res as unknown as { sentry?: string }).sentry).toBe(undefined); + expect(capturedExceptions).toStrictEqual([]); + sdkProcessingMetadata.length = 0; + expect(next).toHaveBeenCalledExactlyOnceWith(err); + }); + + it('uses the default gate when neither option is set', () => { + integrationShouldHandleError = undefined; + const errorMiddleware = expressErrorHandler(); + const res = {} as unknown as ExpressResponse; + const req = { headers: {} } as unknown as ExpressRequest; + const next = vi.fn(); + + // A 4xx error is skipped by `defaultShouldHandleError`, a 5xx one is captured. + errorMiddleware(Object.assign(new Error('client'), { status: 404 }), req, res, next); + expect(capturedExceptions).toStrictEqual([]); + + errorMiddleware(Object.assign(new Error('server'), { status: 503 }), req, res, next); + expect(capturedExceptions).toHaveLength(1); + + capturedExceptions.length = 0; + sdkProcessingMetadata.length = 0; + }); }); describe('setupExpressErrorHandler', () => { diff --git a/packages/core/test/lib/integrations/functiontostring.test.ts b/packages/core/test/lib/integrations/functiontostring.test.ts index 1e992cbd93cb..99b54fa1c3ff 100644 --- a/packages/core/test/lib/integrations/functiontostring.test.ts +++ b/packages/core/test/lib/integrations/functiontostring.test.ts @@ -17,6 +17,7 @@ describe('FunctionToString', () => { afterEach(() => { vi.mocked(currentScopes.getClient).mockClear(); + vi.restoreAllMocks(); }); afterAll(() => { @@ -67,6 +68,22 @@ describe('FunctionToString', () => { expect(foo.bar.toString()).not.toBe(originalFunction); }); + it('does not recurse when Reflect.apply performs a function toString check', () => { + function inspectedFunction(): void {} + + const fts = functionToStringIntegration(); + getClient()?.addIntegration(fts); + const expected = inspectedFunction.toString(); + const originalReflectApply = Reflect.apply; + + vi.spyOn(Reflect, 'apply').mockImplementation((target, thisArgument, argumentsList) => { + target.toString(); + return originalReflectApply(target, thisArgument, argumentsList); + }); + + expect(inspectedFunction.toString()).toBe(expected); + }); + it('falls back to native toString and does not throw when the carrier read throws', () => { const foo = { bar(wat: boolean): boolean { diff --git a/packages/core/test/lib/integrations/http/server-subscription.test.ts b/packages/core/test/lib/integrations/http/server-subscription.test.ts index 6be81c51f210..e5be2e7f4e9b 100644 --- a/packages/core/test/lib/integrations/http/server-subscription.test.ts +++ b/packages/core/test/lib/integrations/http/server-subscription.test.ts @@ -1,4 +1,6 @@ +import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import * as http from 'node:http'; +import * as net from 'node:net'; import type { AddressInfo } from 'node:net'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -67,6 +69,18 @@ describe('getHttpServerSubscriptions', () => { }); } + async function makeRequestWithoutHost(path: string): Promise { + const { port } = server.address() as AddressInfo; + return new Promise((resolve, reject) => { + const socket = net.createConnection(port, '127.0.0.1', () => { + socket.write(`GET ${path} HTTP/1.0\r\nConnection: close\r\n\r\n`); + }); + socket.on('data', () => undefined); + socket.on('end', resolve); + socket.on('error', reject); + }); + } + function instrument(spans: boolean, extra: { ignoreStaticAssets?: boolean } = {}): void { const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions({ spans, ...extra }); // Fire the channel listener manually with the server we're about to use. @@ -107,11 +121,29 @@ describe('getHttpServerSubscriptions', () => { 'sentry.op': 'http.server', 'sentry.origin': 'auto.http.server', 'sentry.source': 'url', + [URL_FULL]: expect.stringMatching(/\/users\/42\?foo=bar$/), + [URL_PATH]: '/users/42', }), }), ); }); + it('omits url.full when the incoming request URL is relative', async () => { + server = http.createServer((_req, res) => res.end('ok')); + await new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve())); + instrument(true); + + await makeRequestWithoutHost('/users/42?foo=bar'); + const transaction = await waitForTransaction(); + + expect(transaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + [URL_PATH]: '/users/42', + }), + ); + expect(transaction.contexts?.trace?.data).not.toHaveProperty(URL_FULL); + }); + it('reports a 500 status with internal_error span status', async () => { server = http.createServer((_req, res) => { res.statusCode = 500; diff --git a/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts new file mode 100644 index 000000000000..de7e23026ad3 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/capturePolicy.test.ts @@ -0,0 +1,392 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCurrentScope, withScope } from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { Scope } from '../../../../src/scope'; +import * as tracingModule from '../../../../src/tracing/trace'; +import { createMockClient, createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP Server Capture Policy', () => { + type MockTransport = ReturnType; + type MockServer = ReturnType; + type MockSpan = ReturnType; + type WrapperOptions = Parameters[1]; + + const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const connectedTransports: MockTransport[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + getCurrentScope().setClient(undefined); + }); + + afterEach(() => { + for (const transport of connectedTransports) { + transport.onclose?.(); + } + connectedTransports.length = 0; + getCurrentScope().setClient(undefined); + }); + + function createClientScope(inputs: boolean, outputs: boolean): Scope { + const scope = new Scope(); + scope.setClient(createMockClient(true, { inputs, outputs })); + return scope; + } + + function createMockSpan() { + return { + setAttributes: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + }; + } + + function queueInactiveSpan(): MockSpan { + const span = createMockSpan(); + startInactiveSpanSpy.mockReturnValueOnce(span as unknown as ReturnType); + return span; + } + + async function connectServer(server: MockServer, sessionId: string): Promise { + const transport = createMockTransport(); + transport.sessionId = sessionId; + connectedTransports.push(transport); + await server.connect(transport); + return transport; + } + + function connectWrappedServer(sessionId: string, options?: WrapperOptions): Promise { + return connectServer(wrapMcpServerWithSentry(createMockMcpServer(), options), sessionId); + } + + function receiveToolCall( + transport: MockTransport, + scope: Scope, + request: { id: string; name?: string; location?: string }, + ): void { + const { id, name = 'weather', location } = request; + const params = { + name, + ...(location !== undefined && { arguments: { location } }), + }; + + withScope(scope, () => { + transport.onmessage?.({ jsonrpc: '2.0', method: 'tools/call', id, params }, {}); + }); + } + + async function sendToolResult( + transport: MockTransport, + scope: Scope, + response: { id: string; text: string }, + ): Promise { + await withScope(scope, () => + transport.send?.({ + jsonrpc: '2.0', + id: response.id, + result: { + content: [{ type: 'text', text: response.text }], + isError: false, + }, + }), + ); + } + + function buildToolSpanConfig(request: { id: string; sessionId: string; name?: string; location?: string }) { + const { id, sessionId, name = 'weather', location } = request; + return { + name: `tools/call ${name}`, + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': name, + 'mcp.request.id': id, + 'mcp.session.id': sessionId, + 'mcp.transport': 'StreamableHTTPServerTransport', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + ...(location !== undefined && { 'mcp.request.argument.location': JSON.stringify(location) }), + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }; + } + + function expectToolResult(span: MockSpan, content?: string): void { + expect(span.setAttributes).toHaveBeenCalledOnce(); + expect(span.setAttributes).toHaveBeenCalledWith({ + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', + ...(content !== undefined && { 'mcp.tool.result.content': content }), + 'mcp.tool.result.is_error': false, + }); + } + + function buildLoggingSpanConfig(options: { + direction: 'client_to_server' | 'server_to_client'; + level: string; + sessionId: string; + }) { + return { + name: 'notifications/message', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'notifications/message', + 'mcp.session.id': options.sessionId, + 'mcp.transport': 'StreamableHTTPServerTransport', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.logging.level': options.level, + 'mcp.logging.logger': 'weather-service', + 'mcp.logging.data_type': 'string', + 'sentry.op': `mcp.notification.${options.direction}`, + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }, + }; + } + + it('defaults to omitting inputs and outputs when an operation has no client', async () => { + const transport = await connectWrappedServer('capture-policy-defaults'); + const scope = new Scope(); + const span = queueInactiveSpan(); + + receiveToolCall(transport, scope, { + id: 'default-policy-request', + location: 'Paris, France', + }); + await sendToolResult(transport, scope, { + id: 'default-policy-request', + text: 'Forecast for Paris', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ + id: 'default-policy-request', + sessionId: 'capture-policy-defaults', + }), + ); + expectToolResult(span); + }); + + it('resolves input capture when an operation starts after wrapping without a client', async () => { + const transport = await connectWrappedServer('capture-policy-input'); + queueInactiveSpan(); + + receiveToolCall(transport, createClientScope(false, false), { + id: 'private-input-request', + location: 'Madrid, Spain', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ id: 'private-input-request', sessionId: 'capture-policy-input' }), + ); + }); + + it('resolves output capture when an operation starts after wrapping without a client', async () => { + const transport = await connectWrappedServer('capture-policy-output'); + const privacyScope = createClientScope(false, false); + const span = queueInactiveSpan(); + + receiveToolCall(transport, privacyScope, { id: 'private-output-request' }); + await sendToolResult(transport, privacyScope, { + id: 'private-output-request', + text: 'Private forecast for Madrid', + }); + + expectToolResult(span); + }); + + it('isolates capture policy between operations running in different scopes', async () => { + const transport = await connectWrappedServer('capture-policy-scopes'); + queueInactiveSpan(); + queueInactiveSpan(); + + receiveToolCall(transport, createClientScope(false, false), { + id: 'private-scope-request', + location: 'Madrid, Spain', + }); + receiveToolCall(transport, createClientScope(true, true), { + id: 'recording-scope-request', + location: 'Berlin, Germany', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledTimes(2); + expect(startInactiveSpanSpy).toHaveBeenNthCalledWith( + 1, + buildToolSpanConfig({ id: 'private-scope-request', sessionId: 'capture-policy-scopes' }), + ); + expect(startInactiveSpanSpy).toHaveBeenNthCalledWith( + 2, + buildToolSpanConfig({ + id: 'recording-scope-request', + sessionId: 'capture-policy-scopes', + location: 'Berlin, Germany', + }), + ); + }); + + it('uses the request policy snapshot when the response runs with a different client', async () => { + const transport = await connectWrappedServer('capture-policy-snapshot'); + const span = queueInactiveSpan(); + + receiveToolCall(transport, createClientScope(true, false), { id: 'snapshot-request' }); + await sendToolResult(transport, createClientScope(false, true), { + id: 'snapshot-request', + text: 'Private forecast for Valencia', + }); + + expectToolResult(span); + }); + + it('keeps explicit overrides while resolving unspecified policy per operation', async () => { + const transport = await connectWrappedServer('capture-policy-explicit-options', { recordInputs: true }); + const operationScope = createClientScope(false, false); + const span = queueInactiveSpan(); + + receiveToolCall(transport, operationScope, { + id: 'explicit-options-request', + location: 'Lisbon, Portugal', + }); + await sendToolResult(transport, operationScope, { + id: 'explicit-options-request', + text: 'Private forecast for Lisbon', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ + id: 'explicit-options-request', + sessionId: 'capture-policy-explicit-options', + location: 'Lisbon, Portugal', + }), + ); + expectToolResult(span); + }); + + it('resolves input capture for incoming notifications after wrapping without a client', async () => { + const transport = await connectWrappedServer('capture-policy-incoming-notification'); + const privacyScope = createClientScope(false, false); + + withScope(privacyScope, () => { + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', logger: 'weather-service', data: 'Private incoming notification' }, + }, + {}, + ); + }); + + expect(startSpanSpy).toHaveBeenCalledOnce(); + expect(startSpanSpy).toHaveBeenCalledWith( + buildLoggingSpanConfig({ + direction: 'client_to_server', + level: 'info', + sessionId: 'capture-policy-incoming-notification', + }), + expect.any(Function), + ); + }); + + it('resolves input capture for outgoing notifications after wrapping without a client', async () => { + const transport = await connectWrappedServer('capture-policy-outgoing-notification'); + const privacyScope = createClientScope(false, false); + + await withScope(privacyScope, () => + transport.send?.({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'warning', logger: 'weather-service', data: 'Private outgoing notification' }, + }), + ); + + expect(startSpanSpy).toHaveBeenCalledOnce(); + expect(startSpanSpy).toHaveBeenCalledWith( + buildLoggingSpanConfig({ + direction: 'server_to_client', + level: 'warning', + sessionId: 'capture-policy-outgoing-notification', + }), + expect.any(Function), + ); + }); + + it('keeps output policies isolated for concurrent requests completed in reverse order', async () => { + const transport = await connectWrappedServer('capture-policy-concurrent-requests'); + const privacyScope = createClientScope(false, false); + const recordingScope = createClientScope(false, true); + const privateSpan = queueInactiveSpan(); + const recordingSpan = queueInactiveSpan(); + + receiveToolCall(transport, privacyScope, { id: 'private-concurrent-request', name: 'private-weather' }); + receiveToolCall(transport, recordingScope, { id: 'recording-concurrent-request', name: 'recording-weather' }); + await sendToolResult(transport, privacyScope, { + id: 'recording-concurrent-request', + text: 'Recorded forecast for Oslo', + }); + await sendToolResult(transport, recordingScope, { + id: 'private-concurrent-request', + text: 'Private forecast for Stockholm', + }); + + expectToolResult(recordingSpan, 'Recorded forecast for Oslo'); + expectToolResult(privateSpan); + }); + + it('combines an explicit output override with the operation input policy', async () => { + const transport = await connectWrappedServer('capture-policy-partial-output-override', { recordOutputs: true }); + const privacyScope = createClientScope(false, false); + const span = queueInactiveSpan(); + + receiveToolCall(transport, privacyScope, { + id: 'partial-output-override-request', + location: 'Tallinn, Estonia', + }); + await sendToolResult(transport, privacyScope, { + id: 'partial-output-override-request', + text: 'Recorded forecast for Tallinn', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ + id: 'partial-output-override-request', + sessionId: 'capture-policy-partial-output-override', + }), + ); + expectToolResult(span, 'Recorded forecast for Tallinn'); + }); + + it('snapshots explicit overrides from the first wrap', async () => { + const options = { recordInputs: false, recordOutputs: false }; + const server = wrapMcpServerWithSentry(createMockMcpServer(), options); + options.recordInputs = true; + options.recordOutputs = true; + wrapMcpServerWithSentry(server, { recordInputs: true, recordOutputs: true }); + const transport = await connectServer(server, 'capture-policy-first-wrap'); + const recordingScope = createClientScope(true, true); + const span = queueInactiveSpan(); + + receiveToolCall(transport, recordingScope, { + id: 'first-wrap-request', + location: 'Reykjavik, Iceland', + }); + await sendToolResult(transport, recordingScope, { + id: 'first-wrap-request', + text: 'Private forecast for Reykjavik', + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + buildToolSpanConfig({ id: 'first-wrap-request', sessionId: 'capture-policy-first-wrap' }), + ); + expectToolResult(span); + }); +}); 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 ef764b86f213..8c0d2429aec4 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -5,6 +5,8 @@ import { buildTransportAttributes, extractSessionDataFromInitializeRequest, extractSessionDataFromInitializeResponse, + extractSessionDataFromMessage, + extractSessionDataFromResponse, getTransportTypes, } from '../../../../src/integrations/mcp-server/sessionExtraction'; import { @@ -498,6 +500,60 @@ describe('MCP Server Transport Instrumentation', () => { }); }); + it('extracts session data from a modern request envelope', () => { + const request = { + jsonrpc: '2.0' as const, + method: 'tools/call', + id: 'modern-tool-call', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { + name: 'modern-client', + title: 'Modern Client', + version: '2.0.0', + }, + }, + name: 'weather', + }, + }; + + const sessionData = extractSessionDataFromMessage(request); + + expect(sessionData).toEqual({ + protocolVersion: '2026-07-28', + clientInfo: { + name: 'modern-client', + title: 'Modern Client', + version: '2.0.0', + }, + }); + }); + + it('extracts server info from modern result metadata', () => { + const result = { + resultType: 'complete', + content: [], + _meta: { + 'io.modelcontextprotocol/serverInfo': { + name: 'modern-server', + title: 'Modern Server', + version: '2.0.0', + }, + }, + }; + + const sessionData = extractSessionDataFromResponse(result); + + expect(sessionData).toEqual({ + serverInfo: { + name: 'modern-server', + title: 'Modern Server', + version: '2.0.0', + }, + }); + }); + it('should store and retrieve session data', () => { const sessionData = { protocolVersion: '2025-06-18', @@ -652,7 +708,7 @@ describe('MCP Server Transport Instrumentation', () => { }); }); - describe('Initialize Span Attributes', () => { + describe('Protocol Metadata Span Attributes', () => { it('should add client info to initialize span on request', async () => { const mockMcpServer = createMockMcpServer(); const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); @@ -721,6 +777,164 @@ describe('MCP Server Transport Instrumentation', () => { ); expect(mockSpan.end).toHaveBeenCalled(); }); + + it('adds modern protocol and client info to request spans', async () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + transport.sessionId = ''; + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'modern-tool-call', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'modern-client', version: '2.0.0' }, + }, + name: 'weather', + }, + }, + { classification: { era: 'modern', revision: '2026-07-28' } }, + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'mcp.client.name': 'modern-client', + 'mcp.client.version': '2.0.0', + 'mcp.protocol.version': '2026-07-28', + }), + }), + ); + }); + + it('ignores legacy session fields outside initialize messages', async () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + transport.sessionId = ''; + const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + startInactiveSpanSpy.mockReturnValue(mockSpan as any); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'custom/process', + id: 'custom-request', + params: { + protocolVersion: 'application-version', + clientInfo: { name: 'application-client', version: '1.0.0' }, + }, + }, + {}, + ); + await transport.send?.({ + jsonrpc: '2.0', + id: 'custom-request', + result: { + protocolVersion: 'application-version', + serverInfo: { name: 'application-server', version: '1.0.0' }, + }, + }); + + expect(getSessionDataForTransport(transport)).toBeUndefined(); + expect(mockSpan.setAttributes).not.toHaveBeenCalled(); + expect(mockSpan.end).toHaveBeenCalledOnce(); + }); + + it('adds modern protocol and client info to notification spans', async () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + transport.sessionId = ''; + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'modern-client', version: '2.0.0' }, + }, + }, + }, + { classification: { era: 'modern', revision: '2026-07-28' } }, + ); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'notifications/tools/list_changed', + forceTransaction: true, + attributes: { + 'mcp.transport': 'StreamableHTTPServerTransport', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.protocol.version': '2026-07-28', + 'mcp.client.name': 'modern-client', + 'mcp.client.version': '2.0.0', + 'mcp.method.name': 'notifications/tools/list_changed', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }, + }, + expect.any(Function), + ); + }); + + it('adds modern server info to completed request spans', async () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + const transport = createMockTransport(); + transport.sessionId = ''; + const mockSpan = { setAttributes: vi.fn(), end: vi.fn() }; + startInactiveSpanSpy.mockReturnValue(mockSpan as any); + + await wrappedMcpServer.connect(transport); + + transport.onmessage?.( + { + jsonrpc: '2.0', + method: 'tools/call', + id: 'modern-tool-call', + params: { + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'modern-client', version: '2.0.0' }, + }, + name: 'weather', + }, + }, + { classification: { era: 'modern', revision: '2026-07-28' } }, + ); + await transport.send?.({ + jsonrpc: '2.0', + id: 'modern-tool-call', + result: { + resultType: 'complete', + content: [{ type: 'text', text: 'Sunny' }], + _meta: { + 'io.modelcontextprotocol/serverInfo': { name: 'modern-server', version: '2.0.0' }, + }, + }, + }); + + expect(mockSpan.setAttributes).toHaveBeenCalledWith({ + 'mcp.server.name': 'modern-server', + 'mcp.server.version': '2.0.0', + }); + expect(mockSpan.end).toHaveBeenCalledOnce(); + }); }); describe('Wrapper Options', () => { diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts index 361af18f19fc..80077f91ed73 100644 --- a/packages/core/test/lib/integrations/supabase.test.ts +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -3,10 +3,15 @@ import * as breadcrumbModule from '../../../src/breadcrumbs'; import * as exportsModule from '../../../src/exports'; import { extractOperation, + getHeader, instrumentSupabaseClient, translateFiltersIntoMethods, } from '../../../src/integrations/supabase'; -import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase'; +import type { + PostgRESTHeaders, + PostgRESTQueryBuilder, + SupabaseClientInstance, +} from '../../../src/integrations/supabase'; import { resolveDataCollectionOptions } from '../../../src/utils/data-collection/resolveDataCollectionOptions'; const tracingMocks = vi.hoisted(() => ({ @@ -39,6 +44,8 @@ type CreateMockSupabaseClientOptions = { method?: string; url?: URL | string; body?: unknown; + /** Defaults to the plain-object shape used by `postgrest-js` v1. Pass a `Headers` instance to emulate v2. */ + headers?: PostgRESTHeaders; /** When set, configures the mocked Sentry client's `dataCollection.databaseQueryData`. Omit to leave `getClient` to the test file `beforeEach`. */ dataCollectionDatabaseQueryData?: boolean; }; @@ -67,10 +74,11 @@ function createMockSupabaseClient(resolveWith: unknown, options?: CreateMockSupa : new URL(options.url) : new URL(DEFAULT_MOCK_SUPABASE_REST_URL); const body = options?.body; + const headers = options?.headers ?? { 'X-Client-Info': 'supabase-js/2.0.0' }; class MockPostgRESTFilterBuilder { method = method; - headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; + headers: PostgRESTHeaders = headers; url = requestUrl; schema = 'public'; body = body; @@ -116,6 +124,28 @@ describe('Supabase Integration', () => { currentScopesMocks.getClient.mockReturnValue(undefined); }); + describe('getHeader', () => { + it('reads a header off a plain object', () => { + expect(getHeader({ 'X-Client-Info': 'supabase-js/2.0.0' }, 'X-Client-Info')).toBe('supabase-js/2.0.0'); + }); + + it('reads a header off a Headers instance', () => { + expect(getHeader(new Headers({ 'X-Client-Info': 'supabase-js/2.112.0' }), 'X-Client-Info')).toBe( + 'supabase-js/2.112.0', + ); + }); + + it('looks up plain object headers case-insensitively', () => { + expect(getHeader({ prefer: 'resolution=merge-duplicates' }, 'Prefer')).toBe('resolution=merge-duplicates'); + }); + + it('returns undefined for unset headers', () => { + expect(getHeader({ Prefer: 'count=exact' }, 'X-Client-Info')).toBeUndefined(); + expect(getHeader(new Headers({ Prefer: 'count=exact' }), 'X-Client-Info')).toBeUndefined(); + expect(getHeader(undefined, 'X-Client-Info')).toBeUndefined(); + }); + }); + describe('extractOperation', () => { it('returns select for GET', () => { expect(extractOperation('GET')).toBe('select'); @@ -129,6 +159,10 @@ describe('Supabase Integration', () => { expect(extractOperation('POST', { Prefer: 'resolution=merge-duplicates' })).toBe('upsert'); }); + it('returns upsert for POST with resolution header on a Headers instance', () => { + expect(extractOperation('POST', new Headers({ Prefer: 'resolution=merge-duplicates' }))).toBe('upsert'); + }); + it('returns update for PATCH', () => { expect(extractOperation('PATCH')).toBe('update'); }); @@ -433,4 +467,53 @@ describe('Supabase Integration', () => { expect(spanOptions.attributes['db.body']).toEqual([{ title: 'Test Todo' }]); }); }); + + describe.each([ + ['plain object headers', (init: Record): PostgRESTHeaders => init], + ['Headers instance', (init: Record): PostgRESTHeaders => new Headers(init)], + ])('%s', (_name, createHeaders) => { + beforeEach(() => { + vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets db.sdk from X-Client-Info', async () => { + tracingMocks.startSpan.mockClear(); + const client = createMockSupabaseClient( + { status: 200 }, + { headers: createHeaders({ 'X-Client-Info': 'supabase-js/2.112.0' }) }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('todos').select().then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { attributes: Record }; + expect(spanOptions.attributes['db.sdk']).toBe('supabase-js/2.112.0'); + }); + + it('detects upsert from the Prefer header', async () => { + tracingMocks.startSpan.mockClear(); + const client = createMockSupabaseClient( + { status: 200 }, + { + method: 'POST', + body: { title: 'Test Todo' }, + headers: createHeaders({ Prefer: 'resolution=merge-duplicates' }), + }, + ); + instrumentSupabaseClient(client); + + await (client as any).from('todos').upsert({}).then(); + + const spanOptions = tracingMocks.startSpan.mock.calls[0]![0] as { + name: string; + attributes: Record; + }; + expect(spanOptions.name).toMatch(/^upsert\(\.\.\.\)/); + expect(spanOptions.attributes['db.operation']).toBe('upsert'); + }); + }); }); diff --git a/packages/core/test/lib/logs/console-integration.test.ts b/packages/core/test/lib/logs/console-integration.test.ts index ef39b4ebdb15..a2a878365064 100644 --- a/packages/core/test/lib/logs/console-integration.test.ts +++ b/packages/core/test/lib/logs/console-integration.test.ts @@ -44,7 +44,6 @@ describe('consoleLoggingIntegration', () => { client = new TestClient({ ...getDefaultTestClientOptions({ dsn: 'https://username@domain/123' }), - enableLogs: true, normalizeDepth: 3, normalizeMaxBreadth: 1000, }); @@ -418,7 +417,6 @@ describe('consoleLoggingIntegration', () => { it('only captures configured levels', () => { const filteredClient = new TestClient({ ...getDefaultTestClientOptions({ dsn: 'https://username@domain/123' }), - enableLogs: true, }); vi.mocked(getClient).mockReturnValue(filteredClient); diff --git a/packages/core/test/lib/logs/internal.test.ts b/packages/core/test/lib/logs/internal.test.ts index 608193b4a838..c98cd8574692 100644 --- a/packages/core/test/lib/logs/internal.test.ts +++ b/packages/core/test/lib/logs/internal.test.ts @@ -22,7 +22,7 @@ describe('_INTERNAL_captureLog', () => { _INTERNAL_resetSequenceNumber(); }); it('captures and sends logs', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -43,9 +43,9 @@ describe('_INTERNAL_captureLog', () => { ); }); - it('does not capture logs when enableLogs is not enabled', () => { + it('does not capture logs when enableLogs is disabled', () => { const logWarnSpy = vi.spyOn(loggerModule.debug, 'warn').mockImplementation(() => undefined); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: false }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -59,7 +59,7 @@ describe('_INTERNAL_captureLog', () => { }); it('includes trace context when available', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -81,7 +81,6 @@ describe('_INTERNAL_captureLog', () => { it('includes release and environment in log attributes when available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, release: '1.0.0', environment: 'test', }); @@ -108,7 +107,6 @@ describe('_INTERNAL_captureLog', () => { it('includes SDK metadata in log attributes when available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -140,7 +138,6 @@ describe('_INTERNAL_captureLog', () => { it('does not include SDK metadata in log attributes when not available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -161,7 +158,7 @@ describe('_INTERNAL_captureLog', () => { describe('attributes', () => { it('includes custom attributes in log', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -190,7 +187,7 @@ describe('_INTERNAL_captureLog', () => { }); it('applies scope attributes attributes to log', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -251,7 +248,7 @@ describe('_INTERNAL_captureLog', () => { }); it('flushes logs buffer when it reaches max size', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -273,7 +270,7 @@ describe('_INTERNAL_captureLog', () => { }); it('does not flush logs buffer when it is empty', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {}); @@ -282,7 +279,7 @@ describe('_INTERNAL_captureLog', () => { }); it('handles parameterized strings correctly', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -310,7 +307,7 @@ describe('_INTERNAL_captureLog', () => { }); it('does not set the template attribute if there are no parameters', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -332,7 +329,6 @@ describe('_INTERNAL_captureLog', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, beforeSendLog, }); const client = new TestClient(options); @@ -396,7 +392,6 @@ describe('_INTERNAL_captureLog', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, beforeSendLog, }); const client = new TestClient(options); @@ -422,7 +417,7 @@ describe('_INTERNAL_captureLog', () => { it('emits beforeCaptureLog and afterCaptureLog events', () => { const beforeCaptureLogSpy = vi.spyOn(TestClient.prototype, 'emit'); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -442,7 +437,7 @@ describe('_INTERNAL_captureLog', () => { describe('replay integration with onlyIfSampled', () => { it('includes replay ID for sampled sessions', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -474,7 +469,7 @@ describe('_INTERNAL_captureLog', () => { }); it('excludes replay ID for unsampled sessions when onlyIfSampled=true', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -501,7 +496,7 @@ describe('_INTERNAL_captureLog', () => { }); it('includes replay ID for buffer mode sessions', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -536,7 +531,7 @@ describe('_INTERNAL_captureLog', () => { }); it('handles missing replay integration gracefully', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -555,7 +550,6 @@ describe('_INTERNAL_captureLog', () => { it('combines replay ID with other log attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, release: '1.0.0', environment: 'test', }); @@ -607,7 +601,7 @@ describe('_INTERNAL_captureLog', () => { }); it('does not set replay ID attribute when getReplayId returns null or undefined', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -635,7 +629,7 @@ describe('_INTERNAL_captureLog', () => { }); it('sets replay_is_buffering attribute when replay is in buffer mode', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -668,7 +662,7 @@ describe('_INTERNAL_captureLog', () => { }); it('does not set replay_is_buffering attribute when replay is in session mode', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -698,7 +692,7 @@ describe('_INTERNAL_captureLog', () => { }); it('does not set replay_is_buffering attribute when replay is undefined mode', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -728,7 +722,7 @@ describe('_INTERNAL_captureLog', () => { }); it('does not set replay_is_buffering attribute when no replay ID is available', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -756,7 +750,7 @@ describe('_INTERNAL_captureLog', () => { }); it('does not set replay_is_buffering attribute when replay integration is missing', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -777,7 +771,6 @@ describe('_INTERNAL_captureLog', () => { it('combines replay_is_buffering with other replay attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, release: '1.0.0', environment: 'test', }); @@ -837,7 +830,6 @@ describe('_INTERNAL_captureLog', () => { it('includes user data in log attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -871,7 +863,6 @@ describe('_INTERNAL_captureLog', () => { it('includes partial user data when only some fields are available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -897,7 +888,6 @@ describe('_INTERNAL_captureLog', () => { it('includes user email and username without id', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -928,7 +918,6 @@ describe('_INTERNAL_captureLog', () => { it('does not include user data when user object is empty', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -947,7 +936,6 @@ describe('_INTERNAL_captureLog', () => { it('combines user data with other log attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, sendDefaultPii: true, release: '1.0.0', environment: 'test', @@ -1002,7 +990,6 @@ describe('_INTERNAL_captureLog', () => { it('handles user data with non-string values', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -1033,7 +1020,6 @@ describe('_INTERNAL_captureLog', () => { it('preserves existing user attributes in log and does not override them', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -1077,7 +1063,6 @@ describe('_INTERNAL_captureLog', () => { it('only adds scope user data for attributes that do not already exist', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -1127,7 +1112,6 @@ describe('_INTERNAL_captureLog', () => { it('overrides user-provided system attributes with SDK values', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - enableLogs: true, release: 'sdk-release-1.0.0', environment: 'sdk-environment', }); @@ -1188,7 +1172,7 @@ describe('_INTERNAL_captureLog', () => { it('increments the sequence number across consecutive logs', () => { vi.spyOn(timeModule, 'timestampInSeconds').mockReturnValue(1000.001); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -1215,7 +1199,7 @@ describe('_INTERNAL_captureLog', () => { return log; }); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true, beforeSendLog }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, beforeSendLog }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -1235,7 +1219,7 @@ describe('_INTERNAL_captureLog', () => { it('produces monotonically increasing sequence numbers within the same millisecond', () => { vi.spyOn(timeModule, 'timestampInSeconds').mockReturnValue(1000.001); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -1258,7 +1242,7 @@ describe('_INTERNAL_captureLog', () => { }); it('resets the sequence number via _INTERNAL_resetSequenceNumber', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -1280,7 +1264,7 @@ describe('_INTERNAL_captureLog', () => { describe.runIf(hasToWellFormed)('lone surrogate sanitization', () => { it('sanitizes lone surrogates in log message body', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -1292,7 +1276,7 @@ describe('_INTERNAL_captureLog', () => { }); it('sanitizes lone surrogates in parameterized (fmt) log message body', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -1305,7 +1289,7 @@ describe('_INTERNAL_captureLog', () => { }); it('sanitizes lone surrogates in log attribute values', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -1327,7 +1311,7 @@ describe('_INTERNAL_captureLog', () => { }); it('sanitizes lone surrogates in log attribute keys', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); @@ -1349,7 +1333,7 @@ describe('_INTERNAL_captureLog', () => { }); it('preserves valid emoji in log messages and attributes', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); const scope = new Scope(); scope.setClient(client); diff --git a/packages/core/test/lib/tracing/ai/utils.test.ts b/packages/core/test/lib/tracing/ai/utils.test.ts index b761d3019e5b..4fc4ecdcced5 100644 --- a/packages/core/test/lib/tracing/ai/utils.test.ts +++ b/packages/core/test/lib/tracing/ai/utils.test.ts @@ -167,7 +167,7 @@ describe('wrapPromiseWithMethods', () => { request_id: 'req_123', }); const instrumented = Promise.resolve('instrumented-data'); - const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(original, instrumented); const result = await wrapped; expect(result).toBe('instrumented-data'); @@ -179,7 +179,7 @@ describe('wrapPromiseWithMethods', () => { request_id: 'req_123', }); const instrumented = Promise.resolve('instrumented-data'); - const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(original, instrumented); const withResponseResult = await (wrapped as typeof original).withResponse(); expect(withResponseResult).toEqual({ @@ -196,7 +196,7 @@ describe('wrapPromiseWithMethods', () => { request_id: 'req_123', }); const instrumented = Promise.resolve('instrumented-data'); - const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(original, instrumented); const response = await (wrapped as typeof original).asResponse(); expect(response).toBe(mockResponse); @@ -205,7 +205,7 @@ describe('wrapPromiseWithMethods', () => { it('returns instrumentedPromise when original is not thenable', async () => { const instrumented = Promise.resolve('instrumented-data'); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const wrapped = wrapPromiseWithMethods(null as any, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(null as any, instrumented); const result = await wrapped; expect(result).toBe('instrumented-data'); @@ -217,7 +217,7 @@ describe('wrapPromiseWithMethods', () => { request_id: 'req_123', }); const instrumented = Promise.reject(new Error('instrumented-error')); - const wrapped = wrapPromiseWithMethods(original, instrumented, 'auto.ai.test'); + const wrapped = wrapPromiseWithMethods(original, instrumented); await expect(wrapped).rejects.toThrow('instrumented-error'); }); diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index 9ef6c834d251..4e5f9eee434b 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -765,6 +765,29 @@ describe('startIdleSpan', () => { expect(spanToJSON(idleSpan).timestamp).toBeDefined(); }); + it('measures the idle timeout from the last child end, not from the auto-finish signal', () => { + const idleSpan = startIdleSpan({ name: 'idle span' }, { disableAutoFinish: true, finalTimeout: 99_999 }); + const idleSpanId = idleSpan.spanContext().spanId; + + const child = startInactiveSpan({ name: 'inner' }); + + vi.advanceTimersByTime(500); + getClient()!.emit('idleSpanEnableAutoFinish', idleSpan); + + vi.advanceTimersByTime(700); + child!.end(); + + vi.advanceTimersByTime(TRACING_DEFAULTS.idleTimeout - 199); + expect(spanToJSON(idleSpan).timestamp).toBeUndefined(); + + const lateChild = startInactiveSpan({ name: 'late' }); + expect(spanToJSON(lateChild!).parent_span_id).toBe(idleSpanId); + + lateChild!.end(); + vi.advanceTimersByTime(TRACING_DEFAULTS.idleTimeout); + expect(spanToJSON(idleSpan).timestamp).toBeDefined(); + }); + it('times out at final timeout if disableAutoFinish=true', () => { const idleSpan = startIdleSpan({ name: 'idle span' }, { disableAutoFinish: true }); expect(idleSpan).toBeDefined(); diff --git a/packages/core/test/lib/tracing/langchain-embeddings.test.ts b/packages/core/test/lib/tracing/langchain-embeddings.test.ts index f1bed062b4b2..af605fcfaf6f 100644 --- a/packages/core/test/lib/tracing/langchain-embeddings.test.ts +++ b/packages/core/test/lib/tracing/langchain-embeddings.test.ts @@ -75,7 +75,7 @@ describe('instrumentEmbeddingMethod', () => { expect(capturedSpanConfig!.attributes[GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE]).toBe('["doc1","doc2"]'); }); - it('captures exception on failure', async () => { + it('rethrows the error to the caller without capturing it', async () => { const error = new Error('API error'); const original = vi.fn().mockRejectedValue(error); const wrapped = instrumentEmbeddingMethod(original); @@ -83,9 +83,7 @@ describe('instrumentEmbeddingMethod', () => { const instance = { constructor: { name: 'OpenAIEmbeddings' }, model: 'error-model' }; await expect(wrapped.call(instance, 'test')).rejects.toThrow('API error'); - expect(captureException).toHaveBeenCalledWith(error, { - mechanism: { handled: false, type: 'auto.ai.langchain' }, - }); + expect(captureException).not.toHaveBeenCalled(); }); it('infers system from class name', async () => { diff --git a/packages/core/test/lib/tracing/langgraph.test.ts b/packages/core/test/lib/tracing/langgraph.test.ts index 6cbd6ff2fdcb..34c859882387 100644 --- a/packages/core/test/lib/tracing/langgraph.test.ts +++ b/packages/core/test/lib/tracing/langgraph.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { instrumentCreateReactAgent, instrumentStateGraphCompile } from '../../../src/tracing/langgraph'; +import { + instrumentCreateReactAgent, + instrumentLangGraph, + instrumentStateGraph, + instrumentStateGraphCompile, +} from '../../../src/tracing/langgraph'; describe('langgraph double-patch guard', () => { it('instrumentStateGraphCompile returns the same wrapper when applied twice', () => { @@ -16,3 +21,19 @@ describe('langgraph double-patch guard', () => { expect(second).toBe(first); }); }); + +describe('instrumentStateGraph', () => { + it('wraps the compile method of a StateGraph instance and returns the same instance', () => { + const originalCompile = () => ({}); + const stateGraph = { compile: originalCompile }; + + const result = instrumentStateGraph(stateGraph); + + expect(result).toBe(stateGraph); + expect(stateGraph.compile).not.toBe(originalCompile); + }); + + it('exposes instrumentLangGraph as a deprecated alias for instrumentStateGraph', () => { + expect(instrumentLangGraph).toBe(instrumentStateGraph); + }); +}); diff --git a/packages/core/test/lib/tracing/sentrySpan.test.ts b/packages/core/test/lib/tracing/sentrySpan.test.ts index 9bf840f12a2f..b96f89fdb8f5 100644 --- a/packages/core/test/lib/tracing/sentrySpan.test.ts +++ b/packages/core/test/lib/tracing/sentrySpan.test.ts @@ -8,16 +8,21 @@ import { } from '../../../src/semanticAttributes'; import { SentrySpan } from '../../../src/tracing/sentrySpan'; import { SPAN_STATUS_ERROR } from '../../../src/tracing/spanstatus'; +import { startInactiveSpan, startSpan, withActiveSpan } from '../../../src/tracing/trace'; import { markSpanAsTracerProviderSpan, markSpanForOtelSourceInference, spanSourceWasExplicitlySet, } from '../../../src/tracing/utils'; -import type { SpanJSON } from '../../../src/types/span'; -import { spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils'; +import type { Span, SpanJSON } from '../../../src/types/span'; +import { getRootSpan, spanToJSON, TRACE_FLAG_NONE, TRACE_FLAG_SAMPLED } from '../../../src/utils/spanUtils'; import { timestampInSeconds } from '../../../src/utils/time'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +function childSpansOf(span: Span): Set { + return (span as unknown as { _sentryChildSpans?: Set })._sentryChildSpans ?? new Set(); +} + describe('SentrySpan', () => { describe('name', () => { it('works with name', () => { @@ -212,6 +217,51 @@ describe('SentrySpan', () => { }); }); + describe('child span retention', () => { + it('stops tracking children on a segment span once it has been captured', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + setCurrentClient(client); + const captureEvent = vi.spyOn(client, 'captureEvent'); + + let rootSpan: Span | undefined; + startSpan({ name: 'root' }, span => { + rootSpan = span; + startSpan({ name: 'child' }, () => {}); + }); + + expect(captureEvent).toHaveBeenCalledTimes(1); + expect(captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ spans: [expect.objectContaining({ description: 'child' })] }), + expect.any(Object), + expect.any(Object), + ); + expect(childSpansOf(rootSpan!).size).toBe(1); + + // A child that starts after the tree was read is not tracked, but can still find its root span, + // which is all that re-emitting it as its own transaction needs. + const lateChild = withActiveSpan(rootSpan!, () => startInactiveSpan({ name: 'late child' })); + expect(childSpansOf(rootSpan!).size).toBe(1); + expect(getRootSpan(lateChild)).toBe(rootSpan); + }); + + it('stops tracking children on a segment span that has streamed', () => { + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, traceLifecycle: 'stream' })); + setCurrentClient(client); + + let rootSpan: Span | undefined; + startSpan({ name: 'root' }, span => { + rootSpan = span; + startSpan({ name: 'child' }, () => {}); + }); + + expect(childSpansOf(rootSpan!).size).toBe(1); + + const lateChild = withActiveSpan(rootSpan!, () => startInactiveSpan({ name: 'late child' })); + expect(childSpansOf(rootSpan!).size).toBe(1); + expect(getRootSpan(lateChild)).toBe(rootSpan); + }); + }); + describe('end', () => { test('simple', () => { const span = new SentrySpan({}); diff --git a/packages/core/test/lib/tracing/workers-ai.test.ts b/packages/core/test/lib/tracing/workers-ai.test.ts index d9c563b59962..78e5dc79fe2f 100644 --- a/packages/core/test/lib/tracing/workers-ai.test.ts +++ b/packages/core/test/lib/tracing/workers-ai.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, startSpan } from '../../../src'; +import { addVercelAiProcessors } from '../../../src/tracing/vercel-ai'; +import { AI_OPERATION_ID_ATTRIBUTE } from '../../../src/tracing/vercel-ai/vercel-ai-attributes'; import { instrumentWorkersAiClient } from '../../../src/tracing/workers-ai'; +import { _INTERNAL_clearAiProviderSkips } from '../../../src/utils/ai/providerSkip'; +import { spanToJSON } from '../../../src/utils/spanUtils'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; describe('instrumentWorkersAiClient', () => { it('passes through non-run methods bound to the original client', () => { @@ -28,4 +34,85 @@ describe('instrumentWorkersAiClient', () => { expect(client.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); expect(result).toEqual({ response: 'Paris' }); }); + + describe('when the Vercel AI SDK drives the binding', () => { + let spans: string[]; + + /** Set up a client with the Vercel AI processors registered, recording every ended span. */ + function setupClient(): void { + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + + spans = []; + const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); + client.on('spanEnd', span => { + spans.push(spanToJSON(span).description ?? ''); + }); + setCurrentClient(client); + addVercelAiProcessors(client); + } + + beforeEach(() => { + _INTERNAL_clearAiProviderSkips(); + setupClient(); + }); + + afterEach(() => { + _INTERNAL_clearAiProviderSkips(); + }); + + /** + * Emit the span the `ai` SDK creates for a model call. Its `spanStart` handler is what marks + * Workers AI as skipped, exactly as it would at runtime. + */ + async function withVercelAiModelCall(callback: () => Promise): Promise { + await startSpan( + { name: 'ai.streamText.doStream', attributes: { [AI_OPERATION_ID_ATTRIBUTE]: 'ai.streamText.doStream' } }, + async () => { + await callback(); + }, + ); + } + + it('does not create a duplicate span for the nested `run` call', async () => { + const client = { run: vi.fn().mockResolvedValue({ response: 'Paris' }) }; + const instrumented = instrumentWorkersAiClient(client); + + await withVercelAiModelCall(() => instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' })); + + // The call is forwarded, but no duplicate `gen_ai.chat` span is emitted — only the + // Vercel AI model-call span remains. + expect(client.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + expect(spans).not.toContain('chat @cf/meta/llama-3.1-8b-instruct'); + expect(spans).toEqual(['streamText.doStream']); + }); + + it('still creates a span for a direct `run` call made before any Vercel AI call', async () => { + const client = { run: vi.fn().mockResolvedValue({ response: 'Paris' }) }; + const instrumented = instrumentWorkersAiClient(client); + + await startSpan({ name: 'root' }, () => instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' })); + + expect(spans).toContain('chat @cf/meta/llama-3.1-8b-instruct'); + }); + + it('clears the skip between clients so a later isolate reuse is unaffected', async () => { + const client = { run: vi.fn().mockResolvedValue({ response: 'Paris' }) }; + const instrumented = instrumentWorkersAiClient(client); + + // First request: the `ai` SDK runs and marks Workers AI as skipped. + await withVercelAiModelCall(() => instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' })); + expect(spans).not.toContain('chat @cf/meta/llama-3.1-8b-instruct'); + + // Second request on the same isolate: `_setupIntegrations` resets the registry, so a direct + // `env.AI.run` call must get its span back. Without the reset this would stay suppressed. + _INTERNAL_clearAiProviderSkips(); + setupClient(); + + await startSpan({ name: 'root' }, () => instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' })); + + expect(spans).toContain('chat @cf/meta/llama-3.1-8b-instruct'); + }); + }); }); diff --git a/packages/core/test/lib/utils/aggregate-errors.test.ts b/packages/core/test/lib/utils/aggregate-errors.test.ts index ac9e0c4f3bc5..3d51bffe520d 100644 --- a/packages/core/test/lib/utils/aggregate-errors.test.ts +++ b/packages/core/test/lib/utils/aggregate-errors.test.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from 'node:vm'; import { describe, expect, test } from 'vitest'; import type { ExtendedError } from '../../../src/types/error'; import type { Event, EventHint } from '../../../src/types/event'; @@ -115,6 +116,24 @@ describe('applyAggregateErrorsToEvent()', () => { }); }); + test('recursively walks errors created in another realm', () => { + const originalException = runInNewContext( + `new AggregateError([new Error('Aggregate child')], 'Root Error', { cause: new Error('Cause') })`, + ) as ExtendedError; + expect(originalException).not.toBeInstanceOf(Error); + + const event: Event = { exception: { values: [exceptionFromError(stackParser, originalException)] } }; + const eventHint: EventHint = { originalException }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, eventHint); + + expect(event.exception?.values?.map(exception => exception.value)).toStrictEqual([ + 'Aggregate child', + 'Cause', + 'Root Error', + ]); + }); + test('should not modify event if there are no attached errors', () => { const originalException: ExtendedError = new Error('Some Error'); diff --git a/packages/core/test/lib/utils/data-collection/filterKeyValueData.test.ts b/packages/core/test/lib/utils/data-collection/filterKeyValueData.test.ts index b472574dc546..2daab52d0bc3 100644 --- a/packages/core/test/lib/utils/data-collection/filterKeyValueData.test.ts +++ b/packages/core/test/lib/utils/data-collection/filterKeyValueData.test.ts @@ -108,6 +108,33 @@ describe('filterKeyValueData', () => { }); }); + describe('non-string values', () => { + const mixedData: Record = { + count: 42, + enabled: true, + nested: { a: 1 }, + password: 'hunter2', + }; + + it('preserves non-string values verbatim when kept', () => { + const result = filterKeyValueData(mixedData, true); + + expect(result.count).toBe(42); + expect(result.enabled).toBe(true); + expect(result.nested).toEqual({ a: 1 }); + // "password" matches the built-in sensitive denylist + expect(result.password).toBe('[Filtered]'); + }); + + it('replaces filtered non-string values with the string placeholder', () => { + const result = filterKeyValueData(mixedData, { allow: ['count'] }); + + expect(result.count).toBe(42); + expect(result.enabled).toBe('[Filtered]'); + expect(result.nested).toBe('[Filtered]'); + }); + }); + describe('edge cases', () => { it('handles empty record', () => { expect(filterKeyValueData({}, true)).toEqual({}); diff --git a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts index 9f554a1897e2..8d6ec21a0ab6 100644 --- a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts @@ -166,6 +166,28 @@ describe('resolveDataCollectionOptions', () => { expect(result.databaseQueryData).toBe(false); }); + + it('supports allow/deny list for stack frame variables', () => { + expect( + resolveDataCollectionOptions({ dataCollection: { stackFrameVariables: { allow: ['user'] } } }) + .stackFrameVariables, + ).toEqual({ allow: ['user'] }); + + expect( + resolveDataCollectionOptions({ dataCollection: { stackFrameVariables: { deny: ['password'] } } }) + .stackFrameVariables, + ).toEqual({ deny: ['password'] }); + }); + + it('supports turning off stack frame variables', () => { + const result = resolveDataCollectionOptions({ + dataCollection: { + stackFrameVariables: false, + }, + }); + + expect(result.stackFrameVariables).toBe(false); + }); }); describe('return type completeness', () => { diff --git a/packages/core/test/lib/utils/eventbuilder.test.ts b/packages/core/test/lib/utils/eventbuilder.test.ts index b882a4562b1c..2a08f073117e 100644 --- a/packages/core/test/lib/utils/eventbuilder.test.ts +++ b/packages/core/test/lib/utils/eventbuilder.test.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from 'node:vm'; import { describe, expect, it, test } from 'vitest'; import type { Client } from '../../../src/client'; import { eventFromMessage, eventFromUnknownInput, exceptionFromError } from '../../../src/utils/eventbuilder'; @@ -106,6 +107,22 @@ describe('eventFromUnknownInput', () => { }); }); + test('object with error prop created in another realm', () => { + const error = runInNewContext(`new Error('Some error')`) as Error; + expect(error).not.toBeInstanceOf(Error); + + const event = eventFromUnknownInput(fakeClient, stackParser, { + err: error, + }); + + expect(event.exception?.values?.[0]).toEqual( + expect.objectContaining({ + type: 'Error', + value: 'Some error', + }), + ); + }); + it('handles class with error prop', () => { const error = new Error('Some error'); diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index d8b0009cffee..caa5c38017e1 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -22,7 +22,9 @@ import type { Span, SpanAttributes, SpanTimeInput, StreamedSpanJSON } from '../. import type { SpanStatus } from '../../../src/types/spanStatus'; import type { OpenTelemetrySdkTraceBaseSpan } from '../../../src/utils/spanUtils'; import { + addChildSpanToSpan, getRootSpan, + getSpanDescendants, spanIsSampled, spanTimeInputToSeconds, spanToJSON, @@ -777,6 +779,72 @@ describe('getRootSpan', () => { }); }); +describe('addChildSpanToSpan', () => { + it('does not track children on an unsampled span', () => { + const parent = new SentrySpan({ name: 'parent', sampled: false }); + const child = new SentrySpan({ name: 'child', sampled: false }); + + addChildSpanToSpan(parent, child); + + expect(getRootSpan(child)).toBe(parent); + expect((parent as unknown as { _sentryChildSpans?: Set })._sentryChildSpans).toBeUndefined(); + }); + + it('does not track children on a segment span that stopped recording', () => { + const parent = new SentrySpan({ name: 'parent', sampled: true }); + parent.end(); + + const child = new SentrySpan({ name: 'child', sampled: true }); + addChildSpanToSpan(parent, child); + + // the child that was not tracked can still find its root span + expect(getRootSpan(child)).toBe(parent); + expect(getSpanDescendants(parent)).toEqual([parent]); + }); + + it('keeps tracking children on an ended span while its segment span is still recording', () => { + const segment = new SentrySpan({ name: 'segment', sampled: true }); + const parent = new SentrySpan({ name: 'parent', sampled: true }); + addChildSpanToSpan(segment, parent); + parent.end(); + + const child = new SentrySpan({ name: 'child', sampled: true }); + addChildSpanToSpan(parent, child); + + // the segment span is still open, so its transaction has not been assembled yet + expect(getSpanDescendants(segment)).toEqual([segment, parent, child]); + }); + + it('stops tracking children on an ended span once its segment span has ended', () => { + const segment = new SentrySpan({ name: 'segment', sampled: true }); + const parent = new SentrySpan({ name: 'parent', sampled: true }); + addChildSpanToSpan(segment, parent); + parent.end(); + segment.end(); + + const child = new SentrySpan({ name: 'child', sampled: true }); + addChildSpanToSpan(parent, child); + + // the child that was not tracked can still find its root span + expect(getRootSpan(child)).toBe(segment); + expect(getSpanDescendants(segment)).toEqual([segment, parent]); + }); + + it('keeps tracking children on a still-recording span after its segment span ended', () => { + const segment = new SentrySpan({ name: 'segment', sampled: true }); + const lateChild = new SentrySpan({ name: 'late child', sampled: true }); + addChildSpanToSpan(segment, lateChild); + segment.end(); + + const grandChild = new SentrySpan({ name: 'grandchild', sampled: true }); + addChildSpanToSpan(lateChild, grandChild); + + // a late child that outlives its segment is re-emitted as its own orphan transaction with its + // subtree, so the subtree must keep collecting + expect(getSpanDescendants(lateChild)).toEqual([lateChild, grandChild]); + }); +}); + describe('updateSpanName', () => { it('updates the span name and source', () => { const span = new SentrySpan({ name: 'old-name', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url' } }); diff --git a/packages/core/test/lib/utils/sql.test.ts b/packages/core/test/lib/utils/sql.test.ts index 4cebd5c423b8..7e5bb2140c38 100644 --- a/packages/core/test/lib/utils/sql.test.ts +++ b/packages/core/test/lib/utils/sql.test.ts @@ -75,12 +75,39 @@ describe('getSqlQuerySummary', () => { 'INSERT shipping_details SELECT orders', ); }); + + it.each([ + ['INSERT OR REPLACE INTO users (id) VALUES (?)', 'INSERT users'], + ['INSERT OR IGNORE INTO users (id) VALUES (?)', 'INSERT users'], + ['INSERT OR ABORT INTO users (id) VALUES (?)', 'INSERT users'], + ['INSERT OR FAIL INTO users (id) VALUES (?)', 'INSERT users'], + ['INSERT OR ROLLBACK INTO users (id) VALUES (?)', 'INSERT users'], + ['insert or replace into orders (id) values (?)', 'insert orders'], + ])('strips the SQLite conflict clause: %j => %j', (input, expected) => { + expect(getSqlQuerySummary(input)).toBe(expected); + }); + + it.each([ + ['REPLACE INTO users (id) VALUES (?)', 'REPLACE users'], + ['replace into orders (id) values (?)', 'replace orders'], + ['REPLACE INTO shipping_details SELECT * FROM orders', 'REPLACE shipping_details SELECT orders'], + ])('handles the REPLACE INTO shorthand: %j => %j', (input, expected) => { + expect(getSqlQuerySummary(input)).toBe(expected); + }); + + it('captures INSERT OR REPLACE...SELECT with both targets', () => { + expect(getSqlQuerySummary('INSERT OR REPLACE INTO shipping_details SELECT * FROM orders')).toBe( + 'INSERT shipping_details SELECT orders', + ); + }); }); describe('UPDATE', () => { it.each([ ['UPDATE users SET name = ? WHERE id = ?', 'UPDATE users'], ['update orders SET status = ? WHERE created_at < ?', 'update orders'], + ['UPDATE OR REPLACE users SET name = ? WHERE id = ?', 'UPDATE users'], + ['UPDATE OR IGNORE orders SET status = ?', 'UPDATE orders'], ])('%j => %j', (input, expected) => { expect(getSqlQuerySummary(input)).toBe(expected); }); diff --git a/packages/deno/package.json b/packages/deno/package.json index 6740b465f816..f668d1d5e075 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/deno", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Deno", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", @@ -28,8 +28,8 @@ ], "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/core": "10.67.0", - "@sentry/server-utils": "10.67.0" + "@sentry/core": "10.73.0", + "@sentry/server-utils": "10.73.0" }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", diff --git a/packages/deno/test/mod.test.ts b/packages/deno/test/mod.test.ts index ecc3a6d4fe9e..2e1f86a09757 100644 --- a/packages/deno/test/mod.test.ts +++ b/packages/deno/test/mod.test.ts @@ -115,7 +115,6 @@ Deno.test('logger.info captures a log envelope item', async () => { const envelopes: Array = []; const client = new DenoClient({ dsn: 'https://233a45e5efe34c47a3536797ce15dafa@nothing.here/5650507', - enableLogs: true, integrations: getDefaultIntegrations({}), stackParser: createStackParser(nodeStackLineParser()), transport: makeTestTransport(envelope => { @@ -151,7 +150,6 @@ Deno.test('logger.info captures a log envelope item', async () => { Deno.test('adds server.address to log attributes', () => { const client = new DenoClient({ dsn: 'https://233a45e5efe34c47a3536797ce15dafa@nothing.here/5650507', - enableLogs: true, serverName: 'test-server', integrations: getDefaultIntegrations({}), stackParser: createStackParser(nodeStackLineParser()), @@ -167,7 +165,6 @@ Deno.test('adds server.address to log attributes', () => { Deno.test('preserves existing log attributes when adding server.address', () => { const client = new DenoClient({ dsn: 'https://233a45e5efe34c47a3536797ce15dafa@nothing.here/5650507', - enableLogs: true, serverName: 'test-server', integrations: getDefaultIntegrations({}), stackParser: createStackParser(nodeStackLineParser()), diff --git a/packages/effect/README.md b/packages/effect/README.md index bfe3c51ce8dc..aa733a4e528e 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -27,7 +27,6 @@ const SentryLive = Layer.mergeAll( Sentry.effectLayer({ dsn: '__DSN__', tracesSampleRate: 1.0, - enableLogs: true, }), Layer.setTracer(Sentry.SentryEffectTracer), Logger.replace(Logger.defaultLogger, Sentry.SentryEffectLogger), @@ -59,7 +58,6 @@ const SentryLive = Layer.mergeAll( Sentry.effectLayer({ dsn: '__DSN__', tracesSampleRate: 1.0, - enableLogs: true, }), Layer.succeed(Tracer.Tracer, Sentry.SentryEffectTracer), Logger.layer([Sentry.SentryEffectLogger]), diff --git a/packages/effect/package.json b/packages/effect/package.json index f03c5457cb4a..8c69300a44c7 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/effect", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Effect", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/effect", @@ -57,9 +57,9 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.67.0", - "@sentry/core": "10.67.0", - "@sentry/node-core": "10.67.0" + "@sentry/browser": "10.73.0", + "@sentry/core": "10.73.0", + "@sentry/node-core": "10.73.0" }, "peerDependencies": { "effect": "^3.0.0 || ^4.0.0-beta.50" diff --git a/packages/effect/src/logger.ts b/packages/effect/src/logger.ts index 654edb639662..f0a55b3bf20e 100644 --- a/packages/effect/src/logger.ts +++ b/packages/effect/src/logger.ts @@ -1,7 +1,11 @@ -import { isObjectLike, logger as sentryLogger } from '@sentry/core'; +import { isObjectLike, logger as sentryLogger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import * as Logger from 'effect/Logger'; import type * as LogLevel from 'effect/LogLevel'; +const LOG_ATTRIBUTES = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.log.effect', +}; + function getLogLevelTag(logLevel: LogLevel.LogLevel): LogLevel.LogLevel | 'Warning' { // Effect v4: logLevel is a string literal directly if (typeof logLevel === 'string') { @@ -34,23 +38,23 @@ export const SentryEffectLogger = Logger.make(({ logLevel, message }) => { switch (tag) { case 'Fatal': - sentryLogger.fatal(msg); + sentryLogger.fatal(msg, LOG_ATTRIBUTES); break; case 'Error': - sentryLogger.error(msg); + sentryLogger.error(msg, LOG_ATTRIBUTES); break; case 'Warning': // Effect v3 case 'Warn': // Effect v4 - sentryLogger.warn(msg); + sentryLogger.warn(msg, LOG_ATTRIBUTES); break; case 'Info': - sentryLogger.info(msg); + sentryLogger.info(msg, LOG_ATTRIBUTES); break; case 'Debug': - sentryLogger.debug(msg); + sentryLogger.debug(msg, LOG_ATTRIBUTES); break; case 'Trace': - sentryLogger.trace(msg); + sentryLogger.trace(msg, LOG_ATTRIBUTES); break; case 'All': case 'None': diff --git a/packages/effect/test/logger.test.ts b/packages/effect/test/logger.test.ts index 5069514fc2c7..9b17311d195f 100644 --- a/packages/effect/test/logger.test.ts +++ b/packages/effect/test/logger.test.ts @@ -21,6 +21,8 @@ vi.mock('@sentry/core', async importOriginal => { }; }); +const LOG_ATTRIBUTES = { 'sentry.origin': 'auto.log.effect' }; + describe('SentryEffectLogger', () => { afterEach(() => { vi.clearAllMocks(); @@ -34,49 +36,49 @@ describe('SentryEffectLogger', () => { it.effect('forwards fatal logs to Sentry', () => Effect.gen(function* () { yield* Effect.logFatal('This is a fatal message'); - expect(sentryCore.logger.fatal).toHaveBeenCalledWith('This is a fatal message'); + expect(sentryCore.logger.fatal).toHaveBeenCalledWith('This is a fatal message', LOG_ATTRIBUTES); }).pipe(Effect.provide(loggerLayer)), ); it.effect('forwards error logs to Sentry', () => Effect.gen(function* () { yield* Effect.logError('This is an error message'); - expect(sentryCore.logger.error).toHaveBeenCalledWith('This is an error message'); + expect(sentryCore.logger.error).toHaveBeenCalledWith('This is an error message', LOG_ATTRIBUTES); }).pipe(Effect.provide(loggerLayer)), ); it.effect('forwards warning logs to Sentry', () => Effect.gen(function* () { yield* Effect.logWarning('This is a warning message'); - expect(sentryCore.logger.warn).toHaveBeenCalledWith('This is a warning message'); + expect(sentryCore.logger.warn).toHaveBeenCalledWith('This is a warning message', LOG_ATTRIBUTES); }).pipe(Effect.provide(loggerLayer)), ); it.effect('forwards info logs to Sentry', () => Effect.gen(function* () { yield* Effect.logInfo('This is an info message'); - expect(sentryCore.logger.info).toHaveBeenCalledWith('This is an info message'); + expect(sentryCore.logger.info).toHaveBeenCalledWith('This is an info message', LOG_ATTRIBUTES); }).pipe(Effect.provide(loggerLayer)), ); it.effect('forwards debug logs to Sentry', () => Effect.gen(function* () { yield* Effect.logDebug('This is a debug message'); - expect(sentryCore.logger.debug).toHaveBeenCalledWith('This is a debug message'); + expect(sentryCore.logger.debug).toHaveBeenCalledWith('This is a debug message', LOG_ATTRIBUTES); }).pipe(withAllLogLevels, Effect.provide(loggerLayer)), ); it.effect('forwards trace logs to Sentry', () => Effect.gen(function* () { yield* Effect.logTrace('This is a trace message'); - expect(sentryCore.logger.trace).toHaveBeenCalledWith('This is a trace message'); + expect(sentryCore.logger.trace).toHaveBeenCalledWith('This is a trace message', LOG_ATTRIBUTES); }).pipe(withAllLogLevels, Effect.provide(loggerLayer)), ); it.effect('handles object messages by stringifying', () => Effect.gen(function* () { yield* Effect.logInfo({ key: 'value', nested: { foo: 'bar' } }); - expect(sentryCore.logger.info).toHaveBeenCalledWith('{"key":"value","nested":{"foo":"bar"}}'); + expect(sentryCore.logger.info).toHaveBeenCalledWith('{"key":"value","nested":{"foo":"bar"}}', LOG_ATTRIBUTES); }).pipe(Effect.provide(loggerLayer)), ); @@ -86,9 +88,9 @@ describe('SentryEffectLogger', () => { yield* Effect.logInfo('Second message'); yield* Effect.logWarning('Third message'); expect(sentryCore.logger.info).toHaveBeenCalledTimes(2); - expect(sentryCore.logger.info).toHaveBeenNthCalledWith(1, 'First message'); - expect(sentryCore.logger.info).toHaveBeenNthCalledWith(2, 'Second message'); - expect(sentryCore.logger.warn).toHaveBeenCalledWith('Third message'); + expect(sentryCore.logger.info).toHaveBeenNthCalledWith(1, 'First message', LOG_ATTRIBUTES); + expect(sentryCore.logger.info).toHaveBeenNthCalledWith(2, 'Second message', LOG_ATTRIBUTES); + expect(sentryCore.logger.warn).toHaveBeenCalledWith('Third message', LOG_ATTRIBUTES); }).pipe(Effect.provide(loggerLayer)), ); @@ -99,7 +101,25 @@ describe('SentryEffectLogger', () => { Effect.map(d => d.toUpperCase()), ); expect(result).toBe('DATA'); - expect(sentryCore.logger.info).toHaveBeenCalledWith('Processing: data'); + expect(sentryCore.logger.info).toHaveBeenCalledWith('Processing: data', LOG_ATTRIBUTES); }).pipe(Effect.provide(loggerLayer)), ); + + it.effect('sets the sentry.origin attribute on every log level', () => + Effect.gen(function* () { + yield* Effect.logFatal('fatal'); + yield* Effect.logError('error'); + yield* Effect.logWarning('warning'); + yield* Effect.logInfo('info'); + yield* Effect.logDebug('debug'); + yield* Effect.logTrace('trace'); + + for (const level of ['fatal', 'error', 'warn', 'info', 'debug', 'trace'] as const) { + expect(sentryCore.logger[level]).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ 'sentry.origin': 'auto.log.effect' }), + ); + } + }).pipe(withAllLogLevels, Effect.provide(loggerLayer)), + ); }); diff --git a/packages/elysia/package.json b/packages/elysia/package.json index 7ad247aa325b..a368fe1e699e 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/elysia", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Elysia", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/elysia", @@ -39,8 +39,9 @@ "access": "public" }, "dependencies": { - "@sentry/bun": "10.67.0", - "@sentry/core": "10.67.0" + "@sentry/bun": "10.73.0", + "@sentry/core": "10.73.0", + "@sentry/conventions": "^0.16.0" }, "peerDependencies": { "elysia": "^1.4.0" diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index abbdc9ec513d..09fa4ee52cb4 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -138,6 +138,8 @@ export { instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentStateGraphCompile, zodErrorsIntegration, diff --git a/packages/elysia/src/withElysia.ts b/packages/elysia/src/withElysia.ts index 84a496300d01..1258f193adeb 100644 --- a/packages/elysia/src/withElysia.ts +++ b/packages/elysia/src/withElysia.ts @@ -1,3 +1,4 @@ +import { HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import type { Span } from '@sentry/core'; import { captureException, @@ -59,18 +60,24 @@ const instrumentedApps = new WeakSet(); function updateRouteTransactionName(request: Request, method: string, route: string): void { const transactionName = `${method} ${route}`; + function applyRouteToSpan(span: Span): void { + updateSpanName(span, transactionName); + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [HTTP_ROUTE]: route, + }); + } + // Try the stored root span first (reliable across async contexts), // then fall back to getActiveSpan() for cases where async context is preserved. const rootSpan = rootSpanForRequest.get(request); if (rootSpan) { - updateSpanName(rootSpan, transactionName); - rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + applyRouteToSpan(rootSpan); } else { const activeSpan = getActiveSpan(); if (activeSpan) { const root = getRootSpan(activeSpan); - updateSpanName(root, transactionName); - root.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + applyRouteToSpan(root); } } @@ -198,6 +205,8 @@ export function withElysia(app: T, options: ElysiaHandlerOp attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [URL_FULL]: request.url, + [URL_PATH]: new URL(request.url).pathname, }, }, rootSpan => { diff --git a/packages/elysia/test/withElysia.test.ts b/packages/elysia/test/withElysia.test.ts index 3f73d9e5d835..751a8175be33 100644 --- a/packages/elysia/test/withElysia.test.ts +++ b/packages/elysia/test/withElysia.test.ts @@ -1,3 +1,4 @@ +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; import type { ErrorContext } from 'elysia'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -32,6 +33,12 @@ const mockGetIsolationScope = vi.fn(() => ({ const mockGetClient = vi.fn(() => ({ on: vi.fn(), })); +const mockRootSpan = { + setAttributes: vi.fn(), + updateName: vi.fn(), +}; +const mockGetActiveSpan = vi.fn(); +const mockGetRootSpan = vi.fn(() => mockRootSpan); const mockGetTraceData = vi.fn(() => ({ 'sentry-trace': 'abc123-def456-1', baggage: 'sentry-environment=test,sentry-trace_id=abc123', @@ -43,8 +50,10 @@ vi.mock('@sentry/core', async importActual => { return { ...actual, captureException: (...args: unknown[]) => mockCaptureException(...args), + getActiveSpan: () => mockGetActiveSpan(), getIsolationScope: () => mockGetIsolationScope(), getClient: () => mockGetClient(), + getRootSpan: () => mockGetRootSpan(), getTraceData: () => mockGetTraceData(), }; }); @@ -88,6 +97,23 @@ describe('withElysia', () => { expect(headers['baggage']).toBe('sentry-environment=test,sentry-trace_id=abc123'); }); + it('sets the matched route on the root span', () => { + mockGetActiveSpan.mockReturnValueOnce(mockRootSpan); + // @ts-expect-error - mock app + withElysia(mockApp); + + onAfterHandleHandler({ + route: '/users/:id', + request: new Request('https://example.com/users/42', { method: 'GET' }), + set: { headers: {} }, + }); + + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'sentry.source': 'route', + [HTTP_ROUTE]: '/users/:id', + }); + }); + it('does not set headers when trace data is empty', () => { mockGetTraceData.mockReturnValueOnce({}); // @ts-expect-error - mock app diff --git a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts index 750f0115ee1a..826912a46c22 100644 --- a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts +++ b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts @@ -14,10 +14,7 @@ import { import type { Client, Span } from '@sentry/core'; import type { EmberRouterMain } from '../types'; import { getBackburner } from './performance'; - -const URL_FULL = 'url.full'; -const URL_PATH = 'url.path'; -const URL_TEMPLATE = 'url.template'; +import { URL_FULL, URL_PATH, URL_TEMPLATE } from '@sentry/conventions/attributes'; type TransitionWithIntent = Transition & { intent?: { url?: string } }; diff --git a/packages/ember/package.json b/packages/ember/package.json index 33be748434ad..a7c048f7fcc7 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/ember", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Ember.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/ember", @@ -32,8 +32,9 @@ "dependencies": { "@babel/core": "^7.29.6", "@embroider/macros": "^1.16.0", - "@sentry/browser": "10.67.0", - "@sentry/core": "10.67.0", + "@sentry/browser": "10.73.0", + "@sentry/core": "10.73.0", + "@sentry/conventions": "^0.16.0", "ember-auto-import": "^2.7.2", "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^6.1.1", diff --git a/packages/eslint-config-sdk/package.json b/packages/eslint-config-sdk/package.json index 41ce9bff9d36..0bc37e83fce5 100644 --- a/packages/eslint-config-sdk/package.json +++ b/packages/eslint-config-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/eslint-config-sdk", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK eslint config", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-config-sdk", @@ -22,8 +22,8 @@ "access": "public" }, "dependencies": { - "@sentry/eslint-plugin-sdk": "10.67.0", - "@sentry/typescript": "10.67.0", + "@sentry/eslint-plugin-sdk": "10.73.0", + "@sentry/typescript": "10.73.0", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "eslint-config-prettier": "^9.1.0", diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index c22affcc4f20..3706fe1405f0 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/eslint-plugin-sdk", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK eslint plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-plugin-sdk", diff --git a/packages/feedback/package.json b/packages/feedback/package.json index 5c702ab109f7..5d27648835c0 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/feedback", - "version": "10.67.0", + "version": "10.73.0", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -40,7 +40,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.67.0" + "@sentry/core": "10.73.0" }, "devDependencies": { "preact": "^10.19.4" diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 452ca5cc2a56..084c01ae8a22 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/gatsby", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Gatsby.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby", @@ -45,13 +45,13 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.67.0", - "@sentry/react": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/react": "10.73.0", "@sentry/webpack-plugin": "^5.3.0" }, "peerDependencies": { "gatsby": "^3.0.0 || ^4.0.0 || ^5.0.0", - "react": "16.x || 17.x || 18.x" + "react": "16.x || 17.x || 18.x || 19.x" }, "devDependencies": { "@testing-library/react": "^15.0.5", diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index d8c85dc30f74..afa7f3f9c5dc 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/google-cloud-serverless", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Google Cloud Functions", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/google-cloud-serverless", @@ -48,9 +48,9 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/node-core": "10.67.0" + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/node-core": "10.73.0" }, "devDependencies": { "@google-cloud/bigquery": "^5.3.0", diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 4ed627ad007e..bd14114053e8 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -140,6 +140,8 @@ export { instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentStateGraphCompile, zodErrorsIntegration, diff --git a/packages/hono/package.json b/packages/hono/package.json index 5af45af84998..0984c23c7bdb 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/hono", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Hono (ALPHA)", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/hono", @@ -92,15 +92,16 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/core": "10.67.0" + "@sentry/core": "10.73.0", + "@sentry/conventions": "^0.16.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x", "@hono/node-server": "^1.x || ^2.x", - "@sentry/bun": "10.67.0", - "@sentry/cloudflare": "10.67.0", - "@sentry/deno": "10.67.0", - "@sentry/node": "10.67.0", + "@sentry/bun": "10.73.0", + "@sentry/cloudflare": "10.73.0", + "@sentry/deno": "10.73.0", + "@sentry/node": "10.73.0", "hono": "^4.x" }, "peerDependenciesMeta": { diff --git a/packages/hono/src/shared/middlewareHandlers.ts b/packages/hono/src/shared/middlewareHandlers.ts index 1bb044e16d49..5f36ac386d92 100644 --- a/packages/hono/src/shared/middlewareHandlers.ts +++ b/packages/hono/src/shared/middlewareHandlers.ts @@ -1,4 +1,5 @@ import { + captureException, getActiveSpan, getClient, getDefaultIsolationScope, @@ -15,6 +16,7 @@ import { defaultShouldHandleError } from './defaultShouldHandleError'; import { resolveRouteName } from './resolveRouteName'; import { type SentryHonoMiddlewareOptions } from '../shared/types'; import { type GetConnInfo } from 'hono/conninfo'; +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; /** * Request handler for Hono framework @@ -91,7 +93,7 @@ export function responseHandler( if (context.error) { if ((shouldHandleError ?? defaultShouldHandleError)(context.error)) { - getClient()?.captureException(context.error, { + captureException(context.error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); } @@ -99,7 +101,8 @@ export function responseHandler( } function updateSpanRouteName(isolationScope: Scope, context: Context): void { - const routeName = `${context.req.method} ${resolveRouteName(context)}`; + const route = resolveRouteName(context); + const routeName = `${context.req.method} ${route}`; const activeSpan = getActiveSpan(); if (activeSpan) { @@ -108,7 +111,10 @@ function updateSpanRouteName(isolationScope: Scope, context: Context): void { const rootSpan = getRootSpan(activeSpan); updateSpanName(rootSpan, routeName); - rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [HTTP_ROUTE]: route, + }); } isolationScope.setTransactionName(routeName); diff --git a/packages/hono/test/shared/middlewareHandlers.test.ts b/packages/hono/test/shared/middlewareHandlers.test.ts index dec527fb1744..0ba38af16bc9 100644 --- a/packages/hono/test/shared/middlewareHandlers.test.ts +++ b/packages/hono/test/shared/middlewareHandlers.test.ts @@ -1,4 +1,5 @@ import * as SentryCore from '@sentry/core'; +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { requestHandler, responseHandler } from '../../src/shared/middlewareHandlers'; @@ -42,10 +43,12 @@ vi.mock('@sentry/core', async () => { getUser: mockGetUser, })), getClient: vi.fn(() => undefined), + captureException: vi.fn(), }; }); const getClientMock = SentryCore.getClient as ReturnType; +const captureExceptionMock = SentryCore.captureException as ReturnType; const getActiveSpanMock = SentryCore.getActiveSpan as ReturnType; function createMockContext(status: number, error?: Error): unknown { @@ -63,108 +66,66 @@ describe('responseHandler', () => { describe('error capture — default behavior (no shouldHandleError)', () => { it('captures error when context.error is set', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ - captureException: mockCaptureException, - }); - const error = new Error('server error'); // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(500, error) as any); - expect(mockCaptureException).toHaveBeenCalledWith(error, { + expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); it('captures plain Error with no status (not an HTTPException) regardless of response status', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ - captureException: mockCaptureException, - }); - const error = new Error('plain error, no status property'); // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(404, error) as any); - expect(mockCaptureException).toHaveBeenCalledWith(error, { + expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); it('does not call captureException when there is no error', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ - captureException: mockCaptureException, - }); - // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(200) as any); - expect(mockCaptureException).not.toHaveBeenCalled(); + expect(captureExceptionMock).not.toHaveBeenCalled(); }); - it('does not throw when client is undefined', () => { - getClientMock.mockReturnValue(undefined); - - // oxlint-disable-next-line typescript/no-explicit-any - expect(() => responseHandler(createMockContext(500, new Error('boom')) as any)).not.toThrow(); - }); - - it('delegates deduplication to captureException — calls it even for errors with __sentry_captured__', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ - captureException: mockCaptureException, - }); - + it('delegates deduplication to the public capture API', () => { const error = new Error('already captured'); Object.defineProperty(error, '__sentry_captured__', { value: true, writable: false }); // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(500, error) as any); - expect(mockCaptureException).toHaveBeenCalledWith(error, { + expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); it('does not capture 4xx HTTPException (status on error object)', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ - captureException: mockCaptureException, - }); - const error = Object.assign(new Error('Not Found'), { status: 404 }); // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(404, error) as any); - expect(mockCaptureException).not.toHaveBeenCalled(); + expect(captureExceptionMock).not.toHaveBeenCalled(); }); it('does not capture 3xx HTTPException (status on error object)', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ - captureException: mockCaptureException, - }); - const error = Object.assign(new Error('Redirect'), { status: 301 }); // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(301, error) as any); - expect(mockCaptureException).not.toHaveBeenCalled(); + expect(captureExceptionMock).not.toHaveBeenCalled(); }); it('captures 5xx HTTPException (status on error object)', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ - captureException: mockCaptureException, - }); - const error = Object.assign(new Error('Service Unavailable'), { status: 503 }); // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(503, error) as any); - expect(mockCaptureException).toHaveBeenCalledWith(error, { + expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); @@ -172,9 +133,6 @@ describe('responseHandler', () => { describe('error capture — custom shouldHandleError', () => { it('calls shouldHandleError with the error and captures when it returns true', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ captureException: mockCaptureException }); - const shouldHandleError = vi.fn().mockReturnValue(true); const error = Object.assign(new Error('Not Found'), { status: 404 }); @@ -182,15 +140,12 @@ describe('responseHandler', () => { responseHandler(createMockContext(404, error) as any, shouldHandleError); expect(shouldHandleError).toHaveBeenCalledWith(error); - expect(mockCaptureException).toHaveBeenCalledWith(error, { + expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); it('does not capture when shouldHandleError returns false', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ captureException: mockCaptureException }); - const shouldHandleError = vi.fn().mockReturnValue(false); const error = new Error('suppressed 500 error'); @@ -198,43 +153,34 @@ describe('responseHandler', () => { responseHandler(createMockContext(500, error) as any, shouldHandleError); expect(shouldHandleError).toHaveBeenCalledWith(error); - expect(mockCaptureException).not.toHaveBeenCalled(); + expect(captureExceptionMock).not.toHaveBeenCalled(); }); it('captures 4xx error that would normally be skipped when shouldHandleError returns true', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ captureException: mockCaptureException }); - const error = Object.assign(new Error('Unauthorized'), { status: 401 }); // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(401, error) as any, () => true); - expect(mockCaptureException).toHaveBeenCalledWith(error, { + expect(captureExceptionMock).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.http.hono.context_error' }, }); }); it('suppresses 5xx error when shouldHandleError returns false', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ captureException: mockCaptureException }); - const error = Object.assign(new Error('Internal Server Error'), { status: 500 }); // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(500, error) as any, () => false); - expect(mockCaptureException).not.toHaveBeenCalled(); + expect(captureExceptionMock).not.toHaveBeenCalled(); }); it('does not invoke shouldHandleError when context.error is absent', () => { - const mockCaptureException = vi.fn(); - getClientMock.mockReturnValue({ captureException: mockCaptureException }); - const shouldHandleError = vi.fn().mockReturnValue(true); // oxlint-disable-next-line typescript/no-explicit-any responseHandler(createMockContext(200) as any, shouldHandleError); expect(shouldHandleError).not.toHaveBeenCalled(); - expect(mockCaptureException).not.toHaveBeenCalled(); + expect(captureExceptionMock).not.toHaveBeenCalled(); }); }); @@ -245,6 +191,18 @@ describe('responseHandler', () => { expect(mockSetTransactionName).toHaveBeenCalledWith('GET /test'); }); + + it('sets http.route on the root span', () => { + getActiveSpanMock.mockReturnValue(mockRootSpan); + + // oxlint-disable-next-line typescript/no-explicit-any + requestHandler(createMockContext(200) as any); + + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'sentry.source': 'route', + [HTTP_ROUTE]: '/test', + }); + }); }); }); diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index 70a679231ac7..d521692b4ade 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/integration-shims", - "version": "10.67.0", + "version": "10.73.0", "description": "Shims for integrations in Sentry SDK.", "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -56,7 +56,7 @@ "url": "https://github.com/getsentry/sentry-javascript/issues" }, "dependencies": { - "@sentry/core": "10.67.0" + "@sentry/core": "10.73.0" }, "engines": { "node": ">=18" diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 26005961a085..dbcc03b4ab9b 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nestjs", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for NestJS", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nestjs", @@ -47,9 +47,9 @@ "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/server-utils": "10.67.0" + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/server-utils": "10.73.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index cf82bff37cbd..04848824b10c 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nextjs", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Next.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs", @@ -46,6 +46,10 @@ "node": "./build/cjs/index.server.js", "import": "./build/esm/index.server.js" }, + "./config": { + "types": "./build/types/config/index.d.ts", + "default": "./build/cjs/config/index.js" + }, "./async-storage-shim": { "import": { "default": "./build/esm/config/templates/requestAsyncStorageShim.js" @@ -78,15 +82,15 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@rollup/plugin-commonjs": "28.0.1", - "@sentry/browser-utils": "10.67.0", + "@sentry/browser-utils": "10.73.0", "@sentry/bundler-plugin-core": "^5.3.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/opentelemetry": "10.67.0", - "@sentry/react": "10.67.0", - "@sentry/server-utils": "10.67.0", - "@sentry/vercel-edge": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/opentelemetry": "10.73.0", + "@sentry/react": "10.73.0", + "@sentry/server-utils": "10.73.0", + "@sentry/vercel-edge": "10.73.0", "@sentry/webpack-plugin": "^5.3.0", "rollup": "^4.60.3", "stacktrace-parser": "^0.1.11" diff --git a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts index 60a9b0d617f7..1785789483ed 100644 --- a/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/pages-router-instrumentation/wrapApiHandlerWithSentry.ts @@ -5,7 +5,9 @@ import { getActiveSpan, httpRequestToRequestData, isString, + isURLObjectRelative, objectify, + parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setHttpStatus, @@ -16,6 +18,7 @@ import type { NextApiRequest } from 'next'; import type { AugmentedNextApiResponse, NextApiHandler } from '../types'; import { flushSafelyWithTimeout, waitUntil } from '../utils/responseEnd'; import { dropNextjsRootContext, escapeNextjsTracing } from '../utils/tracingUtils'; +import { HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; export type AugmentedNextApiRequest = NextApiRequest & { __withSentry_applied__?: boolean; @@ -78,6 +81,9 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz isolationScope.setSDKProcessingMetadata({ normalizedRequest }); isolationScope.setTransactionName(`${reqMethod}${parameterizedRoute}`); + const requestUrl = normalizedRequest.url || req.url; + const urlObject = requestUrl ? parseStringToURLObject(requestUrl) : undefined; + return startSpanManual( { name: `${reqMethod}${parameterizedRoute}`, @@ -86,6 +92,9 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs', + [URL_FULL]: urlObject && !isURLObjectRelative(urlObject) ? urlObject.href : undefined, + [URL_PATH]: urlObject?.pathname, + [HTTP_ROUTE]: parameterizedRoute, }, }, async span => { diff --git a/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts index 61add752008a..baaceacd3455 100644 --- a/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts +++ b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts @@ -1,5 +1,6 @@ import type { Event } from '@sentry/core'; import { getClient } from '@sentry/core'; +import { URL_PATH } from '@sentry/conventions/attributes'; import { getSanitizedRequestUrl } from './urls'; /** @@ -20,7 +21,7 @@ export function setUrlProcessingMetadata(event: Event): void { // Get the route from trace data const componentRoute = traceData['next.route'] || traceData['http.route']; - const httpTarget = traceData['http.target'] as string | undefined; + const httpTarget = (traceData['http.target'] || traceData[URL_PATH]) as string | undefined; if (!componentRoute) { return; diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index d383837cbf17..81bdb0d3eb59 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -19,6 +19,12 @@ import type { EdgeRouteHandler } from '../edge/types'; /** * Wraps Next.js middleware with Sentry error and performance instrumentation. * + * From Next.js 14 onwards the middleware transaction is created by Next.js' native OpenTelemetry + * instrumentation (the `Middleware.execute` span, normalized by `enhanceMiddlewareRootSpan`). In that case this + * wrapper does not start a span of its own, as that would emit a second, redundant middleware span nested inside + * the root span. It only forks an isolation scope, captures errors, and flushes. Next.js 13 does not emit + * `Middleware.execute`, so there the wrapper still starts the transaction itself. + * * @param middleware The middleware handler. * @returns a wrapped middleware handler. */ @@ -32,6 +38,7 @@ export function wrapMiddlewareWithSentry( ? (globalThis as Record)._sentryRewritesTunnelPath : undefined; + // 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 @@ -52,6 +59,7 @@ export function wrapMiddlewareWithSentry( } } } + // TODO: We still should add central isolation scope creation for when our build-time instrumentation does not work anymore with turbopack. return withIsolationScope(isolationScope => { const req: unknown = args[0]; @@ -73,20 +81,37 @@ export function wrapMiddlewareWithSentry( currentScope.setTransactionName(spanName); - const activeSpan = getActiveSpan(); + const runMiddleware = (): ReturnType => + handleCallbackErrors( + () => wrappingTarget.apply(thisArg, args), + error => { + captureException(error, { + mechanism: { + type: 'auto.function.nextjs.wrap_middleware', + handled: false, + }, + }); + }, + () => { + waitUntil(flushSafelyWithTimeout()); + }, + ) as ReturnType; + const activeSpan = getActiveSpan(); if (activeSpan) { - // If there is an active span, it likely means that the automatic Next.js OTEL instrumentation worked and we can - // rely on that for parameterization. - spanName = 'middleware'; - spanSource = 'component'; - + // The native Next.js OTEL instrumentation created the middleware root span (`Middleware.execute`, + // normalized by `enhanceMiddlewareRootSpan`). Bind our forked scopes to it so the transaction picks up + // the isolation scope instead of the global one, and do not start a second, redundant span here. const rootSpan = getRootSpan(activeSpan); if (rootSpan) { setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); } + + return runMiddleware(); } + // Next.js only emits `Middleware.execute` from version 14 onwards. On Next.js 13 nothing else creates a + // middleware span, so this wrapper still has to provide the transaction itself. return startSpan( { name: spanName, @@ -96,22 +121,7 @@ export function wrapMiddlewareWithSentry( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrap_middleware', }, }, - () => { - return handleCallbackErrors( - () => wrappingTarget.apply(thisArg, args), - error => { - captureException(error, { - mechanism: { - type: 'auto.function.nextjs.wrap_middleware', - handled: false, - }, - }); - }, - () => { - waitUntil(flushSafelyWithTimeout()); - }, - ); - }, + runMiddleware, ); }); }, diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index dc5e7fb4f79b..110f904761ae 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -20,6 +20,7 @@ import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavi import type { RouteHandlerContext } from './types'; import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd'; import { commonObjectToIsolationScope } from './utils/tracingUtils'; +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; /** * Wraps a Next.js App Router Route handler with Sentry error and performance instrumentation. @@ -47,8 +48,11 @@ export function wrapRouteHandlerWithSentry any>( edgeRuntimeIsolationScopeOverride = isolationScope; rootSpan.updateName(`${method} ${parameterizedRoute}`); - rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server'); + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [HTTP_ROUTE]: parameterizedRoute, + }); } return withIsolationScope( diff --git a/packages/nextjs/src/config/deprecatedWithSentryConfig.ts b/packages/nextjs/src/config/deprecatedWithSentryConfig.ts new file mode 100644 index 000000000000..f80f3b0f6cc4 --- /dev/null +++ b/packages/nextjs/src/config/deprecatedWithSentryConfig.ts @@ -0,0 +1,24 @@ +import { consoleSandbox } from '@sentry/core'; +import type { SentryBuildOptions } from './types'; +import { withSentryConfig as withSentryConfigImpl } from './withSentryConfig'; + +let hasWarned = false; + +/** + * Deprecation shim for the `withSentryConfig` re-export on the `@sentry/nextjs` entry. Kept separate from + * `./config` so that importing from `@sentry/nextjs/config` stays silent. + */ +export function withSentryConfig(nextConfig?: C, sentryBuildOptions: SentryBuildOptions = {}): C { + if (!hasWarned) { + hasWarned = true; + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] Importing `withSentryConfig` from `@sentry/nextjs` is deprecated and will stop working in v11. Import it from `@sentry/nextjs/config` instead:\n' + + " import { withSentryConfig } from '@sentry/nextjs/config';", + ); + }); + } + + return withSentryConfigImpl(nextConfig, sentryBuildOptions); +} diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index cf8b26f31771..773871383c69 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -1,3 +1,5 @@ +import { loadOrchestrionBundler } from './loadOrchestrionBundler'; + /** * Instrumented packages verified (via e2e) to bundle correctly, removed from Sentry's own * `serverExternalPackages` defaults so the build-time loader can transform them. Everything else @@ -7,16 +9,55 @@ export const BUNDLE_SAFE_INSTRUMENTED_PACKAGES = ['ioredis']; /** - * The orchestrion runtime machinery must stay external — its parser breaks when bundled, which - * silently disables the runtime module hook. + * `@sentry/server-utils` (where `register.ts` and the bundled orchestrion runtime ship) must stay + * external: `register.ts` passes its own `__filename`/`import.meta.url` as the `parentURL` for + * `Module.register('@sentry/server-utils/orchestrion/hook.mjs', …)`, so that self-reference only + * resolves while the code still lives at its real `node_modules` location. Bundled into an app + * server chunk instead, the specifier would have to resolve from the chunk's output location, + * which fails under isolated installs (pnpm) where the package is a transitive dependency. + * + * (The `@apm-js-collab/*` packages no longer appear here: they are bundled into + * `@sentry/server-utils`' build, so no import of them exists at runtime.) */ -export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = [ - '@apm-js-collab/tracing-hooks', - '@apm-js-collab/code-transformer', -]; +export const ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES = ['@sentry/server-utils']; /** Remove the given packages from a `serverExternalPackages` list. */ export function filterInstrumentedExternals(externals: string[], packagesToBundle: string[]): string[] { const set = new Set(packagesToBundle); return externals.filter(name => !set.has(name)); } + +/** + * A webpack `externals` array entry that keeps {@link ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES} truly + * external by resolving each request to an absolute path at build time and emitting a + * `commonjs ` external. + * + * Listing the packages in `serverExternalPackages` is not enough: Next.js only externalizes a + * package when its bare specifier also resolves from the project root (`resolveExternal`'s + * base-resolve check in `next/dist/build/handle-externals.js`) — otherwise the + * `require('')` it emits into the chunk would dangle at runtime, so Next silently + * bundles the package instead. Under isolated installs (pnpm) the package is a transitive + * dependency that never resolves from the project root, so the orchestrion runtime ended up + * compiled into the server chunk — breaking the `Module.register` self-reference described on + * {@link ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES}. Absolute paths sidestep all of this — webpack + * emits `require('/abs/path/…')`, which loads the real files from `node_modules` no matter where + * the chunk lives. + * + * Must be placed *before* Next's own externals handler in the `externals` array: webpack calls + * array entries in order and stops at the first one that returns a result. + */ +export async function externalizeOrchestrionRuntimePackages({ + request, +}: { + request?: string; +}): Promise { + if ( + !request || + !ORCHESTRION_RUNTIME_EXTERNAL_PACKAGES.some(pkg => request === pkg || request.startsWith(`${pkg}/`)) + ) { + return undefined; + } + + const resolved = loadOrchestrionBundler().resolveOrchestrionRuntimeRequest(request); + return resolved ? `commonjs ${resolved}` : undefined; +} diff --git a/packages/nextjs/src/config/loadOrchestrionBundler.ts b/packages/nextjs/src/config/loadOrchestrionBundler.ts new file mode 100644 index 000000000000..331edf13c3ba --- /dev/null +++ b/packages/nextjs/src/config/loadOrchestrionBundler.ts @@ -0,0 +1,29 @@ +import { createRequire } from 'module'; +import type * as orchestrionBundler from '@sentry/server-utils/orchestrion/webpack'; + +type OrchestrionBundlerModule = typeof orchestrionBundler; + +// Use `createRequire` (never the CJS `require` alias) so bundlers don't emit a "Critical +// dependency" warning. Resolving from this file's own location keeps it working under pnpm +// isolated installations. +function getNodeRequire(): ReturnType { + let nodeRequire: ReturnType; + /*! rollup-include-cjs-only */ + nodeRequire = createRequire(__filename); + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + nodeRequire = createRequire(import.meta.url); + /*! rollup-include-esm-only-end */ + return nodeRequire; +} + +/** + * Loads `@sentry/server-utils/orchestrion/webpack` at call time instead of module scope. The + * runtime server entry re-exports `withSentryConfig`, so a static import would run the bundler + * plugins' module-scope side effects on every server-side SDK import (issues #23789, #22794). + * Synchronous because Next.js `webpack` config functions cannot be async. Node's require cache + * already returns the same module on repeated calls, so no memoization is needed. + */ +export function loadOrchestrionBundler(): OrchestrionBundlerModule { + return getNodeRequire()('@sentry/server-utils/orchestrion/webpack') as OrchestrionBundlerModule; +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 9e9aad687f45..1e18aa57bb31 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,10 +1,6 @@ import { debug } from '@sentry/core'; import * as path from 'path'; -import { - getOrchestrionLoaderPath, - getSentryInstrumentations, - serializeInstrumentations, -} from '@sentry/server-utils/orchestrion/webpack'; +import { loadOrchestrionBundler } from '../loadOrchestrionBundler'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { @@ -138,6 +134,8 @@ function maybeAddOrchestrionRule( return rules; } + const { getOrchestrionLoaderPath, getSentryInstrumentations, serializeInstrumentations } = loadOrchestrionBundler(); + return safelyAddTurbopackRule(rules, { matcher: '*.{js,mjs,cjs}', rule: { diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 1ca30de85804..4f3bfcb1faf4 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -47,12 +47,14 @@ export type NextConfigObject = { instrumentationHook?: boolean; clientTraceMetadata?: string[]; serverComponentsExternalPackages?: string[]; // next < v15.0.0 + outputFileTracingIncludes?: Record; // next < v15.0.0 sri?: { algorithm?: string }; }; productionBrowserSourceMaps?: boolean; // https://nextjs.org/docs/pages/api-reference/next-config-js/env env?: Record; serverExternalPackages?: string[]; // next >= v15.0.0 + outputFileTracingIncludes?: Record; // next >= v15.0.0 turbopack?: TurbopackOptions; compiler?: { runAfterProductionCompile?: (context: { distDir: string; projectDir: string }) => Promise | void; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 30532b354360..b13dfac5c11d 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import { createRequire } from 'module'; import * as path from 'path'; import type { VercelCronsConfig } from '../common/types'; +import { externalizeOrchestrionRuntimePackages } from './diagnosticsChannelInjection'; import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions'; import type { RouteManifest } from './manifest/types'; // Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our @@ -22,7 +23,7 @@ import type { WebpackEntryProperty, WebpackPluginInstance, } from './types'; -import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; +import { loadOrchestrionBundler } from './loadOrchestrionBundler'; import { getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; @@ -433,7 +434,10 @@ export function constructWebpackConfigFunction({ // Orchestrion code-transform loader — Node server runtime only, never the edge compilation if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { - newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance); + newConfig.plugins.push( + loadOrchestrionBundler().sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance, + ); + prependOrchestrionRuntimeExternals(newConfig); } return newConfig; @@ -872,6 +876,23 @@ function addOtelWarningIgnoreRule(newConfig: WebpackConfigObjectWithModuleRules) } } +/** + * Prepends {@link externalizeOrchestrionRuntimePackages} to `newConfig.externals`, ahead of + * Next.js's own externals handler, so the orchestrion runtime packages stay external even where + * `serverExternalPackages` can't keep them so. See that function's docs for why this is necessary. + */ +function prependOrchestrionRuntimeExternals(newConfig: WebpackConfigObjectWithModuleRules): void { + const existingExternals = newConfig.externals; + + if (Array.isArray(existingExternals)) { + existingExternals.unshift(externalizeOrchestrionRuntimePackages); + } else if (existingExternals === undefined) { + newConfig.externals = [externalizeOrchestrionRuntimePackages]; + } else { + newConfig.externals = [externalizeOrchestrionRuntimePackages, existingExternals]; + } +} + function addEdgeRuntimePolyfills(newConfig: WebpackConfigObjectWithModuleRules, buildContext: BuildContext): void { // Use ProvidePlugin to inject performance global only when accessed newConfig.plugins = newConfig.plugins || []; diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index 93ec6e42e243..b799c9becfaa 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -1,7 +1,6 @@ import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import { getTracingHooksDirectory } from '@sentry/server-utils/orchestrion/webpack'; import type { NextConfigObject, SentryBuildOptions } from '../types'; /** @@ -54,9 +53,6 @@ export function setUpBuildTimeVariables( // Marker read by the server SDK to warn if the runtime opt-in call is missing. if (userSentryOptions._experimental?.useDiagnosticsChannelInjection) { buildTimeVariables._sentryUseDiagnosticsChannelInjection = 'true'; - // Resolved here (where the SDK is a real on-disk package) and inlined, because the runtime - // module hook can't resolve the bare specifier from a bundled server chunk under pnpm. - buildTimeVariables._sentryOrchestrionTracingHooksDir = getTracingHooksDirectory(); } if (basePath) { diff --git a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts index 528c174e45fa..9429a7c4b1a0 100644 --- a/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/edge/wrapApiHandlerWithSentry.ts @@ -4,10 +4,13 @@ import { getCurrentScope, getRootSpan, handleCallbackErrors, + isURLObjectRelative, + parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, + spanToJSON, startSpan, winterCGRequestToRequestData, withIsolationScope, @@ -15,6 +18,7 @@ import { import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from './types'; +import { HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; /** * Wraps a Next.js edge route handler with Sentry error and performance instrumentation. @@ -48,6 +52,13 @@ export function wrapApiHandlerWithSentry( // If there is an active span, it likely means that the automatic Next.js OTEL instrumentation worked and we can // rely on that for parameterization. + const urlObject = req instanceof Request ? parseStringToURLObject(req.url) : undefined; + + const urlAttributes = { + [URL_FULL]: urlObject && !isURLObjectRelative(urlObject) ? urlObject.href : undefined, + [URL_PATH]: urlObject?.pathname, + }; + const activeSpan = getActiveSpan(); if (activeSpan) { spanName = `handler (${parameterizedRoute})`; @@ -55,12 +66,16 @@ export function wrapApiHandlerWithSentry( const rootSpan = getRootSpan(activeSpan); if (rootSpan) { + const rootSpanAttributes = spanToJSON(rootSpan).data; rootSpan.updateName( req instanceof Request ? `${req.method} ${parameterizedRoute}` : `handler ${parameterizedRoute}`, ); rootSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_FULL]: rootSpanAttributes[URL_FULL] ?? urlAttributes[URL_FULL], + [URL_PATH]: rootSpanAttributes[URL_PATH] ?? urlAttributes[URL_PATH], + [HTTP_ROUTE]: parameterizedRoute, ...headerAttributes, }); setCapturedScopesOnSpan(rootSpan, currentScope, isolationScope); @@ -78,6 +93,8 @@ export function wrapApiHandlerWithSentry( attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.wrap_api_handler', + [HTTP_ROUTE]: parameterizedRoute, + ...urlAttributes, ...headerAttributes, }, }, diff --git a/packages/nextjs/src/index.server.ts b/packages/nextjs/src/index.server.ts index 133b6ecf1da0..c868e5c6571c 100644 --- a/packages/nextjs/src/index.server.ts +++ b/packages/nextjs/src/index.server.ts @@ -1,2 +1,3 @@ -export * from './config'; +export { withSentryConfig } from './config/deprecatedWithSentryConfig'; +export type { SentryBuildOptions } from './config'; export * from './server'; diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index e0d918708bb1..6313f39e4a19 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -8,10 +8,11 @@ import type { Client, Integration, Options, StackParser } from '@sentry/core'; import type * as clientSdk from './client'; import type { ServerComponentContext, VercelCronsConfig } from './common/types'; +import type * as configSdk from './config'; import type * as edgeSdk from './edge'; import type * as serverSdk from './server'; -export * from './config'; +export type { SentryBuildOptions } from './config'; export * from './client'; export * from './server'; export * from './edge'; @@ -44,7 +45,11 @@ export declare const withErrorBoundary: typeof clientSdk.withErrorBoundary; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; -export { withSentryConfig } from './config'; +/** + * @deprecated Import `withSentryConfig` from `@sentry/nextjs/config` instead. The `@sentry/nextjs` export is removed + * in v11. + */ +export declare const withSentryConfig: typeof configSdk.withSentryConfig; /** * Wraps a Next.js Pages Router API route with Sentry error and performance instrumentation. diff --git a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts index d3040fa4bbb3..6822b0feecfc 100644 --- a/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts +++ b/packages/nextjs/src/server/enhanceHandleRequestRootSpan.ts @@ -47,6 +47,7 @@ export function enhanceHandleRequestRootSpan(span: MutableRootSpan): void { const cleanRoute = route.replace(/\/route$/, ''); span.setName(`${method} ${cleanRoute}`); attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; + attributes[HTTP_ROUTE] = cleanRoute; // Preserve next.route in case it did not get hoisted attributes[ATTR_NEXT_ROUTE] = cleanRoute; } @@ -55,6 +56,7 @@ export function enhanceHandleRequestRootSpan(span: MutableRootSpan): void { const routeBackfill = attributes[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]; if (typeof routeBackfill === 'string' && span.getName() !== 'GET /_app') { span.setName(`${typeof method === 'string' ? method : 'GET'} ${routeBackfill}`); + attributes[HTTP_ROUTE] = attributes[HTTP_ROUTE] ?? routeBackfill; } const middlewareMatch = diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index ee0d2346c4f2..d4038fa0e6e1 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -4,6 +4,7 @@ import { HTTP_TARGET, URL_QUERY } from '@sentry/conventions/attributes'; import type { EventProcessor } from '@sentry/core'; import { + addVercelAiProcessors, applySdkMetadata, debug, getClient, @@ -48,23 +49,15 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; _sentryUseDiagnosticsChannelInjection?: string; - _sentryOrchestrionTracingHooksDir?: string; }; /** * EXPERIMENTAL: Next.js-aware variant of `Sentry.experimentalUseDiagnosticsChannelInjection()` * from `@sentry/node` (see its docs for behavior and caveats). - * - * Next.js bundles the SDK into the server build, from where the runtime module hook can't resolve - * the `@apm-js-collab/tracing-hooks` bare specifier under isolated installs (pnpm). This variant - * points the hook at the package location that `withSentryConfig` resolved at build time. - * * @experimental May change or be removed in any release. */ export function experimentalUseDiagnosticsChannelInjection(): void { - const tracingHooksDir = - process.env._sentryOrchestrionTracingHooksDir || globalWithInjectedValues._sentryOrchestrionTracingHooksDir; - nodeExperimentalUseDiagnosticsChannelInjection(tracingHooksDir ? { tracingHooksDir } : undefined); + nodeExperimentalUseDiagnosticsChannelInjection(); } // Call at module level so `next build` prerender workers still register the runner without `init` @@ -218,6 +211,14 @@ export function init(options: NodeOptions): NodeClient | undefined { const client = nodeInit(opts); + // Next.js bundles `ai`, so the integration can neither patch the module nor detect it via `Modules` + // (which only sees the app's own `package.json`, missing workspace and transitive deps). Register + // the processors here instead — after init, so an explicitly constructed `vercelAIIntegration()` + // can't override the default back to the broken behavior. + if (client?.getIntegrationByName('VercelAI')) { + addVercelAiProcessors(client); + } + client?.on('beforeSampling', ({ spanAttributes }, samplingDecision) => { // There are situations where the Next.js Node.js server forwards requests for the Edge Runtime server (e.g. in // middleware) and this causes spans for Sentry ingest requests to be created. These are not exempt from our tracing diff --git a/packages/nextjs/src/server/vercelQueuesMonitoring.ts b/packages/nextjs/src/server/vercelQueuesMonitoring.ts index cfe367c46470..2f759d0af3d8 100644 --- a/packages/nextjs/src/server/vercelQueuesMonitoring.ts +++ b/packages/nextjs/src/server/vercelQueuesMonitoring.ts @@ -1,3 +1,4 @@ +import { URL_FULL } from '@sentry/conventions/attributes'; import type { Span } from '@sentry/core'; import { getIsolationScope, spanToJSON } from '@sentry/core'; @@ -76,7 +77,7 @@ export function maybeEnrichQueueProducerSpan(span: Span): void { const spanData = spanToJSON(span).data; // http.client spans have url.full attribute - const urlFull = spanData?.['url.full'] as string | undefined; + const urlFull = spanData?.[URL_FULL] as string | undefined; if (!urlFull) { return; } diff --git a/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts b/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts new file mode 100644 index 000000000000..808666694d3d --- /dev/null +++ b/packages/nextjs/test/common/wrapMiddlewareWithSentry.test.ts @@ -0,0 +1,90 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { wrapMiddlewareWithSentry } from '../../src/common/wrapMiddlewareWithSentry'; + +describe('wrapMiddlewareWithSentry', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'captureException').mockReturnValue(''); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not start its own span when the Next.js OTEL root span is already active (Next.js >= 14)', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as SentryCore.Span); + vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue({} as SentryCore.Span); + const setCapturedScopesSpy = vi.spyOn(SentryCore, 'setCapturedScopesOnSpan').mockReturnValue(undefined); + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + const handler = vi.fn(async (_req: Request) => new Response('ok')); + const wrapped = wrapMiddlewareWithSentry(handler); + + await wrapped(new Request('https://example.com/foo', { method: 'GET' })); + + // The middleware runs and our forked scopes are bound to the existing OTEL root span... + expect(handler).toHaveBeenCalledTimes(1); + expect(setCapturedScopesSpy).toHaveBeenCalledTimes(1); + // ...but the wrapper never starts a span itself - the `Middleware.execute` span is the transaction. + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('starts its own span when no span is active (Next.js 13)', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(undefined); + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + + const handler = vi.fn(async (_req: Request) => new Response('ok')); + const wrapped = wrapMiddlewareWithSentry(handler); + + await wrapped(new Request('https://example.com/foo', { method: 'GET' })); + + expect(handler).toHaveBeenCalledTimes(1); + // Next.js 13 never emits `Middleware.execute`, so without this span there would be no middleware transaction. + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'middleware GET', + op: 'http.server.middleware', + }), + expect.any(Function), + ); + }); + + it('captures errors thrown by the middleware when a root span is already active', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as SentryCore.Span); + vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue({} as SentryCore.Span); + vi.spyOn(SentryCore, 'setCapturedScopesOnSpan').mockReturnValue(undefined); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue(''); + + const error = new Error('boom'); + const handler = vi.fn(async (_req: Request) => { + throw error; + }); + const wrapped = wrapMiddlewareWithSentry(handler); + + await expect(wrapped(new Request('https://example.com/foo', { method: 'GET' }))).rejects.toThrow('boom'); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { type: 'auto.function.nextjs.wrap_middleware', handled: false }, + }), + ); + }); + + it('captures errors thrown by the middleware when no span is active', async () => { + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(undefined); + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue(''); + + const error = new Error('boom'); + const handler = vi.fn(async (_req: Request) => { + throw error; + }); + const wrapped = wrapMiddlewareWithSentry(handler); + + await expect(wrapped(new Request('https://example.com/foo', { method: 'GET' }))).rejects.toThrow('boom'); + + expect(captureExceptionSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index 881da6a3caf6..50e4e371e85d 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -1,6 +1,9 @@ +import { existsSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; import { describe, expect, it } from 'vitest'; import { BUNDLE_SAFE_INSTRUMENTED_PACKAGES, + externalizeOrchestrionRuntimePackages, filterInstrumentedExternals, } from '../../src/config/diagnosticsChannelInjection'; import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime'; @@ -33,8 +36,7 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => expect(externals).toContain('pg'); expect(externals).toContain('pg-pool'); // The orchestrion machinery must be external for the runtime hook to work. - expect(externals).toContain('@apm-js-collab/tracing-hooks'); - expect(externals).toContain('@apm-js-collab/code-transformer'); + expect(externals).toContain('@sentry/server-utils'); }); it('respects user-provided externals even for bundle-safe packages', () => { @@ -51,6 +53,43 @@ describe('getServerExternalPackagesPatch (diagnostics-channel injection)', () => }); }); +describe('externalizeOrchestrionRuntimePackages', () => { + it.each(['@sentry/server-utils', '@sentry/server-utils/orchestrion', '@sentry/server-utils/orchestrion/register'])( + 'externalizes %s as an absolute-path commonjs require', + async request => { + const external = await externalizeOrchestrionRuntimePackages({ request }); + + expect(external).toMatch(/^commonjs /); + const resolvedPath = external!.slice('commonjs '.length); + expect(isAbsolute(resolvedPath)).toBe(true); + expect(existsSync(resolvedPath)).toBe(true); + }, + ); + + it('ignores the bundled @apm-js-collab packages — no import of them exists in the dist anymore', async () => { + await expect( + externalizeOrchestrionRuntimePackages({ request: '@apm-js-collab/tracing-hooks' }), + ).resolves.toBeUndefined(); + }); + + it('resolves @sentry/server-utils subpaths to the CJS build, since the emitted external is a require()', async () => { + const external = await externalizeOrchestrionRuntimePackages({ + request: '@sentry/server-utils/orchestrion/register', + }); + + expect(external).toMatch(/[/\\]cjs[/\\]/); + }); + + it('ignores unrelated requests so later externals handlers still run', async () => { + await expect(externalizeOrchestrionRuntimePackages({ request: 'some-other-package' })).resolves.toBeUndefined(); + // Prefix matching must not leak beyond a package-name boundary. + await expect( + externalizeOrchestrionRuntimePackages({ request: '@sentry/server-utils-extras' }), + ).resolves.toBeUndefined(); + await expect(externalizeOrchestrionRuntimePackages({})).resolves.toBeUndefined(); + }); +}); + describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { it('injects the flag marker and the tracing-hooks location', () => { const nextConfig: NextConfigObject = {}; @@ -58,8 +97,6 @@ describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { expect(nextConfig.env).toMatchObject({ _sentryUseDiagnosticsChannelInjection: 'true', - // The runtime module hook joins subpaths onto this, so it must be an absolute directory. - _sentryOrchestrionTracingHooksDir: expect.stringMatching(/@apm-js-collab[/+]tracing-hooks/), }); }); @@ -68,6 +105,5 @@ describe('setUpBuildTimeVariables (diagnostics-channel injection)', () => { setUpBuildTimeVariables(nextConfig, {}, undefined); expect(nextConfig.env).not.toHaveProperty('_sentryUseDiagnosticsChannelInjection'); - expect(nextConfig.env).not.toHaveProperty('_sentryOrchestrionTracingHooksDir'); }); }); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index b822495d1a67..00149b640bb3 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -3,6 +3,7 @@ import '../mocks'; import * as core from '@sentry/core'; import { describe, expect, it, vi } from 'vitest'; import * as getBuildPluginOptionsModule from '../../../src/config/getBuildPluginOptions'; +import type * as loadOrchestrionBundlerModule from '../../../src/config/loadOrchestrionBundler'; import * as util from '../../../src/config/util'; import { CLIENT_SDK_CONFIG_FILE, @@ -16,9 +17,18 @@ import { } from '../fixtures'; import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; -vi.mock('@sentry/server-utils/orchestrion/webpack', () => ({ - sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }), -})); +// Stub only the plugin factory. The externals handler under test needs the real +// `resolveOrchestrionRuntimeRequest`. The bundler module loads via native `require`, which +// `vi.mock` cannot intercept, so the stub goes on the loader. +vi.mock('../../../src/config/loadOrchestrionBundler', async importOriginal => { + const original = await importOriginal(); + return { + loadOrchestrionBundler: () => ({ + ...original.loadOrchestrionBundler(), + sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }), + }), + }; +}); describe('constructWebpackConfigFunction()', () => { it('includes expected properties', async () => { @@ -842,4 +852,45 @@ describe('constructWebpackConfigFunction()', () => { expect(findOrchestrionPlugin(finalWebpackConfig)).toBeUndefined(); }); }); + + describe('orchestrion runtime externals', () => { + it('prepends an externals handler that resolves runtime packages to absolute paths when diagnostics-channel injection is enabled', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + const externals = finalWebpackConfig.externals as ((data: { request?: string }) => Promise)[]; + + expect(Array.isArray(externals)).toBe(true); + await expect(externals[0]({ request: '@sentry/server-utils/orchestrion/register' })).resolves.toMatch( + /^commonjs ([/\\]|[A-Za-z]:).*register\.js$/, + ); + await expect(externals[0]({ request: 'some-other-package' })).resolves.toBeUndefined(); + }); + + it('does not touch `externals` when diagnostics-channel injection is not enabled', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverBuildContext, + sentryBuildTimeOptions: {}, + }); + + expect(finalWebpackConfig.externals).toBeUndefined(); + }); + + it('does not touch `externals` on the edge build', async () => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeBuildContext, + sentryBuildTimeOptions: { _experimental: { useDiagnosticsChannelInjection: true } }, + }); + + expect(finalWebpackConfig.externals).toBeUndefined(); + }); + }); }); diff --git a/packages/nextjs/test/config/withSentry.test.ts b/packages/nextjs/test/config/withSentry.test.ts index 5b6643358f57..821966795acd 100644 --- a/packages/nextjs/test/config/withSentry.test.ts +++ b/packages/nextjs/test/config/withSentry.test.ts @@ -1,4 +1,5 @@ import * as SentryCore from '@sentry/core'; +import { HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import type { NextApiRequest, NextApiResponse } from 'next'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -17,7 +18,13 @@ describe('withSentry', () => { const wrappedHandlerNoError = wrapApiHandlerWithSentry(origHandlerNoError, '/my-parameterized-route'); beforeEach(() => { - req = { url: 'http://dogs.are.great' } as NextApiRequest; + req = { + headers: { + host: 'dogs.are.great', + 'x-forwarded-proto': 'https', + }, + url: '/api/dogs?good=true', + } as NextApiRequest; res = { send: function (this: AugmentedNextApiResponse) { this.end(); @@ -36,17 +43,21 @@ describe('withSentry', () => { }); describe('tracing', () => { - it('starts a transaction when tracing is enabled', async () => { + it('starts a transaction with normalized request URL attributes', async () => { await wrappedHandlerNoError(req, res); expect(startSpanManualSpy).toHaveBeenCalledWith( - expect.objectContaining({ + { name: 'GET /my-parameterized-route', op: 'http.server', + forceTransaction: true, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nextjs', + [URL_FULL]: 'https://dogs.are.great/api/dogs?good=true', + [URL_PATH]: '/api/dogs', + [HTTP_ROUTE]: '/my-parameterized-route', }, - }), + }, expect.any(Function), ); }); diff --git a/packages/nextjs/test/configExports.test.ts b/packages/nextjs/test/configExports.test.ts new file mode 100644 index 000000000000..930247fc74db --- /dev/null +++ b/packages/nextjs/test/configExports.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { init, parse } from 'cjs-module-lexer'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/config/withSentryConfig', () => ({ + withSentryConfig: vi.fn((nextConfig: unknown) => nextConfig), +})); + +/** + * `next.config.mjs` is loaded by a plain Node ESM loader, but build-time config code resolves webpack loader and + * template paths with `__dirname`, which is a `ReferenceError` in an ES module. So `./config` deliberately serves the + * CJS build to ESM importers too, rather than splitting `import`/`require` like the runtime entries do. + * + * There is no dual-package hazard here because this code runs at build time only and holds no SDK state. + */ +describe('`./config` subpath export', () => { + const packageExports = ( + JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')) as { + exports: Record; + } + ).exports; + + it('resolves to the CJS build for every condition', () => { + expect(packageExports['./config']).toEqual({ + types: './build/types/config/index.d.ts', + default: './build/cjs/config/index.js', + }); + }); + + it('never points a condition at the ESM config build', () => { + expect(JSON.stringify(packageExports['./config'])).not.toContain('build/esm'); + }); +}); + +/** + * ESM consumers of a CJS file only get the named exports `cjs-module-lexer` can see statically — anything it misses + * links as `undefined`. So `withSentryConfig` has to stay statically detectable for `import { withSentryConfig } from + * '@sentry/nextjs/config'` to work in a `next.config.mjs`. + * + * Exercises the generated artifact, so it needs the package built. + */ +describe('`./config` static exports (generated)', () => { + let staticExports: string[]; + + beforeAll(async () => { + await init(); + staticExports = parse(readFileSync(resolve(__dirname, '../build/cjs/config/index.js'), 'utf8')).exports; + }); + + it('statically exports `withSentryConfig`', () => { + expect(staticExports).toContain('withSentryConfig'); + }); +}); + +describe('deprecated `withSentryConfig` on the `@sentry/nextjs` entry', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('delegates to `@sentry/nextjs/config`', async () => { + const { withSentryConfig } = await import('../src/config/deprecatedWithSentryConfig'); + const { withSentryConfig: withSentryConfigImpl } = await import('../src/config/withSentryConfig'); + const nextConfig = { reactStrictMode: true }; + + expect(withSentryConfig(nextConfig, { silent: true })).toBe(nextConfig); + expect(withSentryConfigImpl).toHaveBeenCalledWith(nextConfig, { silent: true }); + }); + + it('warns once, no matter how often the config is materialized', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { withSentryConfig } = await import('../src/config/deprecatedWithSentryConfig'); + + withSentryConfig({}); + withSentryConfig({}); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("import { withSentryConfig } from '@sentry/nextjs/config'"), + ); + }); + + it('does not warn when imported from `@sentry/nextjs/config`', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const { withSentryConfig } = await import('../src/config'); + + withSentryConfig({}); + + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/test/edge/withSentryAPI.test.ts b/packages/nextjs/test/edge/withSentryAPI.test.ts index 1e659cb699b3..80c702efa227 100644 --- a/packages/nextjs/test/edge/withSentryAPI.test.ts +++ b/packages/nextjs/test/edge/withSentryAPI.test.ts @@ -1,4 +1,7 @@ -import { afterAll, afterEach, describe, it, vi } from 'vitest'; +import * as SentryCore from '@sentry/core'; +import { HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; import { wrapApiHandlerWithSentry } from '../../src/edge'; const origRequest = global.Request; @@ -30,7 +33,7 @@ afterAll(() => { }); afterEach(() => { - vi.clearAllMocks(); + vi.restoreAllMocks(); }); describe('wrapApiHandlerWithSentry', () => { @@ -41,4 +44,50 @@ describe('wrapApiHandlerWithSentry', () => { await wrappedFunction(); }); + + it('adds normalized request URL and route attributes to the active root span', async () => { + const rootSpan = { + updateName: vi.fn(), + setAttributes: vi.fn(), + }; + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValueOnce({} as any); + vi.spyOn(SentryCore, 'getRootSpan').mockReturnValueOnce(rootSpan as any); + vi.spyOn(SentryCore, 'spanToJSON').mockReturnValueOnce({ data: {} } as any); + const origFunction = vi.fn(() => new Response()); + const parameterizedRoute = '/user/[userId]/post/[postId]'; + const wrappedFunction = wrapApiHandlerWithSentry(origFunction, parameterizedRoute); + + await wrappedFunction(new Request('https://dogs.are.great/user/123/post/456?good=true')); + + expect(rootSpan.updateName).toHaveBeenCalledWith(`POST ${parameterizedRoute}`); + expect(rootSpan.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_FULL]: 'https://dogs.are.great/user/123/post/456?good=true', + [URL_PATH]: '/user/123/post/456', + [HTTP_ROUTE]: parameterizedRoute, + }); + }); + + it('replaces a concrete root span route with the parameterized route', async () => { + const rootSpan = { + updateName: vi.fn(), + setAttributes: vi.fn(), + }; + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValueOnce({} as any); + vi.spyOn(SentryCore, 'getRootSpan').mockReturnValueOnce(rootSpan as any); + vi.spyOn(SentryCore, 'spanToJSON').mockReturnValueOnce({ + data: { [HTTP_ROUTE]: '/user/123/post/456' }, + } as any); + const parameterizedRoute = '/user/[userId]/post/[postId]'; + const wrappedFunction = wrapApiHandlerWithSentry(() => new Response(), parameterizedRoute); + + await wrappedFunction(new Request('https://dogs.are.great/user/123/post/456')); + + expect(rootSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + [HTTP_ROUTE]: parameterizedRoute, + }), + ); + }); }); diff --git a/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts index 8373c3a6e744..ae756e5bde5b 100644 --- a/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts +++ b/packages/nextjs/test/server/enhanceHandleRequestRootSpan.test.ts @@ -1,3 +1,4 @@ +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import { describe, expect, it } from 'vitest'; import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; @@ -48,6 +49,7 @@ describe('enhanceHandleRequestRootSpan', () => { expect(getName()).toBe('GET /api/users/[id]'); expect(span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toBe('route'); expect(span.attributes[ATTR_NEXT_ROUTE]).toBe('/api/users/[id]'); + expect(span.attributes[HTTP_ROUTE]).toBe('/api/users/[id]'); }); it('strips trailing /route from app router route handler routes', () => { @@ -64,6 +66,7 @@ describe('enhanceHandleRequestRootSpan', () => { expect(getName()).toBe('POST /api/widgets'); expect(span.attributes[ATTR_NEXT_ROUTE]).toBe('/api/widgets'); + expect(span.attributes[HTTP_ROUTE]).toBe('/api/widgets'); }); it('strips URL query and fragment from the segment name', () => { @@ -105,6 +108,7 @@ describe('enhanceHandleRequestRootSpan', () => { enhanceHandleRequestRootSpan(span); expect(getName()).toBe('GET /posts/[slug]'); + expect(span.attributes[HTTP_ROUTE]).toBe('/posts/[slug]'); }); it('does not apply the backfill for the special GET /_app transaction', () => { diff --git a/packages/nextjs/test/serverEntryBundlerGraph.test.ts b/packages/nextjs/test/serverEntryBundlerGraph.test.ts new file mode 100644 index 000000000000..17b5447a66fc --- /dev/null +++ b/packages/nextjs/test/serverEntryBundlerGraph.test.ts @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Importing the SDK server entry must not load the orchestrion bundler plugins. They are + * build-time-only, and their module-scope side effects break runtimes the build never sees, + * like jsdom/happy-dom test runs (issue #23789) and Cloudflare Workers cold starts (issue #22794). + * Runs in a child process for a clean module cache and real Node resolution. + */ +describe('built CJS server entry', () => { + const serverEntry = resolve(__dirname, '../build/cjs/index.server.js'); + + it('loads under a DOM test environment without pulling in the orchestrion bundler graph', () => { + const script = ` + globalThis.document = { baseURI: 'http://localhost:3000/' }; + require(${JSON.stringify(serverEntry)}); + const toPosix = modulePath => modulePath.split(require('path').sep).join('/'); + const bundlerModules = Object.keys(require.cache).map(toPosix).filter( + modulePath => modulePath.includes('code-transformer-bundler-plugins') || modulePath.includes('orchestrion/bundler'), + ); + if (bundlerModules.length > 0) { + console.error('Bundler-plugin modules loaded at import time:\\n' + bundlerModules.join('\\n')); + process.exit(1); + } + `; + + // On failure, stderr carries either the leaked module list or the import crash itself. + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + }); +}); diff --git a/packages/nextjs/test/utils/setUrlProcessingMetadata.test.ts b/packages/nextjs/test/utils/setUrlProcessingMetadata.test.ts index a170fbaa8a71..d176c8fe9d4f 100644 --- a/packages/nextjs/test/utils/setUrlProcessingMetadata.test.ts +++ b/packages/nextjs/test/utils/setUrlProcessingMetadata.test.ts @@ -1,5 +1,6 @@ import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; +import { HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import { describe, expect, it, vi } from 'vitest'; import { setUrlProcessingMetadata } from '../../src/common/utils/setUrlProcessingMetadata'; @@ -45,6 +46,44 @@ describe('setUrlProcessingMetadata', () => { expect(scopeData.sdkProcessingMetadata.normalizedRequest.url).toBe('https://example.com/api/users/123'); }); + it('preserves the concrete URL when a parameterized http.route is available', () => { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ sendDefaultPii: false }), + } as unknown as SentryCore.Client); + + const scopeData = { + sdkProcessingMetadata: { + normalizedRequest: { + headers: { + 'x-forwarded-proto': 'https', + host: 'example.com', + }, + url: 'https://example.com/api/users/123', + }, + }, + }; + + const event: Event = { + type: 'transaction', + contexts: { + trace: { + op: 'http.server', + data: { + [HTTP_ROUTE]: '/api/users/[id]', + [URL_FULL]: 'https://example.com/api/users/123?token=secret#fragment', + [URL_PATH]: '/api/users/123', + }, + }, + }, + sdkProcessingMetadata: { + capturedSpanIsolationScope: { getScopeData: () => scopeData }, + }, + }; + + setUrlProcessingMetadata(event); + expect(scopeData.sdkProcessingMetadata.normalizedRequest.url).toBe('https://example.com/api/users/123'); + }); + it('skips when no client is available', () => { vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 149f3f334a62..cca864a3c9e1 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nitro", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Nitro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nitro", @@ -36,9 +36,9 @@ }, "dependencies": { "@sentry/bundler-plugin-core": "^5.3.0", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/server-utils": "10.67.0" + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/server-utils": "10.73.0" }, "devDependencies": { "nitro": "^3.0.260415-beta", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 0c30365970d9..606549ef8a78 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-core", - "version": "10.67.0", + "version": "10.73.0", "description": "Sentry Node-Core SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-core", @@ -102,8 +102,8 @@ }, "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/opentelemetry": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/opentelemetry": "10.73.0", "import-in-the-middle": "^3.0.0" }, "devDependencies": { diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index 33e02c6cc9e9..fdf350214f9b 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -23,6 +23,8 @@ import { NET_PEER_PORT, NET_TRANSPORT, SENTRY_HTTP_PREFETCH, + URL_FULL, + URL_PATH, } from '@sentry/conventions/attributes'; import type { Event, @@ -40,6 +42,7 @@ import { getIsolationScope, getSpanStatusFromHttpCode, httpHeadersToSpanAttributes, + isURLObjectRelative, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -172,6 +175,8 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.http', [SENTRY_HTTP_PREFETCH]: isKnownPrefetchRequest(request) || undefined, + [URL_FULL]: urlObj && !isURLObjectRelative(urlObj) ? urlObj.href : undefined, + [URL_PATH]: urlObj?.pathname ?? httpTargetWithoutQueryFragment, // Old Semantic Conventions attributes - added for compatibility with what `@opentelemetry/instrumentation-http` output before /* eslint-disable typescript/no-deprecated */ [HTTP_URL]: fullUrl, diff --git a/packages/node-core/src/integrations/local-variables/common.ts b/packages/node-core/src/integrations/local-variables/common.ts index f86988b4cbfc..aeb77ddf09e3 100644 --- a/packages/node-core/src/integrations/local-variables/common.ts +++ b/packages/node-core/src/integrations/local-variables/common.ts @@ -1,7 +1,19 @@ import type { Debugger } from 'node:inspector'; +import type { CollectBehavior } from '@sentry/core'; +import { _INTERNAL_filterKeyValueData } from '@sentry/core'; export type Variables = Record; +/** + * Filters captured frame variables by name according to a `dataCollection.stackFrameVariables` behavior. + * + * `true` keeps all variables (built-in sensitive names are still scrubbed), `false` drops them all, and the + * `{ allow: [...] }` / `{ deny: [...] }` forms filter by variable name. + */ +export function filterFrameVariables(vars: Variables, behavior: CollectBehavior): Variables { + return _INTERNAL_filterKeyValueData(vars, behavior); +} + export type RateLimitIncrement = () => void; /** diff --git a/packages/node-core/src/integrations/local-variables/local-variables-async.ts b/packages/node-core/src/integrations/local-variables/local-variables-async.ts index 6d2070988d00..b3552f99fd02 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-async.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-async.ts @@ -1,10 +1,10 @@ import { Worker } from 'node:worker_threads'; -import type { Event, EventHint, Exception, IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration } from '@sentry/core'; +import type { CollectBehavior, Event, EventHint, Exception, IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, getClient } from '@sentry/core'; import type { NodeClient } from '../../sdk/client'; import { isDebuggerEnabled } from '../../utils/debug'; import type { FrameVariables, LocalVariablesIntegrationOptions, LocalVariablesWorkerArgs } from './common'; -import { functionNamesMatch, LOCAL_VARIABLES_KEY } from './common'; +import { filterFrameVariables, functionNamesMatch, LOCAL_VARIABLES_KEY } from './common'; // This string is a placeholder that gets overwritten with the worker code. export const base64WorkerScript = '###LocalVariablesWorkerScript###'; @@ -19,7 +19,16 @@ function log(...args: unknown[]): void { export const localVariablesAsyncIntegration = defineIntegration((( integrationOptions: LocalVariablesIntegrationOptions = {}, ) => { - function addLocalVariablesToException(exception: Exception, localVariables: FrameVariables[]): void { + function addLocalVariablesToException( + exception: Exception, + localVariables: FrameVariables[], + behavior: CollectBehavior, + ): void { + // When disabled, nothing is collected so we don't attach empty `vars` to frames + if (behavior === false) { + return; + } + // Filter out frames where the function name is `new Promise` since these are in the error.stack frames // but do not appear in the debugger call frames const frames = (exception.stacktrace?.frames || []).filter(frame => frame.function !== 'new Promise'); @@ -47,7 +56,7 @@ export const localVariablesAsyncIntegration = defineIntegration((( continue; } - frame.vars = frameLocalVariables.vars; + frame.vars = filterFrameVariables(frameLocalVariables.vars, behavior); } } @@ -58,8 +67,10 @@ export const localVariablesAsyncIntegration = defineIntegration((( LOCAL_VARIABLES_KEY in hint.originalException && Array.isArray(hint.originalException[LOCAL_VARIABLES_KEY]) ) { + const behavior = getClient()?.getDataCollectionOptions().stackFrameVariables ?? true; + for (const exception of event.exception?.values || []) { - addLocalVariablesToException(exception, hint.originalException[LOCAL_VARIABLES_KEY]); + addLocalVariablesToException(exception, hint.originalException[LOCAL_VARIABLES_KEY], behavior); } hint.originalException[LOCAL_VARIABLES_KEY] = undefined; diff --git a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts index 8ae1201732c8..043132fcb275 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts @@ -1,5 +1,5 @@ import type { Debugger, InspectorNotification, Runtime, Session } from 'node:inspector'; -import type { Event, Exception, IntegrationFn, StackFrame, StackParser } from '@sentry/core'; +import type { CollectBehavior, Event, Exception, IntegrationFn, StackFrame, StackParser } from '@sentry/core'; import { debug, defineIntegration, getClient, LRUMap } from '@sentry/core'; import { NODE_MAJOR } from '../../nodeVersion'; import type { NodeClient } from '../../sdk/client'; @@ -11,7 +11,7 @@ import type { RateLimitIncrement, Variables, } from './common'; -import { createRateLimiter, functionNamesMatch } from './common'; +import { createRateLimiter, filterFrameVariables, functionNamesMatch } from './common'; /** Creates a unique hash from stack frames */ export function hashFrames(frames: StackFrame[] | undefined): string | undefined { @@ -234,7 +234,7 @@ const _localVariablesSyncIntegration = (( let rateLimiter: RateLimitIncrement | undefined; let shouldProcessEvent = false; - function addLocalVariablesToException(exception: Exception): void { + function addLocalVariablesToException(exception: Exception, behavior: CollectBehavior): void { const hash = hashFrames(exception.stacktrace?.frames); if (hash === undefined) { @@ -245,7 +245,8 @@ const _localVariablesSyncIntegration = (( // remove is identical to get but also removes the entry from the cache const cachedFrame = cachedFrames.remove(hash); - if (cachedFrame === undefined) { + // When disabled, nothing is collected so we don't attach empty `vars` to frames + if (cachedFrame === undefined || behavior === false) { return; } @@ -276,13 +277,13 @@ const _localVariablesSyncIntegration = (( continue; } - frameVariable.vars = cachedFrameVariable.vars; + frameVariable.vars = filterFrameVariables(cachedFrameVariable.vars, behavior); } } function addLocalVariablesToEvent(event: Event): Event { for (const exception of event.exception?.values || []) { - addLocalVariablesToException(exception); + addLocalVariablesToException(exception, getClient()?.getDataCollectionOptions().stackFrameVariables ?? true); } return event; diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index b6b1c777ef30..39ada7429821 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -79,8 +79,7 @@ type PinoOptions = { */ log: { /** - * Levels that trigger capturing of logs. Logs are only captured if - * `enableLogs` is enabled. + * Levels that trigger capturing of logs. * * @default ["trace", "debug", "info", "warn", "error", "fatal"] */ diff --git a/packages/node-core/src/integrations/processSession.ts b/packages/node-core/src/integrations/processSession.ts index f1c7f9b0dadf..d19948cbe6f9 100644 --- a/packages/node-core/src/integrations/processSession.ts +++ b/packages/node-core/src/integrations/processSession.ts @@ -18,11 +18,11 @@ export const processSessionIntegration = defineIntegration(() => { process.on('beforeExit', () => { const session = getIsolationScope().getSession(); - // Only call endSession, if the Session exists on Scope and SessionStatus is not a - // Terminal Status i.e. Exited or Crashed because - // "When a session is moved away from ok it must not be updated anymore." + // Only call endSession if a Session exists on the Scope and has not already reached a + // Terminal Status, because "When a session is moved away from ok it must not be updated + // anymore." `ok` is the only non-terminal status. // Ref: https://develop.sentry.dev/sdk/sessions/ - if (session?.status !== 'ok') { + if (session?.status === 'ok') { endSession(); } }); diff --git a/packages/node-core/src/integrations/winston.ts b/packages/node-core/src/integrations/winston.ts index 1d60c66d1f37..fd0d510599d4 100644 --- a/packages/node-core/src/integrations/winston.ts +++ b/packages/node-core/src/integrations/winston.ts @@ -45,7 +45,7 @@ interface WinstonTransportOptions { } /** - * Creates a new Sentry Winston transport that fowards logs to Sentry. Requires the `enableLogs` option to be enabled. + * Creates a new Sentry Winston transport that fowards logs to Sentry. * * Supports Winston 3.x.x. * diff --git a/packages/node-core/src/logs/exports.ts b/packages/node-core/src/logs/exports.ts index a4a6ee2fef69..166d8a8c6d01 100644 --- a/packages/node-core/src/logs/exports.ts +++ b/packages/node-core/src/logs/exports.ts @@ -1,7 +1,7 @@ import { captureLog, type CaptureLogArgs } from './capture'; /** - * @summary Capture a log with the `trace` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `trace` level. * * You can either pass a message and attributes or a message template, params and attributes. * @@ -28,7 +28,7 @@ export function trace(...args: CaptureLogArgs): void { } /** - * @summary Capture a log with the `debug` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `debug` level. * * You can either pass a message and attributes or a message template, params and attributes. * @@ -55,7 +55,7 @@ export function debug(...args: CaptureLogArgs): void { } /** - * @summary Capture a log with the `info` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `info` level. * * You can either pass a message and attributes or a message template, params and attributes. * @@ -82,7 +82,7 @@ export function info(...args: CaptureLogArgs): void { } /** - * @summary Capture a log with the `warn` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `warn` level. * * You can either pass a message and attributes or a message template, params and attributes. * @@ -110,7 +110,7 @@ export function warn(...args: CaptureLogArgs): void { } /** - * @summary Capture a log with the `error` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `error` level. * * You can either pass a message and attributes or a message template, params and attributes. * @@ -138,7 +138,7 @@ export function error(...args: CaptureLogArgs): void { } /** - * @summary Capture a log with the `fatal` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `fatal` level. * * You can either pass a message and attributes or a message template, params and attributes. * diff --git a/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts b/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts index f1b5af564d79..bb144d701544 100644 --- a/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts +++ b/packages/node-core/test/integrations/httpServerSpansIntegration.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import * as SentryCore from '@sentry/core'; +import { describe, expect, it, vi } from 'vitest'; import { httpServerSpansIntegration, isStaticAssetRequest, @@ -35,6 +38,49 @@ describe('httpIntegration', () => { }); }); + it('omits url.full when the incoming request URL is relative', () => { + let onHttpServerRequest: + | ((request: unknown, response: unknown, normalizedRequest: SentryCore.RequestEventData) => void) + | undefined; + const client = { + on: (hook: string, callback: typeof onHttpServerRequest) => { + if (hook === 'httpServerRequest') { + onHttpServerRequest = callback; + } + }, + getDataCollectionOptions: () => false, + }; + const span = { + end: () => undefined, + setAttributes: () => undefined, + setStatus: () => undefined, + } as unknown as SentryCore.Span; + const startInactiveSpan = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span); + const request = Object.assign(new EventEmitter(), { + headers: {}, + httpVersion: '1.0', + method: 'GET', + socket: {}, + url: '/users/42?foo=bar', + }) as EventEmitter & { + _startSpanCallback?: { deref: () => ((next: () => boolean) => boolean) | undefined }; + }; + const response = Object.assign(new EventEmitter(), { statusCode: 200 }); + + const integration = httpServerSpansIntegration(); + integration.setup?.(client as Parameters>[0]); + onHttpServerRequest?.(request, response, { headers: {}, method: 'GET' }); + request._startSpanCallback?.deref()(() => true); + + const attributes = startInactiveSpan.mock.calls[0]?.[0].attributes; + expect(attributes).toEqual( + expect.objectContaining({ + [URL_PATH]: '/users/42', + }), + ); + expect(attributes?.[URL_FULL]).toBeUndefined(); + }); + describe('processEvent', () => { function runProcessEvent(event: Record, options = {}): any { const integration = httpServerSpansIntegration(options); diff --git a/packages/node-core/test/integrations/localvariables.test.ts b/packages/node-core/test/integrations/localvariables.test.ts index 0c7fd8b52689..82e43ef34135 100644 --- a/packages/node-core/test/integrations/localvariables.test.ts +++ b/packages/node-core/test/integrations/localvariables.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRateLimiter } from '../../src/integrations/local-variables/common'; +import { createRateLimiter, filterFrameVariables } from '../../src/integrations/local-variables/common'; import { createCallbackList } from '../../src/integrations/local-variables/local-variables-sync'; import { NODE_MAJOR } from '../../src/nodeVersion'; @@ -14,6 +14,34 @@ describeIf(NODE_MAJOR >= 18)('LocalVariables', () => { vi.useRealTimers(); }); + describe('filterFrameVariables', () => { + const vars = { user: 'bob', password: 'hunter2', count: 42 }; + + it('keeps all variables on `true` but scrubs sensitive names', () => { + expect(filterFrameVariables(vars, true)).toEqual({ user: 'bob', password: '[Filtered]', count: 42 }); + }); + + it('drops all variables on `false`', () => { + expect(filterFrameVariables(vars, false)).toEqual({}); + }); + + it('keeps only allowed variable names', () => { + expect(filterFrameVariables(vars, { allow: ['user', 'count'] })).toEqual({ + user: 'bob', + password: '[Filtered]', + count: 42, + }); + }); + + it('filters denied variable names', () => { + expect(filterFrameVariables(vars, { deny: ['count'] })).toEqual({ + user: 'bob', + password: '[Filtered]', + count: '[Filtered]', + }); + }); + }); + describe('createCallbackList', () => { it('Should call callbacks in reverse order', () => new Promise(done => { diff --git a/packages/node-core/test/integrations/processSession.test.ts b/packages/node-core/test/integrations/processSession.test.ts new file mode 100644 index 000000000000..4d511035b8f9 --- /dev/null +++ b/packages/node-core/test/integrations/processSession.test.ts @@ -0,0 +1,73 @@ +import { getIsolationScope, setCurrentClient } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { processSessionIntegration } from '../../src/integrations/processSession'; +import { NodeClient } from '../../src/sdk/client'; +import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodeClientOptions'; + +describe('processSessionIntegration', () => { + let client: NodeClient; + let sendSession: ReturnType; + let beforeExitHandler: () => void; + + beforeEach(() => { + getIsolationScope().setSession(undefined); + + client = new NodeClient(getDefaultNodeClientOptions({ release: '1.0.0' })); + setCurrentClient(client); + client.init(); + sendSession = vi.spyOn(client, 'sendSession').mockImplementation(() => undefined); + + const processOn = vi.spyOn(process, 'on').mockImplementation(((event: string, listener: () => void) => { + if (event === 'beforeExit') { + beforeExitHandler = listener; + } + return process; + }) as never); + + processSessionIntegration().setupOnce!(); + processOn.mockRestore(); + }); + + it('has a name', () => { + expect(processSessionIntegration().name).toBe('ProcessSession'); + }); + + it('starts a session on setup', () => { + expect(getIsolationScope().getSession()).toEqual(expect.objectContaining({ status: 'ok' })); + }); + + it('ends the session with status "exited" on a healthy exit', () => { + beforeExitHandler(); + + expect(sendSession).toHaveBeenCalledTimes(1); + expect(sendSession).toHaveBeenCalledWith(expect.objectContaining({ status: 'exited', errors: 0 })); + }); + + it('ends a session that recorded a handled error', () => { + const session = getIsolationScope().getSession()!; + session.errors = 1; + + beforeExitHandler(); + + expect(sendSession).toHaveBeenCalledWith(expect.objectContaining({ status: 'exited', errors: 1 })); + }); + + it.each(['exited', 'crashed', 'abnormal', 'unhandled'] as const)('does not update an already-%s session', status => { + const session = getIsolationScope().getSession()!; + session.status = status; + sendSession.mockClear(); + + beforeExitHandler(); + + expect(sendSession).not.toHaveBeenCalled(); + }); + + it('does nothing when no session is on the scope', () => { + getIsolationScope().setSession(undefined); + sendSession.mockClear(); + + beforeExitHandler(); + + expect(sendSession).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/node-core/test/sdk/client.test.ts b/packages/node-core/test/sdk/client.test.ts index 8dcdf33d4067..423bbe1c8a20 100644 --- a/packages/node-core/test/sdk/client.test.ts +++ b/packages/node-core/test/sdk/client.test.ts @@ -54,6 +54,7 @@ describe('NodeClient', () => { runtime: { name: 'node', version: expect.any(String) }, serverName: expect.any(String), tracesSampleRate: 1, + enableLogs: true, }); }); @@ -315,7 +316,7 @@ describe('NodeClient', () => { describe('log capture', () => { it('adds server name to log attributes', () => { - const options = getDefaultNodeClientOptions({ enableLogs: true }); + const options = getDefaultNodeClientOptions(); const client = new NodeClient(options); const log: Log = { level: 'info', message: 'test message', attributes: {} }; @@ -328,7 +329,7 @@ describe('NodeClient', () => { it('preserves existing log attributes', () => { const serverName = 'test-server'; - const options = getDefaultNodeClientOptions({ serverName, enableLogs: true }); + const options = getDefaultNodeClientOptions({ serverName }); const client = new NodeClient(options); const log: Log = { level: 'info', message: 'test message', attributes: { 'existing.attr': 'value' } }; @@ -389,7 +390,7 @@ describe('NodeClient', () => { it('stops log capture if it was started', async () => { const processOffSpy = vi.spyOn(process, 'off'); - const client = new NodeClient(getDefaultNodeClientOptions({ enableLogs: true })); + const client = new NodeClient(getDefaultNodeClientOptions()); const result = await client.close(); diff --git a/packages/node-native/package.json b/packages/node-native/package.json index 2cb232357d21..ea54795ce285 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-native", - "version": "10.67.0", + "version": "10.73.0", "description": "Native Tools for the Official Sentry Node.js SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-native", @@ -63,8 +63,8 @@ }, "dependencies": { "@sentry/node-native-stacktrace": "^0.5.1", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0" + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/node/package.json b/packages/node/package.json index 3ae000757299..214dfcd5d1ad 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node", - "version": "10.67.0", + "version": "10.73.0", "description": "Sentry Node SDK using OpenTelemetry for performance instrumentation", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node", @@ -69,10 +69,10 @@ "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/node-core": "10.67.0", - "@sentry/opentelemetry": "10.67.0", - "@sentry/server-utils": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/node-core": "10.73.0", + "@sentry/opentelemetry": "10.73.0", + "@sentry/server-utils": "10.73.0", "import-in-the-middle": "^3.0.0" }, "devDependencies": { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 1ad92fd21b8e..3e8a8e786c74 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -152,6 +152,8 @@ export { spanStreamingIntegration, createLangChainCallbackHandler, instrumentLangChainEmbeddings, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentStateGraphCompile, } from '@sentry/core'; diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 5d96e69fb39b..27e5c8e9ccca 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -1,12 +1,6 @@ import type { RequestOptions } from 'node:http'; import type { HttpClientRequest, HttpIncomingMessage, HttpServerResponse, Span } from '@sentry/core'; -import { - defineIntegration, - hasSpansEnabled, - SEMANTIC_ATTRIBUTE_URL_FULL, - stripDataUrlContent, - getRequestUrlFromClientRequest, -} from '@sentry/core'; +import { defineIntegration, hasSpansEnabled, stripDataUrlContent, getRequestUrlFromClientRequest } from '@sentry/core'; import type { NodeClient, SentryHttpInstrumentationOptions, @@ -14,6 +8,7 @@ import type { HttpServerSpansIntegrationOptions, } from '@sentry/node-core'; import { httpServerIntegration, httpServerSpansIntegration, instrumentHttpOutgoingRequests } from '@sentry/node-core'; +import { URL_FULL } from '@sentry/conventions/attributes'; const INTEGRATION_NAME = 'Http' as const; @@ -210,7 +205,7 @@ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => // TODO(v11): Update these to the Sentry semantic attributes. // https://getsentry.github.io/sentry-conventions/attributes/ span.setAttribute('http.url', sanitizedUrl); - span.setAttribute(SEMANTIC_ATTRIBUTE_URL_FULL, sanitizedUrl); + span.setAttribute(URL_FULL, sanitizedUrl); span.updateName(`${request.method || 'GET'} ${sanitizedUrl}`); } options.instrumentation?.requestHook?.(span, request); diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index b69181ddbbb3..1bfcd4d40cc5 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -4,6 +4,7 @@ import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opent import { ensureIsWrapped, generateInstrumentOnce } from '@sentry/node-core'; import { + type ExpressIntegration, type ExpressIntegrationOptions, type IntegrationFn, debug, @@ -72,7 +73,11 @@ const _expressIntegration = ((options?: ExpressInstrumentationConfig) => { setupOnce() { instrumentExpress(options); }, - }; + // Read back by `expressErrorHandler` in `@sentry/core`, which is what captures Express errors. + getShouldHandleError() { + return options?.shouldHandleError; + }, + } satisfies ExpressIntegration; }) satisfies IntegrationFn; export const expressIntegration = defineIntegration(_expressIntegration); diff --git a/packages/node/src/integrations/tracing/fastify/index.ts b/packages/node/src/integrations/tracing/fastify/index.ts index 18af3846e4da..341240ba8d64 100644 --- a/packages/node/src/integrations/tracing/fastify/index.ts +++ b/packages/node/src/integrations/tracing/fastify/index.ts @@ -24,8 +24,6 @@ export { instrumentFastify }; * Options for the Fastify integration. * * `shouldHandleError` - Callback method deciding whether error should be captured and sent to Sentry - * This is used on Fastify v5 where Sentry handles errors in the diagnostics channel. - * Fastify v3 and v4 use `setupFastifyErrorHandler` instead. * * @example * @@ -51,6 +49,22 @@ interface FastifyIntegrationOptions { * @param error Captured Fastify error * @param request Fastify request (or any object containing at least method, routeOptions.url, and routerPath) * @param reply Fastify reply (or any object containing at least statusCode) + * + * @example + * + * If using TypeScript, you can cast the request and reply to get full type safety. + * + * ```typescript + * import type { FastifyRequest, FastifyReply } from 'fastify'; + * + * Sentry.fastifyIntegration({ + * shouldHandleError(error, minimalRequest, minimalReply) { + * const request = minimalRequest as FastifyRequest; + * const reply = minimalReply as FastifyReply; + * return reply.statusCode >= 500; + * }, + * }); + * ``` */ shouldHandleError: (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean; } @@ -63,29 +77,20 @@ interface FastifyHandlerOptions { * @param request Fastify request (or any object containing at least method, routeOptions.url, and routerPath) * @param reply Fastify reply (or any object containing at least statusCode) * - * @example + * @deprecated Configure `shouldHandleError` on `fastifyIntegration()` rather than here, where it + * applies to every supported Fastify version. This option will be removed in v11. * + * @example * * ```javascript - * setupFastifyErrorHandler(app, { - * shouldHandleError(_error, _request, reply) { - * return reply.statusCode >= 400; - * }, - * }); - * ``` - * - * - * If using TypeScript, you can cast the request and reply to get full type safety. - * - * ```typescript - * import type { FastifyRequest, FastifyReply } from 'fastify'; - * - * setupFastifyErrorHandler(app, { - * shouldHandleError(error, minimalRequest, minimalReply) { - * const request = minimalRequest as FastifyRequest; - * const reply = minimalReply as FastifyReply; - * return reply.statusCode >= 500; - * }, + * Sentry.init({ + * integrations: [ + * Sentry.fastifyIntegration({ + * shouldHandleError(_error, _request, reply) { + * return reply.statusCode >= 500; + * }, + * }), + * ], * }); * ``` */ @@ -159,7 +164,9 @@ export const fastifyIntegration = defineIntegration((options: Partial): void { + // oxlint-disable-next-line typescript/no-deprecated if (options?.shouldHandleError) { + // oxlint-disable-next-line typescript/no-deprecated getFastifyIntegration()?.setShouldHandleError(options.shouldHandleError); } diff --git a/packages/node/src/integrations/tracing/langgraph/instrumentation.ts b/packages/node/src/integrations/tracing/langgraph/instrumentation.ts index 6f091a32711e..b41bc4f16b65 100644 --- a/packages/node/src/integrations/tracing/langgraph/instrumentation.ts +++ b/packages/node/src/integrations/tracing/langgraph/instrumentation.ts @@ -6,7 +6,7 @@ import { } from '@opentelemetry/instrumentation'; import { InstrumentationNodeModuleFile } from '../InstrumentationNodeModuleFile'; import type { CompiledGraph, LangGraphOptions } from '@sentry/core'; -import { getClient, instrumentCreateReactAgent, instrumentLangGraph, SDK_VERSION } from '@sentry/core'; +import { getClient, instrumentCreateReactAgent, instrumentStateGraph, SDK_VERSION } from '@sentry/core'; const supportedVersions = ['>=0.0.0 <2.0.0']; @@ -100,7 +100,7 @@ export class SentryLangGraphInstrumentation extends InstrumentationBase unknown }, options); + instrumentStateGraph(exports.StateGraph.prototype as { compile: (...args: unknown[]) => unknown }, options); } // Patch createReactAgent to instrument agent creation and invocation diff --git a/packages/node/src/integrations/tracing/openai/instrumentation.ts b/packages/node/src/integrations/tracing/openai/instrumentation.ts index 0d44a056838a..cac6468b10e3 100644 --- a/packages/node/src/integrations/tracing/openai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/openai/instrumentation.ts @@ -12,7 +12,7 @@ import { SDK_VERSION, } from '@sentry/core'; -const supportedVersions = ['>=4.0.0 <7']; +const supportedVersions = ['>=4.0.0 <8']; export interface OpenAiIntegration extends Integration { options: OpenAiOptions; diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts index 1fce9b35372d..96b120e8acb2 100644 --- a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -47,10 +47,8 @@ export function diagnosticsChannelInjectionIntegrations(): typeof channelIntegra * @experimental May change or be removed in any release. */ export function experimentalUseDiagnosticsChannelInjection( - // Forwarded to `registerDiagnosticsChannelInjection()`; framework SDKs whose bundlers compile - // the SDK into the app (e.g. `@sentry/nextjs`) use it to point the runtime module hook at the - // tracing-hooks package location resolved at build time. Plain Node apps don't need it. - options?: RegisterDiagnosticsChannelInjectionOptions, + // Kept for backwards compatibility only; every field is deprecated and ignored. + _options?: RegisterDiagnosticsChannelInjectionOptions, ): void { setDiagnosticsChannelInjectionLoader((): DiagnosticsChannelInjection => { // These channel integrations 1:1 replace the OTel integration of the @@ -71,7 +69,7 @@ export function experimentalUseDiagnosticsChannelInjection( redisChannelIntegration({ responseHook: cacheResponseHook }), ], replacedOtelIntegrationNames, - register: () => registerDiagnosticsChannelInjection(options), + register: () => registerDiagnosticsChannelInjection(), detect: detectOrchestrionSetup, }; }); diff --git a/packages/node/test/sdk/client.test.ts b/packages/node/test/sdk/client.test.ts index ff58698a7931..933187719454 100644 --- a/packages/node/test/sdk/client.test.ts +++ b/packages/node/test/sdk/client.test.ts @@ -53,6 +53,7 @@ describe('NodeClient', () => { runtime: { name: 'node', version: expect.any(String) }, serverName: expect.any(String), tracesSampleRate: 1, + enableLogs: true, }); }); @@ -301,7 +302,7 @@ describe('NodeClient', () => { describe('log capture', () => { it('adds server name to log attributes', () => { - const options = getDefaultNodeClientOptions({ enableLogs: true }); + const options = getDefaultNodeClientOptions(); const client = new NodeClient(options); const log: Log = { level: 'info', message: 'test message', attributes: {} }; @@ -314,7 +315,7 @@ describe('NodeClient', () => { it('preserves existing log attributes', () => { const serverName = 'test-server'; - const options = getDefaultNodeClientOptions({ serverName, enableLogs: true }); + const options = getDefaultNodeClientOptions({ serverName }); const client = new NodeClient(options); const log: Log = { level: 'info', message: 'test message', attributes: { 'existing.attr': 'value' } }; diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index beb446967056..f68077d99e5a 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nuxt", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Nuxt", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nuxt", @@ -54,15 +54,16 @@ }, "dependencies": { "@nuxt/kit": "^3.13.2", - "@sentry/browser": "10.67.0", - "@sentry/cloudflare": "10.67.0", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/node-core": "10.67.0", + "@sentry/browser": "10.73.0", + "@sentry/bundler-plugin-core": "^5.3.0", + "@sentry/cloudflare": "10.73.0", + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/node-core": "10.73.0", "@sentry/rollup-plugin": "^5.3.0", - "@sentry/server-utils": "10.67.0", + "@sentry/server-utils": "10.73.0", "@sentry/vite-plugin": "^5.3.0", - "@sentry/vue": "10.67.0", + "@sentry/vue": "10.73.0", "local-pkg": "^1.1.2" }, "devDependencies": { diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts new file mode 100644 index 000000000000..a8f017ccc309 --- /dev/null +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -0,0 +1,34 @@ +import { createSentryBuildPluginManager, type Options } from '@sentry/bundler-plugin-core'; + +export function withoutSourceMapDeletion(options: Options): Options { + return { + ...options, + sourcemaps: { + ...options.sourcemaps, + filesToDeleteAfterUpload: undefined, + }, + }; +} + +export async function deleteSourceMapsAfterBuild(options: Options): Promise { + const filesToDeleteAfterUpload = await options.sourcemaps?.filesToDeleteAfterUpload; + + if (filesToDeleteAfterUpload === undefined) { + return; + } + + const deletionOptions: Options = { + ...options, + sourcemaps: { + ...options.sourcemaps, + filesToDeleteAfterUpload, + }, + }; + + const sentryBuildPluginManager = createSentryBuildPluginManager(deletionOptions, { + buildTool: 'nuxt', + loggerPrefix: '[Sentry Nuxt]', + }); + + await sentryBuildPluginManager.deleteArtifacts(); +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index bba2e6440c46..a89317151989 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -4,6 +4,7 @@ import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/vite-plu import type { NitroConfig } from 'nitropack'; import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; +import { deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin'; /** @@ -86,7 +87,7 @@ export function setupSourceMaps( [ validateSourceMapsOptionsPlugin({ nuxt, moduleOptions, sourceMapsEnabled }), // Vite plugin is added on the client and server side (plugin runs for both builds) - ...sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)), + ...sentryVitePlugin(withoutSourceMapDeletion(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))), ], { dev: false, build: true }, // Only add source map plugin during build ); @@ -113,10 +114,16 @@ export function setupSourceMaps( // Add Sentry plugin // Runs only on server-side (Nitro) nitroConfig.rollupConfig.plugins.push( - sentryRollupPlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)), + sentryRollupPlugin(withoutSourceMapDeletion(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))), ); } }); + + nuxt.hook('close', async () => { + if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { + await deleteSourceMapsAfterBuild(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)); + } + }); } /** diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index e0b4956219c3..cbdc995b5041 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -47,13 +47,25 @@ function createMockNuxt(options: { } describe('setupSourceMaps hooks', () => { + const defaultFilesToDeleteAfterUpload = [ + '.*/**/public/**/*.map', + '.*/**/server/**/*.map', + '.*/**/output/**/*.map', + '.*/**/function/**/*.map', + ]; + const mockSentryVitePlugin = vi.fn(() => [{ name: 'sentry-vite-plugin' }]); const mockSentryRollupPlugin = vi.fn(() => ({ name: 'sentry-rollup-plugin' })); + const mockDeleteArtifacts = vi.fn().mockResolvedValue(undefined); + const mockCreateSentryBuildPluginManager = vi.fn(() => ({ deleteArtifacts: mockDeleteArtifacts })); const consoleLogSpy = vi.spyOn(console, 'log'); const consoleWarnSpy = vi.spyOn(console, 'warn'); beforeAll(() => { + vi.doMock('@sentry/bundler-plugin-core', () => ({ + createSentryBuildPluginManager: mockCreateSentryBuildPluginManager, + })); vi.doMock('@sentry/vite-plugin', () => ({ sentryVitePlugin: mockSentryVitePlugin, })); @@ -65,6 +77,7 @@ describe('setupSourceMaps hooks', () => { afterAll(() => { consoleLogSpy.mockRestore(); consoleWarnSpy.mockRestore(); + vi.doUnmock('@sentry/bundler-plugin-core'); vi.doUnmock('@sentry/vite-plugin'); vi.doUnmock('@sentry/rollup-plugin'); }); @@ -74,6 +87,8 @@ describe('setupSourceMaps hooks', () => { consoleWarnSpy.mockClear(); mockSentryVitePlugin.mockClear(); mockSentryRollupPlugin.mockClear(); + mockCreateSentryBuildPluginManager.mockClear(); + mockDeleteArtifacts.mockClear(); }); describe('vite plugin registration', () => { @@ -152,14 +167,7 @@ describe('setupSourceMaps hooks', () => { }); describe('shouldDeleteFilesFallback passed to getPluginOptions in Vite plugin', () => { - const defaultFilesToDeleteAfterUpload = [ - '.*/**/public/**/*.map', - '.*/**/server/**/*.map', - '.*/**/output/**/*.map', - '.*/**/function/**/*.map', - ]; - - it('sentryVitePlugin is called with fallback filesToDeleteAfterUpload when source maps are unset', async () => { + it('does not pass fallback deletion patterns to the Vite plugin', async () => { const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); const mockNuxt = createMockNuxt({ _prepare: false, @@ -172,9 +180,7 @@ describe('setupSourceMaps hooks', () => { expect(mockSentryVitePlugin).toHaveBeenCalledWith( expect.objectContaining({ - sourcemaps: expect.objectContaining({ - filesToDeleteAfterUpload: defaultFilesToDeleteAfterUpload, - }), + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload: undefined }), }), ); }); @@ -194,10 +200,93 @@ describe('setupSourceMaps hooks', () => { const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: {} }, dev: false }; await mockNuxt.triggerHook('nitro:config', nitroConfig); - const pluginOptions = (mockSentryRollupPlugin?.mock?.calls?.[0] as unknown[])?.[0] as { - sourcemaps?: { filesToDeleteAfterUpload?: string[] }; - }; - expect(pluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + expect(mockSentryRollupPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload: undefined }), + }), + ); + }); + }); + + describe('close hook', () => { + it('deletes source maps after the build using fallback patterns', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: undefined, server: undefined }, + }); + const { mockAddVitePlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload: defaultFilesToDeleteAfterUpload }), + }), + { buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' }, + ); + expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1); + }); + + it('uses user-provided deletion patterns after the build', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: true, server: true }, + }); + const { mockAddVitePlugin } = createMockAddVitePlugin(); + const filesToDeleteAfterUpload = ['.output/**/*.map']; + + setupSourceMaps({ sourcemaps: { filesToDeleteAfterUpload } }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload }), + }), + { buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' }, + ); + expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1); + }); + + it('does not create a manager when deletion is not configured', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: true, server: true }, + }); + const { mockAddVitePlugin } = createMockAddVitePlugin(); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); + expect(mockDeleteArtifacts).not.toHaveBeenCalled(); + }); + + it.each([ + { label: 'prepare mode', nuxtOptions: { _prepare: true, dev: false } }, + { label: 'dev mode', nuxtOptions: { _prepare: false, dev: true } }, + ])('does not delete source maps in $label', async ({ nuxtOptions }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt(nuxtOptions); + const { mockAddVitePlugin } = createMockAddVitePlugin(); + + setupSourceMaps( + { sourcemaps: { filesToDeleteAfterUpload: ['.output/**/*.map'] } }, + mockNuxt as unknown as Nuxt, + mockAddVitePlugin, + ); + await mockNuxt.triggerHook('close'); + + expect(mockCreateSentryBuildPluginManager).not.toHaveBeenCalled(); }); }); diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index fa5d75b3d714..e5b8bf9cb3ba 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/opentelemetry", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry utilities for OpenTelemetry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/opentelemetry", @@ -49,7 +49,7 @@ }, "dependencies": { "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0" + "@sentry/core": "10.73.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json index f3ffd657d425..77ba78da2204 100644 --- a/packages/profiling-node/package.json +++ b/packages/profiling-node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/profiling-node", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Node.js Profiling", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/profiling-node", @@ -61,9 +61,9 @@ "test:watch": "vitest --watch" }, "dependencies": { - "@sentry/node-cpu-profiler": "^2.4.2", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0" + "@sentry/node-cpu-profiler": "^2.4.3", + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 943ae5e35c7b..19644cf985e2 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -258,6 +258,12 @@ class ContinuousProfiler { * Starts trace lifecycle profiling. Profiling will remain active as long as there is an active span. */ private _startTraceLifecycleProfiling(): void { + if (!this._sampled) { + DEBUG_BUILD && + debug.log('[Profiling] Profile session not sampled, trace lifecycle profiling will not be started.'); + return; + } + if (!this._client) { DEBUG_BUILD && debug.log( diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts index 04f8736172d8..fb1ff28414d5 100644 --- a/packages/profiling-node/test/integration.test.ts +++ b/packages/profiling-node/test/integration.test.ts @@ -859,9 +859,28 @@ describe('ProfilingIntegration', () => { expect(stopProfilingSpy).not.toHaveBeenCalled(); }); + it('does not start profiler when profile session is not sampled', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileLifecycle: 'trace', + profileSessionSampleRate: 0, + }); + + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const span = Sentry.startInactiveSpan({ forceTransaction: true, name: 'test' }); + + expect(startProfilingSpy).not.toHaveBeenCalled(); + + span.end(); + }); + it('starts profiler when first span is created', () => { const [client] = makeCurrentSpanProfilingClient({ profileLifecycle: 'trace', + profileSessionSampleRate: 1, }); Sentry.setCurrentClient(client); @@ -882,6 +901,7 @@ describe('ProfilingIntegration', () => { it('waits for the tail span to end before stopping the profiler', () => { const [client] = makeCurrentSpanProfilingClient({ profileLifecycle: 'trace', + profileSessionSampleRate: 1, }); Sentry.setCurrentClient(client); @@ -906,6 +926,7 @@ describe('ProfilingIntegration', () => { it('ending last span does not stop the profiler if first span is not ended', () => { const [client] = makeCurrentSpanProfilingClient({ profileLifecycle: 'trace', + profileSessionSampleRate: 1, }); Sentry.setCurrentClient(client); @@ -928,6 +949,7 @@ describe('ProfilingIntegration', () => { it('multiple calls to span.end do not restart the profiler', () => { const [client] = makeCurrentSpanProfilingClient({ profileLifecycle: 'trace', + profileSessionSampleRate: 1, }); Sentry.setCurrentClient(client); diff --git a/packages/react-router/README.md b/packages/react-router/README.md index 6711c2311335..139ff863f6d1 100644 --- a/packages/react-router/README.md +++ b/packages/react-router/README.md @@ -45,7 +45,7 @@ import { HydratedRouter } from 'react-router/dom'; Sentry.init({ dsn: '___PUBLIC_DSN___', - integrations: [Sentry.browserTracingIntegration()], + integrations: [Sentry.reactRouterTracingIntegration()], tracesSampleRate: 1.0, // Capture 100% of the transactions @@ -57,7 +57,7 @@ startTransition(() => { hydrateRoot( document, - + , ); }); @@ -113,9 +113,11 @@ Sentry.init({ }); ``` -In your `entry.server.tsx` file, export the `handleError` function: +In your `entry.server.tsx` file, import the instrumentation file at the very top, export the +`instrumentations` array, and export the `handleError` function: ```tsx +import './instrument.server.mjs'; import * as Sentry from '@sentry/react-router'; import { type HandleErrorFunction } from 'react-router'; @@ -128,13 +130,17 @@ export const handleError: HandleErrorFunction = (error, { request }) => { console.error(error); } }; + +// Register the Sentry server instrumentation so loaders, actions and middleware are traced. +export const instrumentations = [Sentry.createSentryServerInstrumentation()]; // ... rest of your server entry ``` -### Update Scripts +### Loading the Instrumentation via `--import` (Alternative) -Since React Router is running in ESM mode, you need to use the `--import` command line options to load our server-side instrumentation module before the application starts. -Update the `start` and `dev` script to include the instrumentation file: +Instead of importing the instrumentation file at the top of `entry.server.tsx`, you can load it before +the application starts via the `--import` command line option. Since React Router runs in ESM mode, +update the `start` and `dev` scripts accordingly: ```json "scripts": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index b60854c84117..2b2a5335a799 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react-router", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for React Router (Framework)", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react-router", @@ -47,12 +47,12 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", - "@sentry/browser": "10.67.0", + "@sentry/browser": "10.73.0", "@sentry/cli": "^2.58.6", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/react": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/react": "10.73.0", "@sentry/vite-plugin": "^5.3.0", "glob": "^13.0.6" }, diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts index 9ad9421609a4..3af6aec890f0 100644 --- a/packages/react-router/src/client/createClientInstrumentation.ts +++ b/packages/react-router/src/client/createClientInstrumentation.ts @@ -15,8 +15,8 @@ import { startSpan, updateSpanName, } from '@sentry/core'; +import type { ClientInstrumentation } from 'react-router'; import { DEBUG_BUILD } from '../common/debug-build'; -import type { ClientInstrumentation, InstrumentableRoute, InstrumentableRouter } from '../common/types'; import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeRoutePath } from '../common/utils'; import { resolveNavigateAbsoluteUrl, @@ -70,7 +70,7 @@ export function createSentryClientInstrumentation( DEBUG_BUILD && debug.log('React Router client instrumentation API created.'); return { - router(router: InstrumentableRouter) { + router(router) { // Set the flag when React Router actually invokes our instrumentation. // This ensures the flag is only set in Library Mode (where hooks run), // not in Framework Mode (where hooks are never called). @@ -239,7 +239,7 @@ export function createSentryClientInstrumentation( }); }, - route(route: InstrumentableRoute) { + route(route) { const routeId = route.id; route.instrument({ diff --git a/packages/react-router/src/client/tracingIntegration.ts b/packages/react-router/src/client/tracingIntegration.ts index 45f84f1725ae..89653c57e874 100644 --- a/packages/react-router/src/client/tracingIntegration.ts +++ b/packages/react-router/src/client/tracingIntegration.ts @@ -1,6 +1,6 @@ import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/browser'; import type { Integration } from '@sentry/core'; -import type { ClientInstrumentation } from '../common/types'; +import type { ClientInstrumentation } from 'react-router'; import { createSentryClientInstrumentation, type CreateSentryClientInstrumentationOptions, @@ -26,6 +26,10 @@ export interface ReactRouterTracingIntegrationOptions { export interface ReactRouterTracingIntegration extends Integration { /** * Client instrumentation to pass to `HydratedRouter`'s `instrumentations` prop. + * + * @deprecated Use the standalone `createSentryClientInstrumentation()` export instead and pass its + * result to `HydratedRouter`'s `instrumentations` prop. This mirrors the server-side + * `createSentryServerInstrumentation()` API. Will be removed in a future major. */ readonly clientInstrumentation: ClientInstrumentation; } diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index e5978e7b2bea..e1e349306043 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -14,6 +14,11 @@ export function injectTraceMetaTags(body: ReadableStream): ReadableStream { const headClosingTag = ''; const reader = body.getReader(); + const encoder = new TextEncoder(); + // A single streaming decoder carries incomplete multi-byte sequences across chunk + // boundaries. A fresh, non-streaming decoder per chunk would flush a split character + // as U+FFFD, corrupting the response (see https://github.com/whatwg/encoding/issues/184). + const decoder = new TextDecoder(); const stream = new ReadableStream({ async pull(controller) { const { done, value } = await reader.read(); @@ -23,8 +28,7 @@ export function injectTraceMetaTags(body: ReadableStream): ReadableStream { return; } - const encoder = new TextEncoder(); - const html = value instanceof Uint8Array ? new TextDecoder().decode(value) : String(value); + const html = value instanceof Uint8Array ? decoder.decode(value, { stream: true }) : String(value); if (html.includes(headClosingTag)) { const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`); diff --git a/packages/react-router/src/common/types.ts b/packages/react-router/src/common/types.ts deleted file mode 100644 index 4b5370658c73..000000000000 --- a/packages/react-router/src/common/types.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Types for React Router's instrumentation API. - * - * Derived from React Router's `instrumentations` API. - * If React Router changes these types, this file must be updated. - * - * @see https://reactrouter.com/how-to/instrumentation - */ - -export type InstrumentationResult = { status: 'success'; error: undefined } | { status: 'error'; error: unknown }; - -export interface ReadonlyRequest { - method: string; - url: string; - headers: Pick; -} - -export interface RouteHandlerInstrumentationInfo { - readonly request: ReadonlyRequest; - readonly params: Record; - readonly pattern?: string; - readonly unstable_pattern?: string; - readonly context?: unknown; -} - -export interface RouterNavigationInstrumentationInfo { - readonly to: string | number; - readonly currentUrl: string; - readonly formMethod?: string; - readonly formEncType?: string; - readonly formData?: FormData; - readonly body?: unknown; -} - -export interface RouterFetchInstrumentationInfo { - readonly href: string; - readonly currentUrl: string; - readonly fetcherKey: string; - readonly formMethod?: string; - readonly formEncType?: string; - readonly formData?: FormData; - readonly body?: unknown; -} - -export interface RequestHandlerInstrumentationInfo { - readonly request: Request; - readonly context: unknown; -} - -export type InstrumentFunction = (handler: () => Promise, info: T) => Promise; - -export interface RouteInstrumentations { - lazy?: InstrumentFunction; - 'lazy.loader'?: InstrumentFunction; - 'lazy.action'?: InstrumentFunction; - 'lazy.middleware'?: InstrumentFunction; - middleware?: InstrumentFunction; - loader?: InstrumentFunction; - action?: InstrumentFunction; -} - -export interface RouterInstrumentations { - navigate?: InstrumentFunction; - fetch?: InstrumentFunction; -} - -export interface RequestHandlerInstrumentations { - request?: InstrumentFunction; -} - -export interface InstrumentableRoute { - id: string; - index: boolean | undefined; - path: string | undefined; - instrument(instrumentations: RouteInstrumentations): void; -} - -export interface InstrumentableRouter { - instrument(instrumentations: RouterInstrumentations): void; -} - -export interface InstrumentableRequestHandler { - instrument(instrumentations: RequestHandlerInstrumentations): void; -} - -export interface ClientInstrumentation { - router?(router: InstrumentableRouter): void; - route?(route: InstrumentableRoute): void; -} - -export interface ServerInstrumentation { - handler?(handler: InstrumentableRequestHandler): void; - route?(route: InstrumentableRoute): void; -} diff --git a/packages/react-router/src/common/utils.ts b/packages/react-router/src/common/utils.ts index 1585d00fd635..fe97d69e3144 100644 --- a/packages/react-router/src/common/utils.ts +++ b/packages/react-router/src/common/utils.ts @@ -1,6 +1,5 @@ import { captureException, debug } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; -import type { InstrumentationResult } from './types'; /** * Extracts pathname from request URL. @@ -44,7 +43,7 @@ export function normalizeRoutePath(pattern?: string): string | undefined { * Caller must verify result contains an Error before calling. */ export function captureInstrumentationError( - result: InstrumentationResult, + result: { error: unknown }, captureErrors: boolean, mechanismType: string, data: Record, diff --git a/packages/react-router/src/server/createServerInstrumentation.ts b/packages/react-router/src/server/createServerInstrumentation.ts index a4c90ee40f44..36ba55e28a68 100644 --- a/packages/react-router/src/server/createServerInstrumentation.ts +++ b/packages/react-router/src/server/createServerInstrumentation.ts @@ -1,5 +1,5 @@ import { context, createContextKey } from '@opentelemetry/api'; -import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import { debug, flushIfServerless, @@ -13,8 +13,8 @@ import { startSpan, updateSpanName, } from '@sentry/core'; +import type { ServerInstrumentation } from 'react-router'; import { DEBUG_BUILD } from '../common/debug-build'; -import type { InstrumentableRequestHandler, InstrumentableRoute, ServerInstrumentation } from '../common/types'; import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeRoutePath } from '../common/utils'; import { getMiddlewareName } from './serverBuild'; import { markInstrumentationApiUsed } from './serverGlobals'; @@ -46,7 +46,7 @@ export function createSentryServerInstrumentation( DEBUG_BUILD && debug.log('React Router server instrumentation created.'); return { - handler(handler: InstrumentableRequestHandler) { + handler(handler) { // Mark the instrumentation API active only when React Router actually invokes this markInstrumentationApiUsed(); handler.instrument({ @@ -65,6 +65,8 @@ export function createSentryServerInstrumentation( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [URL_FULL]: info.request.url, + [URL_PATH]: pathname, }); try { @@ -88,9 +90,9 @@ export function createSentryServerInstrumentation( [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - 'http.request.method': info.request.method, - 'url.path': pathname, - 'url.full': info.request.url, + [HTTP_REQUEST_METHOD]: info.request.method, + [URL_PATH]: pathname, + [URL_FULL]: info.request.url, }, }, async span => { @@ -114,7 +116,7 @@ export function createSentryServerInstrumentation( }); }, - route(route: InstrumentableRoute) { + route(route) { // Also mark active here, in case route registration runs (mirrors the handler callback above). markInstrumentationApiUsed(); const routeId = route.id; diff --git a/packages/react-router/src/server/getMetaTagTransformer.ts b/packages/react-router/src/server/getMetaTagTransformer.ts index 2b4ce76808de..376bf8919691 100644 --- a/packages/react-router/src/server/getMetaTagTransformer.ts +++ b/packages/react-router/src/server/getMetaTagTransformer.ts @@ -10,15 +10,20 @@ import { getTraceMetaTags } from '@sentry/core'; */ export function getMetaTagTransformer(body: PassThrough): Transform { const headClosingTag = ''; + // A single streaming decoder carries incomplete multi-byte sequences across chunk + // boundaries. Decoding each chunk on its own (e.g. `Buffer.toString()`) would flush a + // split character as U+FFFD, corrupting the response (see + // https://github.com/whatwg/encoding/issues/184). + const decoder = new TextDecoder(); const htmlMetaTagTransformer = new Transform({ transform(chunk, _encoding, callback) { - const html = Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk); + const html = Buffer.isBuffer(chunk) ? decoder.decode(chunk, { stream: true }) : String(chunk); if (html.includes(headClosingTag)) { const modifiedHtml = html.replace(headClosingTag, `${getTraceMetaTags()}${headClosingTag}`); callback(null, modifiedHtml); return; } - callback(null, chunk); + callback(null, html); }, }); htmlMetaTagTransformer.pipe(body); diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index 9364875fd9a6..6f54e933fd81 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -16,6 +16,23 @@ function getSentryConfig(viteConfig: unknown): SentryReactRouterBuildOptions { return (viteConfig as { sentryConfig: SentryReactRouterBuildOptions }).sentryConfig; } +/** + * This hook is the only place that injects debug IDs and uploads source maps for React + * Router, so `disable` has to be honoured wherever the user set it. Reading it from the + * top-level config only would silently ignore `unstable_sentryVitePluginOptions`. + * + * `unstable_sentryVitePluginOptions` takes precedence, since it is documented as being able + * to override the options the SDK passes to the plugin - matching the other SDKs. + */ +function resolveSourceMapsDisable(sentryConfig: SentryReactRouterBuildOptions): boolean | 'disable-upload' | undefined { + // eslint-disable-next-line typescript/no-deprecated + if (sentryConfig.sourceMapsUploadOptions?.enabled === false) { + return true; + } + + return sentryConfig.unstable_sentryVitePluginOptions?.sourcemaps?.disable ?? sentryConfig.sourcemaps?.disable; +} + /** * A build end hook that handles Sentry release creation and source map uploads. * It creates a new Sentry release if configured, uploads source maps to Sentry, @@ -48,8 +65,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo ...unstableSentryVitePluginOptions?.sourcemaps, ...sentryConfig.sourcemaps, ...sourceMapsUploadOptions, - // eslint-disable-next-line typescript/no-deprecated - disable: sourceMapsUploadOptions?.enabled === false ? true : sentryConfig.sourcemaps?.disable, + disable: resolveSourceMapsDisable(sentryConfig), }, release: { ...unstableSentryVitePluginOptions?.release, @@ -80,7 +96,12 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo } } - if (!sourcemaps?.disable && viteConfig.build.sourcemap !== false) { + // `disable: 'disable-upload'` still injects debug IDs, so that source maps can be + // uploaded manually at a later point - only `true` turns source maps off entirely. + const sourceMapsFullyDisabled = sourcemaps?.disable === true; + const uploadDisabled = sourceMapsFullyDisabled || sourcemaps?.disable === 'disable-upload'; + + if (!sourceMapsFullyDisabled && viteConfig.build.sourcemap !== false) { // inject debugIds try { await cliInstance.execute( @@ -92,21 +113,30 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo console.error('[Sentry] Could not inject debug ids', error); } - // upload sourcemaps - try { - await cliInstance.releases.uploadSourceMaps(release?.name || 'undefined', { - include: [ - { - paths: [reactRouterConfig.buildDirectory], - }, - ], - live: 'rejectOnError', - }); - } catch (error) { - // eslint-disable-next-line no-console - console.error('[Sentry] Could not upload sourcemaps', error); + if (!uploadDisabled) { + // upload sourcemaps + try { + await cliInstance.releases.uploadSourceMaps(release?.name || 'undefined', { + include: [ + { + paths: [reactRouterConfig.buildDirectory], + }, + ], + live: 'rejectOnError', + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('[Sentry] Could not upload sourcemaps', error); + } } } + + // Only clean up source maps that were actually uploaded. Deleting them after skipping + // the upload would leave the user with neither, breaking a manual upload. + if (uploadDisabled) { + return; + } + // delete sourcemaps after upload let updatedFilesToDeleteAfterUpload = await sourcemaps?.filesToDeleteAfterUpload; diff --git a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts index 69b07e1da28f..e51ad1449d55 100644 --- a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts +++ b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts @@ -19,6 +19,20 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil release, } = options; + const unstableSourcemapsDisable = unstable_sentryVitePluginOptions?.sourcemaps?.disable; + + // Anything other than `true` would have the Vite plugin inject debug IDs on top of the + // ones `sentryOnBuildEnd` injects, which breaks source map resolution. The value still + // applies to the buildEnd hook - only the Vite plugin ignores it. + if (unstableSourcemapsDisable !== undefined && unstableSourcemapsDisable !== true) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] \`unstable_sentryVitePluginOptions.sourcemaps.disable: ${JSON.stringify( + unstableSourcemapsDisable, + )}\` does not apply to the Vite plugin. Debug ID injection and source map upload are handled by the \`sentryOnBuildEnd\` hook for React Router, so letting the Vite plugin do it as well would inject a second debug ID per chunk. The option still applies to \`sentryOnBuildEnd\`.`, + ); + } + const sentryVitePlugins = sentryVitePlugin({ applicationKey, authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN, @@ -27,27 +41,38 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil org: org ?? process.env.SENTRY_ORG, project: project ?? process.env.SENTRY_PROJECT, telemetry: telemetry ?? true, + // Spread here so it can override the plain options above, but not the objects + // merged below - object spread replaces whole keys rather than deep-merging. + ...unstable_sentryVitePluginOptions, _metaOptions: { + ...unstable_sentryVitePluginOptions?._metaOptions, telemetry: { + ...unstable_sentryVitePluginOptions?._metaOptions?.telemetry, metaFramework: 'react-router', }, - ...unstable_sentryVitePluginOptions?._metaOptions, }, reactComponentAnnotation: { - enabled: reactComponentAnnotation?.enabled ?? undefined, - ignoredComponents: reactComponentAnnotation?.ignoredComponents ?? undefined, + // Only assign when set, as an explicit `undefined` would erase the unstable value + ...(reactComponentAnnotation?.enabled !== undefined && { enabled: reactComponentAnnotation.enabled }), + ...(reactComponentAnnotation?.ignoredComponents !== undefined && { + ignoredComponents: reactComponentAnnotation.ignoredComponents, + }), ...unstable_sentryVitePluginOptions?.reactComponentAnnotation, }, release: { ...unstable_sentryVitePluginOptions?.release, ...release, }, - // will be handled in buildEnd hook sourcemaps: { - disable: true, ...unstable_sentryVitePluginOptions?.sourcemaps, + // Injection and upload are handled in the buildEnd hook, so the Vite plugin must + // never do it too. This is deliberately not overridable - see the warning above. + disable: true, + // The plugin deletes these in a `finally` block that runs regardless of `disable`, + // which would remove the maps before `sentryOnBuildEnd` gets to upload them. + // Deletion is handled there instead, from the same option. + filesToDeleteAfterUpload: undefined, }, - ...unstable_sentryVitePluginOptions, }) as Plugin[]; return sentryVitePlugins; diff --git a/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts b/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts new file mode 100644 index 000000000000..012a426bb5d6 --- /dev/null +++ b/packages/react-router/test/cloudflare/injectTraceMetaTags.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node +import type * as SentryCore from '@sentry/core'; +import { getTraceMetaTags } from '@sentry/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { injectTraceMetaTags } from '../../src/cloudflare/index'; + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + getTraceMetaTags: vi.fn(), +})); + +function streamFromChunks(chunks: Uint8Array[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); +} + +async function readAll(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const parts: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + parts.push(value); + } + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +} + +const REPLACEMENT_CHARACTER = '�'; + +describe('injectTraceMetaTags', () => { + beforeEach(() => { + vi.clearAllMocks(); + (getTraceMetaTags as unknown as ReturnType).mockReturnValue( + '', + ); + }); + + test('injects meta tags before the closing head tag', async () => { + const encoder = new TextEncoder(); + const input = streamFromChunks([encoder.encode('Test')]); + + const output = new TextDecoder().decode(await readAll(injectTraceMetaTags(input))); + + expect(output).toContain(''); + expect(output).not.toContain(''); + }); + + test('preserves a multi-byte character split across chunk boundaries', async () => { + // `©` is 0xC2 0xA9 in UTF-8. Splitting it across two chunks must not corrupt it. + const encoder = new TextEncoder(); + const before = encoder.encode('

'); + const after = encoder.encode(' 2026

'); + + const input = streamFromChunks([ + new Uint8Array([...before, 0xc2]), // first byte of `©` + new Uint8Array([0xa9, ...after]), // second byte of `©` + ]); + + const outputBytes = await readAll(injectTraceMetaTags(input)); + const output = new TextDecoder('utf-8', { fatal: false }).decode(outputBytes); + + expect(output).not.toContain(REPLACEMENT_CHARACTER); + expect(output).toContain('

© 2026

'); + expect(output).toContain(''); + }); + + test('preserves a multi-byte character split across a chunk after ', async () => { + // The corruption is not limited to the `` chunk: every chunk is round-tripped. + const encoder = new TextEncoder(); + const head = encoder.encode(''); + const tail = encoder.encode(' inside body'); + + const input = streamFromChunks([ + head, + new Uint8Array([0xe2, 0x82]), // first two bytes of `€` (0xE2 0x82 0xAC) + new Uint8Array([0xac, ...tail]), // final byte of `€` + ]); + + const output = new TextDecoder('utf-8', { fatal: false }).decode(await readAll(injectTraceMetaTags(input))); + + expect(output).not.toContain(REPLACEMENT_CHARACTER); + expect(output).toContain('€ inside body'); + }); +}); diff --git a/packages/react-router/test/server/createServerInstrumentation.test.ts b/packages/react-router/test/server/createServerInstrumentation.test.ts index 32152b5b6bc0..80b1a4597901 100644 --- a/packages/react-router/test/server/createServerInstrumentation.test.ts +++ b/packages/react-router/test/server/createServerInstrumentation.test.ts @@ -1,4 +1,5 @@ import * as otelApi from '@opentelemetry/api'; +import { URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import * as core from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -116,6 +117,8 @@ describe('createSentryServerInstrumentation', () => { 'sentry.op': 'http.server', 'sentry.origin': 'auto.http.react_router.instrumentation_api', 'sentry.source': 'url', + [URL_FULL]: 'http://example.com/test-path', + [URL_PATH]: '/test-path', }); expect(mockHandleRequest).toHaveBeenCalled(); expect(core.flushIfServerless).toHaveBeenCalled(); diff --git a/packages/react-router/test/server/getMetaTagTransformer.test.ts b/packages/react-router/test/server/getMetaTagTransformer.test.ts index ea2daa0cbafd..c4fdc3978926 100644 --- a/packages/react-router/test/server/getMetaTagTransformer.test.ts +++ b/packages/react-router/test/server/getMetaTagTransformer.test.ts @@ -119,4 +119,32 @@ describe('getMetaTagTransformer', () => { transformer.write(''); transformer.end(); })); + + test('should not corrupt a multi-byte character split across the head-closing chunk', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + const outputChunks: Buffer[] = []; + bodyStream.on('data', chunk => { + outputChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + bodyStream.on('end', () => { + try { + const output = Buffer.concat(outputChunks).toString('utf-8'); + expect(output).not.toContain('�'); + expect(output).toContain('

© 2026

'); + expect(output).toContain(''); + resolve(); + } catch (e) { + reject(e); + } + }); + + // `©` is 0xC2 0xA9 in UTF-8; the closing-head chunk ends mid-character. + transformer.write(Buffer.from([...Buffer.from('

'), 0xc2])); + transformer.write(Buffer.from([0xa9, ...Buffer.from(' 2026

')])); + transformer.end(); + })); }); diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts index a607ff3ccfc6..e0b9a35ad978 100644 --- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts +++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts @@ -178,6 +178,200 @@ describe('sentryOnBuildEnd', () => { expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); }); + it('should not upload source maps when disabled via top-level sourcemaps.disable', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourcemaps: { disable: true }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).not.toHaveBeenCalled(); + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + }); + + // `disable` used to be read from the top-level config only, so this opt-out was + // silently ignored while the Vite plugin honoured it - see #22929. + it('should not upload source maps when disabled via unstable_sentryVitePluginOptions', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + unstable_sentryVitePluginOptions: { + sourcemaps: { disable: true }, + }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).not.toHaveBeenCalled(); + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + }); + + // `unstable_sentryVitePluginOptions` is documented as being able to override the options + // the SDK passes to the plugin, so it wins over the top-level value. + it('should let unstable_sentryVitePluginOptions sourcemaps.disable override the top-level option', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourcemaps: { disable: false }, + unstable_sentryVitePluginOptions: { + sourcemaps: { disable: true }, + }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).not.toHaveBeenCalled(); + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + }); + + it('should let top-level sourcemaps.disable apply when unstable_sentryVitePluginOptions does not set it', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourcemaps: { disable: true }, + unstable_sentryVitePluginOptions: { + sourcemaps: { assets: ['dist/**'] }, + }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + }); + + it('should still upload source maps when unstable_sentryVitePluginOptions only sets unrelated sourcemaps keys', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + unstable_sentryVitePluginOptions: { + sourcemaps: { filesToDeleteAfterUpload: ['./build/**/*.map'] }, + }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); + expect(mockSentryCliInstance.releases.uploadSourceMaps).toHaveBeenCalled(); + expect(glob).toHaveBeenCalledWith(['./build/**/*.map'], { + absolute: true, + nodir: true, + }); + }); + + // `'disable-upload'` means "inject debug IDs, but let me upload the maps myself", so + // injection must still run and the maps must survive. + it('should inject debug IDs but skip upload and deletion when disable is "disable-upload"', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourcemaps: { disable: 'disable-upload' }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + expect(glob).not.toHaveBeenCalled(); + }); + + it('should honour "disable-upload" set via unstable_sentryVitePluginOptions', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + unstable_sentryVitePluginOptions: { + sourcemaps: { disable: 'disable-upload' }, + }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + expect(glob).not.toHaveBeenCalled(); + }); + + // Deleting maps that were never uploaded would leave the user with neither. + it('should not delete source maps when upload is disabled', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourcemaps: { disable: true }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(glob).not.toHaveBeenCalled(); + expect(fs.promises.rm).not.toHaveBeenCalled(); + }); + + it('should not delete source maps when disabled via the deprecated sourceMapsUploadOptions', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourceMapsUploadOptions: { enabled: false }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(glob).not.toHaveBeenCalled(); + }); + it('should delete source maps after upload with default pattern', async () => { // @ts-expect-error - mocking the React config await sentryOnBuildEnd(defaultConfig); diff --git a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts index c38e80ef72df..9cd7ff50548e 100644 --- a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts +++ b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts @@ -1,5 +1,5 @@ import { sentryVitePlugin } from '@sentry/vite-plugin'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { makeCustomSentryVitePlugins } from '../../src/vite/makeCustomSentryVitePlugins'; vi.mock('@sentry/vite-plugin', () => ({ @@ -7,6 +7,12 @@ vi.mock('@sentry/vite-plugin', () => ({ })); describe('makeCustomSentryVitePlugins', () => { + beforeEach(() => { + // Without this, `toHaveBeenCalledWith` can match a call made by an earlier test, + // so assertions pass against stale arguments instead of their own. + vi.clearAllMocks(); + }); + it('should pass release configuration to sentryVitePlugin', async () => { const options = { release: { @@ -33,16 +39,19 @@ describe('makeCustomSentryVitePlugins', () => { unstable_sentryVitePluginOptions: { release: { name: 'unstable-release', + setCommits: { auto: true as const }, }, }, }; await makeCustomSentryVitePlugins(options); + // Top-level `release` wins field-wise, but unstable-only fields are preserved expect(sentryVitePlugin).toHaveBeenCalledWith( expect.objectContaining({ release: { name: 'test-release', + setCommits: { auto: true }, }, }), ); @@ -78,7 +87,7 @@ describe('makeCustomSentryVitePlugins', () => { ); }); - it('should allow overriding sourcemaps via unstable_sentryVitePluginOptions', async () => { + it('should merge sourcemaps options from unstable_sentryVitePluginOptions while keeping disable', async () => { await makeCustomSentryVitePlugins({ unstable_sentryVitePluginOptions: { sourcemaps: { @@ -87,13 +96,223 @@ describe('makeCustomSentryVitePlugins', () => { }, }); - // unstable_sentryVitePluginOptions is spread last, so it fully overrides sourcemaps expect(sentryVitePlugin).toHaveBeenCalledWith( expect.objectContaining({ sourcemaps: { assets: ['dist/**'], + disable: true, }, }), ); }); + + // Regression test for https://github.com/getsentry/sentry-javascript/issues/22929: + // any `sourcemaps` key used to drop `disable: true`, re-enabling debug ID injection + // in the Vite plugin on top of the one done by `sentryOnBuildEnd`. + it('should keep sourcemaps disabled when unstable_sentryVitePluginOptions sets an unrelated sourcemaps key', async () => { + await makeCustomSentryVitePlugins({ + authToken: 'token', + org: 'org', + project: 'project', + unstable_sentryVitePluginOptions: { + release: { name: 'commit-sha', setCommits: { auto: true } }, + sourcemaps: { + filesToDeleteAfterUpload: ['./build/**/*.map'], + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: { + filesToDeleteAfterUpload: undefined, + disable: true, + }, + }), + ); + }); + + // The plugin's `writeBundle` deletes these in a `finally` block that runs even when + // `sourcemaps.disable` is set, which would remove the maps before `sentryOnBuildEnd` + // uploads them. `sentryOnBuildEnd` performs the deletion instead. + it('should not forward filesToDeleteAfterUpload to the Vite plugin', async () => { + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + sourcemaps: { + assets: ['dist/**'], + filesToDeleteAfterUpload: ['./build/**/*.map'], + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: { + assets: ['dist/**'], + disable: true, + filesToDeleteAfterUpload: undefined, + }, + }), + ); + }); + + it('should not let unstable_sentryVitePluginOptions re-enable sourcemaps via disable: false', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + sourcemaps: { + disable: false, + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ disable: true }), + }), + ); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('sourcemaps.disable: false')); + + warnSpy.mockRestore(); + }); + + it('should not let unstable_sentryVitePluginOptions re-enable sourcemaps via disable: "disable-upload"', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + sourcemaps: { + disable: 'disable-upload', + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ disable: true }), + }), + ); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('disable-upload')); + + warnSpy.mockRestore(); + }); + + it('should not warn when unstable_sentryVitePluginOptions sets sourcemaps.disable: true', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { sourcemaps: { disable: true } }, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('should not warn when unstable_sentryVitePluginOptions does not set sourcemaps.disable', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { sourcemaps: { assets: ['dist/**'] } }, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + // metaFramework identifies the SDK to Sentry telemetry, so it stays pinned even + // though unstable_sentryVitePluginOptions can override other options. + it('should keep metaFramework when unstable_sentryVitePluginOptions sets _metaOptions.telemetry', async () => { + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + _metaOptions: { + telemetry: { + metaFramework: 'something-else', + }, + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + _metaOptions: { + telemetry: { + metaFramework: 'react-router', + }, + }, + }), + ); + }); + + it('should keep reactComponentAnnotation from unstable_sentryVitePluginOptions when top-level is unset', async () => { + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + reactComponentAnnotation: { + enabled: true, + ignoredComponents: ['Foo'], + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + reactComponentAnnotation: { + enabled: true, + ignoredComponents: ['Foo'], + }, + }), + ); + }); + + it('should merge reactComponentAnnotation field-wise with unstable_sentryVitePluginOptions', async () => { + await makeCustomSentryVitePlugins({ + reactComponentAnnotation: { enabled: true }, + unstable_sentryVitePluginOptions: { + reactComponentAnnotation: { ignoredComponents: ['Foo'] }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + reactComponentAnnotation: { + enabled: true, + ignoredComponents: ['Foo'], + }, + }), + ); + }); + + // `unstable_sentryVitePluginOptions` is documented as being able to override any + // option the SDK passes to the Vite plugin, so plain top-level keys stay overridable. + it('should let unstable_sentryVitePluginOptions override plain top-level options', async () => { + await makeCustomSentryVitePlugins({ + org: 'top-level-org', + project: 'top-level-project', + telemetry: false, + unstable_sentryVitePluginOptions: { + org: 'unstable-org', + project: 'unstable-project', + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'unstable-org', + project: 'unstable-project', + telemetry: false, + }), + ); + }); + + it('should pass through unstable_sentryVitePluginOptions keys that have no top-level equivalent', async () => { + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + silent: true, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith(expect.objectContaining({ silent: true })); + }); }); diff --git a/packages/react/package.json b/packages/react/package.json index 420cd0c674e8..a78f28e485f2 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for React.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react", @@ -43,8 +43,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.67.0", - "@sentry/core": "10.67.0", + "@sentry/browser": "10.73.0", + "@sentry/core": "10.73.0", "@sentry/conventions": "^0.16.0" }, "peerDependencies": { diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 12b1efecbb07..0c17ff35eda9 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -778,13 +778,20 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio const stableLocationParam = typeof locationArg === 'string' || locationArg?.pathname ? (locationArg as { pathname: string }) : location; + // Register this ``'s routes in the shared set for as long as it is mounted, removing them on + // unmount so they don't leak into later unrelated navigations (#22782). Tying add and remove to the + // same effect lifecycle keeps it correct under StrictMode's mount/unmount/remount. + useIsomorphicLayoutEffect(() => { + const added = addRoutesToAllRoutes(routes); + + return () => removeRoutesFromAllRoutes(added); + }); + useIsomorphicLayoutEffect(() => { const normalizedLocation = typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam; if (isMountRenderPass.current) { - addRoutesToAllRoutes(routes); - updatePageloadTransaction({ activeRootSpan: getActiveRootSpan(), location: normalizedLocation, @@ -1065,14 +1072,29 @@ export function handleNavigation(opts: { } /* Only exported for testing purposes */ -export function addRoutesToAllRoutes(routes: RouteObject[]): void { +export function addRoutesToAllRoutes(routes: RouteObject[]): RouteObject[] { + const added: RouteObject[] = []; routes.forEach(route => { const extractedChildRoutes = getChildRoutesRecursively(route); extractedChildRoutes.forEach(r => { allRoutes.add(r); + added.push(r); }); }); + + return added; +} + +/** + * Removes routes previously added via `addRoutesToAllRoutes` from the shared set. Called when a + * `` unmounts so its routes don't linger and get matched against later, unrelated navigations + * (which produced hybrid names like `/bar/:fooId` across independent routers - see #22782). + */ +function removeRoutesFromAllRoutes(routes: RouteObject[]): void { + routes.forEach(route => { + allRoutes.delete(route); + }); } function getChildRoutesRecursively(route: RouteObject, allRoutes: Set = new Set()): Set { @@ -1371,13 +1393,20 @@ export function createV6CompatibleWithSentryReactRouterRouting

`'s routes in the shared set for as long as it is mounted, removing them on + // unmount so they don't leak into later unrelated navigations (#22782). Tying add and remove to the + // same effect lifecycle keeps it correct under StrictMode's mount/unmount/remount. + useIsomorphicLayoutEffect(() => { + const added = addRoutesToAllRoutes(routes); + + return () => removeRoutesFromAllRoutes(added); + }); + useIsomorphicLayoutEffect( () => { - const routes = _createRoutesFromChildren(props.children) as RouteObject[]; - if (isMountRenderPass.current) { - addRoutesToAllRoutes(routes); - updatePageloadTransaction({ activeRootSpan: getActiveRootSpan(), location, diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index ea6e9a3ea1f2..de6e8623db90 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -82,10 +82,13 @@ export function tanstackRouterBrowserTracingIntegration( const initialWindowLocation = WINDOW.location; if (instrumentPageLoad && initialWindowLocation) { - const routeMatch = resolveRouteMatch( - initialWindowLocation.pathname, - castRouterInstance.options.parseSearch(initialWindowLocation.search), - ); + const initialRouterLocation = castRouterInstance.state?.location; + const routeMatch = initialRouterLocation + ? resolveRouteMatch(initialRouterLocation.pathname, initialRouterLocation.search) + : resolveRouteMatch( + initialWindowLocation.pathname, + castRouterInstance.options.parseSearch(initialWindowLocation.search), + ); const pageloadSpan = startBrowserTracingPageLoadSpan(client, { name: routeMatch ? routeMatch.routeId : initialWindowLocation.pathname, diff --git a/packages/react/src/vendor/tanstackrouter-types.ts b/packages/react/src/vendor/tanstackrouter-types.ts index 3936c429cd5d..c8b296707d10 100644 --- a/packages/react/src/vendor/tanstackrouter-types.ts +++ b/packages/react/src/vendor/tanstackrouter-types.ts @@ -45,6 +45,7 @@ interface VendoredTanstackRouterHistory { interface VendoredTanstackRouterState { matches: Array; pendingMatches?: Array; + location?: VendoredTanstackRouterLocation; } export interface VendoredTanstackRouterRouteMatch { diff --git a/packages/react/test/tanstackrouter.test.ts b/packages/react/test/tanstackrouter.test.ts index c45bd83753c0..c75e9156166c 100644 --- a/packages/react/test/tanstackrouter.test.ts +++ b/packages/react/test/tanstackrouter.test.ts @@ -58,6 +58,7 @@ describe('tanstackRouterBrowserTracingIntegration', () => { beforeEach(() => { vi.clearAllMocks(); startBrowserTracingPageLoadSpanSpy.mockReturnValue(mockPageloadSpan as any); + (SentryBrowser.WINDOW as any).location = { pathname: '/posts/999', search: '' }; vi.stubGlobal('window', { location: { @@ -91,6 +92,39 @@ describe('tanstackRouterBrowserTracingIntegration', () => { }); }); + describe('pageload route matching', () => { + // `window.location.pathname` carries the router basepath, but whether `matchRoutes` wants it is + // version-dependent (newer routers strip it in `parseLocation`, older ones inside `matchRoutes`). + // `state.location` is always in the form the router itself expects, so we match against that. + it('matches against the router location, not window.location', () => { + (SentryBrowser.WINDOW as any).location = { pathname: '/app/posts/999', search: '?q=1' }; + + const integration = tanstackRouterBrowserTracingIntegration( + { ...mockRouter, state: { location: { pathname: '/posts/999', search: { q: 1 } } } }, + { instrumentPageLoad: true, instrumentNavigation: false }, + ); + + integration.afterAllSetup!(mockClient as any); + + expect(mockRouter.matchRoutes).toHaveBeenCalledWith('/posts/999', { q: 1 }, expect.any(Object)); + expect(mockRouter.options.parseSearch).not.toHaveBeenCalled(); + }); + + it('falls back to window.location when the router exposes no location', () => { + (SentryBrowser.WINDOW as any).location = { pathname: '/posts/999', search: '?q=1' }; + + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentPageLoad: true, + instrumentNavigation: false, + }); + + integration.afterAllSetup!(mockClient as any); + + expect(mockRouter.options.parseSearch).toHaveBeenCalledWith('?q=1'); + expect(mockRouter.matchRoutes).toHaveBeenCalledWith('/posts/999', {}, expect.any(Object)); + }); + }); + it('updates pageload span URL attributes on redirect to the same route template', () => { const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { instrumentPageLoad: true, diff --git a/packages/remix/package.json b/packages/remix/package.json index 568efd3c55df..8abc81f04226 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/remix", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Remix", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/remix", @@ -69,10 +69,10 @@ "@remix-run/router": "^1.23.3", "@sentry/cli": "^2.58.6", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/react": "10.67.0", - "@sentry/server-utils": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/react": "10.73.0", + "@sentry/server-utils": "10.73.0", "yargs": "^17.6.0" }, "devDependencies": { diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 3ce2aa4a2caf..459c0b69c039 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -127,6 +127,8 @@ export { instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentStateGraphCompile, zodErrorsIntegration, diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index a9003c925803..c4eb4ed7e420 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -40,6 +40,7 @@ import { createRoutes, getTransactionName, isCloudflareEnv } from '../utils/util import { extractData, isResponse, json } from '../utils/vendor/response'; import { captureRemixServerException, errorHandleDataFunction } from './errors'; import { generateSentryServerTimingHeader, injectServerTimingHeaderValue } from './serverTimingTracePropagation'; +import { HTTP_ROUTE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; type AppData = unknown; type RemixRequest = Parameters[0]; @@ -170,7 +171,7 @@ function updateSpanWithRoute(args: DataFunctionArgs, build: ServerBuild): void { const routes = createRoutes(build.routes); const url = new URL(args.request.url); - const [transactionName] = getTransactionName(routes, url); + const [transactionName, source] = getTransactionName(routes, url); // Preserve the HTTP method prefix if the span already has one const method = args.request.method.toUpperCase(); @@ -178,6 +179,9 @@ function updateSpanWithRoute(args: DataFunctionArgs, build: ServerBuild): void { const newSpanName = currentSpanName?.startsWith(method) ? `${method} ${transactionName}` : transactionName; rootSpan.updateName(newSpanName); + if (source === 'route') { + rootSpan.setAttribute(HTTP_ROUTE, transactionName); + } } catch (e) { DEBUG_BUILD && debug.warn('Failed to update span name with route', e); } @@ -337,8 +341,8 @@ function wrapRequestHandler ServerBuild | Promise DEBUG_BUILD && debug.warn('Failed to normalize Remix request'); } + const url = new URL(request.url); if (options?.instrumentTracing && resolvedRoutes) { - const url = new URL(request.url); [name, source] = getTransactionName(resolvedRoutes, url); isolationScope.setTransactionName(name); @@ -348,6 +352,12 @@ function wrapRequestHandler ServerBuild | Promise if (parentSpan) { const rootSpan = getRootSpan(parentSpan); rootSpan?.updateName(name); + rootSpan?.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + ...(source === 'route' && { + [HTTP_ROUTE]: name, + }), + }); } } @@ -375,7 +385,12 @@ function wrapRequestHandler ServerBuild | Promise [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.remix', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [URL_FULL]: url.href, + [URL_PATH]: url.pathname, method: request.method, + ...(source === 'route' && { + [HTTP_ROUTE]: name, + }), ...httpHeadersToSpanAttributes( winterCGHeadersToDict(request.headers), getClient()?.getDataCollectionOptions(), diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts index 624ab8dad39c..04d5e4a21269 100644 --- a/packages/remix/src/server/integrations/tracing-channel.ts +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -3,6 +3,8 @@ import type { Span, SpanAttributes } from '@sentry/core'; import { getActiveSpan, isObjectLike, + isURLObjectRelative, + parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, @@ -10,7 +12,15 @@ import { waitForTracingChannelBinding, } from '@sentry/core'; import { bindTracingChannelToSpan } from '@sentry/server-utils'; -import { CODE_FUNCTION, HTTP_METHOD, HTTP_ROUTE, HTTP_STATUS_CODE, HTTP_URL } from '@sentry/conventions/attributes'; +import { + CODE_FUNCTION, + HTTP_METHOD, + HTTP_ROUTE, + HTTP_STATUS_CODE, + HTTP_URL, + URL_FULL, + URL_PATH, +} from '@sentry/conventions/attributes'; import { remixChannels } from '@sentry/server-utils/orchestrion'; const ORIGIN = 'auto.http.orchestrion.remix'; @@ -64,6 +74,9 @@ function getRequestAttributes(request: unknown): SpanAttributes { if (typeof url === 'string') { // oxlint-disable-next-line typescript/no-deprecated attributes[HTTP_URL] = url; + const urlObject = parseStringToURLObject(url); + attributes[URL_FULL] = urlObject && !isURLObjectRelative(urlObject) ? urlObject.href : undefined; + attributes[URL_PATH] = urlObject?.pathname; } return attributes; } diff --git a/packages/remix/test/server/instrumentServer.test.ts b/packages/remix/test/server/instrumentServer.test.ts new file mode 100644 index 000000000000..d580357d7174 --- /dev/null +++ b/packages/remix/test/server/instrumentServer.test.ts @@ -0,0 +1,46 @@ +import type { LoaderFunctionArgs, ServerBuild } from '@remix-run/server-runtime'; +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import type { Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { instrumentBuild } from '../../src/server/instrumentServer'; + +describe('instrumentBuild', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets the matched route on the root span when the request handler is not wrapped', async () => { + const rootSpan = { + setAttribute: vi.fn(), + updateName: vi.fn(), + } as unknown as Span; + vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(rootSpan); + vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue(rootSpan); + vi.spyOn(SentryCore, 'spanToJSON').mockReturnValue({ description: 'GET /users/42' }); + const build = { + entry: { module: {} }, + routes: { + root: { + id: 'root', + module: { loader: vi.fn(() => ({})) }, + }, + 'routes/users.$id': { + id: 'routes/users.$id', + parentId: 'root', + path: 'users/:id', + module: {}, + }, + }, + } as unknown as ServerBuild; + const instrumentedBuild = instrumentBuild(build, { instrumentTracing: true }); + + await instrumentedBuild.routes.root?.module.loader?.({ + context: {}, + params: { id: '42' }, + request: new Request('https://example.com/users/42'), + } as LoaderFunctionArgs); + + expect(rootSpan.setAttribute).toHaveBeenCalledWith(HTTP_ROUTE, '/users/:id'); + }); +}); diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index 79afa9f8afc6..91e2b43ab50c 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/replay-canvas", - "version": "10.67.0", + "version": "10.73.0", "description": "Replay canvas integration", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -69,8 +69,8 @@ "@sentry/rrweb": "2.43.2" }, "dependencies": { - "@sentry/replay": "10.67.0", - "@sentry/core": "10.67.0" + "@sentry/replay": "10.73.0", + "@sentry/core": "10.73.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index 4e20efd6aab0..0a1f207df7cb 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/replay", - "version": "10.67.0", + "version": "10.73.0", "description": "User replays for Sentry", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -80,7 +80,7 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@babel/core": "^7.29.6", - "@sentry-internal/replay-worker": "10.67.0", + "@sentry-internal/replay-worker": "10.73.0", "@sentry/rrweb": "2.43.2", "@sentry/rrweb-snapshot": "2.43.2", "fflate": "0.8.2", @@ -88,8 +88,8 @@ "jsdom-worker": "^0.3.0" }, "dependencies": { - "@sentry/browser-utils": "10.67.0", - "@sentry/core": "10.67.0" + "@sentry/browser-utils": "10.73.0", + "@sentry/core": "10.73.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-internal/src/eventBuffer/EventBufferProxy.ts b/packages/replay-internal/src/eventBuffer/EventBufferProxy.ts index 9fb82882a484..181b5b20e434 100644 --- a/packages/replay-internal/src/eventBuffer/EventBufferProxy.ts +++ b/packages/replay-internal/src/eventBuffer/EventBufferProxy.ts @@ -4,6 +4,7 @@ import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } fro import { debug } from '../util/logger'; import { EventBufferArray } from './EventBufferArray'; import { EventBufferCompressionWorker } from './EventBufferCompressionWorker'; +import { WorkerDestroyedError } from './error'; /** * This proxy will try to use the compression worker, and fall back to use the simple buffer if an error occurs there. @@ -130,6 +131,11 @@ export class EventBufferProxy implements EventBuffer { // Can now clear fallback buffer as it's no longer necessary this._fallback.clear(); } catch (error) { + // Destroying the worker (e.g. when the session expires) rejects the + // in-flight requests. This is expected teardown, not a failure. + if (error instanceof WorkerDestroyedError) { + return; + } DEBUG_BUILD && debug.exception(error, 'Failed to add events when switching buffers.'); } } diff --git a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts index dba3c858b711..56d5aa8dc83b 100644 --- a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts +++ b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts @@ -1,6 +1,7 @@ import { DEBUG_BUILD } from '../debug-build'; import type { WorkerRequest, WorkerResponse } from '../types'; import { debug } from '../util/logger'; +import { WorkerDestroyedError } from './error'; interface PendingRequest { method: WorkerRequest['method']; @@ -75,7 +76,7 @@ export class WorkerHandler { public destroy(): void { DEBUG_BUILD && debug.log('Destroying compression worker'); this._worker.removeEventListener('message', this._onMessage); - this._pending.forEach(pending => pending.reject(new Error('Worker destroyed'))); + this._pending.forEach(pending => pending.reject(new WorkerDestroyedError())); this._pending.clear(); this._worker.terminate(); } diff --git a/packages/replay-internal/src/eventBuffer/error.ts b/packages/replay-internal/src/eventBuffer/error.ts index 1d60388d42d7..8a69a7fa4ae1 100644 --- a/packages/replay-internal/src/eventBuffer/error.ts +++ b/packages/replay-internal/src/eventBuffer/error.ts @@ -6,3 +6,10 @@ export class EventBufferSizeExceededError extends Error { super(`Event buffer exceeded maximum size of ${REPLAY_MAX_EVENT_BUFFER_SIZE}.`); } } + +/** This error indicates that the compression worker was intentionally destroyed (e.g. on session expiry). */ +export class WorkerDestroyedError extends Error { + public constructor() { + super('Worker destroyed'); + } +} diff --git a/packages/replay-internal/test/unit/eventBuffer/EventBufferProxy.test.ts b/packages/replay-internal/test/unit/eventBuffer/EventBufferProxy.test.ts index b602c0b4b009..e66c912c6272 100644 --- a/packages/replay-internal/test/unit/eventBuffer/EventBufferProxy.test.ts +++ b/packages/replay-internal/test/unit/eventBuffer/EventBufferProxy.test.ts @@ -6,6 +6,7 @@ import 'jsdom-worker'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { EventBufferProxy } from '../../../src/eventBuffer/EventBufferProxy'; +import { debug } from '../../../src/util/logger'; import { BASE_TIMESTAMP } from '../..'; import { decompress } from '../../utils/compression'; import { getTestEventIncremental } from '../../utils/getTestEvent'; @@ -13,16 +14,47 @@ import { createEventBuffer } from './../../../src/eventBuffer'; const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); +/** + * Worker stub that only answers when the test tells it to, so the buffer can be + * destroyed while the switch to the compression worker is still in flight. + */ +class ControlledWorker extends EventTarget { + public posted: Array<{ id: number; method: string }> = []; + + public postMessage(data: unknown): void { + this.posted.push(data as { id: number; method: string }); + } + + public terminate(): void { + // noop + } + + /** Emit the message the worker sends once its script has loaded. */ + public sendReady(): void { + this.dispatchEvent(new MessageEvent('message', { data: { success: true } })); + } + + /** Answer all posted requests with an unsuccessful response. */ + public failAll(): void { + this.posted.forEach(({ id, method }) => { + this.dispatchEvent(new MessageEvent('message', { data: { id, method, success: false } })); + }); + } +} + describe('Unit | eventBuffer | EventBufferProxy', () => { let consoleErrorSpy: MockInstance; + let exceptionSpy: MockInstance; beforeEach(() => { // Avoid logging errors to console consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exceptionSpy = vi.spyOn(debug, 'exception').mockImplementation(() => {}); }); afterEach(() => { consoleErrorSpy.mockRestore(); + exceptionSpy.mockRestore(); }); it('waits for the worker to be loaded when calling finish', async function () { @@ -67,4 +99,34 @@ describe('Unit | eventBuffer | EventBufferProxy', () => { expect(typeof result2).toBe('string'); expect(result2).toEqual(JSON.stringify([TEST_EVENT, TEST_EVENT, TEST_EVENT])); }); + + it('does not report an error if the worker is destroyed while switching buffers', async function () { + const worker = new ControlledWorker(); + const buffer = new EventBufferProxy(worker as unknown as Worker); + + await buffer.addEvent(TEST_EVENT); + + worker.sendReady(); + await vi.waitFor(() => expect(worker.posted).toHaveLength(1)); + + buffer.destroy(); + + await buffer.ensureWorkerIsLoaded(); + expect(exceptionSpy).not.toHaveBeenCalled(); + }); + + it('reports an error if adding events fails while switching buffers', async function () { + const worker = new ControlledWorker(); + const buffer = new EventBufferProxy(worker as unknown as Worker); + + await buffer.addEvent(TEST_EVENT); + + worker.sendReady(); + await vi.waitFor(() => expect(worker.posted).toHaveLength(1)); + + worker.failAll(); + + await buffer.ensureWorkerIsLoaded(); + expect(exceptionSpy).toHaveBeenCalledWith(expect.any(Error), 'Failed to add events when switching buffers.'); + }); }); diff --git a/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts b/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts index 0b28cec37348..3ee737045b02 100644 --- a/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts +++ b/packages/replay-internal/test/unit/eventBuffer/WorkerHandler.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest'; +import { WorkerDestroyedError } from '../../../src/eventBuffer/error'; import { WorkerHandler } from '../../../src/eventBuffer/WorkerHandler'; import type { WorkerResponse } from '../../../src/types'; @@ -166,8 +167,8 @@ describe('Unit | eventBuffer | WorkerHandler', () => { handler.destroy(); - await expect(p1).rejects.toThrow('Worker destroyed'); - await expect(p2).rejects.toThrow('Worker destroyed'); + await expect(p1).rejects.toThrow(WorkerDestroyedError); + await expect(p2).rejects.toThrow(WorkerDestroyedError); expect(worker.terminated).toBe(true); expect(worker.listenerCount).toBe(0); }); diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index 545cf7bbaff9..6adb81386f2d 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-worker", - "version": "10.67.0", + "version": "10.73.0", "description": "Worker for @sentry/replay", "main": "build/esm/index.js", "module": "build/esm/index.js", diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index e2eaf9768654..a6af4db475de 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/server-utils", - "version": "10.67.0", + "version": "10.73.0", "description": "Server Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/server-utils", @@ -52,6 +52,10 @@ "import": "./build/esm/orchestrion/bundler/webpack.js", "require": "./build/cjs/orchestrion/bundler/webpack.js" }, + "./orchestrion/webpack-loader": { + "import": "./build/esm/orchestrion/bundler/webpack-loader.js", + "require": "./build/cjs/orchestrion/bundler/webpack-loader.js" + }, "./orchestrion/esbuild": { "types": "./build/types/orchestrion/bundler/esbuild.d.ts", "import": "./build/esm/orchestrion/bundler/esbuild.js", @@ -59,6 +63,9 @@ }, "./orchestrion/import-hook": { "import": "./build/orchestrion/import-hook.mjs" + }, + "./orchestrion/hook": { + "import": "./build/esm/orchestrion/runtime/hook.js" } }, "typesVersions": { @@ -116,14 +123,14 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", - "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "meriyah": "^6.1.4" + "@sentry/core": "10.73.0" }, "devDependencies": { + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.3", + "@apm-js-collab/tracing-hooks": "^0.13.0", "@types/node": "^18.19.1", + "meriyah": "^6.1.4", "vite": "^6.4.3" }, "scripts": { diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index f1b3a19655a7..eb86382dd476 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -1,13 +1,103 @@ +import { builtinModules } from 'node:module'; +import commonjs from '@rollup/plugin-commonjs'; +import license from 'rollup-plugin-license'; import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -// EXPERIMENTAL — orchestrion.js runtime hook. A hand-written `.mjs` shim that -// SDKs reference via a `--import .../orchestrion/import-hook` flag. We pass it -// through rollup only to copy it into `build/orchestrion/` at the path the -// package.json `exports` map expects; `external: /.*/` keeps every import (e.g. -// `@sentry/server-utils/orchestrion/config`) as a runtime resolution -// against the installed package. +// The orchestrion runtime dependency chain (`@apm-js-collab/tracing-hooks` → +// `@apm-js-collab/code-transformer` → meriyah/esquery/astring/…) is bundled into this package's +// build instead of installed as runtime dependencies. Everything in the chain is plain JS, and +// bundling removes two whole classes of downstream breakage: +// +// 1. `require(esm)`: the chain's only sync entry (`hook-sync.mjs`) is ESM-only, so an installed +// dependency forces our CJS build through Node's `require(esm)` bridge — unavailable on the AWS +// Lambda runtime (`--no-experimental-require-module`) and broken on `Module.register()` loader +// threads on Node 22.15–24.12 (`The resolveSync() method is not implemented`). Compiled into our +// own dual build, the CJS variant is genuine CJS. +// 2. Tracer/runtime exports-map mismatches: meriyah 6.1's `module-sync`-first exports map is +// resolved differently by build-time tracers (`@vercel/nft`, nf3, Nitro externals) than by the +// runtime CJS loader, producing pruned server bundles that crash with `MODULE_NOT_FOUND` +// (https://github.com/vercel/nft/issues/603, https://github.com/nitrojs/nitro/issues/4456). +// Bundled, there is no runtime package resolution left to get wrong. +// +// `@apm-js-collab/code-transformer-bundler-plugins` (build-time only) is bundled as well so the +// build-time and runtime transforms always ship the same `code-transformer` version, and so this +// package has no `@apm-js-collab/*` install footprint at all. +// +// `requireReturnsDefault: 'auto'`: node-resolve prefers a dependency's ESM build even for CJS +// `require()`s inside the vendored graph. Default-export-only ESM (e.g. esquery) must then resolve +// to the default itself, not a `{ default }` namespace — CJS callers use it as +// `require('esquery').parse(...)`. +// +// `strictRequires: false`: the default `'auto'` wraps conditionally-required modules (e.g. +// `debug`'s browser/node split) in lazy initializers exported as `__require` — an export name that +// downstream re-bundlers mishandle (Turbopack renames it, producing `.require is not a function` +// crashes in Next.js on Cloudflare). Hoisting is safe here: the vendored graph is closed (nothing +// optional/missing) and has no require cycles that depend on lazy evaluation. +const commonJSOptions = { transformMixedEsModules: true, requireReturnsDefault: 'auto', strictRequires: false }; +const commonJSPlugin = commonjs(commonJSOptions); + +// Always vendor `debug`'s Node build. Its default entry picks browser vs node at require time, +// which drags the browser build into this server-only bundle — and, hoisted by +// `strictRequires: false`, the browser build's storage detection probes `localStorage` at import +// time, which on Node >= 26 emits an ExperimentalWarning that pollutes stderr and console +// breadcrumbs in every user app. `order: 'pre'` because the base config's node-resolve plugin +// sorts ahead of package-specific plugins and would otherwise resolve `debug` first. +const debugNodeAlias = { + name: 'debug-node-alias', + resolveId: { + order: 'pre', + handler(source, importer) { + return source === 'debug' ? this.resolve('debug/src/node.js', importer, { skipSelf: true }) : null; + }, + }, +}; + +// This package only runs in Node, but rollup's default CJS replacement for `import.meta.url` +// picks browser behavior whenever a `document` global exists, and jsdom/happy-dom define +// `document` while tests run in Node. Always emit the unconditional Node form instead. +const importMetaUrlNodeShim = { + name: 'import-meta-url-node-shim', + resolveImportMeta(property, { format }) { + if (property === 'url' && format === 'cjs') { + return "require('node:url').pathToFileURL(__filename).href"; + } + return null; + }, +}; + +// Bundling files from the repo-root `node_modules` moves rollup's common source ancestor up to the +// repo root, so `preserveModules` names our own files `packages/server-utils/src/...` — strip that +// prefix to keep the `build/cjs/index.js` layout the `exports` map points at. And npm never packs +// `node_modules` directories, so the vendored dependencies must not be emitted under that name. +const sanitizedFileNames = info => + `${info.name.replace(/^packages\/server-utils\/src\//, '').replace(/node_modules/g, 'vendored')}.js`; + +// The vendored dependencies (see above) are third-party code redistributed inside this package's +// published `build/`, so their licenses require us to carry each one's copyright/permission notice +// (and, for Apache-2.0 deps like `@apm-js-collab/*`, the upstream NOTICE). Rollup strips per-file +// banners, so instead we aggregate them into a single `build/THIRD-PARTY-LICENSES.txt`. The default +// template emits each dependency's license text AND its NOTICE text, which covers the MIT/ISC/BSD +// notice requirement and the Apache-2.0 §4(d) NOTICE requirement. Only bundled (non-external) +// packages are collected — our own `@sentry/*` deps stay external and are excluded. +// +// Both the CJS and ESM build variants run this and bundle the same dependency set, so each writes +// the same file; the last write wins and the content is identical. +const thirdPartyLicensePlugin = license({ + thirdParty: { + includePrivate: false, + output: { + file: 'build/THIRD-PARTY-LICENSES.txt', + }, + }, +}); + const orchestrionRuntimeHooks = [ + // EXPERIMENTAL — orchestrion.js runtime hook. A hand-written `.mjs` shim that SDKs reference via + // a `--import .../orchestrion/import-hook` flag. We pass it through rollup only to copy it into + // `build/orchestrion/` at the path the package.json `exports` map expects; `external: /.*/` keeps + // every import (e.g. `@sentry/server-utils/orchestrion/config`) as a runtime resolution against + // the installed package. defineConfig({ input: 'src/orchestrion/runtime/import-hook.mjs', external: /.*/, @@ -33,25 +123,36 @@ export default [ // subpath export; the Node SDK `require`s it synchronously from // `Sentry.init()` to install the channel-injection hooks. 'src/orchestrion/runtime/register.ts', + // The async module hooks passed to `Module.register()`. They load on Node's ESM loader + // thread, which cannot resolve bare specifiers into our bundled dependency graph — but + // relative imports of on-disk files work, and `build/esm` is a `"type": "module"` scope, so + // this entrypoint shares the vendored chunks with the rest of the build. The `./orchestrion/ + // hook` export only maps its `import` condition (nothing ever `require()`s it), so the copy + // in `build/cjs` is unused. + 'src/orchestrion/runtime/hook.mjs', 'src/orchestrion/bundler/vite.ts', 'src/orchestrion/bundler/rollup.ts', 'src/orchestrion/bundler/webpack.ts', + 'src/orchestrion/bundler/webpack-loader.ts', 'src/orchestrion/bundler/esbuild.ts', ], packageSpecificConfig: { + plugins: [debugNodeAlias, commonJSPlugin, importMetaUrlNodeShim, thirdPartyLicensePlugin], output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named', // set preserveModules to true because we don't want to bundle everything into one file. preserveModules: true, - // `@apm-js-collab/code-transformer-bundler-plugins` ships CJS entries as bare - // `module.exports = fn` with no `__esModule`/`.default`. The repo default - // `interop: 'esModule'` assumes ESM-shaped externals and would dereference a nonexistent - // `.default`, so a default import compiles to `codeTransformer.default(...)` → "not a - // function". Use 'auto' for just these so Rollup emits its interop helper. Scoped here (not - // repo-wide) because 'auto' also turns `import * as x` into a copy, which breaks in-place - // monkey-patching that other packages (e.g. the OTel fs instrumentation) depend on. - interop: id => (id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') ? 'auto' : 'esModule'), + entryFileNames: sanitizedFileNames, + // The repo default `interop: 'esModule'` dereferences `.default` on default imports of + // externals. The commonjs-converted vendored dependencies import Node builtins that way + // (e.g. `require('path')` → default import of `path`), and builtins have no `.default` in + // CJS — so builtins need `'default'` interop (the module itself is the default export). + interop: id => (id && (id.startsWith('node:') || builtinModules.includes(id)) ? 'default' : 'esModule'), + // The vendored dependencies import builtins unprefixed (`import … from 'tty'`), which + // Deno rejects outright and vite-node (Node 26) misresolves as a relative path. Emit them + // `node:`-prefixed. + paths: Object.fromEntries(builtinModules.map(m => [m, `node:${m}`])), }, }, }), diff --git a/packages/server-utils/src/integrations/tracing-channel/express/index.ts b/packages/server-utils/src/integrations/tracing-channel/express/index.ts index 2c7f19b4206a..4e812b7ffc4e 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/index.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/index.ts @@ -1,5 +1,5 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; -import type { IntegrationFn } from '@sentry/core'; +import type { ExpressIntegration, IntegrationFn } from '@sentry/core'; import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; import type { ExpressIntegrationOptions } from './types'; import { instrumentExpress } from './instrumentation'; @@ -21,7 +21,11 @@ const _expressChannelIntegration = ((options: ExpressIntegrationOptions = {}) => instrumentExpress(options, diagnosticsChannel.tracingChannel); }); }, - }; + // Read back by `expressErrorHandler` in `@sentry/core`, which is what captures Express errors. + getShouldHandleError() { + return options.shouldHandleError; + }, + } satisfies ExpressIntegration; }) satisfies IntegrationFn; /** diff --git a/packages/server-utils/src/integrations/tracing-channel/express/types.ts b/packages/server-utils/src/integrations/tracing-channel/express/types.ts index 1cd56b104083..4f9241404578 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/types.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/types.ts @@ -1,3 +1,5 @@ +import type { ExpressShouldHandleError } from '@sentry/core'; + export type ExpressLayerType = 'router' | 'middleware' | 'request_handler'; /** @@ -60,4 +62,15 @@ export interface ExpressIntegrationOptions { ignoreLayers?: IgnoreMatcher[]; /** Ignore specific layers based on their type */ ignoreLayersType?: ExpressLayerType[]; + /** + * Callback deciding whether an error passed to `next(error)` should be captured + * and sent to Sentry. + * + * By default, 5xx errors (and errors without a resolvable status) are sent, while + * 3xx and 4xx errors are not. Set to `false` to capture no errors at all. + * + * Capturing Express errors still requires `setupExpressErrorHandler(app)`, which + * reads this option back off the integration. + */ + shouldHandleError?: ExpressShouldHandleError; } diff --git a/packages/server-utils/src/integrations/tracing-channel/langchain.ts b/packages/server-utils/src/integrations/tracing-channel/langchain.ts index 008d52638241..a7663e7c3758 100644 --- a/packages/server-utils/src/integrations/tracing-channel/langchain.ts +++ b/packages/server-utils/src/integrations/tracing-channel/langchain.ts @@ -92,10 +92,10 @@ const _langChainChannelIntegration = ((options: LangChainOptions = {}) => { waitForTracingChannelBinding(() => { for (const channelName of langchainEmbeddingsChannels) { DEBUG_BUILD && debug.log(`[orchestrion:langchain] subscribing to channel "${channelName}"`); - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(channelName), - data => createEmbeddingsSpan(data, options), - { captureError: () => ({ mechanism: { handled: false, type: 'auto.ai.langchain' } }) }, + // Embedding errors reject to the caller, so we only open the span (which + // bindTracingChannelToSpan still marks failed on error) and do not capture them. + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(channelName), data => + createEmbeddingsSpan(data, options), ); } }); diff --git a/packages/server-utils/src/orchestrion/apmTypes.ts b/packages/server-utils/src/orchestrion/apmTypes.ts new file mode 100644 index 000000000000..0df0a57d9069 --- /dev/null +++ b/packages/server-utils/src/orchestrion/apmTypes.ts @@ -0,0 +1,165 @@ +// Vendored copies of the `@apm-js-collab/code-transformer` / +// `@apm-js-collab/code-transformer-bundler-plugins` types that appear in this package's public API. +// Those packages are bundled devDependencies, so the emitted `build/types` declarations must not +// reference them — consumers don't have them installed and their `tsc` would fail with TS2307. + +/** The kind of function */ +export type FunctionKind = 'Sync' | 'Async' | 'Callback' | 'Auto'; + +/** Describes which function to instrument */ +export type FunctionQuery = + | { className: string; methodName: string; kind: FunctionKind; index?: number | null; isExportAlias?: boolean } + | { className: string; privateMethodName: string; kind: FunctionKind; index?: number | null } + | { className: string; index?: number | null; isExportAlias?: boolean } + | { methodName: string; kind: FunctionKind; index?: number | null } + | { functionName: string; kind: FunctionKind; index?: number | null; isExportAlias?: boolean } + | { expressionName: string; kind: FunctionKind; index?: number | null; isExportAlias?: boolean }; + +/** + * A custom transform function registered via `addTransform`. Receives the instrumentation state + * and the matched AST node. + * + * Upstream types the node parameters with estree's `Node`; here they are `unknown` so the shipped + * declarations don't depend on `@types/estree` being installed. + */ +export type CustomTransform = (state: unknown, node: unknown, parent: unknown, ancestry: unknown[]) => void; + +/** + * The behaviour-only fields of a `FunctionQuery`. Used together with `astQuery`, where the raw + * selector chooses the node and these fields drive how it is wrapped (the name-based matching + * fields are ignored). + */ +export interface FunctionBehavior { + kind?: FunctionKind; + index?: number | null; + callbackIndex?: number; + mutableResult?: boolean; +} + +/** Describes the module and file path you would like to match */ +export interface ModuleMatcher { + /** The name of the module you want to match */ + name: string; + /** The semver range that you want to match */ + versionRange: string; + /** The path of the file you want to match from the module root */ + filePath: string | RegExp; +} + +/** + * Configuration for injecting instrumentation code. + * + * Either `functionQuery` (name-based matching) or `astQuery` (a raw esquery selector) must + * identify the node(s) to instrument. When `astQuery` is set it takes precedence over + * `functionQuery`'s matching fields, and `functionQuery` becomes an optional bag of behaviour + * options ({@link FunctionBehavior}). + */ +export type InstrumentationConfig = + | { + /** The name of the diagnostics channel to publish to */ + channelName: string; + /** The module matcher to identify the module and file to instrument */ + module: ModuleMatcher; + /** The function query to identify the function to instrument */ + functionQuery: FunctionQuery; + /** + * A raw esquery selector that chooses the node(s) to instrument. When set, it takes + * precedence over `functionQuery`'s matching fields. + */ + astQuery?: string; + /** + * The name of a custom transform registered via `addTransform`. When set, takes precedence + * over `functionQuery.kind`. + */ + transform?: string; + } + | { + channelName: string; + module: ModuleMatcher; + /** + * A raw esquery selector that chooses the node(s) to instrument. This is the escape hatch + * for shapes the name-based `functionQuery` can't express, e.g. an anonymous arrow returned + * by a factory function. + */ + astQuery: string; + /** Behaviour options for the matched node(s); matching fields are ignored. */ + functionQuery?: FunctionBehavior; + transform?: string; + }; + +/** + * A plain-object encoding of a `RegExp` that survives JSON serialization. Revive it with + * `new RegExp(source, flags)`. + */ +export interface SerializedRegExp { + type: 'RegExp'; + source: string; + flags: string; +} + +/** + * An `InstrumentationConfig` whose `module.filePath` is never a `RegExp` instance — regexes are + * encoded as {@link SerializedRegExp} — making the whole config a POJO that can cross + * serialization boundaries such as Turbopack's loader options. + */ +export type SerializableInstrumentationConfig = InstrumentationConfig extends infer T + ? T extends { module: InstrumentationConfig['module'] } + ? Omit & { module: Omit & { filePath: string | SerializedRegExp } } + : never + : never; + +/** Either the native config shape or its JSON-safe counterpart. */ +export type AnyInstrumentationConfig = InstrumentationConfig | SerializableInstrumentationConfig; + +/** Diagnostics passed to the `injectDiagnostics` callback. */ +export interface TransformDiagnostics { + transformedModules: string[]; + failedModules: string[]; +} + +/** + * A matcher for module ids, mirroring the shape accepted by the bundler transform hook filter + * (Rollup >= 4.38, Rolldown, Vite). A single string/RegExp (or array) is treated as an `include`; + * the object form allows both. + */ +export type TransformIdFilter = + | string + | RegExp + | Array + | { + include?: string | RegExp | Array; + exclude?: string | RegExp | Array; + }; + +/** Options accepted by the code-transformer bundler plugins. */ +export interface CodeTransformerPluginOptions { + /** Array of instrumentation configurations */ + instrumentations: InstrumentationConfig[]; + /** Optional path to a polyfill module for diagnostics_channel */ + dcModule?: string; + /** Optional callback that that injects the code returned */ + injectDiagnostics?: (diagnostics: TransformDiagnostics) => string | undefined; + /** + * Custom transforms registered on the matcher via orchestrion's `addTransform`. An + * `InstrumentationConfig` opts in by naming one of these in its `transform` field; the function + * is then called for every AST node matched by that config's `functionQuery`/`astQuery` with + * `(state, node, parent, ancestry)`, where `state` is the matched config spread together with + * `{ dcModule, moduleType, moduleVersion }`. + * + * A single transform can serve many configs — each invocation can branch on + * `state.module.name` or `state.channelName` to tell the sites apart. + */ + customTransforms?: Record; + /** + * Restricts which modules the transform hook runs on, via the bundler's hook filter + * (Rollup >= 4.38, Rolldown, Vite). All built-in instrumentations live within `node_modules`, + * which is the default. Provide your own matcher to broaden or narrow this — e.g. to also + * transform your own source — or pass `false` to disable filtering entirely. + * + * Bundlers without hook-filter support (esbuild, webpack) ignore this; the transformer skips + * non-matching modules regardless. + * + * @default /node_modules/ + */ + transformFilter?: TransformIdFilter | false; +} diff --git a/packages/server-utils/src/orchestrion/bundler/esbuild.ts b/packages/server-utils/src/orchestrion/bundler/esbuild.ts index 307e5e78a033..7215c48c7f3c 100644 --- a/packages/server-utils/src/orchestrion/bundler/esbuild.ts +++ b/packages/server-utils/src/orchestrion/bundler/esbuild.ts @@ -1,4 +1,5 @@ import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/esbuild'; +import type { Plugin } from 'esbuild'; import { escapeStringForRegex } from '@sentry/core'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; @@ -28,7 +29,7 @@ function matchesEsbuildExternal(entry: string, moduleName: string): boolean { * await esbuild.build({ plugins: [sentryOrchestrionPlugin()] }); * ``` */ -export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { +export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { const plugin = codeTransformer(orchestrionTransformOptions(options)); const moduleNames = instrumentedModuleNames(options.instrumentations); const setup = plugin.setup; diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index 7776c878bbf3..1d8efcc61532 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -1,7 +1,7 @@ import type { InstrumentationConfig, CustomTransform } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import { subscribeInjectionOptions } from './subscribeInjection'; -import type { CodeTransformerPluginOptions } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import type { CodeTransformerPluginOptions } from '../apmTypes'; export type PluginOptions = { /** diff --git a/packages/server-utils/src/orchestrion/bundler/rollup.ts b/packages/server-utils/src/orchestrion/bundler/rollup.ts index d42abede972c..f9262d9bd46e 100644 --- a/packages/server-utils/src/orchestrion/bundler/rollup.ts +++ b/packages/server-utils/src/orchestrion/bundler/rollup.ts @@ -1,5 +1,5 @@ import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; -import type { NormalizedInputOptions, PluginContext } from 'rollup'; +import type { NormalizedInputOptions, Plugin, PluginContext } from 'rollup'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalizedModulesWarning, orchestrionTransformOptions } from './options'; @@ -17,7 +17,7 @@ import { externalizedModulesWarning, orchestrionTransformOptions } from './optio * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { +export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { const moduleNames = instrumentedModuleNames(options.instrumentations); return { diff --git a/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts b/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts index 9bb89257ab89..aae6df7be47c 100644 --- a/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts +++ b/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts @@ -1,4 +1,4 @@ -import type { CustomTransform } from '@apm-js-collab/code-transformer'; +import type { CustomTransform } from '../apmTypes'; import { parse } from 'meriyah'; import { SUBSCRIBE_INJECTIONS } from '../config'; import { subscriberExportForModule } from '../config/channel-integration-definitions'; @@ -10,6 +10,13 @@ import type { PluginOptions } from './options'; // once per file. A `WeakSet` keyed by the node avoids mutating the emitted AST. const injectedPrograms = new WeakSet(); +/** + * Assignment target that keeps the injected call from being tree-shaken. See + * {@link subscribeSnippet}. The value written is always `undefined`; only the + * assignment matters. + */ +const SUBSCRIBE_INJECTION_SINK = 'globalThis.__SENTRY_ORCHESTRION_INJECT__'; + interface ProgramNode { type: string; body: Array<{ type: string; directive?: string }>; @@ -28,13 +35,20 @@ interface ProgramNode { * "only-active-when-bundled" property the runtime module hook gives unbundled * Node, but without a hook (workerd can't monkey-patch requires). The helper is * generic (references no factory), so importing it alongside doesn't pull siblings. + * + * The call result is assigned to a global rather than discarded. The helper + * returns `void` and `@sentry/server-utils` is `sideEffects: false`, so a bare + * call statement is something a bundler can prove droppable: rollup >= 4.63.0 + * does exactly that and removes the whole registration, leaving the module + * instrumented but unsubscribed. Writing to a property of `globalThis` is a + * side effect no bundler can shake out, so the call survives. */ function subscribeSnippet(exportName: string, esm: boolean): string { const importStmt = esm ? `import { ${exportName}, registerOrchestrionChannelIntegration } from '@sentry/server-utils/orchestrion';` : `const { ${exportName}, registerOrchestrionChannelIntegration } = require('@sentry/server-utils/orchestrion');`; - return `${importStmt}\nregisterOrchestrionChannelIntegration(${JSON.stringify(exportName)}, ${exportName});`; + return `${importStmt}\n${SUBSCRIBE_INJECTION_SINK} = registerOrchestrionChannelIntegration(${JSON.stringify(exportName)}, ${exportName});`; } /** diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 0b586f0b682c..dc3426e9ba02 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -1,5 +1,5 @@ import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; -import type { ResolvedConfig } from 'vite'; +import type { Plugin, ResolvedConfig } from 'vite'; import { instrumentedModuleNames } from '../config'; import type { PluginOptions } from './options'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; @@ -18,9 +18,15 @@ import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTran * export default { plugins: [sentryOrchestrionPlugin()] }; * ``` */ -export function sentryOrchestrionPlugin(options: PluginOptions = {}): ReturnType { +export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return { ...codeTransformer(orchestrionTransformOptions(options)), + applyToEnvironment(environment) { + // Orchestrion splices `node:diagnostics_channel` calls into instrumented modules, which only + // exist server-side. Only apply to server-consumed environments so injected `tracingChannel` + // calls never land in a browser (`client`) bundle (where they'd throw `X is not a function`). + return environment.config.consumer === 'server'; + }, config(): { ssr: { noExternal: string[] } } { // Force-bundle every instrumented package so the code transform actually // sees its source. Vite externalizes dependencies in SSR builds by diff --git a/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts new file mode 100644 index 000000000000..26de2b44d5c6 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/webpack-loader.ts @@ -0,0 +1,13 @@ +// EXPERIMENTAL — the webpack/Turbopack code-transform loader, re-exported so it compiles into this +// package's build (the `@apm-js-collab` packages are bundled devDependencies and not resolvable on +// user installs). Bundlers reference it by on-disk path via `getOrchestrionLoaderPath()`, so it +// needs its own entrypoint/subpath rather than being reachable from another module. +import codeTransformerLoaderImpl from '@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'; + +// Explicitly typed so the emitted declaration doesn't reference the bundled devDependency. +// (Nothing imports this subpath from TS — bundlers load it by file path — so the loose +// signature is never consumed.) +const codeTransformerLoader: (this: unknown, code: string, inputSourceMap?: unknown) => void = + codeTransformerLoaderImpl; + +export default codeTransformerLoader; diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 74c63f29633e..cb608b55b2e6 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -2,17 +2,22 @@ // separately because Turbopack can only take webpack loaders (via `turbopack.rules`), not plugins. import { createRequire } from 'node:module'; -import { dirname } from 'node:path'; import type { Compiler } from 'webpack'; import type { InstrumentationConfig } from '..'; import { instrumentedModuleNames, SENTRY_INSTRUMENTATIONS } from '../config'; import codeTransformerWebpack from '@apm-js-collab/code-transformer-bundler-plugins/webpack'; import type { PluginOptions } from './options'; -export { serializeInstrumentations } from '@apm-js-collab/code-transformer-bundler-plugins/core'; -export type { SerializableInstrumentationConfig } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import { serializeInstrumentations as serializeInstrumentationsImpl } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import type { AnyInstrumentationConfig, SerializableInstrumentationConfig } from '../apmTypes'; import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options'; +// Explicitly annotated with the vendored types so the emitted declaration doesn't reference +// `@apm-js-collab/code-transformer-bundler-plugins` — a bundled devDependency consumers don't have. +export const serializeInstrumentations: (configs: AnyInstrumentationConfig[]) => SerializableInstrumentationConfig[] = + serializeInstrumentationsImpl; +export type { SerializableInstrumentationConfig } from '../apmTypes'; + // Both branches use `createRequire` (never alias the CJS `require`) so bundlers consuming this // module don't emit a "Critical dependency" warning. function getOrchestrionRequire(): ReturnType { @@ -26,21 +31,31 @@ function getOrchestrionRequire(): ReturnType { return nodeRequire; } -/** Absolute path to the code-transform loader (a webpack loader; also usable as a Turbopack loader). */ +/** + * Absolute path to the code-transform loader (a webpack loader; also usable as a Turbopack loader). + * Resolved via self-reference to this package's own bundled copy — the `@apm-js-collab` packages + * are bundled devDependencies and not resolvable on user installs. + */ export function getOrchestrionLoaderPath(): string { - return getOrchestrionRequire().resolve('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader'); + return getOrchestrionRequire().resolve('@sentry/server-utils/orchestrion/webpack-loader'); } /** - * Absolute path to the `@apm-js-collab/tracing-hooks` package directory, resolved from this - * package's own dependency graph. SDKs inject it at build time so the runtime module hook can - * load the package even where the bare specifier doesn't resolve (bundled SDK code under - * isolated installs, e.g. pnpm). + * Resolves a request for one of the orchestrion runtime packages (`@sentry/server-utils` itself, via + * self-reference, or its `@apm-js-collab/*` dependencies) to an absolute path, from this package's + * own on-disk location — where the whole dependency graph always resolves, regardless of the + * consuming app's install layout. Returns `undefined` when the request can't be resolved. + * + * Bundler configs use this to emit absolute-path `commonjs` externals: a bare-specifier external + * emitted into a bundled chunk resolves from the chunk's output location at runtime, which fails + * under isolated installs (pnpm) where these packages are transitive dependencies. */ -export function getTracingHooksDirectory(): string { - const packageJsonPath = getOrchestrionRequire().resolve('@apm-js-collab/tracing-hooks/package.json'); - // This avoids any backslash-escaping concerns on Windows - return dirname(packageJsonPath).replace(/\\/g, '/'); +export function resolveOrchestrionRuntimeRequest(request: string): string | undefined { + try { + return getOrchestrionRequire().resolve(request); + } catch { + return undefined; + } } /** The central instrumentation config, to pass as the loader's `instrumentations` option. */ @@ -69,13 +84,34 @@ function externalizedWebpackModules(externals: unknown, moduleNames: string[]): ); } +// The upstream plugin computes its loader path relative to its own file location, which after +// bundling points into our `vendored/` tree at a file rollup never emitted. Replace it in the +// rule the plugin just unshifted with our own bundled loader entrypoint. +function fixupLoaderPath(compiler: Compiler): void { + for (const rule of compiler.options.module?.rules ?? []) { + if (!rule || typeof rule !== 'object' || !('use' in rule) || !Array.isArray(rule.use)) { + continue; + } + for (const use of rule.use) { + if ( + use && + typeof use === 'object' && + typeof use.loader === 'string' && + use.loader.endsWith('webpack-loader.cjs') + ) { + use.loader = getOrchestrionLoaderPath(); + } + } + } +} + /** * The code-transform webpack plugin, pre-fed the instrumentation config. * * Instrumented packages marked as `externals` never pass through the code * transform, so a compilation warning is emitted for them. */ -export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): ReturnType { +export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): { apply(compiler: Compiler): void } { const plugin = codeTransformerWebpack(orchestrionTransformOptions(options)); const moduleNames = instrumentedModuleNames(options.instrumentations); // The upstream plugin is a class instance, so `apply` is overridden in place @@ -89,6 +125,7 @@ export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): Ret }); } apply(compiler); + fixupLoaderPath(compiler); }; return plugin; } diff --git a/packages/server-utils/src/orchestrion/config/aws-sdk.ts b/packages/server-utils/src/orchestrion/config/aws-sdk.ts index d9e6bf35726d..a6d1d73fe5dd 100644 --- a/packages/server-utils/src/orchestrion/config/aws-sdk.ts +++ b/packages/server-utils/src/orchestrion/config/aws-sdk.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '../apmTypes'; import { toSubscribeInjections } from './subscribe-injection'; // The AWS SDK (v3) routes every command through the smithy `Client.prototype.send` method. Which diff --git a/packages/server-utils/src/orchestrion/config/koa.ts b/packages/server-utils/src/orchestrion/config/koa.ts index 8e4ddfab0fff..5699f2bab8b0 100644 --- a/packages/server-utils/src/orchestrion/config/koa.ts +++ b/packages/server-utils/src/orchestrion/config/koa.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '../apmTypes'; import { toSubscribeInjections } from './subscribe-injection'; export const koaConfig = [ diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index 9055cf0bb1ab..6e465018ac70 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -7,25 +7,25 @@ export const openaiConfig = [ // `filePath` exactly, hence one entry per built file (`.js` for `require`, `.mjs` for `import`). ...['resources/chat/completions/completions.js', 'resources/chat/completions/completions.mjs'].map(filePath => ({ channelName: 'chat', - module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, + module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, })), // OpenAI responses API — same `create(body, options)` shape as chat completions. ...['resources/responses/responses.js', 'resources/responses/responses.mjs'].map(filePath => ({ channelName: 'chat', - module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, + module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, functionQuery: { className: 'Responses', methodName: 'create', kind: 'Auto' as const }, })), // OpenAI embeddings API — same `create(body, options)` shape as chat completions. ...['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ channelName: 'embeddings', - module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, + module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Auto' as const }, })), // OpenAI conversations API — same `create(body, options)` shape as chat completions. ...['resources/conversations/conversations.js', 'resources/conversations/conversations.mjs'].map(filePath => ({ channelName: 'chat', - module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, + module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Auto' as const }, })), ] satisfies InstrumentationConfig[]; diff --git a/packages/server-utils/src/orchestrion/config/subscribe-injection.ts b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts index 129fc637feaf..43faff7313e1 100644 --- a/packages/server-utils/src/orchestrion/config/subscribe-injection.ts +++ b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '../apmTypes'; /** * Name shared by the `Program` injection configs (their `transform` field) and diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index b27f74b9b28d..941fd474a7b5 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -72,7 +72,7 @@ export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../i export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracing-channel/postgres-js'; export { redisChannelIntegration } from '../integrations/tracing-channel/redis'; export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integrations/tracing-channel/redis'; -export type { InstrumentationConfig, CustomTransform } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +export type { InstrumentationConfig, CustomTransform } from './apmTypes'; // The structural `graphql` package types are the single source of truth shared with `@sentry/node`'s // vendored OTel graphql instrumentation (re-exported from here so the two can't drift). diff --git a/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts new file mode 100644 index 000000000000..24a6b6a58a0f --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/apm-js-collab-tracing-hooks.d.ts @@ -0,0 +1,35 @@ +// Ambient declarations for `@apm-js-collab/tracing-hooks`, which ships no types of its own. + +declare module '@apm-js-collab/tracing-hooks' { + type InstrumentationConfig = unknown; + + type PatchConfig = { instrumentations: InstrumentationConfig[] }; + + /** Patches `Module.prototype._compile` to transform CJS modules as they load. */ + export default class ModulePatch { + public constructor(config?: PatchConfig); + public patch(): void; + public unpatch(): void; + } +} + +declare module '@apm-js-collab/tracing-hooks/lib/diagnostics.js' { + type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; + + export function setDiagnosticsHook(callback: (event: DiagnosticsEvent) => void): void; + export function emitDiagnostics(event: DiagnosticsEvent): void; +} + +declare module '@apm-js-collab/tracing-hooks/hook-sync.mjs' { + import type { MessagePort } from 'node:worker_threads'; + import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + + type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; + type InitializeData = { instrumentations?: InstrumentationConfig[]; diagnosticsPort?: MessagePort }; + + export function initialize(data?: InitializeData): void; + export function resolve(specifier: string, context: unknown, nextResolve: Function): unknown; + export function load(url: string, context: unknown, nextLoad: Function): unknown; + export function setDiagnosticsHook(callback: (event: DiagnosticsEvent) => void): void; + export function createDiagnosticsPort(): MessagePort; +} diff --git a/packages/server-utils/src/orchestrion/runtime/hook.mjs b/packages/server-utils/src/orchestrion/runtime/hook.mjs new file mode 100644 index 000000000000..776fed04537d --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/hook.mjs @@ -0,0 +1,10 @@ +// EXPERIMENTAL — the async module hooks handed to `Module.register()` by +// `registerDiagnosticsChannelInjection()` (Node 18.19–24.12, where the stable sync +// `Module.registerHooks` API isn't available). +// +// `Module.register()` loads its target on Node's ESM loader thread, so the target must be a real, +// on-disk ES module graph — the loader thread cannot resolve bare specifiers into the dependency +// graph this package bundles away, but it can follow relative imports. This shim is therefore an +// entrypoint of the regular ESM build (sharing the vendored dependency chunks) and exposed via the +// `@sentry/server-utils/orchestrion/hook` subpath. +export * from '@apm-js-collab/tracing-hooks/hook.mjs'; diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index bd559b2a6e48..485d4613a1d8 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -1,53 +1,35 @@ -import { debug, GLOBAL_OBJ } from '@sentry/core'; -import { createRequire } from 'node:module'; +import { debug, GLOBAL_OBJ, parseSemver } from '@sentry/core'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; -import { isMainThread, MessageChannel, parentPort } from 'node:worker_threads'; +import { isMainThread, parentPort } from 'node:worker_threads'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import type { register } from 'node:module'; -import type { InstrumentationConfig } from '..'; - -type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; - -type TracingHooksSync = { - initialize: (opts: { instrumentations: InstrumentationConfig[] }) => void; - resolve: Function; - load: Function; -}; - -type TracingHooksDiagnostics = { - setDiagnosticsHook: (callback: (event: DiagnosticsEvent) => void) => void; -}; +import ModulePatch from '@apm-js-collab/tracing-hooks'; +import { initialize, load, resolve, createDiagnosticsPort } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'; +import { setDiagnosticsHook } from '@apm-js-collab/tracing-hooks/lib/diagnostics.js'; type NodeModule = { - registerHooks?: (options: unknown) => { deregister: () => void }; + registerHooks?: (options: { load: Function; resolve: Function }) => { deregister: () => void }; register?: typeof register; }; export interface RegisterDiagnosticsChannelInjectionOptions { /** - * Absolute directory of the `@apm-js-collab/tracing-hooks` package (forward slashes). - * - * Needed when SDK code is bundled into an app's server build: the default bare-specifier - * require then resolves from the emitted chunk, which fails under isolated installs (pnpm). - * Framework SDKs (e.g. `@sentry/nextjs`) resolve the package at build time and pass its - * location here; it is loaded through an opaque `createRequire` that bundlers can't trace. + * @deprecated No longer used and ignored. The `@apm-js-collab/tracing-hooks` runtime is compiled + * into this package's build, so there is no package location left to point the module hook at. */ tracingHooksDir?: string; } /** `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. */ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolean { - const parseVersion = (v: string): number[] => v.split('.').map(n => parseInt(n, 10)); - const nodeVersion = parseVersion(process.versions.node ?? '0.0.0'); - const denoVersion = parseVersion(denoVersionString ?? '0.0.0'); - return ( - (nodeVersion[0] ?? 0) > 25 || - (nodeVersion[0] === 25 && (nodeVersion[1] ?? 0) >= 1) || - (nodeVersion[0] === 24 && (nodeVersion[1] ?? 0) >= 13) || - (denoVersion[0] ?? 0) > 2 || - (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8) - ); + if (denoVersionString) { + const { major = 0, minor = 0 } = parseSemver(denoVersionString); + return major > 2 || (major === 2 && minor >= 8); + } + + const { major = 0, minor = 0 } = parseSemver(process.versions.node ?? '0.0.0'); + return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); } /** @@ -62,7 +44,7 @@ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolea * Libraries imported *after* this call publish the `tracingChannel` events that * the channel-based integrations subscribe to. */ -export function registerDiagnosticsChannelInjection(options?: RegisterDiagnosticsChannelInjectionOptions): void { +export function registerDiagnosticsChannelInjection(_options?: RegisterDiagnosticsChannelInjectionOptions): void { // Skip Node's internal loader (hooks) threads, recognizable as the only threads without a // `parentPort`. Node re-runs `--require` preloads (though not `--import` ones) on the loader // thread it spawns for `Module.register()`, so this function runs there too — but that thread @@ -82,74 +64,27 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic const globalAny = globalThis as { Bun?: unknown; Deno?: { version?: { deno?: string } } }; const stableSyncHooks = hasStableSyncModuleHooks(globalAny.Deno?.version?.deno); - let thisModuleUrl: string; - /*! rollup-include-cjs-only */ - thisModuleUrl = pathToFileURL(__filename).href; - /*! rollup-include-cjs-only-end */ - /*! rollup-include-esm-only */ - thisModuleUrl = import.meta.url; - /*! rollup-include-esm-only-end */ - - // Default: bare specifiers via a plain (aliased) `require`, so bundlers see and resolve them - // like any other dependency. Override: with `tracingHooksDir`, absolute paths are loaded through - // `createRequire`, which bundlers leave as a true runtime require — they must not statically - // resolve these (Turbopack fails the build on an absolute request, and the machinery breaks when - // bundled anyway). `createRequire` rather than ignore-comments because webpack only honors - // `webpackIgnore` on `import()`, not `require()` (it compiles the call to a broken module stub). - let nodeRequire: (specifier: string) => unknown; - /*! rollup-include-cjs-only */ - nodeRequire = require; - /*! rollup-include-cjs-only-end */ - /*! rollup-include-esm-only */ - nodeRequire = createRequire(import.meta.url); - /*! rollup-include-esm-only-end */ - - const tracingHooksDir = options?.tracingHooksDir; - const requireFromHooksDir = tracingHooksDir ? createRequire(thisModuleUrl) : undefined; - // `Module.registerHooks` / `Module.register` are newer than the @types/node // we build against, hence the cast. const mod = Module as NodeModule; + setDiagnosticsHook(({ moduleName, error }): void => { + if (error) { + debug.warn(`[orchestrion] failed to inject diagnostics-channel into ${moduleName}:`, error); + } else { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); + } + }); + // runs both at `--import` time and (synchronously) inside `Sentry.init()`, // so an unguarded throw would either abort startup or make `init()` throw. // On any failure (e.g. dep resolution, `require(esm)` / Node-compat // incompatibility) we warn (DEBUG only) and continue without channel // injection try { - // `lib/diagnostics.js` is plain CJS, so unlike the ESM hook entry points it can be - // require()d on every supported Node version. It holds the hook state shared by - // everything that can transform a module on this thread (the sync ESM hooks and the - // `_compile` patch), so setting the hook once here covers both branches below. - const { setDiagnosticsHook } = ( - requireFromHooksDir - ? requireFromHooksDir(`${tracingHooksDir}/lib/diagnostics.js`) - : nodeRequire('@apm-js-collab/tracing-hooks/lib/diagnostics.js') - ) as TracingHooksDiagnostics; - - const onDiagnostics = ({ moduleName, error }: DiagnosticsEvent): void => { - if (error) { - debug.warn(`[orchestrion] failed to inject diagnostics-channel into ${moduleName}:`, error); - } else { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); - } - }; - - setDiagnosticsHook(onDiagnostics); - if (typeof mod.registerHooks === 'function' && stableSyncHooks) { - // Sync hooks cover CJS and ESM, no separate `_compile` patch needed. - // We require() this ESM module so that we can synchronously load it, - // including from a CommonJS Sentry build; all versions in - // stableSyncHooks support require(esm). - const { initialize, resolve, load } = ( - requireFromHooksDir - ? requireFromHooksDir(`${tracingHooksDir}/hook-sync.mjs`) - : nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') - ) as TracingHooksSync; - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); mod.registerHooks({ resolve, load }); debug.log('Registered diagnostics-channel injection via Module.registerHooks()'); @@ -157,26 +92,23 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 // path. Bun/Deno are excluded: they don't support this combination and // must use the stable `registerHooks` path above (or none at all). - // `Module.register` resolves ESM-style: a bare package specifier is resolved against - // `parentURL`, but a filesystem path (the `tracingHooksDir` override) is not a valid ESM - // specifier and must be passed as a file:// URL. - const hookSpecifier = tracingHooksDir - ? pathToFileURL(`${tracingHooksDir}/hook.mjs`).href - : '@apm-js-collab/tracing-hooks/hook.mjs'; - - // The `Module.register` hooks run on a loader thread with its own copy of - // `lib/diagnostics.js`, so the hook set above never fires there; the loader thread - // posts diagnostics back over a MessagePort instead. This replicates - // `createDiagnosticsPort` from hook.mjs, which is ESM and therefore not - // synchronously loadable on all Node versions that take this branch. - const { port1, port2 } = new MessageChannel(); - port1.on('message', onDiagnostics); - // The diagnostics channel must not keep the process alive. - port1.unref(); - mod.register(hookSpecifier, { - parentURL: thisModuleUrl, - data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort: port2 }, - transferList: [port2], + const diagnosticsPort = createDiagnosticsPort(); + + let parentURL: string; + /*! rollup-include-cjs-only */ + parentURL = pathToFileURL(__filename).href; + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + parentURL = import.meta.url; + /*! rollup-include-esm-only-end */ + + // Our own bundled copy of the tracing-hooks async hooks (see + // `src/orchestrion/runtime/hook.mjs`) — the dependency itself is bundled into this package's + // build and no longer resolvable as a bare specifier at runtime. + mod.register('@sentry/server-utils/orchestrion/hook', { + parentURL, + data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort }, + transferList: [diagnosticsPort], }); // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM @@ -184,13 +116,6 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // are resolved through the CJS machinery and never reach the ESM // register hook, so without this patch the file we want to instrument // loads untransformed. - const ModulePatch = ( - requireFromHooksDir && tracingHooksDir - ? requireFromHooksDir(tracingHooksDir) - : nodeRequire('@apm-js-collab/tracing-hooks') - ) as new (opts: { instrumentations: unknown }) => { - patch: () => void; - }; new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); debug.log('Registered diagnostics-channel injection via Module.register()'); } else { diff --git a/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts index 0b9e7f39e101..fead1c746383 100644 --- a/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts @@ -28,6 +28,7 @@ import { import { GEN_AI_EXECUTE_TOOL_SPAN_OP, GEN_AI_INVOKE_AGENT_SPAN_OP } from '@sentry/conventions/op'; import type { Span, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_skipAiProviderWrapping, captureException, GEN_AI_CONVERSATION_ID_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, @@ -66,6 +67,8 @@ const GEN_AI_RERANK_OPERATION = 'rerank'; // The model-call op matches the Vercel AI OTel integration (`gen_ai.generate_content`) rather than // the generic `gen_ai.chat`, so v6 (OTel) and v7 (channel) produce the same spans. const GEN_AI_GENERATE_CONTENT_OPERATION = 'generate_content'; +// TODO(v11): export the constant from server-utils and import it here instead. +const WORKERS_AI_INTEGRATION_NAME = 'WorkersAI'; // Subset of the `vercel.ai.*` passthrough attributes the OTel integration emits that we reproduce. const VERCEL_AI_OPERATION_ID_ATTRIBUTE = 'vercel.ai.operationId'; @@ -395,6 +398,8 @@ export function createSpanFromMessage( // the OTel path derives from the SDK's Zod schema is not reconstructed on the channel path. return buildInvokeAgentSpan(event, baseAttributes, recordInputs, enableTruncation, callId, type === 'streamText'); case 'languageModelCall': + _INTERNAL_skipAiProviderWrapping([WORKERS_AI_INTEGRATION_NAME]); + return buildModelCallSpan(event, baseAttributes, recordInputs, enableTruncation, callId, modelId); case 'executeTool': return buildToolSpan(event, recordInputs); diff --git a/packages/server-utils/test/integrations/tracing-channel/fastify-errors.test.ts b/packages/server-utils/test/integrations/tracing-channel/fastify-errors.test.ts new file mode 100644 index 000000000000..521be21ab282 --- /dev/null +++ b/packages/server-utils/test/integrations/tracing-channel/fastify-errors.test.ts @@ -0,0 +1,105 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { handleFastifyError } from '../../../src/integrations/tracing-channel/fastify/errors'; +import { fastifyIntegration } from '../../../src/integrations/tracing-channel/fastify/index'; +import type { FastifyReply, FastifyRequest } from '../../../src/integrations/tracing-channel/fastify/types'; + +type ShouldHandleError = (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean; + +const request = {} as FastifyRequest; + +function reply(statusCode: number): FastifyReply { + return { statusCode } as FastifyReply; +} + +/** Register a `Fastify` integration exposing `shouldHandleError`, as `fastifyIntegration()` does. */ +function mockFastifyIntegration(shouldHandleError?: ShouldHandleError): void { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getIntegrationByName: (name: string) => + name === 'Fastify' ? { name, getShouldHandleError: () => shouldHandleError } : undefined, + } as unknown as SentryCore.Client); +} + +describe('handleFastifyError', () => { + let captureExceptionSpy: MockInstance; + + beforeEach(() => { + captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('eventId'); + // `handleFastifyError` keeps `diagnosticsChannelExists` on the function object itself, so it + // survives between tests unless it is cleared. + (handleFastifyError as { diagnosticsChannelExists?: boolean }).diagnosticsChannelExists = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // The `onError` hook is the path `setupFastifyErrorHandler` registers — Fastify v3 and v4. + describe('via the `onError` hook', () => { + it('skips the error when the integration option returns false', () => { + mockFastifyIntegration(() => false); + + handleFastifyError.call(handleFastifyError, new Error('err'), request, reply(500), 'onError-hook'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('captures the error when the integration option returns true', () => { + mockFastifyIntegration(() => true); + + handleFastifyError.call(handleFastifyError, new Error('err'), request, reply(404), 'onError-hook'); + + expect(captureExceptionSpy).toHaveBeenCalledOnce(); + }); + }); + + // Fastify v5 publishes errors on a diagnostics channel, which the integration subscribes to. + describe('via the diagnostics channel', () => { + it('skips the error when the integration option returns false', () => { + mockFastifyIntegration(() => false); + + handleFastifyError.call(handleFastifyError, new Error('err'), request, reply(500), 'diagnostics-channel'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('captures the error when the integration option returns true', () => { + mockFastifyIntegration(() => true); + + handleFastifyError.call(handleFastifyError, new Error('err'), request, reply(404), 'diagnostics-channel'); + + expect(captureExceptionSpy).toHaveBeenCalledOnce(); + }); + }); + + it('falls back to the default gate when the integration sets no option', () => { + mockFastifyIntegration(undefined); + + handleFastifyError.call(handleFastifyError, new Error('client'), request, reply(404), 'onError-hook'); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + + handleFastifyError.call(handleFastifyError, new Error('server'), request, reply(503), 'onError-hook'); + expect(captureExceptionSpy).toHaveBeenCalledOnce(); + }); +}); + +describe('fastifyIntegration', () => { + it('exposes the configured `shouldHandleError` so the `onError` hook can read it back', () => { + const shouldHandleError: ShouldHandleError = () => false; + const integration = fastifyIntegration({ shouldHandleError }); + + integration.setupOnce?.(); + + expect(integration.getShouldHandleError()).toBe(shouldHandleError); + }); + + it('falls back to the default gate when no option is configured', () => { + const integration = fastifyIntegration(); + + integration.setupOnce?.(); + + const gate = integration.getShouldHandleError(); + expect(gate(new Error('client'), request, reply(404))).toBe(false); + expect(gate(new Error('server'), request, reply(503))).toBe(true); + }); +}); diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index f13209fba323..ee1c746fc6de 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { isAbsolute } from 'node:path'; import type { OnStartResult, PluginBuild } from 'esbuild'; import type { NormalizedInputOptions, PluginContext } from 'rollup'; import type { ResolvedConfig } from 'vite'; @@ -8,7 +8,10 @@ import { describe, expect, it, vi } from 'vitest'; import { sentryOrchestrionPlugin as esbuildPlugin } from '../../src/orchestrion/bundler/esbuild'; import { sentryOrchestrionPlugin as rollupPlugin } from '../../src/orchestrion/bundler/rollup'; import { sentryOrchestrionPlugin as vitePlugin } from '../../src/orchestrion/bundler/vite'; -import { getTracingHooksDirectory, sentryOrchestrionWebpackPlugin } from '../../src/orchestrion/bundler/webpack'; +import { + resolveOrchestrionRuntimeRequest, + sentryOrchestrionWebpackPlugin, +} from '../../src/orchestrion/bundler/webpack'; // The upstream transform plugins are mocked so tests exercise only the hooks // added on top of them (the externalized-modules warnings). @@ -136,14 +139,31 @@ describe('sentryOrchestrionPlugin (vite)', () => { }); }); -describe('getTracingHooksDirectory', () => { - it('returns the tracing-hooks package directory with the runtime hook entry points', () => { - const dir = getTracingHooksDirectory(); +describe('resolveOrchestrionRuntimeRequest', () => { + it.each([ + // Self-references — resolve through this package's own exports map to the CJS build. + '@sentry/server-utils/orchestrion/register', + '@sentry/server-utils/orchestrion', + // Dependencies of this package, including subpaths only reachable from its location. + '@apm-js-collab/tracing-hooks', + '@apm-js-collab/tracing-hooks/hook.mjs', + '@apm-js-collab/tracing-hooks/hook-sync.mjs', + '@apm-js-collab/tracing-hooks/lib/diagnostics.js', + '@apm-js-collab/code-transformer', + ])('resolves %s to an existing absolute path', request => { + const resolved = resolveOrchestrionRuntimeRequest(request); + + expect(resolved).toBeDefined(); + expect(isAbsolute(resolved!)).toBe(true); + expect(existsSync(resolved!)).toBe(true); + }); + + it('resolves self-references with require conditions, so the paths are loadable via require()', () => { + expect(resolveOrchestrionRuntimeRequest('@sentry/server-utils/orchestrion/register')).toMatch(/[/\\]cjs[/\\]/); + }); - expect(dir).not.toContain('\\'); - // The runtime module hook loads these files by joining them onto the directory. - expect(existsSync(join(dir, 'hook-sync.mjs'))).toBe(true); - expect(existsSync(join(dir, 'hook.mjs'))).toBe(true); - expect(existsSync(join(dir, 'package.json'))).toBe(true); + it('returns undefined for unresolvable requests', () => { + expect(resolveOrchestrionRuntimeRequest('@sentry/server-utils/no-such-subpath')).toBeUndefined(); + expect(resolveOrchestrionRuntimeRequest('some-package-that-does-not-exist')).toBeUndefined(); }); }); diff --git a/packages/server-utils/test/orchestrion/bundlerBuildOutput.test.ts b/packages/server-utils/test/orchestrion/bundlerBuildOutput.test.ts new file mode 100644 index 000000000000..04258099b948 --- /dev/null +++ b/packages/server-utils/test/orchestrion/bundlerBuildOutput.test.ts @@ -0,0 +1,38 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const nodeRequire = createRequire(import.meta.url); +const BUILD_CJS_DIR = resolve(__dirname, '../../build/cjs'); + +// The five entries share vendored chunks, and the require cache would keep a chunk's module scope +// from running again after the first test. Drop everything under `build/cjs` first, so each test +// really executes the code it claims to. +function requireFresh(entry: string): unknown { + for (const key of Object.keys(nodeRequire.cache)) { + if (key.startsWith(BUILD_CJS_DIR)) { + Reflect.deleteProperty(nodeRequire.cache, key); + } + } + return nodeRequire(resolve(BUILD_CJS_DIR, 'orchestrion/bundler', `${entry}.js`)); +} + +/** + * The bundler entries must load in Node even when a `document` global exists, which is the case + * under jsdom/happy-dom: the vendored code must never treat `document` as proof of a browser. + * Runs against `build/cjs` because that guard lives in the emitted code, not the sources. + * Reference Issue: https://github.com/getsentry/sentry-javascript/issues/23789 + */ +describe('built CJS bundler entries load under DOM test environments', () => { + afterEach(() => { + delete (globalThis as { document?: unknown }).document; + }); + + it.each(['webpack', 'webpack-loader', 'esbuild', 'vite', 'rollup'])( + 'build/cjs/orchestrion/bundler/%s.js loads while a `document` global is defined', + entry => { + (globalThis as { document?: unknown }).document = { baseURI: 'http://localhost:3000/' }; + expect(() => requireFresh(entry)).not.toThrow(); + }, + ); +}); diff --git a/packages/server-utils/test/orchestrion/subscribeInjection.test.ts b/packages/server-utils/test/orchestrion/subscribeInjection.test.ts index eaa94cf5173d..f6c595209068 100644 --- a/packages/server-utils/test/orchestrion/subscribeInjection.test.ts +++ b/packages/server-utils/test/orchestrion/subscribeInjection.test.ts @@ -75,6 +75,10 @@ describe('subscribe-injection transform option', () => { expect(result!.code).toContain( 'registerOrchestrionChannelIntegration("mysqlChannelIntegration", mysqlChannelIntegration)', ); + // The result is assigned to a global. `@sentry/server-utils` is `sideEffects: false` and the + // helper returns `void`, so a bare call statement is one a bundler can prove droppable. + // rollup >= 4.63.0 removes it, leaving the module instrumented but unsubscribed. + expect(result!.code).toContain('globalThis.__SENTRY_ORCHESTRION_INJECT__ = registerOrchestrionChannelIntegration('); // No separate @sentry/core import at the injection site — the helper owns that. expect(result!.code).not.toContain('@sentry/core'); // It imports ONLY the mysql factory — no central dispatch pulling in others. diff --git a/packages/server-utils/test/vercel-ai/skip-workers-ai.test.ts b/packages/server-utils/test/vercel-ai/skip-workers-ai.test.ts new file mode 100644 index 000000000000..b42565d595ee --- /dev/null +++ b/packages/server-utils/test/vercel-ai/skip-workers-ai.test.ts @@ -0,0 +1,81 @@ +import { + _INTERNAL_clearAiProviderSkips, + _INTERNAL_shouldSkipAiProviderWrapping, + Client, + createTransport, + getCurrentScope, + getGlobalScope, + getIsolationScope, + initAndBind, + resolvedSyncPromise, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createSpanFromMessage } from '../../src/vercel-ai/vercel-ai-dc-subscriber'; + +// Must match `WORKERS_AI_INTEGRATION_NAME` in core's `tracing/workers-ai/constants`. +const WORKERS_AI_INTEGRATION_NAME = 'WorkersAI'; + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +describe('vercel ai tracing channel: workers-ai dedup', () => { + beforeEach(() => { + _INTERNAL_clearAiProviderSkips(); + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + initTestClient(); + }); + + afterEach(() => { + _INTERNAL_clearAiProviderSkips(); + }); + + it('marks Workers AI as skipped on a model call, so the binding does not double-instrument', () => { + expect(_INTERNAL_shouldSkipAiProviderWrapping(WORKERS_AI_INTEGRATION_NAME)).toBe(false); + + const span = createSpanFromMessage( + { + type: 'languageModelCall', + event: { provider: 'workers-ai', modelId: '@cf/meta/llama-3.1-8b-instruct' }, + } as Parameters[0], + {} as Parameters[1], + ); + span?.end(); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(WORKERS_AI_INTEGRATION_NAME)).toBe(true); + }); + + it('does not mark Workers AI as skipped for tool calls', () => { + // A tool calling `env.AI.run` itself is a genuine separate inference the `ai` SDK does not + // instrument, so it must keep its own span. + const span = createSpanFromMessage( + { + type: 'executeTool', + event: { toolName: 'getWeather', toolCallId: 'call_1' }, + } as Parameters[0], + {} as Parameters[1], + ); + span?.end(); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(WORKERS_AI_INTEGRATION_NAME)).toBe(false); + }); +}); diff --git a/packages/solid/package.json b/packages/solid/package.json index 484401f88e93..daa648eac63b 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solid", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Solid", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solid", @@ -54,12 +54,12 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.67.0", - "@sentry/core": "10.67.0", + "@sentry/browser": "10.73.0", + "@sentry/core": "10.73.0", "@sentry/conventions": "^0.16.0" }, "peerDependencies": { - "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", + "@solidjs/router": ">=0.13.4 <2.0.0-0", "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, @@ -72,7 +72,7 @@ } }, "devDependencies": { - "@solidjs/router": "^0.15.0", + "@solidjs/router": "^1.0.0", "@solidjs/testing-library": "0.8.5", "@tanstack/solid-router": "^1.169.2", "@testing-library/dom": "^7.21.4", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 7627a6259721..121786dd3d9c 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solidstart", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Solid Start", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solidstart", @@ -57,7 +57,7 @@ "access": "public" }, "peerDependencies": { - "@solidjs/router": "^0.13.4 || ^0.14.0 || ^0.15.0", + "@solidjs/router": ">=0.13.4 <2.0.0-0", "@solidjs/start": "^1.0.0" }, "peerDependenciesMeta": { @@ -66,13 +66,13 @@ } }, "dependencies": { - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/solid": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/solid": "10.73.0", "@sentry/vite-plugin": "^5.3.0" }, "devDependencies": { - "@solidjs/router": "^0.15.0", + "@solidjs/router": "^1.0.0", "@solidjs/start": "^1.2.1", "@solidjs/testing-library": "0.8.5", "@testing-library/jest-dom": "^6.4.5", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index dbb82778f557..f83c370d943e 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/svelte", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Svelte", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/svelte", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.67.0", - "@sentry/core": "10.67.0", + "@sentry/browser": "10.73.0", + "@sentry/core": "10.73.0", "magic-string": "~0.30.0" }, "peerDependencies": { diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 00fa86d61542..c947edff6bd9 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/sveltekit", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for SvelteKit", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/sveltekit", @@ -56,11 +56,11 @@ } }, "dependencies": { - "@sentry/cloudflare": "10.67.0", - "@sentry/core": "10.67.0", + "@sentry/cloudflare": "10.73.0", + "@sentry/core": "10.73.0", "@sentry/conventions": "^0.16.0", - "@sentry/node": "10.67.0", - "@sentry/svelte": "10.67.0", + "@sentry/node": "10.73.0", + "@sentry/svelte": "10.73.0", "@sentry/vite-plugin": "^5.3.0", "@sveltejs/acorn-typescript": "^1.0.9", "acorn": "^8.14.0", diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 2e30253aaaf2..20965b8e6ddb 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -23,6 +23,7 @@ import { import type { Handle, ResolveOptions } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; import { getTracePropagationData, sendErrorToSentry } from './utils'; +import { HTTP_ROUTE, HTTP_URL, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; export type SentryHandleOptions = { /** @@ -168,7 +169,8 @@ async function instrumentHandle( const kitRootSpanAttributes = spanJson.data; const originalName = spanJson.description; - const routeName = kitRootSpanAttributes['http.route']; + const kitRoute = kitRootSpanAttributes[HTTP_ROUTE]; + const routeName = typeof kitRoute === 'string' ? kitRoute : routeId; if (routeName && typeof routeName === 'string') { updateSpanName(kitRootSpan, `${event.request.method ?? 'GET'} ${routeName}`); } @@ -178,6 +180,12 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeName ? 'route' : 'url', 'sveltekit.tracing.original_name': originalName, + // oxlint-disable-next-line typescript-eslint(no-deprecated) + [URL_FULL]: kitRootSpanAttributes[URL_FULL] ?? kitRootSpanAttributes[HTTP_URL] ?? event.url.href, + [URL_PATH]: kitRootSpanAttributes[URL_PATH] ?? event.url.pathname, + ...(routeName && { + [HTTP_ROUTE]: routeName, + }), ...httpHeadersToSpanAttributes( winterCGHeadersToDict(event.request.headers), getClient()?.getDataCollectionOptions() ?? false, @@ -207,6 +215,11 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url', 'http.method': event.request.method, + [URL_FULL]: event.url.href, + [URL_PATH]: event.url.pathname, + ...(routeId && { + [HTTP_ROUTE]: routeId, + }), ...httpHeadersToSpanAttributes( winterCGHeadersToDict(event.request.headers), getClient()?.getDataCollectionOptions() ?? false, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index bfc3714a621b..4d7bfdda88dc 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -129,6 +129,8 @@ export { instrumentOpenAiClient, instrumentAnthropicAiClient, instrumentGoogleGenAIClient, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentStateGraphCompile, zodErrorsIntegration, diff --git a/packages/sveltekit/src/vite/autoInstrument.ts b/packages/sveltekit/src/vite/autoInstrument.ts index 07e71966beda..c8f0dfc29aec 100644 --- a/packages/sveltekit/src/vite/autoInstrument.ts +++ b/packages/sveltekit/src/vite/autoInstrument.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { Plugin } from 'vite'; import { WRAPPED_MODULE_SUFFIX } from '../common/utils'; -import type { BackwardsForwardsCompatibleSvelteConfig } from './svelteConfig'; +import type { BackwardsForwardsCompatibleKitConfig, BackwardsForwardsCompatibleSvelteConfig } from './svelteConfig'; const AcornParser = acorn.Parser.extend(tsPlugin()); @@ -132,9 +132,18 @@ function isNativeServerTracingEnabled(plugins: readonly Plugin[] | undefined): b } for (const plugin of plugins) { - const options = (plugin?.api as { options?: BackwardsForwardsCompatibleSvelteConfig } | undefined)?.options; - // SvelteKit 3 (>= next.8) promoted `tracing` out of `experimental`; older versions nest it there. - if (options?.kit?.tracing?.server || options?.kit?.experimental?.tracing?.server) { + const options = ( + plugin?.api as + | { options?: BackwardsForwardsCompatibleSvelteConfig & BackwardsForwardsCompatibleKitConfig } + | undefined + )?.options; + + // SvelteKit 3 flattened the plugin config: what used to live under `kit` now sits + // at the top level of the exposed options. + const kitConfig = options?.kit ?? options; + + // SvelteKit 3 promoted `tracing` out of `experimental`; older versions nest it there. + if (kitConfig?.tracing?.server || kitConfig?.experimental?.tracing?.server) { return true; } } diff --git a/packages/sveltekit/src/vite/svelteConfig.ts b/packages/sveltekit/src/vite/svelteConfig.ts index 425154aaef6b..da38ea0e1022 100644 --- a/packages/sveltekit/src/vite/svelteConfig.ts +++ b/packages/sveltekit/src/vite/svelteConfig.ts @@ -18,9 +18,10 @@ export type SvelteKitTracingConfig = { * The location of SvelteKit's native tracing config differs by version: * - SvelteKit 2.31+ and early Kit 3 prereleases nest it under `kit.experimental.tracing` * - SvelteKit 3 (>= 3.0.0-next.8) promoted it to `kit.tracing` - * We type (and read) both so detection works across the supported peer range. + * - SvelteKit 3 (>= 3.0.0-next.21) dropped the `kit` nesting entirely, leaving `tracing` + * We type (and read) all of them so detection works across the supported peer range. */ -type BackwardsForwardsCompatibleKitConfig = Config['kit'] & +export type BackwardsForwardsCompatibleKitConfig = Config['kit'] & Pick & { experimental?: SvelteKitTracingConfig }; export interface BackwardsForwardsCompatibleSvelteConfig extends Config { diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index de2146c9e259..ddf599ea7b65 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -50,6 +50,7 @@ export { lastEventId, linkedErrorsIntegration, logger, + metrics, requestDataIntegration, rewriteFramesIntegration, Scope, diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 54d935fed7cd..025e8fd09f1e 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -1,4 +1,5 @@ import type { EventEnvelopeHeaders, Span } from '@sentry/core'; +import { HTTP_ROUTE } from '@sentry/conventions/attributes'; import { getRootSpan, getSpanDescendants, @@ -143,6 +144,7 @@ describe('sentryHandle', () => { expect(spanToJSON(_span!).op).toEqual('http.server'); expect(spanToJSON(_span!).status).toEqual(isError ? 'internal_error' : 'ok'); expect(spanToJSON(_span!).data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toEqual('route'); + expect(spanToJSON(_span!).data?.[HTTP_ROUTE]).toEqual('/users/[id]'); expect(spanToJSON(_span!).timestamp).toBeDefined(); @@ -151,6 +153,7 @@ describe('sentryHandle', () => { }); it("doesn't start a span if sveltekit tracing is enabled", async () => { + const kitRootSpan = SentryCore.startInactiveSpan({ name: 'sveltekit.handle.root' }); let _span: Span | undefined = undefined; client.on('spanEnd', span => { if (span === getRootSpan(span)) { @@ -160,7 +163,7 @@ describe('sentryHandle', () => { try { await sentryHandle()({ - event: mockEvent({ tracing: { enabled: true } }), + event: mockEvent({ tracing: { enabled: true, root: kitRootSpan } }), resolve: resolve(type, isError), }); } catch { @@ -168,6 +171,10 @@ describe('sentryHandle', () => { } expect(_span).toBeUndefined(); + expect(spanToJSON(kitRootSpan).description).toEqual('GET /users/[id]'); + expect(spanToJSON(kitRootSpan).data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]).toEqual('route'); + expect(spanToJSON(kitRootSpan).data?.[HTTP_ROUTE]).toEqual('/users/[id]'); + kitRootSpan.end(); }); it('starts a child span for nested server calls (i.e. if there is an active span)', async () => { diff --git a/packages/sveltekit/test/vite/autoInstrument.test.ts b/packages/sveltekit/test/vite/autoInstrument.test.ts index 42d805565d4c..20c6d5e5db59 100644 --- a/packages/sveltekit/test/vite/autoInstrument.test.ts +++ b/packages/sveltekit/test/vite/autoInstrument.test.ts @@ -225,30 +225,31 @@ describe('makeAutoInstrumentationPlugin()', () => { // `onlyInstrumentClient` option computed from it is `false`); the config is exposed on the // SvelteKit Vite plugin's `api.options` instead. // The tracing config location differs by SvelteKit version: + // - SvelteKit 3 (>= 3.0.0-next.21): `tracing.server` (the `kit` nesting was flattened away) // - SvelteKit 3 (>= 3.0.0-next.8): `kit.tracing.server` // - SvelteKit 2.31+ and early Kit 3 prereleases: `kit.experimental.tracing.server` function configWithKitTracing( ssr: boolean, serverTracing: boolean, - location: 'tracing' | 'experimental' = 'tracing', + location: 'tracing' | 'experimental' | 'flat' = 'tracing', ): unknown { - const kit = - location === 'tracing' - ? { tracing: { server: serverTracing } } - : { experimental: { tracing: { server: serverTracing } } }; + const tracing = { tracing: { server: serverTracing } }; + const options = + location === 'flat' ? tracing : { kit: location === 'tracing' ? tracing : { experimental: tracing } }; + return { build: { ssr }, plugins: [ { name: 'some-other-plugin' }, { name: 'vite-plugin-sveltekit-setup', - api: { options: { kit } }, + api: { options }, }, ], }; } - describe.each(['tracing', 'experimental'] as const)('with the config under `kit.%s`', location => { + describe.each(['tracing', 'experimental', 'flat'] as const)('with the config in the `%s` location', location => { it.each(['path/to/+page.server.ts', 'path/to/+layout.server.js', 'path/to/+page.ts', 'path/to/+layout.mjs'])( "doesn't wrap %s in the SSR build when native tracing is enabled, even if `onlyInstrumentClient` is `false`", async (path: string) => { diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 7067dd86f9f7..9fd2e00eb535 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart-react", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for TanStack Start React", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart-react", @@ -64,11 +64,11 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/browser-utils": "10.67.0", + "@sentry/browser-utils": "10.73.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0", - "@sentry/react": "10.67.0", + "@sentry/core": "10.73.0", + "@sentry/node": "10.73.0", + "@sentry/react": "10.73.0", "@sentry/vite-plugin": "^5.3.0" }, "devDependencies": { diff --git a/packages/tanstackstart/package.json b/packages/tanstackstart/package.json index 54260c1479f8..1e824f19f41d 100644 --- a/packages/tanstackstart/package.json +++ b/packages/tanstackstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart", - "version": "10.67.0", + "version": "10.73.0", "description": "Utilities for the Sentry TanStack Start SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart", diff --git a/packages/types/package.json b/packages/types/package.json index c3dd64d7dea0..b6a5dc8936ec 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/types", - "version": "10.67.0", + "version": "10.73.0", "description": "Types for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/types", @@ -56,7 +56,7 @@ "yalc:publish": "yalc publish --push --sig" }, "dependencies": { - "@sentry/core": "10.67.0" + "@sentry/core": "10.73.0" }, "volta": { "extends": "../../package.json" diff --git a/packages/typescript/package.json b/packages/typescript/package.json index a93fb660cb46..e27aed273fb3 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/typescript", - "version": "10.67.0", + "version": "10.73.0", "description": "Typescript configuration used at Sentry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/typescript", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index fa59fed2cb81..76114da15267 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vercel-edge", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for the Vercel Edge Runtime", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vercel-edge", @@ -40,12 +40,12 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/core": "10.67.0" + "@sentry/core": "10.73.0" }, "devDependencies": { "@edge-runtime/types": "4.0.0", "@opentelemetry/sdk-trace-base": "^2.9.0", - "@sentry/opentelemetry": "10.67.0" + "@sentry/opentelemetry": "10.73.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index b7eb963d4f6c..81da79c2877a 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -74,6 +74,8 @@ export { // eslint-disable-next-line typescript/no-deprecated inboundFiltersIntegration, instrumentOpenAiClient, + instrumentStateGraph, + // eslint-disable-next-line typescript/no-deprecated instrumentLangGraph, instrumentGoogleGenAIClient, instrumentAnthropicAiClient, diff --git a/packages/vercel-edge/src/logs/exports.ts b/packages/vercel-edge/src/logs/exports.ts index c21477e378b3..5a7e065eb3ee 100644 --- a/packages/vercel-edge/src/logs/exports.ts +++ b/packages/vercel-edge/src/logs/exports.ts @@ -19,7 +19,7 @@ function captureLog( } /** - * @summary Capture a log with the `trace` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `trace` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { userId: 100, route: '/dashboard' }. @@ -48,7 +48,7 @@ export function trace(message: ParameterizedString, attributes?: Log['attributes } /** - * @summary Capture a log with the `debug` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `debug` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { component: 'Header', state: 'loading' }. @@ -78,7 +78,7 @@ export function debug(message: ParameterizedString, attributes?: Log['attributes } /** - * @summary Capture a log with the `info` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `info` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { feature: 'checkout', status: 'completed' }. @@ -108,7 +108,7 @@ export function info(message: ParameterizedString, attributes?: Log['attributes' } /** - * @summary Capture a log with the `warn` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `warn` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { browser: 'Chrome', version: '91.0' }. @@ -139,7 +139,7 @@ export function warn(message: ParameterizedString, attributes?: Log['attributes' } /** - * @summary Capture a log with the `error` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `error` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { error: 'NetworkError', url: '/api/data' }. @@ -171,7 +171,7 @@ export function error(message: ParameterizedString, attributes?: Log['attributes } /** - * @summary Capture a log with the `fatal` level. Requires the `enableLogs` option to be enabled. + * @summary Capture a log with the `fatal` level. * * @param message - The message to log. * @param attributes - Arbitrary structured data that stores information about the log - e.g., { appState: 'corrupted', sessionId: 'abc-123' }. diff --git a/packages/vue/package.json b/packages/vue/package.json index 52704ffe4354..0ffc6b3f9c8e 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vue", - "version": "10.67.0", + "version": "10.73.0", "description": "Official Sentry SDK for Vue.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vue", @@ -51,8 +51,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.67.0", - "@sentry/core": "10.67.0", + "@sentry/browser": "10.73.0", + "@sentry/core": "10.73.0", "@sentry/conventions": "^0.16.0" }, "peerDependencies": { diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 0c5af2908793..72086019f2a1 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/wasm", - "version": "10.67.0", + "version": "10.73.0", "description": "Support for WASM.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/wasm", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.67.0", - "@sentry/core": "10.67.0" + "@sentry/browser": "10.73.0", + "@sentry/core": "10.73.0" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", diff --git a/yarn.lock b/yarn.lock index b8af2055f529..9da484518387 100644 --- a/yarn.lock +++ b/yarn.lock @@ -404,10 +404,10 @@ dependencies: json-schema-to-ts "^3.1.1" -"@apm-js-collab/code-transformer-bundler-plugins@^0.7.1": - version "0.7.1" - resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.1.tgz#f9e7128b87cdabb50e91f2ce3a5c48505b511c41" - integrity sha512-Yidf5GOl60db80UxUtNdKK3pnY7obU/gs0xOfA0SCdnvVLMCvfYIer/egC3TqpPiT0Jg22eg3RlzcO+zKfPMcA== +"@apm-js-collab/code-transformer-bundler-plugins@^0.7.3": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.3.tgz#c439f6d63306c1800a430733aac3af6e496d9fe8" + integrity sha512-qNbPwuMZ8f5ZuGj/ttPeB7a6C/S1bB6tNYaEL5vNiRKydSAxa4AU0gxCWgaP4fVju+AuwhcumSFjrEcGF9Dv7Q== dependencies: "@apm-js-collab/code-transformer" "^0.18.0" es-module-lexer "^2.1.0" @@ -3023,6 +3023,17 @@ resolved "https://registry.yarnpkg.com/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz#d4a3df263ddbfde855bca268be79ea6062856a54" integrity sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw== +"@cloudflare/vite-plugin@1.34.0": + version "1.34.0" + resolved "https://registry.yarnpkg.com/@cloudflare/vite-plugin/-/vite-plugin-1.34.0.tgz#4a1ad34e26e378c20635bf3333bd0cb4caabfa56" + integrity sha512-ZsdedDrK5WiJzelgKtgy3FHTbnG1dfrLNDy+5JPqrMx4el63eTYFLV89uI9LefVwyE6BfMz5UHbpYOdRbkUVlg== + dependencies: + "@cloudflare/unenv-preset" "2.16.1" + miniflare "4.20260426.0" + unenv "2.0.0-rc.24" + wrangler "4.86.0" + ws "8.18.0" + "@cloudflare/workerd-darwin-64@1.20260124.0": version "1.20260124.0" resolved "https://registry.yarnpkg.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260124.0.tgz#958e475f8a5fce1d9453d47b98c09526f1a45438" @@ -3471,11 +3482,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== -"@esbuild/android-arm64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" - integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== - "@esbuild/android-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4" @@ -3506,11 +3512,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.18.tgz#266d40b8fdcf87962df8af05b76219bc786b4f80" integrity sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw== -"@esbuild/android-arm@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" - integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== - "@esbuild/android-arm@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824" @@ -3536,11 +3537,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== -"@esbuild/android-x64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" - integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== - "@esbuild/android-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d" @@ -3566,11 +3562,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== -"@esbuild/darwin-arm64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" - integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== - "@esbuild/darwin-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e" @@ -3596,11 +3587,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== -"@esbuild/darwin-x64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" - integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== - "@esbuild/darwin-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd" @@ -3626,11 +3612,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== -"@esbuild/freebsd-arm64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" - integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== - "@esbuild/freebsd-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487" @@ -3656,11 +3637,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== -"@esbuild/freebsd-x64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" - integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== - "@esbuild/freebsd-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c" @@ -3686,11 +3662,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== -"@esbuild/linux-arm64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" - integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== - "@esbuild/linux-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b" @@ -3716,11 +3687,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== -"@esbuild/linux-arm@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" - integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== - "@esbuild/linux-arm@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef" @@ -3746,11 +3712,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== -"@esbuild/linux-ia32@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" - integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== - "@esbuild/linux-ia32@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601" @@ -3786,11 +3747,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.5.tgz#91aef76d332cdc7c8942b600fa2307f3387e6f82" integrity sha512-UHkDFCfSGTuXq08oQltXxSZmH1TXyWsL+4QhZDWvvLl6mEJQqk3u7/wq1LjhrrAXYIllaTtRSzUXl4Olkf2J8A== -"@esbuild/linux-loong64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" - integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== - "@esbuild/linux-loong64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299" @@ -3816,11 +3772,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== -"@esbuild/linux-mips64el@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" - integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== - "@esbuild/linux-mips64el@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec" @@ -3846,11 +3797,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== -"@esbuild/linux-ppc64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" - integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== - "@esbuild/linux-ppc64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8" @@ -3876,11 +3822,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== -"@esbuild/linux-riscv64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" - integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== - "@esbuild/linux-riscv64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf" @@ -3906,11 +3847,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== -"@esbuild/linux-s390x@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" - integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== - "@esbuild/linux-s390x@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8" @@ -3936,11 +3872,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== -"@esbuild/linux-x64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" - integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== - "@esbuild/linux-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78" @@ -3986,11 +3917,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== -"@esbuild/netbsd-x64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" - integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== - "@esbuild/netbsd-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b" @@ -4036,11 +3962,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== -"@esbuild/openbsd-x64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" - integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== - "@esbuild/openbsd-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0" @@ -4086,11 +4007,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== -"@esbuild/sunos-x64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" - integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== - "@esbuild/sunos-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30" @@ -4116,11 +4032,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== -"@esbuild/win32-arm64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" - integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== - "@esbuild/win32-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae" @@ -4146,11 +4057,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== -"@esbuild/win32-ia32@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" - integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== - "@esbuild/win32-ia32@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67" @@ -4176,11 +4082,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== -"@esbuild/win32-x64@0.18.20": - version "0.18.20" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" - integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== - "@esbuild/win32-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae" @@ -7204,130 +7105,130 @@ estree-walker "^2.0.2" picomatch "^4.0.2" -"@rollup/rollup-android-arm-eabi@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz#3a04f01e9f01392bbef5920b94aa3b88794be7ab" - integrity sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ== - -"@rollup/rollup-android-arm64@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz#e371b653ceabc900790ae73f5548a0fd7cd63a70" - integrity sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw== - -"@rollup/rollup-darwin-arm64@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz#2a5aa70432e39816d666d79287a7324cfc3b4e72" - integrity sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA== - -"@rollup/rollup-darwin-x64@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz#c3b5b49629379cd9cdc5d841bf00ed44ebf393dd" - integrity sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg== - -"@rollup/rollup-freebsd-arm64@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz#f929d8e0462fae6602fc960beeabd7287d859283" - integrity sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g== - -"@rollup/rollup-freebsd-x64@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz#c01cb58031226f95d0900b1ec847f4fb32c6e809" - integrity sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw== - -"@rollup/rollup-linux-arm-gnueabihf@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz#f29d890c4858c8e0d3be01677eef4f6a359eed9d" - integrity sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA== - -"@rollup/rollup-linux-arm-musleabihf@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz#1ebfc8eb9f66136ed2faae5f44995add5ca3c964" - integrity sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w== - -"@rollup/rollup-linux-arm64-gnu@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz#c1fa823c2c4ce46ba7f61de1a4c3fdadd4fb4e7b" - integrity sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg== - -"@rollup/rollup-linux-arm64-musl@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz#a7f18854d0471b78bda8ea38f0891a4e059b571d" - integrity sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A== - -"@rollup/rollup-linux-loong64-gnu@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz#83658a9a4576bcce8cef85b2c78b9b649d2200c4" - integrity sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ== - -"@rollup/rollup-linux-loong64-musl@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz#fd2af677ae3417bb58d57ae37dd0d84686e40244" - integrity sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw== - -"@rollup/rollup-linux-ppc64-gnu@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz#6481647181c4cf8f1ddbd99f62c84cfc56c1a94a" - integrity sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg== - -"@rollup/rollup-linux-ppc64-musl@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz#18610a1a1550e28a5042ca916f898419540f17f4" - integrity sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A== - -"@rollup/rollup-linux-riscv64-gnu@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz#597bb80465a2621dbe0de0a41c66394a8a7e9a6e" - integrity sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA== - -"@rollup/rollup-linux-riscv64-musl@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz#a2a919a9f927ef7f24a60af77e3cb55f1ad59e4d" - integrity sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw== - -"@rollup/rollup-linux-s390x-gnu@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz#3166f6ceae7df9bbfddf9f36be1937231e13e3c6" - integrity sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ== - -"@rollup/rollup-linux-x64-gnu@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz#23c9bf79771d804fb87415eb0767569f273261e5" - integrity sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ== - -"@rollup/rollup-linux-x64-musl@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz#97941c6b94d67fe25cde0f027c10a19f2d1fdd39" - integrity sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg== - -"@rollup/rollup-openbsd-x64@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz#7aeb7d92e2cd1d399f56daf75c39040b777b6c77" - integrity sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA== - -"@rollup/rollup-openharmony-arm64@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz#925de61ae83bf99aa636e8acea87432e8c0ffaab" - integrity sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg== - -"@rollup/rollup-win32-arm64-msvc@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz#888ab83842721491044c46a7407e1f38f3235bb4" - integrity sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw== - -"@rollup/rollup-win32-ia32-msvc@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz#fa30ac24e3f0232139d2a47500560a28695764d4" - integrity sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA== - -"@rollup/rollup-win32-x64-gnu@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz#223e2bc93f86e0707568e1fadb5b537e50c976c7" - integrity sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw== - -"@rollup/rollup-win32-x64-msvc@4.60.4": - version "4.60.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz#da4f1676d87e2bdf744291b504b0ab79550c3e61" - integrity sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw== +"@rollup/rollup-android-arm-eabi@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz#5e9849b661c2229cf967a08dbe2dbbe9e8c991e5" + integrity sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg== + +"@rollup/rollup-android-arm64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz#5b0699ee5dd484b222c9ed74aff43c91ea8b17f8" + integrity sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw== + +"@rollup/rollup-darwin-arm64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz#8bc52c9d7a3ce8d0533c351a9c935de781daa06f" + integrity sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A== + +"@rollup/rollup-darwin-x64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz#ba2ef3e8fb310f0af35588f270cfa5aa96e48764" + integrity sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA== + +"@rollup/rollup-freebsd-arm64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz#93b10bdbfe8ada226b8bc0c02ef6b7f544474d96" + integrity sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw== + +"@rollup/rollup-freebsd-x64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz#3e8aa38ef3c9c300946871e3fdbb0c30e0a20f86" + integrity sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg== + +"@rollup/rollup-linux-arm-gnueabihf@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz#1d7994384bb0ad1bc41921b506e1642d4f9d7fc3" + integrity sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg== + +"@rollup/rollup-linux-arm-musleabihf@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz#a6540f47cf844a56b80ca9ff95d2acdfb2cef97b" + integrity sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA== + +"@rollup/rollup-linux-arm64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz#404f2045651840cbf48da91ba6d0f490f0bc2cbf" + integrity sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA== + +"@rollup/rollup-linux-arm64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz#a3404ffddf7b474b48c99b9c893b6247bb765ba5" + integrity sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ== + +"@rollup/rollup-linux-loong64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz#e8aac6d549b377945e349882f199b7c8eb75ca38" + integrity sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg== + +"@rollup/rollup-linux-loong64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz#6e2e44ea50310b3a582078a915e5feb879c820d4" + integrity sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ== + +"@rollup/rollup-linux-ppc64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz#6898302da6d77a0537cde64b2b4c6b60659bd110" + integrity sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A== + +"@rollup/rollup-linux-ppc64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz#333717c95dd5a66bef8f63e7ef8a9fd845fd18d0" + integrity sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w== + +"@rollup/rollup-linux-riscv64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz#81bc06ba380352004d01f4826eb7cdccefa05bad" + integrity sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg== + +"@rollup/rollup-linux-riscv64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz#95a7cd39de21389ad6788a5284eaaa738e29ca4c" + integrity sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q== + +"@rollup/rollup-linux-s390x-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz#06e6db2ec1bc48b5374c7923ef83c2eb024b2452" + integrity sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg== + +"@rollup/rollup-linux-x64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz#5dc818988285e09e88790c6462def72413df2da3" + integrity sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A== + +"@rollup/rollup-linux-x64-musl@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz#2080f4a93349e9afd34be6fc1a37e01fc8bfc80f" + integrity sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg== + +"@rollup/rollup-openbsd-x64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz#21d64a8acb66221724b923e51af5333df1af044b" + integrity sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg== + +"@rollup/rollup-openharmony-arm64@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz#8e0fcd9d02141e337b4c5b5cff576cb9a76b1ba0" + integrity sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA== + +"@rollup/rollup-win32-arm64-msvc@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz#bdb4cc4efd58efe808203347f0f5463f0ea16e52" + integrity sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg== + +"@rollup/rollup-win32-ia32-msvc@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz#dbaebde5afd24eae0eefe915d901632e7cb59860" + integrity sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q== + +"@rollup/rollup-win32-x64-gnu@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz#84109e85fea5f8f1353499f96578fdc2a0e8b138" + integrity sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg== + +"@rollup/rollup-win32-x64-msvc@4.62.2": + version "4.62.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz#3671ce3f9b928d5c01f879792d5c0b60ae14d4ad" + integrity sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA== "@rtsao/scc@^1.1.0": version "1.1.0" @@ -7431,10 +7332,10 @@ resolved "https://registry.yarnpkg.com/@sentry/conventions/-/conventions-0.16.0.tgz#3b58d15714cf44dca1518496c00749eec5525009" integrity sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ== -"@sentry/node-cpu-profiler@^2.4.2": - version "2.4.2" - resolved "https://registry.yarnpkg.com/@sentry/node-cpu-profiler/-/node-cpu-profiler-2.4.2.tgz#d0ba01370545297d015df1497daf7f81e27f2ab5" - integrity sha512-E6q+eE/sTpiofzW9jFKAx6ZQaDAoZDnsaLA/nRlkiK+K2X4k+hSyKhhLfw8PJlejB8edk7uxJF57r5JoRnyaPA== +"@sentry/node-cpu-profiler@^2.4.3": + version "2.4.3" + resolved "https://registry.yarnpkg.com/@sentry/node-cpu-profiler/-/node-cpu-profiler-2.4.3.tgz#1119aa07fb34672435fea3e386ade7dfe73fdf34" + integrity sha512-18g/yJKRUk4PNcnZYEPyT2nS+9UoFcTRrIrct1DbpfI7k//RtQ+8araZrR0WH5MT8xoiLCm67GEX/RcxDsI0iQ== dependencies: detect-libc "^2.0.3" node-abi "^3.73.0" @@ -8141,10 +8042,10 @@ resolved "https://registry.yarnpkg.com/@solidjs/meta/-/meta-0.29.4.tgz#28a444db5200d1c9e4e62d8762ea808d3e8beffd" integrity sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g== -"@solidjs/router@^0.15.0": - version "0.15.4" - resolved "https://registry.yarnpkg.com/@solidjs/router/-/router-0.15.4.tgz#e2b2d797541dcbdc36641e5f610f93ab4fa11c8a" - integrity sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ== +"@solidjs/router@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@solidjs/router/-/router-1.0.0.tgz#9e4e5d6dbdeb725e8e4a9b5a3c7158c39fff096f" + integrity sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q== "@solidjs/start@^1.2.1": version "1.2.1" @@ -8925,10 +8826,10 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.1", "@types/estree@^1.0.6", "@types/estree@^1.0.8": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" - integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/estree@*", "@types/estree@1.0.9", "@types/estree@^1.0.0", "@types/estree@^1.0.1", "@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== "@types/estree@0.0.39": version "0.0.39" @@ -15876,34 +15777,6 @@ esbuild@^0.15.0: esbuild-windows-64 "0.15.18" esbuild-windows-arm64 "0.15.18" -esbuild@^0.18.10: - version "0.18.20" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" - integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== - optionalDependencies: - "@esbuild/android-arm" "0.18.20" - "@esbuild/android-arm64" "0.18.20" - "@esbuild/android-x64" "0.18.20" - "@esbuild/darwin-arm64" "0.18.20" - "@esbuild/darwin-x64" "0.18.20" - "@esbuild/freebsd-arm64" "0.18.20" - "@esbuild/freebsd-x64" "0.18.20" - "@esbuild/linux-arm" "0.18.20" - "@esbuild/linux-arm64" "0.18.20" - "@esbuild/linux-ia32" "0.18.20" - "@esbuild/linux-loong64" "0.18.20" - "@esbuild/linux-mips64el" "0.18.20" - "@esbuild/linux-ppc64" "0.18.20" - "@esbuild/linux-riscv64" "0.18.20" - "@esbuild/linux-s390x" "0.18.20" - "@esbuild/linux-x64" "0.18.20" - "@esbuild/netbsd-x64" "0.18.20" - "@esbuild/openbsd-x64" "0.18.20" - "@esbuild/sunos-x64" "0.18.20" - "@esbuild/win32-arm64" "0.18.20" - "@esbuild/win32-ia32" "0.18.20" - "@esbuild/win32-x64" "0.18.20" - esbuild@^0.19.2: version "0.19.12" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04" @@ -20805,7 +20678,7 @@ magic-string@^0.26.0, magic-string@^0.26.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.8: +magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21, magic-string@~0.30.8: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -24886,7 +24759,7 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.27, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.14, postcss@^8.5.3, postcss@^8.5.6: +postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.14, postcss@^8.5.3, postcss@^8.5.6: version "8.5.15" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c" integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== @@ -26428,45 +26301,38 @@ rollup@^2.70.0: optionalDependencies: fsevents "~2.3.2" -rollup@^3.27.1: - version "3.30.0" - resolved "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz" - integrity sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA== - optionalDependencies: - fsevents "~2.3.2" - rollup@^4.34.9, rollup@^4.60.2, rollup@^4.60.3: - version "4.60.4" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.60.4.tgz#ca3814f5900da3ac3981d2e0c61944b7e6e0cb09" - integrity sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g== + version "4.62.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.62.2.tgz#d90fc4cb811f071303c890b779595634f35f9541" + integrity sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA== dependencies: - "@types/estree" "1.0.8" + "@types/estree" "1.0.9" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.60.4" - "@rollup/rollup-android-arm64" "4.60.4" - "@rollup/rollup-darwin-arm64" "4.60.4" - "@rollup/rollup-darwin-x64" "4.60.4" - "@rollup/rollup-freebsd-arm64" "4.60.4" - "@rollup/rollup-freebsd-x64" "4.60.4" - "@rollup/rollup-linux-arm-gnueabihf" "4.60.4" - "@rollup/rollup-linux-arm-musleabihf" "4.60.4" - "@rollup/rollup-linux-arm64-gnu" "4.60.4" - "@rollup/rollup-linux-arm64-musl" "4.60.4" - "@rollup/rollup-linux-loong64-gnu" "4.60.4" - "@rollup/rollup-linux-loong64-musl" "4.60.4" - "@rollup/rollup-linux-ppc64-gnu" "4.60.4" - "@rollup/rollup-linux-ppc64-musl" "4.60.4" - "@rollup/rollup-linux-riscv64-gnu" "4.60.4" - "@rollup/rollup-linux-riscv64-musl" "4.60.4" - "@rollup/rollup-linux-s390x-gnu" "4.60.4" - "@rollup/rollup-linux-x64-gnu" "4.60.4" - "@rollup/rollup-linux-x64-musl" "4.60.4" - "@rollup/rollup-openbsd-x64" "4.60.4" - "@rollup/rollup-openharmony-arm64" "4.60.4" - "@rollup/rollup-win32-arm64-msvc" "4.60.4" - "@rollup/rollup-win32-ia32-msvc" "4.60.4" - "@rollup/rollup-win32-x64-gnu" "4.60.4" - "@rollup/rollup-win32-x64-msvc" "4.60.4" + "@rollup/rollup-android-arm-eabi" "4.62.2" + "@rollup/rollup-android-arm64" "4.62.2" + "@rollup/rollup-darwin-arm64" "4.62.2" + "@rollup/rollup-darwin-x64" "4.62.2" + "@rollup/rollup-freebsd-arm64" "4.62.2" + "@rollup/rollup-freebsd-x64" "4.62.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.62.2" + "@rollup/rollup-linux-arm-musleabihf" "4.62.2" + "@rollup/rollup-linux-arm64-gnu" "4.62.2" + "@rollup/rollup-linux-arm64-musl" "4.62.2" + "@rollup/rollup-linux-loong64-gnu" "4.62.2" + "@rollup/rollup-linux-loong64-musl" "4.62.2" + "@rollup/rollup-linux-ppc64-gnu" "4.62.2" + "@rollup/rollup-linux-ppc64-musl" "4.62.2" + "@rollup/rollup-linux-riscv64-gnu" "4.62.2" + "@rollup/rollup-linux-riscv64-musl" "4.62.2" + "@rollup/rollup-linux-s390x-gnu" "4.62.2" + "@rollup/rollup-linux-x64-gnu" "4.62.2" + "@rollup/rollup-linux-x64-musl" "4.62.2" + "@rollup/rollup-openbsd-x64" "4.62.2" + "@rollup/rollup-openharmony-arm64" "4.62.2" + "@rollup/rollup-win32-arm64-msvc" "4.62.2" + "@rollup/rollup-win32-ia32-msvc" "4.62.2" + "@rollup/rollup-win32-x64-gnu" "4.62.2" + "@rollup/rollup-win32-x64-msvc" "4.62.2" fsevents "~2.3.2" rou3@^0.8.1: @@ -30021,18 +29887,7 @@ vite-plugin-vue-tracer@^1.0.1: pathe "^2.0.3" source-map-js "^1.2.1" -vite@^4.4.9: - version "4.4.11" - resolved "https://registry.yarnpkg.com/vite/-/vite-4.4.11.tgz#babdb055b08c69cfc4c468072a2e6c9ca62102b0" - integrity sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A== - dependencies: - esbuild "^0.18.10" - postcss "^8.4.27" - rollup "^3.27.1" - optionalDependencies: - fsevents "~2.3.2" - -"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.5, vite@^6.4.1, vite@^6.4.3: +vite@7.3.5, vite@^4.4.9, "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.5, vite@^6.4.1, vite@^6.4.3: version "6.4.3" resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.3.tgz#85a164db7ce706f2a776812efa2b340f1721858e" integrity sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==