diff --git a/.craft.yml b/.craft.yml index f9c56070d9b2..c5055acf329c 100644 --- a/.craft.yml +++ b/.craft.yml @@ -146,16 +146,13 @@ targets: # AWS Lambda Layer target - name: aws-lambda-layer includeNames: /^sentry-node-serverless-\d+.\d+.\d+(-(beta|alpha|rc)\.\d+)?\.zip$/ - layerName: SentryNodeServerlessSDKv9 + layerName: SentryNodeServerlessSDKv10 compatibleRuntimes: - name: node versions: - - nodejs10.x - - nodejs12.x - - nodejs14.x - - nodejs16.x - nodejs18.x - nodejs20.x + - nodejs22.x license: MIT # CDN Bundle Target diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ca8ebedfdd75..8ab3e3d77091 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,8 +47,6 @@ env: ${{ github.workspace }}/packages/*/lib ${{ github.workspace }}/packages/ember/*.d.ts ${{ github.workspace }}/packages/gatsby/*.d.ts - ${{ github.workspace }}/packages/utils/cjs - ${{ github.workspace }}/packages/utils/esm BUILD_CACHE_TARBALL_KEY: tarball-${{ github.event.inputs.commit || github.sha }} @@ -747,6 +745,29 @@ jobs: directory: dev-packages/node-integration-tests token: ${{ secrets.CODECOV_TOKEN }} + job_cloudflare_integration_tests: + name: Cloudflare Integration Tests + needs: [job_get_metadata, job_build] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) + uses: actions/checkout@v4 + with: + ref: ${{ env.HEAD_COMMIT }} + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: 'package.json' + - name: Restore caches + uses: ./.github/actions/restore-cache + with: + dependency_cache_key: ${{ needs.job_build.outputs.dependency_cache_key }} + + - name: Run integration tests + working-directory: dev-packages/cloudflare-integration-tests + run: yarn test + job_remix_integration_tests: name: Remix (Node ${{ matrix.node }}) Tests needs: [job_get_metadata, job_build] @@ -950,13 +971,13 @@ jobs: retention-days: 7 - name: Pre-process E2E Test Dumps - if: always() + if: failure() run: | node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps uses: actions/upload-artifact@v4 - if: always() + if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) path: ${{ runner.temp }}/test-application/event-dumps @@ -1062,13 +1083,13 @@ jobs: run: pnpm ${{ matrix.assert-command || 'test:assert' }} - name: Pre-process E2E Test Dumps - if: always() + if: failure() run: | node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps uses: actions/upload-artifact@v4 - if: always() + if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) path: ${{ runner.temp }}/test-application/event-dumps @@ -1095,6 +1116,7 @@ jobs: job_deno_unit_tests, job_node_unit_tests, job_node_integration_tests, + job_cloudflare_integration_tests, job_browser_playwright_tests, job_browser_loader_tests, job_remix_integration_tests, diff --git a/.gitignore b/.gitignore index f784704ac31a..f381e7e6e24d 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ packages/gatsby/gatsby-node.d.ts # intellij *.iml +/**/.wrangler/* diff --git a/.size-limit.js b/.size-limit.js index 685b40b00fbe..d53eaae56712 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -217,6 +217,15 @@ module.exports = [ gzip: true, limit: '41 KB', }, + // Node-Core SDK (ESM) + { + name: '@sentry/node-core', + path: 'packages/node-core/build/esm/index.js', + import: createImport('init'), + ignore: [...builtinModules, ...nodePrefixedBuiltinModules], + gzip: true, + limit: '116 KB', + }, // Node SDK (ESM) { name: '@sentry/node', @@ -224,7 +233,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '170 KB', + limit: '147 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index 89bc7a377ee5..4fcec79fe17d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,73 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.0.0 + +Version `10.0.0` marks a release of the Sentry JavaScript SDKs that contains breaking changes. The goal of this release is to primarily upgrade the underlying OpenTelemetry dependencies to v2 with minimal breaking changes. + +### How To Upgrade + +Please carefully read through the migration guide in the Sentry docs on how to upgrade from version 9 to version 10. Make sure to select your specific platform/framework in the top left corner: https://docs.sentry.io/platforms/javascript/migration/v9-to-v10/ + +A comprehensive migration guide outlining all changes can be found within the Sentry JavaScript SDK Repository: https://github.com/getsentry/sentry-javascript/blob/develop/MIGRATION.md + +### Breaking Changes + +- feat!: Bump to OpenTelemetry v2 ([#16872](https://github.com/getsentry/sentry-javascript/pull/16872)) +- feat(browser)!: Remove FID web vital collection ([#17076](https://github.com/getsentry/sentry-javascript/pull/17076)) +- feat(core)!: Remove `BaseClient` ([#17071](https://github.com/getsentry/sentry-javascript/pull/17071)) +- feat(core)!: Remove `enableLogs` and `beforeSendLog` experimental options ([#17063](https://github.com/getsentry/sentry-javascript/pull/17063)) +- feat(core)!: Remove `hasTracingEnabled` ([#17072](https://github.com/getsentry/sentry-javascript/pull/17072)) +- feat(core)!: Remove deprecated logger ([#17061](https://github.com/getsentry/sentry-javascript/pull/17061)) +- feat(replay)!: Promote `_experiments.autoFlushOnFeedback` option as default ([#17220](https://github.com/getsentry/sentry-javascript/pull/17220)) +- chore(deps)!: Bump bundler plugins to v4 ([#17089](https://github.com/getsentry/sentry-javascript/pull/17089)) + +### Other Changes + +- feat(astro): Implement Request Route Parametrization for Astro 5 ([#17105](https://github.com/getsentry/sentry-javascript/pull/17105)) +- feat(astro): Parametrize routes on client-side ([#17133](https://github.com/getsentry/sentry-javascript/pull/17133)) +- feat(aws): Add `SentryNodeServerlessSDKv10` v10 AWS Lambda Layer ([#17069](https://github.com/getsentry/sentry-javascript/pull/17069)) +- feat(aws): Create unified lambda layer for ESM and CJS ([#17012](https://github.com/getsentry/sentry-javascript/pull/17012)) +- feat(aws): Detect SDK source for AWS Lambda layer ([#17128](https://github.com/getsentry/sentry-javascript/pull/17128)) +- feat(core): Add missing openai tool calls attributes ([#17226](https://github.com/getsentry/sentry-javascript/pull/17226)) +- feat(core): Add shared `flushIfServerless` function ([#17177](https://github.com/getsentry/sentry-javascript/pull/17177)) +- feat(core): Implement `strictTraceContinuation` ([#16313](https://github.com/getsentry/sentry-javascript/pull/16313)) +- feat(core): MCP server instrumentation without breaking Miniflare ([#16817](https://github.com/getsentry/sentry-javascript/pull/16817)) +- feat(deps): bump @prisma/instrumentation from 6.11.1 to 6.12.0 ([#17117](https://github.com/getsentry/sentry-javascript/pull/17117)) +- feat(meta): Unify detection of serverless environments and add Cloud Run ([#17168](https://github.com/getsentry/sentry-javascript/pull/17168)) +- feat(nestjs): Switch to OTel core instrumentation ([#17068](https://github.com/getsentry/sentry-javascript/pull/17068)) +- feat(node-native): Upgrade `@sentry-internal/node-native-stacktrace` to `0.2.2` ([#17207](https://github.com/getsentry/sentry-javascript/pull/17207)) +- feat(node): Add `shouldHandleError` option to `fastifyIntegration` ([#16845](https://github.com/getsentry/sentry-javascript/pull/16845)) +- feat(node): Add firebase integration ([#16719](https://github.com/getsentry/sentry-javascript/pull/16719)) +- feat(node): Instrument stream responses for openai ([#17110](https://github.com/getsentry/sentry-javascript/pull/17110)) +- feat(react-router): Add `createSentryHandleError` ([#17235](https://github.com/getsentry/sentry-javascript/pull/17235)) +- feat(react-router): Automatically flush on serverless for loaders/actions ([#17234](https://github.com/getsentry/sentry-javascript/pull/17234)) +- feat(react-router): Automatically flush on Vercel for request handlers ([#17232](https://github.com/getsentry/sentry-javascript/pull/17232)) +- fix(astro): Construct parametrized route during runtime ([#17190](https://github.com/getsentry/sentry-javascript/pull/17190)) +- fix(aws): Add layer build output to nx cache ([#17148](https://github.com/getsentry/sentry-javascript/pull/17148)) +- fix(aws): Fix path to packages directory ([#17112](https://github.com/getsentry/sentry-javascript/pull/17112)) +- fix(aws): Resolve all Sentry packages to local versions in layer build ([#17106](https://github.com/getsentry/sentry-javascript/pull/17106)) +- fix(aws): Use file link in dependency version ([#17111](https://github.com/getsentry/sentry-javascript/pull/17111)) +- fix(cloudflare): Allow non uuid workflow instance IDs ([#17121](https://github.com/getsentry/sentry-javascript/pull/17121)) +- fix(cloudflare): Avoid turning DurableObject sync methods into async ([#17184](https://github.com/getsentry/sentry-javascript/pull/17184)) +- fix(core): Fix OpenAI SDK private field access by binding non-instrumented fns ([#17163](https://github.com/getsentry/sentry-javascript/pull/17163)) +- fix(core): Fix operation name for openai responses API ([#17206](https://github.com/getsentry/sentry-javascript/pull/17206)) +- fix(core): Update ai.response.object to gen_ai.response.object ([#17153](https://github.com/getsentry/sentry-javascript/pull/17153)) +- fix(nextjs): Flush in route handlers ([#17223](https://github.com/getsentry/sentry-javascript/pull/17223)) +- fix(nextjs): Handle async params in url extraction ([#17162](https://github.com/getsentry/sentry-javascript/pull/17162)) +- fix(nextjs): Update stackframe calls for next v15.5 ([#17156](https://github.com/getsentry/sentry-javascript/pull/17156)) +- fix(node): Add mechanism to `fastifyIntegration` error handler ([#17208](https://github.com/getsentry/sentry-javascript/pull/17208)) +- fix(node): Ensure tool errors for `vercelAiIntegration` have correct trace connected ([#17132](https://github.com/getsentry/sentry-javascript/pull/17132)) +- fix(node): Fix exports for openai instrumentation ([#17238](https://github.com/getsentry/sentry-javascript/pull/17238)) +- fix(node): Handle stack traces with data URI filenames ([#17218](https://github.com/getsentry/sentry-javascript/pull/17218)) +- fix(react): Memoize wrapped component to prevent rerenders ([#17230](https://github.com/getsentry/sentry-javascript/pull/17230)) +- fix(remix): Ensure source maps upload fails silently if Sentry CLI fails ([#17082](https://github.com/getsentry/sentry-javascript/pull/17082)) +- fix(replay): Fix re-sampled sessions after a click ([#17008](https://github.com/getsentry/sentry-javascript/pull/17008)) +- fix(svelte): Do not insert preprocess code in script module in Svelte 5 ([#17114](https://github.com/getsentry/sentry-javascript/pull/17114)) +- fix(sveltekit): Align error status filtering and mechanism in `handleErrorWithSentry` ([#17157](https://github.com/getsentry/sentry-javascript/pull/17157)) + +Work in this release was contributed by @richardjelinek-fastest. Thank you for your contribution! + ## 9.40.0 ### Important Changes diff --git a/MIGRATION.md b/MIGRATION.md index ac2a46a8d50e..ceaa6578e8eb 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,532 +5,80 @@ These docs walk through how to migrate our JavaScript SDKs through different maj - Upgrading from [SDK 4.x to 5.x/6.x](./docs/migration/v4-to-v5_v6.md) - Upgrading from [SDK 6.x to 7.x](./docs/migration/v6-to-v7.md) - Upgrading from [SDK 7.x to 8.x](./docs/migration/v7-to-v8.md) -- Upgrading from [SDK 8.x to 9.x](#upgrading-from-8x-to-9x) +- Upgrading from [SDK 8.x to 9.x](./docs/migration/v8-to-v9.md) +- Upgrading from [SDK 9.x to 10.x](#upgrading-from-9x-to-10x) -# Deprecations in 9.x +# Upgrading from 9.x to 10.x -## Deprecated `@sentry/core` SDK internal `logger` export +Version 10 of the Sentry JavaScript SDK primarily focuses on upgrading underlying OpenTelemetry dependencies to v2 with minimal breaking changes. -The internal SDK `logger` export from `@sentry/core` has been deprecated in favor of the `debug` export. `debug` only exposes `log`, `warn`, and `error` methods but is otherwise identical to `logger`. Note that this deprecation does not affect the `logger` export from other packages (like `@sentry/browser` or `@sentry/node`) which is used for Sentry Logging. - -```js -import { logger, debug } from '@sentry/core'; - -// before -logger.info('This is an info message'); - -// after -debug.log('This is an info message'); -``` - -# Upgrading from 8.x to 9.x - -Version 9 of the Sentry JavaScript SDK primarily introduces API cleanup and version support changes. -This update contains behavioral changes that will not be caught by type checkers, linters, or tests, so we recommend carefully reading through the entire migration guide instead of relying on automatic tooling. - -Version 9 of the SDK is compatible with Sentry self-hosted versions 24.4.2 or higher (unchanged from v8). +Version 10 of the SDK is compatible with Sentry self-hosted versions 24.4.2 or higher (unchanged from v9). Lower versions may continue to work, but may not support all features. ## 1. Version Support Changes: -Version 9 of the Sentry SDK has new compatibility ranges for runtimes and frameworks. - -### General Runtime Support Changes - -**ECMAScript Version:** All the JavaScript code in the Sentry SDK packages may now contain ECMAScript 2020 features. -This includes features like Nullish Coalescing (`??`), Optional Chaining (`?.`), `String.matchAll()`, Logical Assignment Operators (`&&=`, `||=`, `??=`), and `Promise.allSettled()`. - -If you observe failures due to syntax or features listed above, it may indicate that your current runtime does not support ES2020. -If your runtime does not support ES2020, we recommend transpiling the SDK using Babel or similar tooling. - -**Node.js:** The minimum supported Node.js version is **18.0.0** (Released Apr 19, 2022), except for ESM-only SDKs (`@sentry/astro`, `@sentry/nuxt`, `@sentry/sveltekit`) which require Node.js version **18.19.1** (Released Feb 14, 2024) or higher. - -**Browsers:** Due to SDK code now including ES2020 features, the minimum supported browser list now looks as follows: - -- Chrome 80 (Released Feb 5, 2020) -- Edge 80 (Released Feb 7, 2020) -- Safari 14, iOS Safari 14.4 (Released Sep 16, 2020) -- Firefox 74 (Released Mar 10, 2020) -- Opera 67 (Released Mar 12, 2020) -- Samsung Internet 13.0 (Released Nov 20, 2020) - -If you need to support older browsers, we recommend transpiling your code using SWC, Babel or similar tooling. - -**Deno:** The minimum supported Deno version is now **2.0.0**. - -### Framework and Library Support Changes - -Support for the following frameworks and library versions are dropped: +Version 10 of the Sentry SDK has new compatibility ranges for runtimes and frameworks. -- **Remix**: Version `1.x` -- **TanStack Router**: Version `1.63.0` and lower (relevant when using `tanstackRouterBrowserTracingIntegration`) -- **SvelteKit**: Version `1.x` -- **Ember.js**: Version `3.x` and lower (minimum supported version is `4.x`) -- **Prisma**: Version `5.x` - -### TypeScript Version Policy - -In preparation for v2 of the OpenTelemetry SDK, the minimum required TypeScript version is increased to version `5.0.4`. +### `@sentry/node` / All SDKs running in Node.js -Additionally, like the OpenTelemetry SDK, the Sentry JavaScript SDK will follow [DefinitelyType's version support policy](https://github.com/DefinitelyTyped/DefinitelyTyped#support-window) which has a support time frame of 2 years for any released version of TypeScript. +All OpenTelemetry dependencies have been bumped to 2.x.x / 0.20x.x respectively and all OpenTelemetry instrumentations have been upgraded to their latest version. -Older TypeScript versions _may_ continue to be compatible, but no guarantees apply. +If you cannot run with OpenTelmetry v2 versions, consider either staying on Version 9 of our SDKs or using `@sentry/node-core` instead which ships with widened OpenTelemetry peer dependencies. ### AWS Lambda Layer Changes -A new AWS Lambda Layer for version 9 will be published as `SentryNodeServerlessSDKv9`. -The ARN will be published in the [Sentry docs](https://docs.sentry.io/platforms/javascript/guides/aws-lambda/install/cjs-layer/) once available. - -The previous `SentryNodeServerlessSDK` layer will not receive new updates anymore. - -Updates and fixes for version 8 will be published as `SentryNodeServerlessSDKv8`. +A new AWS Lambda Layer for version 10 will be published as `SentryNodeServerlessSDKv10`. The ARN will be published in the [Sentry docs](https://docs.sentry.io/platforms/javascript/guides/aws-lambda/install/cjs-layer/) once available. -## 2. Behavior Changes - -### `@sentry/core` / All SDKs - -- Dropping spans in the `beforeSendSpan` hook is no longer possible. - This means you can no longer return `null` from the `beforeSendSpan` hook. - This hook is intended to be used to add additional data to spans or remove unwanted attributes (for example for PII stripping). - To control which spans are recorded, we recommend configuring [integrations](https://docs.sentry.io/platforms/javascript/configuration/integrations/) instead. - -- The `beforeSendSpan` hook now receives the root span as well as the child spans. - We recommend checking your `beforeSendSpan` to account for this change. - -- The `request` property on the `samplingContext` argument passed to the `tracesSampler` and `profilesSampler` options has been removed. - `samplingContext.normalizedRequest` can be used instead. - Note that the type of `normalizedRequest` differs from `request`. - -- The `startSpan` behavior was changed if you pass a custom `scope`: - While in v8, the passed scope was set active directly on the passed scope, in v9, the scope is cloned. This behavior change does not apply to `@sentry/node` where the scope was already cloned. - This change was made to ensure that the span only remains active within the callback and to align behavior between `@sentry/node` and all other SDKs. - As a result of the change, span hierarchy should be more accurate. - However, modifying the scope (for example, setting tags) within the `startSpan` callback behaves a bit differently now. - - ```js - startSpan({ name: 'example', scope: customScope }, () => { - getCurrentScope().setTag('tag-a', 'a'); // this tag will only remain within the callback - // set the tag directly on customScope in addition, if you want to to persist the tag outside of the callback - customScope.setTag('tag-a', 'a'); - }); - ``` - -- Passing `undefined` as a `tracesSampleRate` option value will now be treated the same as if the attribute was not defined at all. - In previous versions, it was checked whether the `tracesSampleRate` property existed in the SDK options to decide if trace data should be propagated for tracing. - Consequentially, this sometimes caused the SDK to propagate negative sampling decisions when `tracesSampleRate: undefined` was passed. - This is no longer the case and sampling decisions will be deferred to downstream SDKs for distributed tracing. - This is more of a bugfix rather than a breaking change, however, depending on the setup of your SDKs, an increase in sampled traces may be observed. - -- If you use the optional `captureConsoleIntegration` and set `attachStackTrace: true` in your `Sentry.init` call, console messages will no longer be marked as unhandled (`handled: false`) but as handled (`handled: true`). - If you want to keep sending them as unhandled, configure the `handled` option when adding the integration: - - ```js - Sentry.init({ - integrations: [Sentry.captureConsoleIntegration({ handled: false })], - attachStackTrace: true, - }); - ``` - -### `@sentry/browser` / All SDKs running in the browser - -- The SDK no longer instructs the Sentry backend to automatically infer IP addresses by default. - Depending on the version of the Sentry backend (self-hosted), this may lead to IP addresses no longer showing up in Sentry, and events being grouped to "anonymous users". - At the time of writing, the Sentry SaaS solution will still continue to infer IP addresses, but this will change in the near future. - Set `sendDefaultPii: true` in `Sentry.init()` to instruct the Sentry backend to always infer IP addresses. - -### `@sentry/node` / All SDKs running in Node.js - -- The `tracesSampler` hook will no longer be called for _every_ span. - Root spans may however have incoming trace data from a different service, for example when using distributed tracing. - -- The `requestDataIntegration` will no longer automatically set the user from `request.user` when `express` is used. - Starting in v9, you'll need to manually call `Sentry.setUser()` e.g. in a middleware to set the user on Sentry events. - -- The `processThreadBreadcrumbIntegration` was renamed to `childProcessIntegration`. - -- The `childProcessIntegration`'s (previously `processThreadBreadcrumbIntegration`) `name` value has been changed from `"ProcessAndThreadBreadcrumbs"` to `"ChildProcess"`. - Any filtering logic for registered integrations should be updated to account for the changed name. - -- The `vercelAIIntegration`'s `name` value has been changed from `"vercelAI"` to `"VercelAI"` (capitalized). - Any filtering logic for registered integrations should be updated to account for the changed name. - -- The Prisma integration no longer supports Prisma v5 and supports Prisma v6 by default. As per Prisma v6, the `previewFeatures = ["tracing"]` client generator option in your Prisma Schema is no longer required to use tracing with the Prisma integration. - - For performance instrumentation using other/older Prisma versions: - - 1. Install the `@prisma/instrumentation` package with the desired version. - 1. Pass a `new PrismaInstrumentation()` instance as exported from `@prisma/instrumentation` to the `prismaInstrumentation` option of this integration: - - ```js - import { PrismaInstrumentation } from '@prisma/instrumentation'; - Sentry.init({ - integrations: [ - prismaIntegration({ - // Override the default instrumentation that Sentry uses - prismaInstrumentation: new PrismaInstrumentation(), - }), - ], - }); - ``` - - The passed instrumentation instance will override the default instrumentation instance the integration would use, while the `prismaIntegration` will still ensure data compatibility for the various Prisma versions. - - 1. Depending on your Prisma version (prior to Prisma version 6), add `previewFeatures = ["tracing"]` to the client generator block of your Prisma schema: - - ``` - generator client { - provider = "prisma-client-js" - previewFeatures = ["tracing"] - } - ``` - -- When `skipOpenTelemetrySetup: true` is configured, `httpIntegration({ spans: false })` will be configured by default. - You no longer have to specify this manually. - With this change, no spans are emitted once `skipOpenTelemetrySetup: true` is configured, without any further configuration being needed. - -### All Meta-Framework SDKs (`@sentry/nextjs`, `@sentry/nuxt`, `@sentry/sveltekit`, `@sentry/astro`, `@sentry/solidstart`) - -- SDKs no longer transform user-provided values for source map generation in build configurations (like Vite config, Rollup config, or `next.config.js`). - - If source maps are explicitly disabled, the SDK will not enable them. If source maps are explicitly enabled, the SDK will not change how they are emitted. **However,** the SDK will also _not_ delete source maps after uploading them. If source map generation is not configured, the SDK will turn it on and delete them after the upload. - - To customize which files are deleted after upload, define the `filesToDeleteAfterUpload` array with globs. - -### `@sentry/react` - -- The `componentStack` field in the `ErrorBoundary` component is now typed as `string` instead of `string | null | undefined` for the `onError` and `onReset` lifecycle methods. This more closely matches the actual behavior of React, which always returns a `string` whenever a component stack is available. +Updates and fixes for version 9 will be published as `SentryNodeServerlessSDKv9`. - In the `onUnmount` lifecycle method, the `componentStack` field is now typed as `string | null`. The `componentStack` is `null` when no error has been thrown at time of unmount. - -### `@sentry/nextjs` - -- By default, client-side source maps will now be automatically deleted after being uploaded to Sentry during the build. - You can opt out of this behavior by explicitly setting `sourcemaps.deleteSourcemapsAfterUpload` to `false` in your Sentry config. - -- The Sentry Next.js SDK will no longer use the Next.js Build ID as fallback identifier for releases. - The SDK will continue to attempt to read CI-provider-specific environment variables and the current git SHA to automatically determine a release name. - If you examine that you no longer see releases created in Sentry, it is recommended to manually provide a release name to `withSentryConfig` via the `release.name` option. - - This behavior was changed because the Next.js Build ID is non-deterministic, causing build artifacts to be non-deterministic, because the release name is injected into client bundles. - -- Source maps are now automatically enabled for both client and server builds unless explicitly disabled via `sourcemaps.disable`. - Client builds use `hidden-source-map` while server builds use `source-map` as their webpack `devtool` setting unless any other value than `false` or `undefined` has been assigned already. - -- The `sentry` property on the Next.js config object has officially been discontinued. - Pass options to `withSentryConfig` directly. - -## 3. Package Removals - -The `@sentry/utils` package will no longer be published. - -The `@sentry/types` package will continue to be published, however, it is deprecated and its API will not be extended. -It will not be published as part of future major versions. - -All exports and APIs of `@sentry/utils` and `@sentry/types` (except for the ones that are explicitly called out in this migration guide to be removed) have been moved into the `@sentry/core` package. - -## 4. Removed APIs +## 2. Removed APIs ### `@sentry/core` / All SDKs -- **The metrics API has been removed from the SDK.** - - The Sentry metrics beta has ended and the metrics API has been removed from the SDK. Learn more in the Sentry [help center docs](https://sentry.zendesk.com/hc/en-us/articles/26369339769883-Metrics-Beta-Ended-on-October-7th). - -- The `transactionContext` property on the `samplingContext` argument passed to the `tracesSampler` and `profilesSampler` options has been removed. - All object attributes are available in the top-level of `samplingContext`: - - ```diff - Sentry.init({ - // Custom traces sampler - tracesSampler: samplingContext => { - - if (samplingContext.transactionContext.name === '/health-check') { - + if (samplingContext.name === '/health-check') { - return 0; - } else { - return 0.5; - } - }, +- `BaseClient` was removed, use `Client` as a direct replacement. +- `hasTracingEnabled` was removed, use `hasSpansEnabled` as a direct replacement. +- `logger` and type `Logger` were removed, use `debug` and type `SentryDebugLogger` instead. +- The `_experiments.enableLogs` and `_experiments.beforeSendLog` options were removed, use the top-level `enableLogs` and `beforeSendLog` options instead. - // Custom profiles sampler - profilesSampler: samplingContext => { - - if (samplingContext.transactionContext.name === '/health-check') { - + if (samplingContext.name === '/health-check') { - return 0; - } else { - return 0.5; - } +```js +// before +Sentry.init({ + _experiments: { + enableLogs: true, + beforeSendLog: log => { + return log; }, - }) - ``` - -- The `enableTracing` option was removed. - Instead, set `tracesSampleRate: 1` or `tracesSampleRate: 0`. - -- The `autoSessionTracking` option was removed. + }, +}); - To enable session tracking, ensure that either, in browser environments the `browserSessionIntegration` is added, or in server environments the `httpIntegration` is added. (both are added by default) - - To disable session tracking, remove the `browserSessionIntegration` in browser environments, or in server environments configure the `httpIntegration` with the `trackIncomingRequestsAsSessions` option set to `false`. - Additionally, in Node.js environments, a session was automatically created for every node process when `autoSessionTracking` was set to `true`. This behavior has been replaced by the `processSessionIntegration` which is configured by default. - -- The `getCurrentHub()`, `Hub` and `getCurrentHubShim()` APIs have been removed. They were on compatibility life support since the release of v8 and have now been fully removed from the SDK. - -- The `addOpenTelemetryInstrumentation` method has been removed. Use the `openTelemetryInstrumentations` option in `Sentry.init()` or your custom Sentry Client instead. - - ```js - import * as Sentry from '@sentry/node'; - - // before - Sentry.addOpenTelemetryInstrumentation(new GenericPoolInstrumentation()); - - // after - Sentry.init({ - openTelemetryInstrumentations: [new GenericPoolInstrumentation()], - }); - ``` - -- The `debugIntegration` has been removed. To log outgoing events, use [Hook Options](https://docs.sentry.io/platforms/javascript/configuration/options/#hooks) (`beforeSend`, `beforeSendTransaction`, ...). - -- The `sessionTimingIntegration` has been removed. To capture session durations alongside events, use [Context](https://docs.sentry.io/platforms/javascript/enriching-events/context/) (`Sentry.setContext()`). - -### Server-side SDKs (`@sentry/node` and all dependents) - -- The `addOpenTelemetryInstrumentation` method was removed. - Use the `openTelemetryInstrumentations` option in `Sentry.init()` or your custom Sentry Client instead. - -- `registerEsmLoaderHooks` now only accepts `true | false | undefined`. - The SDK will default to wrapping modules that are used as part of OpenTelemetry Instrumentation. - -- The `nestIntegration` was removed. - Use the NestJS SDK (`@sentry/nestjs`) instead. - -- The `setupNestErrorHandler` was removed. - Use the NestJS SDK (`@sentry/nestjs`) instead. - -### `@sentry/browser` - -- The `captureUserFeedback` method has been removed. - Use the `captureFeedback` method instead and update the `comments` field to `message`. - -### `@sentry/nextjs` - -- The `hideSourceMaps` option was removed without replacements. - The SDK emits hidden sourcemaps by default. - -### `@sentry/solidstart` - -- The `sentrySolidStartVite` plugin is no longer exported. Instead, wrap the SolidStart config with `withSentry` and - provide Sentry options as the second parameter. - - ```ts - // app.config.ts - import { defineConfig } from '@solidjs/start/config'; - import { withSentry } from '@sentry/solidstart'; - - export default defineConfig( - withSentry( - { - /* SolidStart config */ - }, - { - /* Sentry build-time config (like project and org) */ - }, - ), - ); - ``` - -### `@sentry/nestjs` - -- Removed `@WithSentry` decorator. - Use the `@SentryExceptionCaptured` decorator as a drop-in replacement. - -- Removed `SentryService`. - - - If you are using `@sentry/nestjs` you can safely remove any references to the `SentryService`. - - If you are using another package migrate to `@sentry/nestjs` and remove the `SentryService` afterward. - -- Removed `SentryTracingInterceptor`. - - - If you are using `@sentry/nestjs` you can safely remove any references to the `SentryTracingInterceptor`. - - If you are using another package migrate to `@sentry/nestjs` and remove the `SentryTracingInterceptor` afterward. - -- Removed `SentryGlobalGenericFilter`. - Use the `SentryGlobalFilter` as a drop-in replacement. - -- Removed `SentryGlobalGraphQLFilter`. - Use the `SentryGlobalFilter` as a drop-in replacement. - -### `@sentry/react` - -- The `wrapUseRoutes` method has been removed. - Depending on what version of react router you are using, use the `wrapUseRoutesV6` or `wrapUseRoutesV7` methods instead. - -- The `wrapCreateBrowserRouter` method has been removed. - Depending on what version of react router you are using, use the `wrapCreateBrowserRouterV6` or `wrapCreateBrowserRouterV7` methods instead. - -### `@sentry/vue` - -- The options `tracingOptions`, `trackComponents`, `timeout`, `hooks` have been removed everywhere except in the `tracingOptions` option of `vueIntegration()`. - - These options should now be configured as follows: - - ```js - import * as Sentry from '@sentry/vue'; - - Sentry.init({ - integrations: [ - Sentry.vueIntegration({ - tracingOptions: { - trackComponents: true, - timeout: 1000, - hooks: ['mount', 'update', 'unmount'], - }, - }), - ], - }); - ``` - -- The option `logErrors` in the `vueIntegration` has been removed. The Sentry Vue error handler will always propagate the error to a user-defined error handler or re-throw the error (which will log the error without modifying). - -- The option `stateTransformer` in `createSentryPiniaPlugin()` now receives the full state from all stores as its parameter. - The top-level keys of the state object are the store IDs. - -### `@sentry/nuxt` - -- The `tracingOptions` option in `Sentry.init()` was removed in favor of passing the `vueIntegration()` to `Sentry.init({ integrations: [...] })` and setting `tracingOptions` there. - -- The option `stateTransformer` in the `piniaIntegration` now receives the full state from all stores as its parameter. - The top-level keys of the state object are the store IDs. - -### `@sentry/vue` and `@sentry/nuxt` - -- When component tracking is enabled, "update" spans are no longer created by default. - - Add an `"update"` item to the `tracingOptions.hooks` option via the `vueIntegration()` to restore this behavior. - - ```ts - Sentry.init({ - integrations: [ - Sentry.vueIntegration({ - tracingOptions: { - trackComponents: true, - hooks: [ - 'mount', - 'update', // add this line to re-enable update spans - 'unmount', - ], - }, - }), - ], - }); - ``` - -### `@sentry/remix` - -- The `autoInstrumentRemix` option was removed. - The SDK now always behaves as if the option were set to `true`. - -### `@sentry/sveltekit` - -- The `fetchProxyScriptNonce` option in `sentryHandle()` was removed due to security concerns. If you previously specified this option for your CSP policy, specify a [script hash](https://docs.sentry.io/platforms/javascript/guides/sveltekit/manual-setup/#configure-csp-for-client-side-fetch-instrumentation) in your CSP config or [disable](https://docs.sentry.io/platforms/javascript/guides/sveltekit/manual-setup/#disable-client-side-fetch-proxy-script) the injection of the script entirely. - -### `@sentry/core` - -- A `sampleRand` field on `PropagationContext` is now required. This is relevant if you used `scope.setPropagationContext(...)` - -- The `DEFAULT_USER_INCLUDES` constant has been removed. There is no replacement. - -- The `BAGGAGE_HEADER_NAME` export has been removed. Use a `"baggage"` string constant directly instead. - -- The `extractRequestData` method has been removed. Manually extract relevant data of request objects instead. - -- The `addRequestDataToEvent` method has been removed. Use `httpRequestToRequestData` instead and put the resulting object directly on `event.request`. - -- The `addNormalizedRequestDataToEvent` method has been removed. Use `httpRequestToRequestData` instead and put the resulting object directly on `event.request`. - -- The `generatePropagationContext()` method was removed. - Use `generateTraceId()` directly. - -- The `spanId` field on `propagationContext` was removed. - It was replaced with an **optional** field `propagationSpanId` having the same semantics but only being defined when a unit of execution should be associated with a particular span ID. - -- The `initSessionFlusher` method on the `ServerRuntimeClient` was removed without replacements. - Any mechanisms creating sessions will flush themselves. - -- The `IntegrationClass` type was removed. - Instead, use `Integration` or `IntegrationFn`. - -- The following exports have been removed without replacement: - - - `getNumberOfUrlSegments` - - `validSeverityLevels` - - `makeFifoCache` - - `arrayify` - - `flatten` - - `urlEncode` - - `getDomElement` - - `memoBuilder` - - `extractPathForTransaction` - - `_browserPerformanceTimeOriginMode` - - `addTracingHeadersToFetchRequest` - - `SessionFlusher` - -- The following types have been removed without replacement: - - - `Request` - `RequestEventData` - - `TransactionNamingScheme` - - `RequestDataIntegrationOptions` - - `SessionFlusherLike` - - `RequestSession` - - `RequestSessionStatus` - -### `@sentry/opentelemetry` - -- Removed `getPropagationContextFromSpan` without replacement. -- Removed `generateSpanContextForPropagationContext` without replacement. - -#### Other/Internal Changes - -The following changes are unlikely to affect users of the SDK. They are listed here only for completion sake, and to alert users that may be relying on internal behavior. - -- `client._prepareEvent()` now requires both `currentScope` and `isolationScope` to be passed as arguments. -- `client.recordDroppedEvent()` no longer accepts an `event` as third argument. - The event was no longer used for some time, instead you can (optionally) pass a count of dropped events as third argument. - -## 5. Build Changes - -- The CJS code for the SDK now only contains compatibility statements for CJS/ESM in modules that have default exports: - - ```js - Object.defineProperty(exports, '__esModule', { value: true }); - ``` - - Let us know if this is causing issues in your setup by opening an issue on GitHub. - -- `@sentry/deno` is no longer published on the `deno.land` registry so you'll need to import the SDK from npm: +// after +Sentry.init({ + enableLogs: true, + beforeSendLog: log => { + return log; + }, +}); +``` - ```javascript - import * as Sentry from 'npm:@sentry/deno'; +- (Session Replay) The `_experiments.autoFlushOnFeedback` option was removed and is now default behavior. - Sentry.init({ - dsn: '__DSN__', - // ... - }); - ``` +## 3. Behaviour Changes -## 6. Type Changes +### Removal of First Input Delay (FID) Web Vital Reporting -- `Scope` usages now always expect `Scope` instances +Affected SDKs: All SDKs running in browser applications (`@sentry/browser`, `@sentry/react`, `@sentry/nextjs`, etc.) -- `Client` usages now always expect `BaseClient` instances. - The abstract `Client` class was removed. - Client classes now have to extend from `BaseClient`. +In v10, the SDK stopped reporting the First Input Delay (FID) web vital. +This was done because FID has been replaced by Interaction to Next Paint (INP) and is therefore no longer relevant for assessing and tracking a website's performance. +For reference, FID has long been deprecated by Google's official `web-vitals` library and was eventually removed in version `5.0.0`. +Sentry now follows Google's lead by also removing it. -These changes should not affect most users unless you relied on passing things with a similar shape to internal methods. +The removal entails **no breaking API changes**. However, in rare cases, you might need to adjust some of your Sentry SDK and product setup: -In v8, interfaces have been exported from `@sentry/types`, while implementations have been exported from other packages. +- Remove any logic in `beforeSend` or other filtering/event processing logic that depends on FID or replace it with INP logic. +- If you set up Sentry Alerts that depend on FID, be aware that these could trigger once you upgrade the SDK, due to a lack of new values. + To replace them, adjust your alerts (or dashbaords) to use INP. ## No Version Support Timeline diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/init.js b/dev-packages/browser-integration-tests/suites/public-api/logger/init.js index 27397e0f90ce..8026df91ea46 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/init.js @@ -4,7 +4,5 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - _experiments: { - enableLogs: true, - }, + enableLogs: true, }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js index e0ceaaebf017..809b78739e77 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js @@ -4,8 +4,6 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - _experiments: { - enableLogs: true, - }, + enableLogs: true, integrations: [Sentry.consoleLoggingIntegration()], }); diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js index ecbfac30016e..7b73a029761d 100644 --- a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js +++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js @@ -5,9 +5,6 @@ window.Replay = Sentry.replayIntegration({ flushMinDelay: 200, flushMaxDelay: 200, useCompression: false, - _experiments: { - autoFlushOnFeedback: true, - }, }); Sentry.init({ diff --git a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts index 336e09a331e1..a83f6e9fa5ce 100644 --- a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -4,7 +4,6 @@ import { expectedClickBreadcrumb, expectedCLSPerformanceSpan, expectedFCPPerformanceSpan, - expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedLCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -56,7 +55,6 @@ sentryTest( expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, expectedCLSPerformanceSpan, - expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, // two memory spans - once per flush diff --git a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts index 2c059bb226f4..14d6ce3783b1 100644 --- a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts @@ -4,7 +4,6 @@ import { expectedClickBreadcrumb, expectedCLSPerformanceSpan, expectedFCPPerformanceSpan, - expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedLCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -77,7 +76,6 @@ sentryTest( expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, expectedCLSPerformanceSpan, - expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, // two memory spans - once per flush @@ -117,7 +115,6 @@ sentryTest( expectedReloadPerformanceSpan, expectedLCPPerformanceSpan, expectedCLSPerformanceSpan, - expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -188,7 +185,6 @@ sentryTest( expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, expectedCLSPerformanceSpan, - expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, @@ -326,7 +322,6 @@ sentryTest( expectedNavigationPerformanceSpan, expectedLCPPerformanceSpan, expectedCLSPerformanceSpan, - expectedFIDPerformanceSpan, expectedFPPerformanceSpan, expectedFCPPerformanceSpan, expectedMemoryPerformanceSpan, diff --git a/dev-packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts b/dev-packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts index 996e39fbd21c..e02007bc9f37 100644 --- a/dev-packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts @@ -1,4 +1,5 @@ import { expect } from '@playwright/test'; +import type { replayIntegration as actualReplayIntegration } from '@sentry-internal/replay'; import { sentryTest } from '../../../utils/fixtures'; import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates'; import { @@ -13,55 +14,122 @@ import { // Session should expire after 2s - keep in sync with init.js const SESSION_TIMEOUT = 2000; -sentryTest('handles an expired session', async ({ browserName, forceFlushReplay, getLocalTestUrl, page }) => { - if (shouldSkipReplayTest() || browserName !== 'chromium') { - sentryTest.skip(); - } +sentryTest( + 'handles an expired session that re-samples to session', + async ({ browserName, forceFlushReplay, getLocalTestUrl, page }) => { + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } - const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); - const url = await getLocalTestUrl({ testDir: __dirname }); + const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - const req0 = await reqPromise0; + await page.goto(url); + const req0 = await reqPromise0; - const replayEvent0 = getReplayEvent(req0); - expect(replayEvent0).toEqual(getExpectedReplayEvent({})); + const replayEvent0 = getReplayEvent(req0); + expect(replayEvent0).toEqual(getExpectedReplayEvent({})); - const fullSnapshots0 = getFullRecordingSnapshots(req0); - expect(fullSnapshots0.length).toEqual(1); - const stringifiedSnapshot = normalize(fullSnapshots0[0]); - expect(stringifiedSnapshot).toMatchSnapshot('snapshot-0.json'); + const fullSnapshots0 = getFullRecordingSnapshots(req0); + expect(fullSnapshots0.length).toEqual(1); + const stringifiedSnapshot = normalize(fullSnapshots0[0]); + expect(stringifiedSnapshot).toMatchSnapshot('snapshot-0.json'); - // We wait for another segment 0 - const reqPromise2 = waitForReplayRequest(page, 0); + // We wait for another segment 0 + const reqPromise2 = waitForReplayRequest(page, 0); - await page.locator('#button1').click(); - await forceFlushReplay(); - const req1 = await reqPromise1; + await page.locator('#button1').click(); + await forceFlushReplay(); + const req1 = await reqPromise1; - const replayEvent1 = getReplayEvent(req1); - expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] })); + const replayEvent1 = getReplayEvent(req1); + expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] })); - const replay = await getReplaySnapshot(page); - const oldSessionId = replay.session?.id; + const replay = await getReplaySnapshot(page); + const oldSessionId = replay.session?.id; - await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT)); + await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT)); - await page.locator('#button2').click(); - await forceFlushReplay(); - const req2 = await reqPromise2; + await page.locator('#button2').click(); + await forceFlushReplay(); + const req2 = await reqPromise2; - const replay2 = await getReplaySnapshot(page); + const replay2 = await getReplaySnapshot(page); - expect(replay2.session?.id).not.toEqual(oldSessionId); + expect(replay2.session?.id).not.toEqual(oldSessionId); - const replayEvent2 = getReplayEvent(req2); - expect(replayEvent2).toEqual(getExpectedReplayEvent({})); + const replayEvent2 = getReplayEvent(req2); + expect(replayEvent2).toEqual(getExpectedReplayEvent({})); - const fullSnapshots2 = getFullRecordingSnapshots(req2); - expect(fullSnapshots2.length).toEqual(1); - const stringifiedSnapshot2 = normalize(fullSnapshots2[0]); - expect(stringifiedSnapshot2).toMatchSnapshot('snapshot-2.json'); -}); + const fullSnapshots2 = getFullRecordingSnapshots(req2); + expect(fullSnapshots2.length).toEqual(1); + const stringifiedSnapshot2 = normalize(fullSnapshots2[0]); + expect(stringifiedSnapshot2).toMatchSnapshot('snapshot-2.json'); + }, +); + +sentryTest( + 'handles an expired session that re-samples to buffer', + async ({ browserName, forceFlushReplay, getLocalTestUrl, page }) => { + if (shouldSkipReplayTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + const req0 = await reqPromise0; + + const replayEvent0 = getReplayEvent(req0); + expect(replayEvent0).toEqual(getExpectedReplayEvent({})); + + const fullSnapshots0 = getFullRecordingSnapshots(req0); + expect(fullSnapshots0.length).toEqual(1); + const stringifiedSnapshot = normalize(fullSnapshots0[0]); + expect(stringifiedSnapshot).toMatchSnapshot('snapshot-0.json'); + + await page.locator('#button1').click(); + await forceFlushReplay(); + const req1 = await reqPromise1; + + const replayEvent1 = getReplayEvent(req1); + expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] })); + + const replay = await getReplaySnapshot(page); + const oldSessionId = replay.session?.id; + + await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT)); + await page.evaluate(() => { + const replayIntegration = (window as unknown as Window & { Replay: ReturnType }) + .Replay; + replayIntegration['_replay'].getOptions().errorSampleRate = 1.0; + replayIntegration['_replay'].getOptions().sessionSampleRate = 0.0; + }); + + let wasReplayFlushed = false; + page.on('request', request => { + if (request.url().includes('/api/1337/envelope/')) { + wasReplayFlushed = true; + } + }); + await page.locator('#button2').click(); + await forceFlushReplay(); + + // This timeout is not ideal, but not sure of a better way to ensure replay is not flushed + await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT)); + + expect(wasReplayFlushed).toBe(false); + + const currentSessionId = await page.evaluate(() => { + // @ts-expect-error - Replay is not typed + return window.Replay._replay.session?.id; + }); + + expect(currentSessionId).not.toEqual(oldSessionId); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-fid/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-fid/template.html deleted file mode 100644 index a3aeb9048dd8..000000000000 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-fid/template.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-fid/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-fid/test.ts deleted file mode 100644 index a481364077a1..000000000000 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-fid/test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect } from '@playwright/test'; -import type { Event } from '@sentry/core'; -import { sentryTest } from '../../../../utils/fixtures'; -import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; - -sentryTest('should capture a FID vital.', async ({ browserName, getLocalTestUrl, page }) => { - // FID measurement is not generated on webkit - if (shouldSkipTracingTest() || browserName === 'webkit') { - sentryTest.skip(); - } - - const url = await getLocalTestUrl({ testDir: __dirname }); - - await page.goto(url); - // To trigger FID - await page.locator('#fid-btn').click(); - - const eventData = await getFirstSentryEnvelopeRequest(page); - - expect(eventData.measurements).toBeDefined(); - expect(eventData.measurements?.fid?.value).toBeDefined(); - - const fidSpan = eventData.spans?.filter(({ description }) => description === 'first input delay')[0]; - - expect(fidSpan).toBeDefined(); - expect(fidSpan?.op).toBe('ui.action'); - expect(fidSpan?.parent_span_id).toBe(eventData.contexts?.trace?.span_id); -}); diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index 56ff33f878fa..97787c2de26e 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -148,19 +148,6 @@ export const expectedCLSPerformanceSpan = { }, }; -export const expectedFIDPerformanceSpan = { - op: 'web-vital', - description: 'first-input-delay', - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - data: { - value: expect.any(Number), - rating: expect.any(String), - size: expect.any(Number), - nodeIds: expect.any(Array), - }, -}; - export const expectedINPPerformanceSpan = { op: 'web-vital', description: 'interaction-to-next-paint', diff --git a/dev-packages/cloudflare-integration-tests/.eslintrc.js b/dev-packages/cloudflare-integration-tests/.eslintrc.js new file mode 100644 index 000000000000..899a60f9a2bd --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/.eslintrc.js @@ -0,0 +1,35 @@ +module.exports = { + env: { + node: true, + }, + extends: ['../../.eslintrc.js'], + overrides: [ + { + files: ['*.ts'], + parserOptions: { + project: ['tsconfig.json'], + sourceType: 'module', + }, + }, + { + files: ['suites/**/*.ts', 'suites/**/*.mjs'], + globals: { + fetch: 'readonly', + }, + rules: { + '@typescript-eslint/typedef': 'off', + // Explicitly allow ts-ignore with description for Node integration tests + // Reason: We run these tests on TS3.8 which doesn't support `@ts-expect-error` + '@typescript-eslint/ban-ts-comment': [ + 'error', + { + 'ts-ignore': 'allow-with-description', + 'ts-expect-error': true, + }, + ], + // We rely on having imports after init() is called for OTEL + 'import/first': 'off', + }, + }, + ], +}; diff --git a/dev-packages/cloudflare-integration-tests/expect.ts b/dev-packages/cloudflare-integration-tests/expect.ts new file mode 100644 index 000000000000..6050ff6816c4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/expect.ts @@ -0,0 +1,78 @@ +import type { Contexts, Envelope, Event, SdkInfo } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; +import { expect } from 'vitest'; + +export const UUID_MATCHER = expect.stringMatching(/^[0-9a-f]{32}$/); +export const UUID_V4_MATCHER = expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, +); +export const SHORT_UUID_MATCHER = expect.stringMatching(/^[0-9a-f]{16}$/); +export const ISO_DATE_MATCHER = expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + +function dropUndefinedKeys>(obj: T): T { + for (const [key, value] of Object.entries(obj)) { + if (value === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete obj[key]; + } + } + return obj; +} + +function getSdk(): SdkInfo { + return { + integrations: expect.any(Array), + name: 'sentry.javascript.cloudflare', + packages: [ + { + name: 'npm:@sentry/cloudflare', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }; +} + +function defaultContexts(eventContexts: Contexts = {}): Contexts { + return dropUndefinedKeys({ + trace: { + trace_id: UUID_MATCHER, + span_id: SHORT_UUID_MATCHER, + }, + cloud_resource: { 'cloud.provider': 'cloudflare' }, + culture: { timezone: expect.any(String) }, + runtime: { name: 'cloudflare' }, + ...eventContexts, + }); +} + +export function expectedEvent(event: Event): Event { + return dropUndefinedKeys({ + event_id: UUID_MATCHER, + timestamp: expect.any(Number), + environment: 'production', + platform: 'javascript', + sdk: getSdk(), + ...event, + contexts: defaultContexts(event.contexts), + }); +} + +export function eventEnvelope(event: Event): Envelope { + return [ + { + event_id: UUID_MATCHER, + sent_at: ISO_DATE_MATCHER, + sdk: { name: 'sentry.javascript.cloudflare', version: SDK_VERSION }, + trace: { + environment: event.environment || 'production', + public_key: 'public', + trace_id: UUID_MATCHER, + sample_rate: expect.any(String), + sampled: expect.any(String), + transaction: expect.any(String), + }, + }, + [[{ type: 'event' }, expectedEvent(event)]], + ]; +} diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json new file mode 100644 index 000000000000..a974d4c9d8ef --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -0,0 +1,27 @@ +{ + "name": "@sentry-internal/cloudflare-integration-tests", + "version": "9.40.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "private": true, + "scripts": { + "lint": "eslint . --format stylish", + "fix": "eslint . --format stylish --fix", + "test": "vitest run", + "test:watch": "yarn test --watch" + }, + "dependencies": { + "@sentry/cloudflare": "9.40.0" + }, + "devDependencies": { + "@sentry-internal/test-utils": "link:../test-utils", + "@cloudflare/workers-types": "^4.20250708.0", + "vitest": "^3.2.4", + "wrangler": "4.22.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts new file mode 100644 index 000000000000..849b011250f9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -0,0 +1,235 @@ +import type { Envelope, EnvelopeItemType } from '@sentry/core'; +import { normalize } from '@sentry/core'; +import { createBasicSentryServer } from '@sentry-internal/test-utils'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { inspect } from 'util'; +import { expect } from 'vitest'; + +const CLEANUP_STEPS = new Set<() => void>(); + +export function cleanupChildProcesses(): void { + for (const step of CLEANUP_STEPS) { + step(); + } + CLEANUP_STEPS.clear(); +} + +process.on('exit', cleanupChildProcesses); + +function deferredPromise( + done?: () => void, +): { resolve: (val: T) => void; reject: (reason?: unknown) => void; promise: Promise } { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = (val: T) => { + res(val); + }; + reject = (reason: Error) => { + rej(reason); + }; + }); + if (!resolve || !reject) { + throw new Error('Failed to create deferred promise'); + } + return { + resolve, + reject, + promise: promise.finally(() => done?.()), + }; +} + +type Expected = Envelope | ((envelope: Envelope) => void); + +type StartResult = { + completed(): Promise; + makeRequest( + method: 'get' | 'post', + path: string, + options?: { headers?: Record; data?: BodyInit; expectError?: boolean }, + ): Promise; +}; + +/** Creates a test runner */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createRunner(...paths: string[]) { + const testPath = join(...paths); + + if (!existsSync(testPath)) { + throw new Error(`Test scenario not found: ${testPath}`); + } + + const expectedEnvelopes: Expected[] = []; + // By default, we ignore session & sessions + const ignored: Set = new Set(['session', 'sessions', 'client_report']); + + return { + expect: function (expected: Expected) { + expectedEnvelopes.push(expected); + return this; + }, + expectN: function (n: number, expected: Expected) { + for (let i = 0; i < n; i++) { + expectedEnvelopes.push(expected); + } + return this; + }, + ignore: function (...types: EnvelopeItemType[]) { + types.forEach(t => ignored.add(t)); + return this; + }, + unignore: function (...types: EnvelopeItemType[]) { + for (const t of types) { + ignored.delete(t); + } + return this; + }, + start: function (): StartResult { + const { resolve, reject, promise: isComplete } = deferredPromise(cleanupChildProcesses); + const expectedEnvelopeCount = expectedEnvelopes.length; + + let envelopeCount = 0; + const { resolve: setWorkerPort, promise: workerPortPromise } = deferredPromise(); + let child: ReturnType | undefined; + + /** Called after each expect callback to check if we're complete */ + function expectCallbackCalled(): void { + envelopeCount++; + if (envelopeCount === expectedEnvelopeCount) { + resolve(); + } + } + + function newEnvelope(envelope: Envelope): void { + if (process.env.DEBUG) log('newEnvelope', inspect(envelope, false, null, true)); + + const envelopeItemType = envelope[1][0][0].type; + + if (ignored.has(envelopeItemType)) { + return; + } + + const expected = expectedEnvelopes.shift(); + + // Catch any error or failed assertions and pass them to done to end the test quickly + try { + if (!expected) { + return; + } + + if (typeof expected === 'function') { + expected(envelope); + } else { + expect(envelope).toEqual(expected); + } + expectCallbackCalled(); + } catch (e) { + reject(e); + } + } + + createBasicSentryServer(newEnvelope) + .then(([mockServerPort, mockServerClose]) => { + if (mockServerClose) { + CLEANUP_STEPS.add(() => { + mockServerClose(); + }); + } + + if (process.env.DEBUG) log('Starting scenario', testPath); + + const stdio: ('inherit' | 'ipc' | 'ignore')[] = process.env.DEBUG + ? ['inherit', 'inherit', 'inherit', 'ipc'] + : ['ignore', 'ignore', 'ignore', 'ipc']; + + child = spawn( + 'wrangler', + [ + 'dev', + '--config', + join(testPath, 'wrangler.jsonc'), + '--show-interactive-dev-session', + 'false', + '--var', + `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, + ], + { stdio }, + ); + + CLEANUP_STEPS.add(() => { + child?.kill(); + }); + + child.on('error', e => { + // eslint-disable-next-line no-console + console.error('Error starting child process:', e); + reject(e); + }); + + child.on('message', (message: string) => { + const msg = JSON.parse(message) as { event: string; port?: number }; + if (msg.event === 'DEV_SERVER_READY' && typeof msg.port === 'number') { + setWorkerPort(msg.port); + if (process.env.DEBUG) log('worker ready on port', msg.port); + } + }); + }) + .catch(e => reject(e)); + + return { + completed: async function (): Promise { + return isComplete; + }, + makeRequest: async function ( + method: 'get' | 'post', + path: string, + options: { headers?: Record; data?: BodyInit; expectError?: boolean } = {}, + ): Promise { + const url = `http://localhost:${await workerPortPromise}${path}`; + const body = options.data; + const headers = options.headers || {}; + const expectError = options.expectError || false; + + if (process.env.DEBUG) log('making request', method, url, headers, body); + + try { + const res = await fetch(url, { headers, method, body }); + + if (!res.ok) { + if (!expectError) { + reject(new Error(`Expected request to "${path}" to succeed, but got a ${res.status} response`)); + } + + return; + } + + if (expectError) { + reject(new Error(`Expected request to "${path}" to fail, but got a ${res.status} response`)); + return; + } + + if (res.headers.get('content-type')?.includes('application/json')) { + return await res.json(); + } + + return (await res.text()) as T; + } catch (e) { + if (expectError) { + return; + } + + reject(e); + return; + } + }, + }; + }, + }; +} + +function log(...args: unknown[]): void { + // eslint-disable-next-line no-console + console.log(...args.map(arg => normalize(arg))); +} diff --git a/dev-packages/cloudflare-integration-tests/suites/basic/index.ts b/dev-packages/cloudflare-integration-tests/suites/basic/index.ts new file mode 100644 index 000000000000..08c497cf1172 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/basic/index.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + }), + { + async fetch(_request, _env, _ctx) { + throw new Error('This is a test error from the Cloudflare integration tests'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts new file mode 100644 index 000000000000..1bd7b4bac094 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts @@ -0,0 +1,32 @@ +import { expect, it } from 'vitest'; +import { eventEnvelope } from '../../expect'; +import { createRunner } from '../../runner'; + +it('Basic error in fetch handler', async () => { + const runner = createRunner(__dirname) + .expect( + eventEnvelope({ + level: 'error', + exception: { + values: [ + { + type: 'Error', + value: 'This is a test error from the Cloudflare integration tests', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'cloudflare', handled: false }, + }, + ], + }, + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.any(String), + }, + }), + ) + .start(); + await runner.makeRequest('get', '/', { expectError: true }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/basic/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/basic/wrangler.jsonc new file mode 100644 index 000000000000..24fb2861023d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/basic/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"] +} diff --git a/dev-packages/cloudflare-integration-tests/tsconfig.json b/dev-packages/cloudflare-integration-tests/tsconfig.json new file mode 100644 index 000000000000..38816b36116e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["suites/**/*.ts", "*.ts"], + + "compilerOptions": { + // Although this seems wrong to include `DOM` here, it's necessary to make + // global fetch available in tests in lower Node versions. + "lib": ["ES2020"], + "esModuleInterop": true, + } +} diff --git a/dev-packages/cloudflare-integration-tests/vite.config.mts b/dev-packages/cloudflare-integration-tests/vite.config.mts new file mode 100644 index 000000000000..cfa15b12c3f1 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/vite.config.mts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vite/vite.config'; + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + coverage: { + enabled: false, + }, + isolate: false, + include: ['./suites/**/test.ts'], + testTimeout: 20_000, + // Ensure we can see debug output when DEBUG=true + ...(process.env.DEBUG + ? { + disableConsoleIntercept: true, + silent: false, + } + : {}), + // By default Vitest uses child processes to run tests but all our tests + // already run in their own processes. We use threads instead because the + // overhead is significantly less. + pool: 'threads', + reporters: process.env.DEBUG + ? ['default', { summary: false }] + : process.env.GITHUB_ACTIONS + ? ['dot', 'github-actions'] + : ['verbose'], + }, +}); diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.ts b/dev-packages/e2e-tests/lib/getTestMatrix.ts index 890496edb440..07ba73b8b6f2 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.ts +++ b/dev-packages/e2e-tests/lib/getTestMatrix.ts @@ -143,8 +143,27 @@ function getAffectedTestApplications( .map(line => line.trim()) .filter(Boolean); - // If something in e2e tests themselves are changed, just run everything + // If something in e2e tests themselves are changed, check if only test applications were changed if (affectedProjects.includes('@sentry-internal/e2e-tests')) { + try { + const changedTestApps = getChangedTestApps(base, head); + + // Shared code was changed, run all tests + if (changedTestApps === false) { + return testApplications; + } + + // Only test applications that were changed, run selectively + if (changedTestApps.size > 0) { + return testApplications.filter(testApp => changedTestApps.has(testApp)); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to get changed files, running all tests:', error); + return testApplications; + } + + // Fall back to running all tests return testApplications; } @@ -153,3 +172,32 @@ function getAffectedTestApplications( return sentryDependencies.some(dep => affectedProjects.includes(dep)); }); } + +function getChangedTestApps(base: string, head?: string): false | Set { + const changedFiles = execSync(`git diff --name-only ${base}${head ? `..${head}` : ''} -- dev-packages/e2e-tests/`, { + encoding: 'utf-8', + }) + .toString() + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + + const changedTestApps: Set = new Set(); + const testAppsPrefix = 'dev-packages/e2e-tests/test-applications/'; + + for (const file of changedFiles) { + if (!file.startsWith(testAppsPrefix)) { + // Shared code change - need to run all tests + return false; + } + + const pathAfterPrefix = file.slice(testAppsPrefix.length); + const slashIndex = pathAfterPrefix.indexOf('/'); + + if (slashIndex > 0) { + changedTestApps.add(pathAfterPrefix.slice(0, slashIndex)); + } + } + + return changedTestApps; +} diff --git a/dev-packages/e2e-tests/test-applications/astro-4/src/pages/api/user/[userId].json.js b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/api/user/[userId].json.js new file mode 100644 index 000000000000..562bbf7b2cb4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/api/user/[userId].json.js @@ -0,0 +1,10 @@ +export const prerender = false; + +export function GET({ params }) { + return new Response( + JSON.stringify({ + greeting: `Hello ${params.userId}`, + userId: params.userId, + }), + ); +} diff --git a/dev-packages/e2e-tests/test-applications/astro-4/src/pages/catchAll/[...path].astro b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/catchAll/[...path].astro new file mode 100644 index 000000000000..bb225c166241 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/catchAll/[...path].astro @@ -0,0 +1,12 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; + +const params = Astro.params; + +--- + + +

params: {params}

+
diff --git a/dev-packages/e2e-tests/test-applications/astro-4/src/pages/user-page/[userId].astro b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/user-page/[userId].astro new file mode 100644 index 000000000000..e35bd3a34d97 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/user-page/[userId].astro @@ -0,0 +1,17 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; + +const { userId } = Astro.params; + +const response = await fetch(Astro.url.origin + `/api/user/${userId}.json`) +const data = await response.json(); + +--- + + +

{data.greeting}

+ +

data: {JSON.stringify(data)}

+
diff --git a/dev-packages/e2e-tests/test-applications/astro-4/src/pages/user-page/settings.astro b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/user-page/settings.astro new file mode 100644 index 000000000000..5a46ac891a24 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-4/src/pages/user-page/settings.astro @@ -0,0 +1,10 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; + +--- + + +

User Settings

+
diff --git a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts index 644afc377545..07e0467382da 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.dynamic.test.ts @@ -30,11 +30,11 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', span_id: expect.stringMatching(/[a-f0-9]{16}/), parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -55,9 +55,7 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: '/test-ssr', - transaction_info: { - source: 'url', - }, + transaction_info: { source: 'route' }, type: 'transaction', }); @@ -113,10 +111,259 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { start_timestamp: expect.any(Number), timestamp: expect.any(Number), transaction: 'GET /test-ssr', - transaction_info: { - source: 'route', - }, + transaction_info: { source: 'route' }, type: 'transaction', }); }); }); + +test.describe('nested SSR routes (client, server, server request)', () => { + /** The user-page route fetches from an endpoint and creates a deeply nested span structure: + * pageload — /user-page/myUsername123 + * ├── browser.** — multiple browser spans + * └── browser.request — /user-page/myUsername123 + * └── http.server — GET /user-page/[userId] (SSR page request) + * └── http.client — GET /api/user/myUsername123.json (executing fetch call from SSR page - span) + * └── http.server — GET /api/user/myUsername123.json (server request) + */ + test('sends connected server and client pageload and request spans with the same trace id', async ({ page }) => { + const clientPageloadTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('/user-page/') ?? false; + }); + + const serverPageRequestTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /user-page/') ?? false; + }); + + const serverHTTPServerRequestTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /api/user/') ?? false; + }); + + await page.goto('/user-page/myUsername123'); + + const clientPageloadTxn = await clientPageloadTxnPromise; + const serverPageRequestTxn = await serverPageRequestTxnPromise; + const serverHTTPServerRequestTxn = await serverHTTPServerRequestTxnPromise; + const serverRequestHTTPClientSpan = serverPageRequestTxn.spans?.find( + span => span.op === 'http.client' && span.description?.includes('/api/user/'), + ); + + const clientPageloadTraceId = clientPageloadTxn.contexts?.trace?.trace_id; + + // Verify all spans have the same trace ID + expect(clientPageloadTraceId).toEqual(serverPageRequestTxn.contexts?.trace?.trace_id); + expect(clientPageloadTraceId).toEqual(serverHTTPServerRequestTxn.contexts?.trace?.trace_id); + expect(clientPageloadTraceId).toEqual(serverRequestHTTPClientSpan?.trace_id); + + // serverPageRequest has no parent (root span) + expect(serverPageRequestTxn.contexts?.trace?.parent_span_id).toBeUndefined(); + + // clientPageload's parent and serverRequestHTTPClient's parent is serverPageRequest + const serverPageRequestSpanId = serverPageRequestTxn.contexts?.trace?.span_id; + expect(clientPageloadTxn.contexts?.trace?.parent_span_id).toEqual(serverPageRequestSpanId); + expect(serverRequestHTTPClientSpan?.parent_span_id).toEqual(serverPageRequestSpanId); + + // serverHTTPServerRequest's parent is serverRequestHTTPClient + expect(serverHTTPServerRequestTxn.contexts?.trace?.parent_span_id).toEqual(serverRequestHTTPClientSpan?.span_id); + }); + + test('sends parametrized pageload, server and API request transaction names', async ({ page }) => { + const clientPageloadTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('/user-page/') ?? false; + }); + + const serverPageRequestTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /user-page/') ?? false; + }); + + const serverHTTPServerRequestTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /api/user/') ?? false; + }); + + await page.goto('/user-page/myUsername123'); + + const clientPageloadTxn = await clientPageloadTxnPromise; + const serverPageRequestTxn = await serverPageRequestTxnPromise; + const serverHTTPServerRequestTxn = await serverHTTPServerRequestTxnPromise; + + const serverRequestHTTPClientSpan = serverPageRequestTxn.spans?.find( + span => span.op === 'http.client' && span.description?.includes('/api/user/'), + ); + + const routeNameMetaContent = await page.locator('meta[name="sentry-route-name"]').getAttribute('content'); + expect(routeNameMetaContent).toBe('%2Fuser-page%2F%5BuserId%5D'); + + // Client pageload transaction - actual URL with pageload operation + expect(clientPageloadTxn).toMatchObject({ + transaction: '/user-page/[userId]', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.astro', + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', + }, + }, + }, + }); + + // Server page request transaction - parametrized transaction name with actual URL in data + expect(serverPageRequestTxn).toMatchObject({ + transaction: 'GET /user-page/[userId]', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.astro', + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.astro', + 'sentry.source': 'route', + url: expect.stringContaining('/user-page/myUsername123'), + }, + }, + }, + request: { url: expect.stringContaining('/user-page/myUsername123') }, + }); + + // HTTP client span - actual API URL with client operation + expect(serverRequestHTTPClientSpan).toMatchObject({ + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + description: 'GET http://localhost:3030/api/user/myUsername123.json', // http.client does not need to be parametrized + data: { + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.node_fetch', + 'url.full': expect.stringContaining('/api/user/myUsername123.json'), + 'url.path': '/api/user/myUsername123.json', + url: expect.stringContaining('/api/user/myUsername123.json'), + }, + }); + + // Server HTTP request transaction - should be parametrized + expect(serverHTTPServerRequestTxn).toMatchObject({ + transaction: 'GET /api/user/myUsername123.json', // todo: parametrize + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.astro', + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.astro', + 'sentry.source': 'route', + url: expect.stringContaining('/api/user/myUsername123.json'), + }, + }, + }, + request: { url: expect.stringContaining('/api/user/myUsername123.json') }, + }); + }); + + test('sends parametrized pageload and server transaction names for catch-all routes', async ({ page }) => { + const clientPageloadTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('/catchAll/') ?? false; + }); + + const serverPageRequestTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /catchAll/') ?? false; + }); + + await page.goto('/catchAll/hell0/whatever-do'); + + const routeNameMetaContent = await page.locator('meta[name="sentry-route-name"]').getAttribute('content'); + expect(routeNameMetaContent).toBe('%2FcatchAll%2F%5Bpath%5D'); + + const clientPageloadTxn = await clientPageloadTxnPromise; + const serverPageRequestTxn = await serverPageRequestTxnPromise; + + expect(clientPageloadTxn).toMatchObject({ + transaction: '/catchAll/[path]', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.astro', + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', + }, + }, + }, + }); + + expect(serverPageRequestTxn).toMatchObject({ + transaction: 'GET /catchAll/[path]', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.astro', + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.astro', + 'sentry.source': 'route', + url: expect.stringContaining('/catchAll/hell0/whatever-do'), + }, + }, + }, + request: { url: expect.stringContaining('/catchAll/hell0/whatever-do') }, + }); + }); +}); + +// Case for `user-page/[id]` vs. `user-page/settings` static routes +test.describe('parametrized vs static paths', () => { + test('should use static route name for static route in parametrized path', async ({ page }) => { + const clientPageloadTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('/user-page/') ?? false; + }); + + const serverPageRequestTxnPromise = waitForTransaction('astro-4', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /user-page/') ?? false; + }); + + await page.goto('/user-page/settings'); + + const clientPageloadTxn = await clientPageloadTxnPromise; + const serverPageRequestTxn = await serverPageRequestTxnPromise; + + expect(clientPageloadTxn).toMatchObject({ + transaction: '/user-page/settings', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.astro', + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', + }, + }, + }, + }); + + expect(serverPageRequestTxn).toMatchObject({ + transaction: 'GET /user-page/settings', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.astro', + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.astro', + 'sentry.source': 'route', + url: expect.stringContaining('/user-page/settings'), + }, + }, + }, + request: { url: expect.stringContaining('/user-page/settings') }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts index c04bbb568f2e..30bcbee1a026 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-4/tests/tracing.static.test.ts @@ -35,11 +35,11 @@ test.describe('tracing in static/pre-rendered routes', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', parent_span_id: metaParentSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: metaTraceId, @@ -48,12 +48,12 @@ test.describe('tracing in static/pre-rendered routes', () => { platform: 'javascript', transaction: '/test-static', transaction_info: { - source: 'url', + source: 'route', }, type: 'transaction', }); - expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static%2F'); // URL-encoded for 'GET /test-static/' + expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static'); // URL-encoded for 'GET /test-static' expect(baggageMetaTagContent).toContain('sentry-sampled=true'); await page.waitForTimeout(1000); // wait another sec to ensure no server transaction is sent diff --git a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/api/user/[userId].json.js b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/api/user/[userId].json.js new file mode 100644 index 000000000000..481c8979dc89 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/api/user/[userId].json.js @@ -0,0 +1,8 @@ +export function GET({ params }) { + return new Response( + JSON.stringify({ + greeting: `Hello ${params.userId}`, + userId: params.userId, + }), + ); +} diff --git a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/catchAll/[...path].astro b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/catchAll/[...path].astro new file mode 100644 index 000000000000..bb225c166241 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/catchAll/[...path].astro @@ -0,0 +1,12 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; + +const params = Astro.params; + +--- + + +

params: {params}

+
diff --git a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/index.astro b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/index.astro index 457d94f43457..61bf20bfe31e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/index.astro +++ b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/index.astro @@ -16,6 +16,7 @@ import Layout from '../layouts/Layout.astro'; SSR page Static Page Server Island + Test Parametrized Routes diff --git a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/user-page/[userId].astro b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/user-page/[userId].astro new file mode 100644 index 000000000000..e35bd3a34d97 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/user-page/[userId].astro @@ -0,0 +1,17 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; + +const { userId } = Astro.params; + +const response = await fetch(Astro.url.origin + `/api/user/${userId}.json`) +const data = await response.json(); + +--- + + +

{data.greeting}

+ +

data: {JSON.stringify(data)}

+
diff --git a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/user-page/settings.astro b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/user-page/settings.astro new file mode 100644 index 000000000000..8260e632c07b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/user-page/settings.astro @@ -0,0 +1,7 @@ +--- +import Layout from '../../layouts/Layout.astro'; +--- + + +

User Settings

+
diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts index eb70f7362e63..b7dda807c65c 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.dynamic.test.ts @@ -30,11 +30,11 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', span_id: expect.stringMatching(/[a-f0-9]{16}/), parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: expect.stringMatching(/[a-f0-9]{32}/), @@ -56,7 +56,7 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { timestamp: expect.any(Number), transaction: '/test-ssr', transaction_info: { - source: 'url', + source: 'route', }, type: 'transaction', }); @@ -119,3 +119,254 @@ test.describe('tracing in dynamically rendered (ssr) routes', () => { }); }); }); + +test.describe('nested SSR routes (client, server, server request)', () => { + /** The user-page route fetches from an endpoint and creates a deeply nested span structure: + * pageload — /user-page/myUsername123 + * ├── browser.** — multiple browser spans + * └── browser.request — /user-page/myUsername123 + * └── http.server — GET /user-page/[userId] (SSR page request) + * └── http.client — GET /api/user/myUsername123.json (executing fetch call from SSR page - span) + * └── http.server — GET /api/user/myUsername123.json (server request) + */ + test('sends connected server and client pageload and request spans with the same trace id', async ({ page }) => { + const clientPageloadTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('/user-page/') ?? false; + }); + + const serverPageRequestTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /user-page/') ?? false; + }); + + const serverHTTPServerRequestTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /api/user/') ?? false; + }); + + await page.goto('/user-page/myUsername123'); + + const clientPageloadTxn = await clientPageloadTxnPromise; + const serverPageRequestTxn = await serverPageRequestTxnPromise; + const serverHTTPServerRequestTxn = await serverHTTPServerRequestTxnPromise; + const serverRequestHTTPClientSpan = serverPageRequestTxn.spans?.find( + span => span.op === 'http.client' && span.description?.includes('/api/user/'), + ); + + const clientPageloadTraceId = clientPageloadTxn.contexts?.trace?.trace_id; + + // Verify all spans have the same trace ID + expect(clientPageloadTraceId).toEqual(serverPageRequestTxn.contexts?.trace?.trace_id); + expect(clientPageloadTraceId).toEqual(serverHTTPServerRequestTxn.contexts?.trace?.trace_id); + expect(clientPageloadTraceId).toEqual(serverRequestHTTPClientSpan?.trace_id); + + // serverPageRequest has no parent (root span) + expect(serverPageRequestTxn.contexts?.trace?.parent_span_id).toBeUndefined(); + + // clientPageload's parent and serverRequestHTTPClient's parent is serverPageRequest + const serverPageRequestSpanId = serverPageRequestTxn.contexts?.trace?.span_id; + expect(clientPageloadTxn.contexts?.trace?.parent_span_id).toEqual(serverPageRequestSpanId); + expect(serverRequestHTTPClientSpan?.parent_span_id).toEqual(serverPageRequestSpanId); + + // serverHTTPServerRequest's parent is serverRequestHTTPClient + expect(serverHTTPServerRequestTxn.contexts?.trace?.parent_span_id).toEqual(serverRequestHTTPClientSpan?.span_id); + }); + + test('sends parametrized pageload, server and API request transaction names', async ({ page }) => { + const clientPageloadTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('/user-page/') ?? false; + }); + + const serverPageRequestTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /user-page/') ?? false; + }); + + const serverHTTPServerRequestTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /api/user/') ?? false; + }); + + await page.goto('/user-page/myUsername123'); + + const clientPageloadTxn = await clientPageloadTxnPromise; + const serverPageRequestTxn = await serverPageRequestTxnPromise; + const serverHTTPServerRequestTxn = await serverHTTPServerRequestTxnPromise; + + const serverRequestHTTPClientSpan = serverPageRequestTxn.spans?.find( + span => span.op === 'http.client' && span.description?.includes('/api/user/'), + ); + + const routeNameMetaContent = await page.locator('meta[name="sentry-route-name"]').getAttribute('content'); + expect(routeNameMetaContent).toBe('%2Fuser-page%2F%5BuserId%5D'); + + // Client pageload transaction - actual URL with pageload operation + expect(clientPageloadTxn).toMatchObject({ + transaction: '/user-page/[userId]', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.astro', + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', + }, + }, + }, + }); + + // Server page request transaction - parametrized transaction name with actual URL in data + expect(serverPageRequestTxn).toMatchObject({ + transaction: 'GET /user-page/[userId]', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.astro', + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.astro', + 'sentry.source': 'route', + url: expect.stringContaining('/user-page/myUsername123'), + }, + }, + }, + request: { url: expect.stringContaining('/user-page/myUsername123') }, + }); + + // HTTP client span - actual API URL with client operation + expect(serverRequestHTTPClientSpan).toMatchObject({ + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + description: 'GET http://localhost:3030/api/user/myUsername123.json', // http.client does not need to be parametrized + data: { + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.node_fetch', + 'url.full': expect.stringContaining('/api/user/myUsername123.json'), + 'url.path': '/api/user/myUsername123.json', + url: expect.stringContaining('/api/user/myUsername123.json'), + }, + }); + + // Server HTTP request transaction + expect(serverHTTPServerRequestTxn).toMatchObject({ + transaction: 'GET /api/user/[userId].json', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.astro', + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.astro', + 'sentry.source': 'route', + url: expect.stringContaining('/api/user/myUsername123.json'), + }, + }, + }, + request: { url: expect.stringContaining('/api/user/myUsername123.json') }, + }); + }); + + test('sends parametrized pageload and server transaction names for catch-all routes', async ({ page }) => { + const clientPageloadTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('/catchAll/') ?? false; + }); + + const serverPageRequestTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /catchAll/') ?? false; + }); + + await page.goto('/catchAll/hell0/whatever-do'); + + const routeNameMetaContent = await page.locator('meta[name="sentry-route-name"]').getAttribute('content'); + expect(routeNameMetaContent).toBe('%2FcatchAll%2F%5B...path%5D'); + + const clientPageloadTxn = await clientPageloadTxnPromise; + const serverPageRequestTxn = await serverPageRequestTxnPromise; + + expect(clientPageloadTxn).toMatchObject({ + transaction: '/catchAll/[...path]', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.astro', + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', + }, + }, + }, + }); + + expect(serverPageRequestTxn).toMatchObject({ + transaction: 'GET /catchAll/[...path]', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.astro', + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.astro', + 'sentry.source': 'route', + url: expect.stringContaining('/catchAll/hell0/whatever-do'), + }, + }, + }, + request: { url: expect.stringContaining('/catchAll/hell0/whatever-do') }, + }); + }); +}); + +// Case for `user-page/[id]` vs. `user-page/settings` static routes +test.describe('parametrized vs static paths', () => { + test('should use static route name for static route in parametrized path', async ({ page }) => { + const clientPageloadTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('/user-page/') ?? false; + }); + + const serverPageRequestTxnPromise = waitForTransaction('astro-5', txnEvent => { + return txnEvent?.transaction?.startsWith('GET /user-page/') ?? false; + }); + + await page.goto('/user-page/settings'); + + const clientPageloadTxn = await clientPageloadTxnPromise; + const serverPageRequestTxn = await serverPageRequestTxnPromise; + + expect(clientPageloadTxn).toMatchObject({ + transaction: '/user-page/settings', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.astro', + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', + }, + }, + }, + }); + + expect(serverPageRequestTxn).toMatchObject({ + transaction: 'GET /user-page/settings', + transaction_info: { source: 'route' }, + contexts: { + trace: { + op: 'http.server', + origin: 'auto.http.astro', + data: { + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.astro', + 'sentry.source': 'route', + url: expect.stringContaining('/user-page/settings'), + }, + }, + }, + request: { url: expect.stringContaining('/user-page/settings') }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts index fc396999d76e..202051e7a57e 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.serverIslands.test.ts @@ -32,11 +32,11 @@ test.describe('tracing in static routes with server islands', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', parent_span_id: metaParentSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: metaTraceId, @@ -45,7 +45,7 @@ test.describe('tracing in static routes with server islands', () => { platform: 'javascript', transaction: '/server-island', transaction_info: { - source: 'url', + source: 'route', }, type: 'transaction', }); @@ -63,7 +63,7 @@ test.describe('tracing in static routes with server islands', () => { ]), ); - expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Fserver-island%2F'); // URL-encoded for 'GET /test-static/' + expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Fserver-island'); // URL-encoded for 'GET /server-island' expect(baggageMetaTagContent).toContain('sentry-sampled=true'); const serverIslandEndpointTxn = await serverIslandEndpointTxnPromise; diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts index 9db35c72a47d..7593d6823d9b 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/tracing.static.test.ts @@ -35,11 +35,11 @@ test.describe('tracing in static/pre-rendered routes', () => { trace: { data: expect.objectContaining({ 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.browser', - 'sentry.source': 'url', + 'sentry.origin': 'auto.pageload.astro', + 'sentry.source': 'route', }), op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.astro', parent_span_id: metaParentSpanId, span_id: expect.stringMatching(/[a-f0-9]{16}/), trace_id: metaTraceId, @@ -48,12 +48,12 @@ test.describe('tracing in static/pre-rendered routes', () => { platform: 'javascript', transaction: '/test-static', transaction_info: { - source: 'url', + source: 'route', }, type: 'transaction', }); - expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static%2F'); // URL-encoded for 'GET /test-static/' + expect(baggageMetaTagContent).toContain('sentry-transaction=GET%20%2Ftest-static'); // URL-encoded for 'GET /test-static' expect(baggageMetaTagContent).toContain('sentry-sampled=true'); await page.waitForTimeout(1000); // wait another sec to ensure no server transaction is sent diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json index 1d98acc92859..25489cf0a35e 100644 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json @@ -1,7 +1,8 @@ { - "name": "node-express-app", + "name": "aws-lambda-layer-cjs", "version": "1.0.0", "private": true, + "type": "commonjs", "scripts": { "start": "node src/run.js", "test": "playwright test", diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts index 3393b2a559dd..b8f7a4b4d51e 100644 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts @@ -18,7 +18,7 @@ test('Lambda layer SDK bundle sends events', async ({ request }) => { ); child_process.execSync('pnpm start', { - stdio: 'ignore', + stdio: 'inherit', }); const transactionEvent = await transactionEventPromise; @@ -69,4 +69,9 @@ test('Lambda layer SDK bundle sends events', async ({ request }) => { op: 'test', }), ); + + // shows that the SDK source is correctly detected + expect(transactionEvent.sdk?.packages).toContainEqual( + expect.objectContaining({ name: 'aws-lambda-layer:@sentry/aws-serverless' }), + ); }); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/.npmrc b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/package.json b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/package.json new file mode 100644 index 000000000000..7a25061dde1c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/package.json @@ -0,0 +1,23 @@ +{ + "name": "aws-lambda-layer-esm", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node src/run.mjs", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "//": "Link from local Lambda layer build", + "dependencies": { + "@sentry/aws-serverless": "link:../../../../packages/aws-serverless/build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless" + }, + "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "@playwright/test": "~1.53.2" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/playwright.config.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/playwright.config.ts new file mode 100644 index 000000000000..174593c307df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/playwright.config.ts @@ -0,0 +1,3 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig(); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/lambda-function.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/lambda-function.mjs new file mode 100644 index 000000000000..a9cdd48c1197 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/lambda-function.mjs @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/aws-serverless'; + +import * as http from 'node:http'; + +async function handle() { + await Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => { + await new Promise(resolve => { + http.get('http://example.com', res => { + res.on('data', d => { + process.stdout.write(d); + }); + + res.on('end', () => { + resolve(); + }); + }); + }); + }); +} + +export { handle }; diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run-lambda.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run-lambda.mjs new file mode 100644 index 000000000000..c30903f9883d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run-lambda.mjs @@ -0,0 +1,8 @@ +import { handle } from './lambda-function.mjs'; + +const event = {}; +const context = { + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', + functionName: 'my-lambda', +}; +await handle(event, context); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run.mjs new file mode 100644 index 000000000000..4bcd5886a865 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run.mjs @@ -0,0 +1,17 @@ +import child_process from 'node:child_process'; + +child_process.execSync('node ./src/run-lambda.mjs', { + stdio: 'inherit', + env: { + ...process.env, + // On AWS, LAMBDA_TASK_ROOT is usually /var/task but for testing, we set it to the CWD to correctly apply our handler + LAMBDA_TASK_ROOT: process.cwd(), + _HANDLER: 'src/lambda-function.handle', + + NODE_OPTIONS: '--import @sentry/aws-serverless/awslambda-auto', + SENTRY_DSN: 'http://public@localhost:3031/1337', + SENTRY_TRACES_SAMPLE_RATE: '1.0', + SENTRY_DEBUG: 'true', + }, + cwd: process.cwd(), +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/start-event-proxy.mjs new file mode 100644 index 000000000000..03fc10269998 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'aws-lambda-layer-esm', +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/tests/basic.test.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/tests/basic.test.ts new file mode 100644 index 000000000000..14ae8f9b81b0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/tests/basic.test.ts @@ -0,0 +1,77 @@ +import * as child_process from 'child_process'; +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Lambda layer SDK bundle sends events', async ({ request }) => { + const transactionEventPromise = waitForTransaction('aws-lambda-layer-esm', transactionEvent => { + return transactionEvent?.transaction === 'my-lambda'; + }); + + // Waiting for 1s here because attaching the listener for events in `waitForTransaction` is not synchronous + // Since in this test, we don't start a browser via playwright, we don't have the usual delays (page.goto, etc) + // which are usually enough for us to never have noticed this race condition before. + // This is a workaround but probably sufficient as long as we only experience it in this test. + await new Promise(resolve => + setTimeout(() => { + resolve(); + }, 1000), + ); + + child_process.execSync('pnpm start', { + stdio: 'inherit', + }); + + const transactionEvent = await transactionEventPromise; + + // shows the SDK sent a transaction + expect(transactionEvent.transaction).toEqual('my-lambda'); // name should be the function name + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.op': 'function.aws.lambda', + 'cloud.account.id': '123453789012', + 'faas.id': 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', + 'faas.coldstart': true, + 'otel.kind': 'SERVER', + }, + op: 'function.aws.lambda', + origin: 'auto.otel.aws-lambda', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.spans).toHaveLength(2); + + // shows that the Otel Http instrumentation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + url: 'http://example.com/', + }), + description: 'GET http://example.com/', + op: 'http.client', + }), + ); + + // shows that the manual span creation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'test', + 'sentry.origin': 'manual', + }), + description: 'manual-span', + op: 'test', + }), + ); + + // shows that the SDK source is correctly detected + expect(transactionEvent.sdk?.packages).toContainEqual( + expect.objectContaining({ name: 'aws-lambda-layer:@sentry/aws-serverless' }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/tests/basic.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-esm/tests/basic.test.ts index b27e16bdaa85..803484881837 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/tests/basic.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-esm/tests/basic.test.ts @@ -82,4 +82,9 @@ test('AWS Serverless SDK sends events in ESM mode', async ({ request }) => { op: 'manual', }), ); + + // shows that the SDK source is correctly detected + expect(transactionEvent.sdk?.packages).toContainEqual( + expect.objectContaining({ name: 'npm:@sentry/aws-serverless' }), + ); }); diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json index fcda3617e5c5..3321552a5442 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@sentry/browser": "latest || *", - "@sentry/vite-plugin": "^3.5.0" + "@sentry/vite-plugin": "^4.0.0" }, "volta": { "node": "20.19.2", diff --git a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json index 17f7b1b21e0a..68973f3ffd72 100644 --- a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/package.json @@ -15,7 +15,7 @@ "devDependencies": { "rollup": "^4.35.0", "vitest": "^0.34.6", - "@sentry/rollup-plugin": "2.22.6" + "@sentry/rollup-plugin": "^4.0.0" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts index 2b8c555d7322..ac4c8bdea83e 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts @@ -97,7 +97,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { component: '@nestjs/core', 'nestjs.version': expect.any(String), 'nestjs.type': 'request_context', - 'http.request.method': 'GET', + 'http.method': 'GET', 'http.url': '/test-transaction', 'http.route': '/test-transaction', 'nestjs.controller': 'AppController', diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts index 384e1e35055d..f0645c9fd8e5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts @@ -42,7 +42,10 @@ test('Should send a transaction and an error event for a faulty generateMetadata }); const errorEventPromise = waitForError('nextjs-14', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'generateMetadata Error'; + return ( + errorEvent?.exception?.values?.[0]?.value === 'generateMetadata Error' && + errorEvent.transaction === 'Page.generateMetadata (/generation-functions)' + ); }); await page.goto(`/generation-functions?metadataTitle=${testTitle}&shouldThrowInGenerateMetadata=1`); @@ -50,8 +53,6 @@ test('Should send a transaction and an error event for a faulty generateMetadata const errorEvent = await errorEventPromise; const transactionEvent = await transactionPromise; - expect(errorEvent.transaction).toBe('Page.generateMetadata (/generation-functions)'); - // Assert that isolation scope works properly expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore index ebdbfc025b6a..0c60c8eeaee8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/.gitignore @@ -44,3 +44,5 @@ next-env.d.ts test-results event-dumps + +.tmp_dev_server_logs diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/ai-error-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/ai-error-test/page.tsx new file mode 100644 index 000000000000..bd75c0062228 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/ai-error-test/page.tsx @@ -0,0 +1,50 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +// Error trace handling in tool calls +async function runAITest() { + const result = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async args => { + throw new Error('Tool call failed'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); +} + +export default async function Page() { + await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { + return await runAITest(); + }); + + return ( +
+

AI Test Results

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/page.tsx new file mode 100644 index 000000000000..04618df0d754 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Next 15 test app

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 8216f06f7be6..063f36d3b164 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "build": "next build > .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", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test", "test:dev-turbo": "TEST_ENV=dev-turbopack playwright test", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs index f2aa01e3e3c8..e1be6810f4dc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs @@ -7,11 +7,11 @@ if (!testEnv) { const getStartCommand = () => { if (testEnv === 'dev-turbopack') { - return 'pnpm next dev -p 3030 --turbopack'; + return 'pnpm next dev -p 3030 --turbopack 2>&1 | tee .tmp_dev_server_logs'; } if (testEnv === 'development') { - return 'pnpm next dev -p 3030'; + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; } if (testEnv === 'production') { 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 new file mode 100644 index 000000000000..32fd12ee0ed5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-error.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +test('should create AI spans with correct attributes and error linking', async ({ page }) => { + const aiTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === 'GET /ai-error-test'; + }); + + const errorEventPromise = waitForError('nextjs-15', async errorEvent => { + return errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); + }); + + await page.goto('/ai-error-test'); + + const aiTransaction = await aiTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(aiTransaction).toBeDefined(); + expect(aiTransaction.transaction).toBe('GET /ai-error-test'); + + const spans = aiTransaction.spans || []; + + // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate + // Plus a span for the tool call + // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working + // because of this, only spans that are manually opted-in at call time will be captured + // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future + const aiPipelineSpans = spans.filter(span => span.op === 'gen_ai.invoke_agent'); + const aiGenerateSpans = spans.filter(span => span.op === 'gen_ai.generate_text'); + const toolCallSpans = spans.filter(span => span.op === 'gen_ai.execute_tool'); + + expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); + expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); + expect(toolCallSpans.length).toBeGreaterThanOrEqual(0); + + expect(errorEvent).toBeDefined(); + + //Verify error is linked to the same trace as the transaction + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/async-params.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/async-params.test.ts new file mode 100644 index 000000000000..c8b35ea491ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/async-params.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import fs from 'fs'; + +test('should not print warning for async params', async ({ page }) => { + test.skip( + process.env.TEST_ENV !== 'development' && process.env.TEST_ENV !== 'dev-turbopack', + 'should be skipped for non-dev mode', + ); + await page.goto('/'); + + // If the server exits with code 1, the test will fail (see instrumentation.ts) + const devStdout = fs.readFileSync('.tmp_dev_server_logs', 'utf-8'); + expect(devStdout).not.toContain('`params` should be awaited before using its properties.'); + + await expect(page.getByText('Next 15 test app')).toBeVisible(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json index 85bc81d19132..e25c4ec84053 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -1,5 +1,5 @@ { - "name": "create-next-app", + "name": "nextjs-app-dir", "version": "0.1.0", "private": true, "scripts": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts index d1ca11ad9a9e..ea9fd112778f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts @@ -1,35 +1,30 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; -test.describe('dev mode error symbolification', () => { - if (process.env.TEST_ENV !== 'development') { - test.skip('should be skipped for non-dev mode', () => {}); - return; - } +test('should have symbolicated dev errors', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'development', 'should be skipped for non-dev mode'); - test('should have symbolicated dev errors', async ({ page }) => { - await page.goto('/'); + await page.goto('/'); - const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'Click Error'; - }); + const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Click Error'; + }); - await page.getByText('Throw error').click(); + await page.getByText('Throw error').click(); - const errorEvent = await errorEventPromise; - const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; - expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( - expect.objectContaining({ - function: 'onClick', - filename: 'components/client-error-debug-tools.tsx', - lineno: 54, - colno: expect.any(Number), - in_app: true, - pre_context: [' {'], - context_line: " throw new Error('Click Error');", - post_context: [' }}', ' >', ' Throw error'], - }), - ); - }); + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: 'onClick', + filename: 'components/client-error-debug-tools.tsx', + lineno: 54, + colno: expect.any(Number), + in_app: true, + pre_context: [' {'], + context_line: " throw new Error('Click Error');", + post_context: [' }}', ' >', ' Throw error'], + }), + ); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/.gitignore new file mode 100644 index 000000000000..ebdbfc025b6a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/.gitignore @@ -0,0 +1,46 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# 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* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +!*.d.ts + +# Sentry +.sentryclirc + +.vscode + +test-results +event-dumps diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/app/index.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/app/index.tsx new file mode 100644 index 000000000000..0f86a210c1d2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/app/index.tsx @@ -0,0 +1 @@ +// Without this file (or better said without the app directory), we run into a otel dependency issue on the edge runtime diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/assert-build.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/assert-build.ts new file mode 100644 index 000000000000..b965e5fcd473 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/assert-build.ts @@ -0,0 +1,44 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as assert from 'assert/strict'; + +const packageJson = require('./package.json'); +const nextjsVersion = packageJson.dependencies.next; + +const buildStdout = fs.readFileSync('.tmp_build_stdout', 'utf-8'); +const buildStderr = fs.readFileSync('.tmp_build_stderr', 'utf-8'); + +const getLatestNextVersion = async () => { + try { + const response = await fetch('https://registry.npmjs.org/next/latest'); + const data = await response.json(); + return data.version as string; + } catch { + return '0.0.0'; + } +}; + +(async () => { + // Assert that there was no funky build time warning when we are on a stable (pinned) version + if ( + !nextjsVersion.includes('-canary') && + !nextjsVersion.includes('-rc') && + // If we install latest we cannot assert on "latest" because the package json will contain the actual version number + nextjsVersion !== (await getLatestNextVersion()) + ) { + assert.doesNotMatch( + buildStderr, + /Import trace for requested module/, // This is Next.js/Webpack speech for "something is off" + `The E2E tests detected a build warning in the Next.js build output:\n\n--------------\n\n${buildStderr}\n\n--------------\n\n`, + ); + } + + // Read the contents of the directory + const files = fs.readdirSync(path.join(process.cwd(), '.next', 'static')); + const mapFiles = files.filter(file => path.extname(file) === '.map'); + if (mapFiles.length > 0) { + throw new Error( + 'Client bundle .map files found even though `sourcemaps.deleteSourcemapsAfterUpload` option is set!', + ); + } +})(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/components/client-error-debug-tools.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/components/client-error-debug-tools.tsx new file mode 100644 index 000000000000..278da75e850c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/components/client-error-debug-tools.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { captureException } from '@sentry/nextjs'; +import { useContext, useState } from 'react'; +import { SpanContext } from './span-context'; + +export function ClientErrorDebugTools() { + const spanContextValue = useContext(SpanContext); + const [spanName, setSpanName] = useState(''); + + const [isFetchingAPIRoute, setIsFetchingAPIRoute] = useState(); + const [isFetchingEdgeAPIRoute, setIsFetchingEdgeAPIRoute] = useState(); + const [isFetchingExternalAPIRoute, setIsFetchingExternalAPIRoute] = useState(); + const [renderError, setRenderError] = useState(); + + if (renderError) { + throw new Error('Render Error'); + } + + return ( +
+ {spanContextValue.spanActive ? ( + + ) : ( + <> + { + setSpanName(e.target.value); + }} + /> + + + )} +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/components/span-context.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/components/span-context.tsx new file mode 100644 index 000000000000..834ccc3fadf3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/components/span-context.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { startInactiveSpan, Span } from '@sentry/nextjs'; +import { PropsWithChildren, createContext, useState } from 'react'; + +export const SpanContext = createContext< + { spanActive: false; start: (spanName: string) => void } | { spanActive: true; stop: () => void } +>({ + spanActive: false, + start: () => undefined, +}); + +export function SpanContextProvider({ children }: PropsWithChildren) { + const [span, setSpan] = useState(undefined); + + return ( + { + span.end(); + setSpan(undefined); + }, + } + : { + spanActive: false, + start: (spanName: string) => { + const span = startInactiveSpan({ name: spanName }); + setSpan(span); + }, + } + } + > + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/globals.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/globals.d.ts new file mode 100644 index 000000000000..109dbcd55648 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/globals.d.ts @@ -0,0 +1,4 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation-client.ts new file mode 100644 index 000000000000..4870c64e7959 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/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, + sendDefaultPii: true, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts new file mode 100644 index 000000000000..a95bb9ee95ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/instrumentation.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/nextjs'; + +export function register() { + if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') { + 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, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, + }); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts new file mode 100644 index 000000000000..abc565f438b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/middleware.ts @@ -0,0 +1,24 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function middleware(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next-env.d.ts new file mode 100644 index 000000000000..725dd6f24515 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js new file mode 100644 index 000000000000..ee7efe23508f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/next.config.js @@ -0,0 +1,15 @@ +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + serverActions: true, + }, +}; + +module.exports = withSentryConfig(nextConfig, { + debug: true, + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json new file mode 100644 index 000000000000..03a7efd1d521 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json @@ -0,0 +1,54 @@ +{ + "name": "nextjs-pages-dir", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build > .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", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:test-build": "pnpm ts-node --script-mode assert-build.ts", + "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@beta && pnpm add react-dom@beta && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@latest && pnpm add react@latest && pnpm add react-dom@latest && pnpm build", + "test:build-13": "pnpm install && pnpm add next@13.5.11 && pnpm build", + "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@sentry/core": "latest || *", + "@types/node": "^18.19.1", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "14.2.25", + "react": "18.2.0", + "react-dom": "18.2.0", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "ts-node": "10.9.1" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "test:build-13", + "label": "nextjs-pages-dir (next@13)" + } + ], + "optionalVariants": [ + { + "build-command": "test:build-canary", + "label": "nextjs-pages-dir (canary)" + }, + { + "build-command": "test:build-latest", + "label": "nextjs-pages-dir (latest)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/async-context-edge-endpoint.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/async-context-edge-endpoint.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/async-context-edge-endpoint.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/async-context-edge-endpoint.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/edge-endpoint.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/edge-endpoint.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/edge-endpoint.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-faulty-middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/endpoint-behind-faulty-middleware.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-faulty-middleware.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/endpoint-behind-faulty-middleware.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/endpoint-behind-middleware.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint-behind-middleware.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/endpoint-behind-middleware.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/endpoint.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/endpoint.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/endpoint.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/error-edge-endpoint.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/error-edge-endpoint.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/error-edge-endpoint.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/request-instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/request-instrumentation.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/api/request-instrumentation.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/api/request-instrumentation.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/index.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/index.tsx new file mode 100644 index 000000000000..109542e2fba5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/index.tsx @@ -0,0 +1,10 @@ +import { ClientErrorDebugTools } from '../components/client-error-debug-tools'; + +export default function Page() { + return ( +
+

Page (/)

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/pages-router/ssr-error-class.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/pages-router/ssr-error-class.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/pages-router/ssr-error-class.tsx rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/pages-router/ssr-error-class.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/pages-router/ssr-error-fc.tsx b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/pages-router/ssr-error-fc.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/pages/pages-router/ssr-error-fc.tsx rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/pages/pages-router/ssr-error-fc.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/playwright.config.mjs new file mode 100644 index 000000000000..c675d003853a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/playwright.config.mjs @@ -0,0 +1,13 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const config = getPlaywrightConfig({ + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/start-event-proxy.mjs new file mode 100644 index 000000000000..00d301804ba1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/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-pages-dir', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-pages-dir-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/async-context-edge.test.ts similarity index 95% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/async-context-edge.test.ts index cb92cb2bab49..d823a1cf5605 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/async-context-edge.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/async-context-edge.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('Should allow for async context isolation in the edge SDK', async ({ request }) => { - const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + const edgerouteTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'GET /api/async-context-edge-endpoint' && transactionEvent.contexts?.runtime?.name === 'vercel-edge' diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/devErrorSymbolification.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/devErrorSymbolification.test.ts new file mode 100644 index 000000000000..c846fab3464c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/devErrorSymbolification.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should have symbolicated dev errors', async ({ page }) => { + test.skip(process.env.TEST_ENV !== 'development', 'should be skipped for non-dev mode'); + + await page.goto('/'); + + const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Click Error'; + }); + + await page.getByText('Throw error').click(); + + const errorEvent = await errorEventPromise; + const errorEventFrames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + + expect(errorEventFrames?.[errorEventFrames?.length - 1]).toEqual( + expect.objectContaining({ + function: 'onClick', + filename: 'components/client-error-debug-tools.tsx', + lineno: 54, + colno: expect.any(Number), + in_app: true, + pre_context: [' {'], + context_line: " throw new Error('Click Error');", + post_context: [' }}', ' >', ' Throw error'], + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/edge-route.test.ts similarity index 93% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/edge-route.test.ts index 88460e3ab533..d2ede428b978 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/edge-route.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for edge routes', async ({ request }) => { - const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + const edgerouteTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'GET /api/edge-endpoint' && transactionEvent.contexts?.runtime?.name === 'vercel-edge' @@ -24,14 +24,14 @@ test('Should create a transaction for edge routes', async ({ request }) => { }); test('Faulty edge routes', async ({ request }) => { - const edgerouteTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + const edgerouteTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'GET /api/error-edge-endpoint' && transactionEvent.contexts?.runtime?.name === 'vercel-edge' ); }); - const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { return ( errorEvent?.exception?.values?.[0]?.value === 'Edge Route Error' && errorEvent.contexts?.runtime?.name === 'vercel-edge' diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts similarity index 95% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index ebd60b8e3824..b9c0e7b4b602 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for middleware', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware'; }); @@ -22,11 +22,11 @@ test('Should create a transaction for middleware', async ({ request }) => { }); test('Faulty middlewares', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-faulty-middleware'; }); - const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; }); @@ -53,7 +53,7 @@ test('Faulty middlewares', async ({ request }) => { }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { - const middlewareTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware' && !!transactionEvent.spans?.find(span => span.op === 'http.client') diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/pages-ssr-errors.test.ts similarity index 85% rename from dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts rename to dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/pages-ssr-errors.test.ts index 10a4cd77f111..c3925f52ba48 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/pages-ssr-errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/pages-ssr-errors.test.ts @@ -2,11 +2,11 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Will capture error for SSR rendering error with a connected trace (Class Component)', async ({ page }) => { - const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Pages SSR Error Class'; }); - const serverComponentTransaction = waitForTransaction('nextjs-app-dir', async transactionEvent => { + const serverComponentTransaction = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'GET /pages-router/ssr-error-class' && (await errorEventPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id @@ -20,11 +20,11 @@ test('Will capture error for SSR rendering error with a connected trace (Class C }); test('Will capture error for SSR rendering error with a connected trace (Functional Component)', async ({ page }) => { - const errorEventPromise = waitForError('nextjs-app-dir', errorEvent => { + const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Pages SSR Error FC'; }); - const ssrTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { + const ssrTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( transactionEvent?.transaction === 'GET /pages-router/ssr-error-fc' && (await errorEventPromise).contexts?.trace?.trace_id === transactionEvent.contexts?.trace?.trace_id diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts new file mode 100644 index 000000000000..c65ba88c39c3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/request-instrumentation.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Note(lforst): I officially declare bancruptcy on this test. I tried a million ways to make it work but it kept flaking. +// Sometimes the request span was included in the handler span, more often it wasn't. I have no idea why. Maybe one day we will +// figure it out. Today is not that day. +test.skip('Should send a transaction with a http span', async ({ request }) => { + const transactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { + return transactionEvent?.transaction === 'GET /api/request-instrumentation'; + }); + + await request.get('/api/request-instrumentation'); + + expect((await transactionPromise).spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'http.method': 'GET', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + }), + description: 'GET https://example.com/', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts new file mode 100644 index 000000000000..918297898de7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +const packageJson = require('../package.json'); + +test('Sends a pageload transaction', async ({ page }) => { + const nextjsVersion = packageJson.dependencies.next; + const nextjsMajor = Number(nextjsVersion.split('.')[0]); + const isDevMode = process.env.TEST_ENV === 'development'; + + const pageloadTransactionEventPromise = waitForTransaction('nextjs-pages-dir', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/', + transaction_info: { source: 'route' }, + type: 'transaction', + contexts: { + react: { + version: expect.any(String), + }, + trace: { + // Next.js >= 15 propagates a trace ID to the client via a meta tag. Also, only dev mode emits a meta tag because + // the requested page is static and only in dev mode SSR is kicked off. + parent_span_id: nextjsMajor >= 15 && isDevMode ? expect.any(String) : undefined, + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + op: 'pageload', + origin: 'auto.pageload.nextjs.pages_router_instrumentation', + data: expect.objectContaining({ + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', + 'sentry.source': 'route', + }), + }, + }, + request: { + headers: { + 'User-Agent': expect.any(String), + }, + url: 'http://localhost:3030/', + }, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tsconfig.json new file mode 100644 index 000000000000..bd69196a9ca4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js", ".next/types/**/*.ts"], + "exclude": ["node_modules", "playwright.config.ts"], + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json index e4f02f7438f3..46aba39d865c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build": "next build --turbopack > .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", "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-turbo/playwright.config.mjs index a62bec62a5c8..9a74af430808 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/playwright.config.mjs @@ -7,7 +7,7 @@ if (!testEnv) { const config = getPlaywrightConfig( { - startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030 --turbo' : 'pnpm next start -p 3030', + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030 --turbopack' : 'pnpm next start -p 3030', port: 3030, }, { diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.gitignore new file mode 100644 index 000000000000..686a0277246c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.gitignore @@ -0,0 +1,2 @@ +dist +.vscode diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json new file mode 100644 index 000000000000..d445419ece51 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/package.json @@ -0,0 +1,36 @@ +{ + "name": "node-core-express-otel-v1-custom-sampler", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1", + "@opentelemetry/core": "^1.30.1", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/instrumentation-http": "^0.57.1", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-node": "^1.30.1", + "@opentelemetry/semantic-conventions": "^1.30.0", + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/express": "4.17.17", + "@types/node": "^18.19.1", + "express": "4.19.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/app.ts new file mode 100644 index 000000000000..e5da185262ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/app.ts @@ -0,0 +1,51 @@ +import './instrument'; + +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const PORT = 3030; +const app = express(); + +const wait = (duration: number) => { + return new Promise(res => { + setTimeout(() => res(), duration); + }); +}; + +app.get('/task', async (_req, res) => { + await Sentry.startSpan({ name: 'Long task', op: 'custom.op' }, async () => { + await wait(200); + }); + res.send('ok'); +}); + +app.get('/unsampled/task', async (_req, res) => { + await wait(200); + res.send('ok'); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // Explicitly capture the error with Sentry + Sentry.captureException(err); + + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(PORT, () => { + console.log('App listening on ', PORT); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/custom-sampler.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/custom-sampler.ts new file mode 100644 index 000000000000..cbaaac57c8ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/custom-sampler.ts @@ -0,0 +1,31 @@ +import { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'; +import { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-node'; +import { wrapSamplingDecision } from '@sentry/opentelemetry'; + +export class CustomSampler implements Sampler { + public shouldSample( + context: Context, + _traceId: string, + _spanName: string, + _spanKind: SpanKind, + attributes: Attributes, + _links: Link[], + ): SamplingResult { + const route = attributes['http.route']; + const target = attributes['http.target']; + const decision = + (typeof route === 'string' && route.includes('/unsampled')) || + (typeof target === 'string' && target.includes('/unsampled')) + ? 0 + : 1; + return wrapSamplingDecision({ + decision, + context, + spanAttributes: attributes, + }); + } + + public toString(): string { + return CustomSampler.name; + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/instrument.ts new file mode 100644 index 000000000000..b01601ad8910 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/src/instrument.ts @@ -0,0 +1,27 @@ +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import * as Sentry from '@sentry/node-core'; +import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry'; +import { CustomSampler } from './custom-sampler'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + openTelemetryInstrumentations: [new HttpInstrumentation()], +}); + +const provider = new NodeTracerProvider({ + sampler: new CustomSampler(), + spanProcessors: [new SentrySpanProcessor()], +}); + +provider.register({ + propagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), +}); + +Sentry.validateOpenTelemetrySetup(); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/start-event-proxy.mjs new file mode 100644 index 000000000000..fd7ba2bfcbc2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v1-custom-sampler', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/errors.test.ts new file mode 100644 index 000000000000..a5f45ddc4b52 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/errors.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-core-express-otel-v1-custom-sampler', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + // For node-core without Express integration, transaction name is the actual URL + expect(errorEvent.transaction).toEqual('GET /test-exception/123'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts new file mode 100644 index 000000000000..60e2424552cd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tests/sampling.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a sampled API route transaction', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-core-express-otel-v1-custom-sampler', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /task'; + }); + + await fetch(`${baseURL}/task`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.source': 'url', + 'sentry.op': 'http.server', + 'sentry.origin': 'manual', + url: 'http://localhost:3030/task', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/task', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/task', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': 3030, + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + }, + origin: 'manual', + op: 'http.server', + status: 'ok', + }); + + expect(transactionEvent.spans?.length).toBe(1); + + expect(transactionEvent.spans).toContainEqual({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.origin': 'manual', + 'sentry.op': 'custom.op', + }, + description: 'Long task', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'custom.op', + origin: 'manual', + }); +}); + +test('Does not send an unsampled API route transaction', async ({ baseURL }) => { + const unsampledTransactionEventPromise = waitForTransaction( + 'node-core-express-otel-v1-custom-sampler', + transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /unsampled/task' + ); + }, + ); + + await fetch(`${baseURL}/unsampled/task`); + + const promiseShouldNotResolve = () => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resolve(); // Test passes because promise did not resolve within timeout + }, 1000); + + unsampledTransactionEventPromise.then( + () => { + clearTimeout(timeout); + reject(new Error('Promise should not have resolved')); + }, + () => { + clearTimeout(timeout); + reject(new Error('Promise should not have been rejected')); + }, + ); + }); + + expect(promiseShouldNotResolve()).resolves.not.toThrow(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-custom-sampler/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json new file mode 100644 index 000000000000..868334df93fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/package.json @@ -0,0 +1,38 @@ +{ + "name": "node-core-express-otel-v1-sdk-node", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1", + "@opentelemetry/core": "^1.30.1", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/instrumentation-http": "^0.57.2", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-node": "^1.30.1", + "@opentelemetry/semantic-conventions": "^1.30.0", + "@opentelemetry/sdk-node": "^0.57.2", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.2", + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/express": "4.17.17", + "@types/node": "^18.19.1", + "express": "4.19.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/playwright.config.mjs @@ -0,0 +1,34 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + }, + { + webServer: [ + { + command: `node ./start-event-proxy.mjs`, + port: 3031, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: `node ./start-otel-proxy.mjs`, + port: 3032, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: 3030, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT: 3030, + }, + }, + ], + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/app.ts new file mode 100644 index 000000000000..69f55b25e6ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/app.ts @@ -0,0 +1,55 @@ +import './instrument'; + +// Other imports below +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (req, res) { + Sentry.withActiveSpan(null, async () => { + Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + }); + + await Sentry.flush(); + + res.send({}); + }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // Explicitly capture the error with Sentry because @sentry/node-core doesn't have + // a way to capture errors from express like @sentry/node does. + res.sentry = Sentry.captureException(err); + + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/instrument.ts new file mode 100644 index 000000000000..276b4f55ac73 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/src/instrument.ts @@ -0,0 +1,35 @@ +const opentelemetry = require('@opentelemetry/sdk-node'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); +const Sentry = require('@sentry/node-core'); +const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); +const { SentrySpanProcessor, SentryPropagator, SentrySampler } = require('@sentry/opentelemetry'); + +const sentryClient = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: true, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); + +if (sentryClient) { + const sdk = new opentelemetry.NodeSDK({ + sampler: new SentrySampler(sentryClient), + textMapPropagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), + spanProcessors: [ + new SentrySpanProcessor(), + new opentelemetry.node.BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], + instrumentations: [new HttpInstrumentation()], + }); + + sdk.start(); + + Sentry.validateOpenTelemetrySetup(); +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-event-proxy.mjs new file mode 100644 index 000000000000..815dabeb77f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v1-sdk-node', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-otel-proxy.mjs new file mode 100644 index 000000000000..ecbbbabea624 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-core-express-otel-v1-sdk-node-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/errors.test.ts new file mode 100644 index 000000000000..7377bd3d91ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/errors.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-core-express-otel-v1-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/123'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); + +test('Errors do not leak between requests', async ({ baseURL }) => { + // Set up promises to capture errors for both requests + const firstErrorPromise = waitForError('node-core-express-otel-v1-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 111'; + }); + + const secondErrorPromise = waitForError('node-core-express-otel-v1-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 222'; + }); + + // Make first error request + await fetch(`${baseURL}/test-exception/111`); + + // Make second error request + await fetch(`${baseURL}/test-exception/222`); + + // Wait for both error events to be captured + const [firstError, secondError] = await Promise.all([firstErrorPromise, secondErrorPromise]); + + // Verify first error has correct data and doesn't contain data from second error + expect(firstError.exception?.values?.[0]?.value).toBe('This is an exception with id 111'); + expect(firstError.transaction).toEqual('GET /test-exception/111'); + expect(firstError.request?.url).toBe('http://localhost:3030/test-exception/111'); + + // Verify second error has correct data and doesn't contain data from first error + expect(secondError.exception?.values?.[0]?.value).toBe('This is an exception with id 222'); + expect(secondError.transaction).toEqual('GET /test-exception/222'); + expect(secondError.request?.url).toBe('http://localhost:3030/test-exception/222'); + + // Verify errors have different trace contexts (no leakage) + expect(firstError.contexts?.trace?.trace_id).not.toEqual(secondError.contexts?.trace?.trace_id); + expect(firstError.contexts?.trace?.span_id).not.toEqual(secondError.contexts?.trace?.span_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts new file mode 100644 index 000000000000..6141261d8954 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tests/transactions.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-core-express-otel-v1-sdk-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + // Ensure we also send data to the OTLP endpoint + const otelPromise = waitForPlainRequest('node-core-express-otel-v1-sdk-node-otel', data => { + const json = JSON.parse(data) as any; + + return json.resourceSpans.length > 0; + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + const otelData = await otelPromise; + + // For now we do not test the actual shape of this, but only existence + expect(otelData).toBeDefined(); + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'url', + 'sentry.origin': 'manual', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'url', + }, + }), + ); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-core-express-otel-v1-sdk-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/777' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/777'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v1-sdk-node/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.gitignore new file mode 100644 index 000000000000..686a0277246c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.gitignore @@ -0,0 +1,2 @@ +dist +.vscode diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json new file mode 100644 index 000000000000..3b6c974f44b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json @@ -0,0 +1,36 @@ +{ + "name": "node-core-express-otel-v2-custom-sampler", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-http": "^0.203.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/express": "4.17.17", + "@types/node": "^18.19.1", + "express": "4.19.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/app.ts new file mode 100644 index 000000000000..e5da185262ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/app.ts @@ -0,0 +1,51 @@ +import './instrument'; + +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const PORT = 3030; +const app = express(); + +const wait = (duration: number) => { + return new Promise(res => { + setTimeout(() => res(), duration); + }); +}; + +app.get('/task', async (_req, res) => { + await Sentry.startSpan({ name: 'Long task', op: 'custom.op' }, async () => { + await wait(200); + }); + res.send('ok'); +}); + +app.get('/unsampled/task', async (_req, res) => { + await wait(200); + res.send('ok'); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // Explicitly capture the error with Sentry + Sentry.captureException(err); + + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(PORT, () => { + console.log('App listening on ', PORT); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/custom-sampler.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/custom-sampler.ts new file mode 100644 index 000000000000..cbaaac57c8ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/custom-sampler.ts @@ -0,0 +1,31 @@ +import { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'; +import { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-node'; +import { wrapSamplingDecision } from '@sentry/opentelemetry'; + +export class CustomSampler implements Sampler { + public shouldSample( + context: Context, + _traceId: string, + _spanName: string, + _spanKind: SpanKind, + attributes: Attributes, + _links: Link[], + ): SamplingResult { + const route = attributes['http.route']; + const target = attributes['http.target']; + const decision = + (typeof route === 'string' && route.includes('/unsampled')) || + (typeof target === 'string' && target.includes('/unsampled')) + ? 0 + : 1; + return wrapSamplingDecision({ + decision, + context, + spanAttributes: attributes, + }); + } + + public toString(): string { + return CustomSampler.name; + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/instrument.ts new file mode 100644 index 000000000000..b01601ad8910 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/src/instrument.ts @@ -0,0 +1,27 @@ +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import * as Sentry from '@sentry/node-core'; +import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry'; +import { CustomSampler } from './custom-sampler'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + openTelemetryInstrumentations: [new HttpInstrumentation()], +}); + +const provider = new NodeTracerProvider({ + sampler: new CustomSampler(), + spanProcessors: [new SentrySpanProcessor()], +}); + +provider.register({ + propagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), +}); + +Sentry.validateOpenTelemetrySetup(); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/start-event-proxy.mjs new file mode 100644 index 000000000000..1c678218dde5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v2-custom-sampler', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/errors.test.ts new file mode 100644 index 000000000000..f2de2878ed55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/errors.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-core-express-otel-v2-custom-sampler', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + // For node-core without Express integration, transaction name is the actual URL + expect(errorEvent.transaction).toEqual('GET /test-exception/123'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts new file mode 100644 index 000000000000..134f9f22b429 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tests/sampling.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a sampled API route transaction', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-core-express-otel-v2-custom-sampler', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /task'; + }); + + await fetch(`${baseURL}/task`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.source': 'url', + 'sentry.op': 'http.server', + 'sentry.origin': 'manual', + url: 'http://localhost:3030/task', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/task', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/task', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': 3030, + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + }, + origin: 'manual', + op: 'http.server', + status: 'ok', + }); + + expect(transactionEvent.spans?.length).toBe(1); + + expect(transactionEvent.spans).toContainEqual({ + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'sentry.origin': 'manual', + 'sentry.op': 'custom.op', + }, + description: 'Long task', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + op: 'custom.op', + origin: 'manual', + }); +}); + +test('Does not send an unsampled API route transaction', async ({ baseURL }) => { + const unsampledTransactionEventPromise = waitForTransaction( + 'node-core-express-otel-v2-custom-sampler', + transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /unsampled/task' + ); + }, + ); + + await fetch(`${baseURL}/unsampled/task`); + + const promiseShouldNotResolve = () => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resolve(); // Test passes because promise did not resolve within timeout + }, 1000); + + unsampledTransactionEventPromise.then( + () => { + clearTimeout(timeout); + reject(new Error('Promise should not have resolved')); + }, + () => { + clearTimeout(timeout); + reject(new Error('Promise should not have been rejected')); + }, + ); + }); + + expect(promiseShouldNotResolve()).resolves.not.toThrow(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json new file mode 100644 index 000000000000..9da336fdf8d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json @@ -0,0 +1,38 @@ +{ + "name": "node-core-express-otel-v2-sdk-node", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-http": "^0.203.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-node": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sdk-node": "^0.203.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", + "@sentry/node-core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/express": "4.17.17", + "@types/node": "^18.19.1", + "express": "4.19.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/playwright.config.mjs @@ -0,0 +1,34 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + }, + { + webServer: [ + { + command: `node ./start-event-proxy.mjs`, + port: 3031, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: `node ./start-otel-proxy.mjs`, + port: 3032, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: 3030, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT: 3030, + }, + }, + ], + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/app.ts new file mode 100644 index 000000000000..69f55b25e6ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/app.ts @@ -0,0 +1,55 @@ +import './instrument'; + +// Other imports below +import * as Sentry from '@sentry/node-core'; +import express from 'express'; + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (req, res) { + Sentry.withActiveSpan(null, async () => { + Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + }); + + await Sentry.flush(); + + res.send({}); + }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // Explicitly capture the error with Sentry because @sentry/node-core doesn't have + // a way to capture errors from express like @sentry/node does. + res.sentry = Sentry.captureException(err); + + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/instrument.ts new file mode 100644 index 000000000000..276b4f55ac73 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/src/instrument.ts @@ -0,0 +1,35 @@ +const opentelemetry = require('@opentelemetry/sdk-node'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); +const Sentry = require('@sentry/node-core'); +const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); +const { SentrySpanProcessor, SentryPropagator, SentrySampler } = require('@sentry/opentelemetry'); + +const sentryClient = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: true, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); + +if (sentryClient) { + const sdk = new opentelemetry.NodeSDK({ + sampler: new SentrySampler(sentryClient), + textMapPropagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), + spanProcessors: [ + new SentrySpanProcessor(), + new opentelemetry.node.BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], + instrumentations: [new HttpInstrumentation()], + }); + + sdk.start(); + + Sentry.validateOpenTelemetrySetup(); +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-event-proxy.mjs new file mode 100644 index 000000000000..5c5352234039 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-express-otel-v2-sdk-node', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-otel-proxy.mjs new file mode 100644 index 000000000000..8875601e95bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-core-express-otel-v2-sdk-node-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/errors.test.ts new file mode 100644 index 000000000000..ec43573dc910 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/errors.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-core-express-otel-v2-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/123'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); + +test('Errors do not leak between requests', async ({ baseURL }) => { + // Set up promises to capture errors for both requests + const firstErrorPromise = waitForError('node-core-express-otel-v2-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 111'; + }); + + const secondErrorPromise = waitForError('node-core-express-otel-v2-sdk-node', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 222'; + }); + + // Make first error request + await fetch(`${baseURL}/test-exception/111`); + + // Make second error request + await fetch(`${baseURL}/test-exception/222`); + + // Wait for both error events to be captured + const [firstError, secondError] = await Promise.all([firstErrorPromise, secondErrorPromise]); + + // Verify first error has correct data and doesn't contain data from second error + expect(firstError.exception?.values?.[0]?.value).toBe('This is an exception with id 111'); + expect(firstError.transaction).toEqual('GET /test-exception/111'); + expect(firstError.request?.url).toBe('http://localhost:3030/test-exception/111'); + + // Verify second error has correct data and doesn't contain data from first error + expect(secondError.exception?.values?.[0]?.value).toBe('This is an exception with id 222'); + expect(secondError.transaction).toEqual('GET /test-exception/222'); + expect(secondError.request?.url).toBe('http://localhost:3030/test-exception/222'); + + // Verify errors have different trace contexts (no leakage) + expect(firstError.contexts?.trace?.trace_id).not.toEqual(secondError.contexts?.trace?.trace_id); + expect(firstError.contexts?.trace?.span_id).not.toEqual(secondError.contexts?.trace?.span_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts new file mode 100644 index 000000000000..08c8f80cd9f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tests/transactions.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-core-express-otel-v2-sdk-node', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + // Ensure we also send data to the OTLP endpoint + const otelPromise = waitForPlainRequest('node-core-express-otel-v2-sdk-node-otel', data => { + const json = JSON.parse(data) as any; + + return json.resourceSpans.length > 0; + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + const otelData = await otelPromise; + + // For now we do not test the actual shape of this, but only existence + expect(otelData).toBeDefined(); + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'url', + 'sentry.origin': 'manual', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'manual', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'url', + }, + }), + ); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-core-express-otel-v2-sdk-node', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/777' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/777'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tsconfig.json new file mode 100644 index 000000000000..2887ec11a81d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json index e7f854cb7943..cab490640de9 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json @@ -16,11 +16,11 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.202.0", - "@opentelemetry/instrumentation-http": "^0.202.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-http": "^0.203.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", - "@opentelemetry/semantic-conventions": "^1.30.0", + "@opentelemetry/semantic-conventions": "^1.34.0", "@types/express": "^4.17.21", "@types/node": "^18.19.1", "express": "^4.21.2", diff --git a/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/tests/server.test.ts index 75e718016a90..a403a23bebda 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-cjs-preload/tests/server.test.ts @@ -65,7 +65,6 @@ test('Should record a transaction for route with parameters', async ({ request } data: { 'express.name': 'query', 'express.type': 'middleware', - 'http.route': '/', 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', }, @@ -84,7 +83,6 @@ test('Should record a transaction for route with parameters', async ({ request } data: { 'express.name': 'expressInit', 'express.type': 'middleware', - 'http.route': '/', 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', }, diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-loader/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-express-esm-loader/tests/server.test.ts index a1b596072a6a..d919c75ea61b 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-loader/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-loader/tests/server.test.ts @@ -65,7 +65,6 @@ test('Should record a transaction for route with parameters', async ({ request } data: { 'express.name': 'query', 'express.type': 'middleware', - 'http.route': '/', 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', }, @@ -84,7 +83,6 @@ test('Should record a transaction for route with parameters', async ({ request } data: { 'express.name': 'expressInit', 'express.type': 'middleware', - 'http.route': '/', 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', }, diff --git a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts index 2f3208a33bbb..6a4283a9caa9 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-esm-preload/tests/server.test.ts @@ -65,7 +65,6 @@ test('Should record a transaction for route with parameters', async ({ request } data: { 'express.name': 'query', 'express.type': 'middleware', - 'http.route': '/', 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', }, @@ -84,7 +83,6 @@ test('Should record a transaction for route with parameters', async ({ request } data: { 'express.name': 'expressInit', 'express.type': 'middleware', - 'http.route': '/', 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', }, diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-v5/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/.npmrc b/dev-packages/e2e-tests/test-applications/node-express-v5/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json new file mode 100644 index 000000000000..b7caf4610712 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json @@ -0,0 +1,35 @@ +{ + "name": "node-express-v5-app", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.10.2", + "@sentry/node": "latest || *", + "@trpc/server": "10.45.2", + "@trpc/client": "10.45.2", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^5.1.0", + "typescript": "~5.0.0", + "zod": "~3.24.3" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "latest || *" + }, + "resolutions": { + "@types/qs": "6.9.17" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-v5/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts new file mode 100644 index 000000000000..20dfa5bf84c5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts @@ -0,0 +1,152 @@ +import * as Sentry from '@sentry/node'; + +declare global { + namespace globalThis { + var transactionIds: string[]; + } +} + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + enableLogs: true, +}); + +import { TRPCError, initTRPC } from '@trpc/server'; +import * as trpcExpress from '@trpc/server/adapters/express'; +import express from 'express'; +import { z } from 'zod'; +import { mcpRouter } from './mcp'; + +const app = express(); +const port = 3030; + +app.use(mcpRouter); + +app.get('/crash-in-with-monitor/:id', async (req, res) => { + try { + await Sentry.withMonitor('express-crash', async () => { + throw new Error(`This is an exception withMonitor: ${req.params.id}`); + }); + res.sendStatus(200); + } catch (error: any) { + res.status(500); + res.send({ message: error.message, pid: process.pid }); + } +}); + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-log', function (req, res) { + Sentry.logger.debug('Accessed /test-log route'); + res.send({ message: 'Log sent' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + + res.send({ status: 'ok' }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.get('/test-local-variables-uncaught', function (req, res) { + const randomVariableToRecord = Math.random(); + throw new Error(`Uncaught Local Variable Error - ${JSON.stringify({ randomVariableToRecord })}`); +}); + +app.get('/test-local-variables-caught', function (req, res) { + const randomVariableToRecord = Math.random(); + + let exceptionId: string; + try { + throw new Error('Local Variable Error'); + } catch (e) { + exceptionId = Sentry.captureException(e); + } + + res.send({ exceptionId, randomVariableToRecord }); +}); + +Sentry.setupExpressErrorHandler(app); + +// @ts-ignore +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); + +Sentry.addEventProcessor(event => { + global.transactionIds = global.transactionIds || []; + + if (event.type === 'transaction') { + const eventId = event.event_id; + + if (eventId) { + global.transactionIds.push(eventId); + } + } + + return event; +}); + +export const t = initTRPC.context().create(); + +const procedure = t.procedure.use(Sentry.trpcMiddleware({ attachRpcInput: true })); + +export const appRouter = t.router({ + getSomething: procedure.input(z.string()).query(opts => { + return { id: opts.input, name: 'Bilbo' }; + }), + createSomething: procedure.mutation(async () => { + await new Promise(resolve => setTimeout(resolve, 400)); + return { success: true }; + }), + crashSomething: procedure + .input(z.object({ nested: z.object({ nested: z.object({ nested: z.string() }) }) })) + .mutation(() => { + throw new Error('I crashed in a trpc handler'); + }), + badRequest: procedure.mutation(() => { + throw new TRPCError({ code: 'BAD_REQUEST', cause: new Error('Bad Request') }); + }), +}); + +export type AppRouter = typeof appRouter; + +const createContext = () => ({ someStaticValue: 'asdf' }); +type Context = Awaited>; + +app.use( + '/trpc', + trpcExpress.createExpressMiddleware({ + router: appRouter, + createContext, + }), +); diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts new file mode 100644 index 000000000000..c5f2c24c61b8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/src/mcp.ts @@ -0,0 +1,64 @@ +import express from 'express'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { z } from 'zod'; +import { wrapMcpServerWithSentry } from '@sentry/node'; + +const mcpRouter = express.Router(); + +const server = wrapMcpServerWithSentry( + new McpServer({ + name: 'Echo', + version: '1.0.0', + }), +); + +server.resource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), async (uri, { message }) => ({ + contents: [ + { + uri: uri.href, + text: `Resource echo: ${message}`, + }, + ], +})); + +server.tool('echo', { message: z.string() }, async ({ message }, rest) => { + return { + content: [{ type: 'text', text: `Tool echo: ${message}` }], + }; +}); + +server.prompt('echo', { message: z.string() }, ({ message }, extra) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}`, + }, + }, + ], +})); + +const transports: Record = {}; + +mcpRouter.get('/sse', async (_, res) => { + const transport = new SSEServerTransport('/messages', res); + transports[transport.sessionId] = transport; + res.on('close', () => { + delete transports[transport.sessionId]; + }); + await server.connect(transport); +}); + +mcpRouter.post('/messages', async (req, res) => { + const sessionId = req.query.sessionId; + const transport = transports[sessionId as string]; + if (transport) { + await transport.handlePostMessage(req, res); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + +export { mcpRouter }; diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-v5/start-event-proxy.mjs new file mode 100644 index 000000000000..9bc400437556 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-v5', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/errors.test.ts new file mode 100644 index 000000000000..2b810615039a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/errors.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-v5', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); + +test('Should record caught exceptions with local variable', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-v5', event => { + return event.transaction === 'GET /test-local-variables-caught'; + }); + + await fetch(`${baseURL}/test-local-variables-caught`); + + const errorEvent = await errorEventPromise; + + const frames = errorEvent.exception?.values?.[0]?.stacktrace?.frames; + expect(frames?.[frames.length - 1]?.vars?.randomVariableToRecord).toBeDefined(); +}); + +test('To not crash app from withMonitor', async ({ baseURL }) => { + const doRequest = async (id: number) => { + const response = await fetch(`${baseURL}/crash-in-with-monitor/${id}`) + return response.json(); + } + const [response1, response2] = await Promise.all([doRequest(1), doRequest(2)]) + expect(response1.message).toBe('This is an exception withMonitor: 1') + expect(response2.message).toBe('This is an exception withMonitor: 2') + expect(response1.pid).toBe(response2.pid) //Just to double-check, TBS +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/logs.test.ts new file mode 100644 index 000000000000..34bce0a0b978 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/logs.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; + +test('should send logs', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('node-express-v5', envelope => { + return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; + }); + + await fetch(`${baseURL}/test-log`); + + const logEnvelope = await logEnvelopePromise; + const log = (logEnvelope[1] as SerializedLogContainer).items[0]; + expect(log?.level).toBe('debug'); + expect(log?.body).toBe('Accessed /test-log route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts new file mode 100644 index 000000000000..b3680b4fa1dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/mcp.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; + +test('Should record transactions for mcp handlers', async ({ baseURL }) => { + const transport = new SSEClientTransport(new URL(`${baseURL}/sse`)); + + const client = new Client({ + name: 'test-client', + version: '1.0.0', + }); + + await client.connect(transport); + + await test.step('tool handler', async () => { + const postTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const toolTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'tools/call echo'; + }); + + const toolResult = await client.callTool({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(toolResult).toMatchObject({ + content: [ + { + text: 'Tool echo: foobar', + type: 'text', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + + const toolTransaction = await toolTransactionPromise; + expect(toolTransaction).toBeDefined(); + + // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction + }); + + await test.step('resource handler', async () => { + const postTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const resourceTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'resources/read echo://foobar'; + }); + + const resourceResult = await client.readResource({ + uri: 'echo://foobar', + }); + + expect(resourceResult).toMatchObject({ + contents: [{ text: 'Resource echo: foobar', uri: 'echo://foobar' }], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + + const resourceTransaction = await resourceTransactionPromise; + expect(resourceTransaction).toBeDefined(); + + // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction + }); + + await test.step('prompt handler', async () => { + const postTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'POST /messages'; + }); + const promptTransactionPromise = waitForTransaction('node-express-v5', transactionEvent => { + return transactionEvent.transaction === 'prompts/get echo'; + }); + + const promptResult = await client.getPrompt({ + name: 'echo', + arguments: { + message: 'foobar', + }, + }); + + expect(promptResult).toMatchObject({ + messages: [ + { + content: { + text: 'Please process this message: foobar', + type: 'text', + }, + role: 'user', + }, + ], + }); + + const postTransaction = await postTransactionPromise; + expect(postTransaction).toBeDefined(); + + const promptTransaction = await promptTransactionPromise; + expect(promptTransaction).toBeDefined(); + + // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction + }); +}); 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 new file mode 100644 index 000000000000..86fdffd3b452 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/transactions.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-express-v5', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-transaction', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent.contexts?.response).toEqual({ + status_code: 200, + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + // Manually started span + expect(spans).toContainEqual({ + data: { 'sentry.origin': 'manual' }, + description: 'test-span', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // auto instrumented span + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + }, + description: '/test-transaction', + op: 'request_handler.express', + origin: 'auto.http.otel.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-v5', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/trpc.test.ts new file mode 100644 index 000000000000..1618313ff444 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/trpc.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from '../src/app'; + +test('Should record span for trpc query', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-v5', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/getSomething') + ); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.getSomething.query('foobar'); + + await expect(transactionEventPromise).resolves.toBeDefined(); + const transaction = await transactionEventPromise; + + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'rpc.server', + 'sentry.origin': 'auto.rpc.trpc', + }), + description: `trpc/getSomething`, + }), + ); +}); + +test('Should record transaction for trpc mutation', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-v5', transactionEvent => { + return ( + transactionEvent.transaction === 'POST /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/createSomething') + ); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await trpcClient.createSomething.mutate(); + + await expect(transactionEventPromise).resolves.toBeDefined(); + const transaction = await transactionEventPromise; + + expect(transaction.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'rpc.server', + 'sentry.origin': 'auto.rpc.trpc', + }), + description: `trpc/createSomething`, + }), + ); +}); + +test('Should record transaction and error for a crashing trpc handler', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-v5', transactionEvent => { + return ( + transactionEvent.transaction === 'POST /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/crashSomething') + ); + }); + + const errorEventPromise = waitForError('node-express-v5', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('I crashed in a trpc handler')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.crashSomething.mutate({ nested: { nested: { nested: 'foobar' } } })).rejects.toBeDefined(); + + await expect(transactionEventPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); + + expect((await errorEventPromise).contexts?.trpc?.['procedure_type']).toBe('mutation'); + expect((await errorEventPromise).contexts?.trpc?.['procedure_path']).toBe('crashSomething'); + + // Should record nested context + expect((await errorEventPromise).contexts?.trpc?.['input']).toEqual({ + nested: { + nested: { + nested: 'foobar', + }, + }, + }); +}); + +test('Should record transaction and error for a trpc handler that returns a status code', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-v5', transactionEvent => { + return ( + transactionEvent.transaction === 'POST /trpc' && + !!transactionEvent.spans?.find(span => span.description === 'trpc/badRequest') + ); + }); + + const errorEventPromise = waitForError('node-express-v5', errorEvent => { + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Bad Request')); + }); + + const trpcClient = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/trpc`, + }), + ], + }); + + await expect(trpcClient.badRequest.mutate()).rejects.toBeDefined(); + + await expect(transactionEventPromise).resolves.toBeDefined(); + await expect(errorEventPromise).resolves.toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-v5/tsconfig.json new file mode 100644 index 000000000000..0060abd94682 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} 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 9f7b0055b66d..2a7cccf238cc 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 @@ -13,9 +13,7 @@ Sentry.init({ debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - _experiments: { - enableLogs: true, - }, + enableLogs: true, }); import { TRPCError, initTRPC } from '@trpc/server'; @@ -27,6 +25,8 @@ import { mcpRouter } from './mcp'; const app = express(); const port = 3030; +app.use(express.json()); + app.use(mcpRouter); app.get('/crash-in-with-monitor/:id', async (req, res) => { @@ -54,20 +54,11 @@ app.get('/test-param/:param', function (req, res) { res.send({ paramWas: req.params.param }); }); -app.get('/test-transaction', function (req, res) { - Sentry.withActiveSpan(null, async () => { - Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => { - Sentry.startSpan({ name: 'test-span' }, () => undefined); - }); - - await Sentry.flush(); +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); - res.send({ - transactionIds: global.transactionIds || [], - }); - }); + res.send({ status: 'ok' }); }); - app.get('/test-error', async function (req, res) { const exceptionId = Sentry.captureException(new Error('This is an error')); 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 c5f2c24c61b8..d4944ddcfa2d 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 @@ -55,7 +55,7 @@ mcpRouter.post('/messages', async (req, res) => { const sessionId = req.query.sessionId; const transport = transports[sessionId as string]; if (transport) { - await transport.handlePostMessage(req, res); + await transport.handlePostMessage(req, res, req.body); } else { res.status(400).send('No transport found for sessionId'); } diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts index a4faaf137eb7..00f9f413906f 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/errors.test.ts @@ -13,7 +13,7 @@ test('Sends correct error event', async ({ baseURL }) => { expect(errorEvent.exception?.values).toHaveLength(1); expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); - expect(errorEvent.request).toEqual({ + expect(errorEvent.request).toMatchObject({ method: 'GET', cookies: {}, headers: expect.any(Object), @@ -43,11 +43,11 @@ test('Should record caught exceptions with local variable', async ({ baseURL }) test('To not crash app from withMonitor', async ({ baseURL }) => { const doRequest = async (id: number) => { - const response = await fetch(`${baseURL}/crash-in-with-monitor/${id}`) + const response = await fetch(`${baseURL}/crash-in-with-monitor/${id}`); return response.json(); - } - const [response1, response2] = await Promise.all([doRequest(1), doRequest(2)]) - expect(response1.message).toBe('This is an exception withMonitor: 1') - expect(response2.message).toBe('This is an exception withMonitor: 2') - expect(response1.pid).toBe(response2.pid) //Just to double-check, TBS + }; + const [response1, response2] = await Promise.all([doRequest(1), doRequest(2)]); + expect(response1.message).toBe('This is an exception withMonitor: 1'); + expect(response2.message).toBe('This is an exception withMonitor: 2'); + expect(response1.pid).toBe(response2.pid); //Just to double-check, TBS }); 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 a36e635685b6..910fee629f3e 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 @@ -18,7 +18,7 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { return transactionEvent.transaction === 'POST /messages'; }); const toolTransactionPromise = waitForTransaction('node-express', transactionEvent => { - return transactionEvent.transaction === 'mcp-server/tool:echo'; + return transactionEvent.transaction === 'tools/call echo'; }); const toolResult = await client.callTool({ @@ -39,10 +39,12 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { const postTransaction = await postTransactionPromise; expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); const toolTransaction = await toolTransactionPromise; expect(toolTransaction).toBeDefined(); - + expect(toolTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(toolTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('tools/call'); // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); @@ -51,7 +53,7 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { return transactionEvent.transaction === 'POST /messages'; }); const resourceTransactionPromise = waitForTransaction('node-express', transactionEvent => { - return transactionEvent.transaction === 'mcp-server/resource:echo'; + return transactionEvent.transaction === 'resources/read echo://foobar'; }); const resourceResult = await client.readResource({ @@ -64,10 +66,12 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { const postTransaction = await postTransactionPromise; expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); const resourceTransaction = await resourceTransactionPromise; expect(resourceTransaction).toBeDefined(); - + expect(resourceTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(resourceTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('resources/read'); // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); @@ -76,7 +80,7 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { return transactionEvent.transaction === 'POST /messages'; }); const promptTransactionPromise = waitForTransaction('node-express', transactionEvent => { - return transactionEvent.transaction === 'mcp-server/prompt:echo'; + return transactionEvent.transaction === 'prompts/get echo'; }); const promptResult = await client.getPrompt({ @@ -100,10 +104,12 @@ test('Should record transactions for mcp handlers', async ({ baseURL }) => { const postTransaction = await postTransactionPromise; expect(postTransaction).toBeDefined(); + expect(postTransaction.contexts?.trace?.op).toEqual('http.server'); const promptTransaction = await promptTransactionPromise; expect(promptTransaction).toBeDefined(); - + expect(promptTransaction.contexts?.trace?.op).toEqual('mcp.server'); + expect(promptTransaction.contexts?.trace?.data?.['mcp.method.name']).toEqual('prompts/get'); // TODO: When https://github.com/modelcontextprotocol/typescript-sdk/pull/358 is released check for trace id equality between the post transaction and the handler transaction }); }); 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 7f9b18b4cc50..b47feebcd728 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 @@ -62,11 +62,24 @@ test('Sends an API route transaction', async ({ baseURL }) => { const spans = transactionEvent.spans || []; + // Manually started span + expect(spans).toContainEqual({ + data: { 'sentry.origin': 'manual' }, + description: 'test-span', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // auto instrumented spans expect(spans).toContainEqual({ data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'query', 'express.type': 'middleware', }, @@ -85,7 +98,6 @@ test('Sends an API route transaction', async ({ baseURL }) => { data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'expressInit', 'express.type': 'middleware', }, @@ -144,7 +156,6 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'query', 'express.type': 'middleware', }, @@ -163,7 +174,6 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'expressInit', 'express.type': 'middleware', }, @@ -192,8 +202,9 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), - status: 'ok', + status: 'unknown_error', timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), + measurements: {}, }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json index 3beb69e26e59..006a33585fd3 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/package.json @@ -4,11 +4,13 @@ "private": true, "scripts": { "start": "ts-node src/app.ts", + "start:override": "ts-node src/app-handle-error-override.ts", "test": "playwright test", + "test:override": "playwright test --config playwright.override.config.mjs", "clean": "npx rimraf node_modules pnpm-lock.yaml", "typecheck": "tsc", "test:build": "pnpm install && pnpm run typecheck", - "test:assert": "pnpm test" + "test:assert": "pnpm test && pnpm test:override" }, "dependencies": { "@sentry/node": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/playwright.override.config.mjs b/dev-packages/e2e-tests/test-applications/node-fastify-3/playwright.override.config.mjs new file mode 100644 index 000000000000..ee1f461c57c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/playwright.override.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start:override`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app-handle-error-override.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app-handle-error-override.ts new file mode 100644 index 000000000000..378ef99fa309 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app-handle-error-override.ts @@ -0,0 +1,178 @@ +import type * as S from '@sentry/node'; +const Sentry = require('@sentry/node') as typeof S; + +// We wrap console.warn to find out if a warning is incorrectly logged +console.warn = new Proxy(console.warn, { + apply: function (target, thisArg, argumentsList) { + const msg = argumentsList[0]; + if (typeof msg === 'string' && msg.startsWith('[Sentry]')) { + console.error(`Sentry warning was triggered: ${msg}`); + process.exit(1); + } + + return target.apply(thisArg, argumentsList); + }, +}); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + integrations: [ + Sentry.fastifyIntegration({ + shouldHandleError: (error, _request, _reply) => { + return true; + }, + }), + ], + tracesSampleRate: 1, + tunnel: 'http://localhost:3031/', // proxy server + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); + +import type * as H from 'http'; +import type * as F from 'fastify'; + +// Make sure fastify is imported after Sentry is initialized +const { fastify } = require('fastify') as typeof F; +const http = require('http') as typeof H; + +const app = fastify(); +const port = 3030; +const port2 = 3040; + +Sentry.setupFastifyErrorHandler(app, { + shouldHandleError: (error, _request, _reply) => { + // @ts-ignore // Fastify V3 is not typed correctly + if (_request.url?.includes('/test-error-not-captured')) { + // Errors from this path will not be captured by Sentry + return false; + } + + return true; + }, +}); + +app.get('/test-success', function (_req, res) { + res.send({ version: 'v1' }); +}); + +app.get<{ Params: { param: string } }>('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get<{ Params: { id: string } }>('/test-inbound-headers/:id', function (req, res) { + const headers = req.headers; + + res.send({ headers, id: req.params.id }); +}); + +app.get<{ Params: { id: string } }>('/test-outgoing-http/:id', async function (req, res) { + const id = req.params.id; + const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); + + res.send(data); +}); + +app.get<{ Params: { id: string } }>('/test-outgoing-fetch/:id', async function (req, res) { + const id = req.params.id; + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + + res.send(data); +}); + +app.get('/test-transaction', async function (req, res) { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + + res.send({}); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-error-not-captured', async function () { + // This error will not be captured by Sentry + throw new Error('This is an error that will not be captured'); +}); + +app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.get('/test-outgoing-fetch-external-allowed', async function (req, res) { + const fetchResponse = await fetch(`http://localhost:${port2}/external-allowed`); + const data = await fetchResponse.json(); + + res.send(data); +}); + +app.get('/test-outgoing-fetch-external-disallowed', async function (req, res) { + const fetchResponse = await fetch(`http://localhost:${port2}/external-disallowed`); + const data = await fetchResponse.json(); + + res.send(data); +}); + +app.get('/test-outgoing-http-external-allowed', async function (req, res) { + const data = await makeHttpRequest(`http://localhost:${port2}/external-allowed`); + res.send(data); +}); + +app.get('/test-outgoing-http-external-disallowed', async function (req, res) { + const data = await makeHttpRequest(`http://localhost:${port2}/external-disallowed`); + res.send(data); +}); + +app.post('/test-post', function (req, res) { + res.send({ status: 'ok', body: req.body }); +}); + +app.listen({ port: port }); + +// A second app so we can test header propagation between external URLs +const app2 = fastify(); +app2.get('/external-allowed', function (req, res) { + const headers = req.headers; + + res.send({ headers, route: '/external-allowed' }); +}); + +app2.get('/external-disallowed', function (req, res) { + const headers = req.headers; + + res.send({ headers, route: '/external-disallowed' }); +}); + +app2.listen({ port: port2 }); + +function makeHttpRequest(url: string) { + return new Promise(resolve => { + const data: any[] = []; + + http + .request(url, httpRes => { + httpRes.on('data', chunk => { + data.push(chunk); + }); + httpRes.on('error', error => { + resolve({ error: error.message, url }); + }); + httpRes.on('end', () => { + try { + const json = JSON.parse(Buffer.concat(data).toString()); + resolve(json); + } catch { + resolve({ data: Buffer.concat(data).toString(), url }); + } + }); + }) + .end(); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app.ts index 73ffafcfd04d..5b4a2f0d16ac 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/src/app.ts @@ -17,7 +17,19 @@ console.warn = new Proxy(console.warn, { Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, - integrations: [], + integrations: [ + Sentry.fastifyIntegration({ + shouldHandleError: (error, _request, _reply) => { + // @ts-ignore // Fastify V3 is not typed correctly + if (_request.url?.includes('/test-error-not-captured')) { + // Errors from this path will not be captured by Sentry + return false; + } + + return true; + }, + }), + ], tracesSampleRate: 1, tunnel: 'http://localhost:3031/', // proxy server tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], @@ -81,6 +93,11 @@ app.get('/test-error', async function (req, res) { res.send({ exceptionId }); }); +app.get('/test-error-not-captured', async function () { + // This error will not be captured by Sentry + throw new Error('This is an error that will not be captured'); +}); + app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) { throw new Error(`This is an exception with id ${req.params.id}`); }); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/errors.test.ts index 1a37fc244413..c0be1b0292a3 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-3/tests/errors.test.ts @@ -28,3 +28,18 @@ test('Sends correct error event', async ({ baseURL }) => { parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), }); }); + +test('Does not send error when shouldHandleError returns false', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-fastify-3', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an error that will not be captured'; + }); + + errorEventPromise.then(() => { + throw new Error('This error should not be captured'); + }); + + await fetch(`${baseURL}/test-error-not-captured`); + + // wait for a short time to ensure the error is not captured + await new Promise(resolve => setTimeout(resolve, 1000)); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json index 7441e4335fb4..01f4879d5a68 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/package.json @@ -4,11 +4,13 @@ "private": true, "scripts": { "start": "ts-node src/app.ts", + "start:override": "ts-node src/app-handle-error-override.ts", "test": "playwright test", + "test:override": "playwright test --config playwright.override.config.mjs", "clean": "npx rimraf node_modules pnpm-lock.yaml", "typecheck": "tsc", "test:build": "pnpm install && pnpm run typecheck", - "test:assert": "pnpm test" + "test:assert": "pnpm test && pnpm test:override" }, "dependencies": { "@sentry/node": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/playwright.override.config.mjs b/dev-packages/e2e-tests/test-applications/node-fastify-4/playwright.override.config.mjs new file mode 100644 index 000000000000..ee1f461c57c8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/playwright.override.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start:override`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app-handle-error-override.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app-handle-error-override.ts new file mode 100644 index 000000000000..72270efc05de --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app-handle-error-override.ts @@ -0,0 +1,187 @@ +import type * as S from '@sentry/node'; +const Sentry = require('@sentry/node') as typeof S; + +// We wrap console.warn to find out if a warning is incorrectly logged +console.warn = new Proxy(console.warn, { + apply: function (target, thisArg, argumentsList) { + const msg = argumentsList[0]; + if (typeof msg === 'string' && msg.startsWith('[Sentry]')) { + console.error(`Sentry warning was triggered: ${msg}`); + process.exit(1); + } + + return target.apply(thisArg, argumentsList); + }, +}); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + integrations: [ + Sentry.fastifyIntegration({ + shouldHandleError: (error, _request, _reply) => { + return true; + }, + }), + ], + tracesSampleRate: 1, + tunnel: 'http://localhost:3031/', // proxy server + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); + +import type * as H from 'http'; +import type * as F from 'fastify'; + +// Make sure fastify is imported after Sentry is initialized +const { fastify } = require('fastify') as typeof F; +const http = require('http') as typeof H; + +const app = fastify(); +const port = 3030; +const port2 = 3040; + +Sentry.setupFastifyErrorHandler(app, { + shouldHandleError: (error, _request, _reply) => { + if (_request.routeOptions?.url?.includes('/test-error-not-captured')) { + // Errors from this path will not be captured by Sentry + return false; + } + + return true; + }, +}); + +app.get('/test-success', function (_req, res) { + res.send({ version: 'v1' }); +}); + +app.get<{ Params: { param: string } }>('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get<{ Params: { id: string } }>('/test-inbound-headers/:id', function (req, res) { + const headers = req.headers; + + res.send({ headers, id: req.params.id }); +}); + +app.get<{ Params: { id: string } }>('/test-outgoing-http/:id', async function (req, res) { + const id = req.params.id; + const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); + + res.send(data); +}); + +app.get<{ Params: { id: string } }>('/test-outgoing-fetch/:id', async function (req, res) { + const id = req.params.id; + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + + res.send(data); +}); + +app.get('/test-transaction', async function (req, res) { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + + res.send({}); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-error-not-captured', async function () { + // This error will not be captured by Sentry + throw new Error('This is an error that will not be captured'); +}); + +app.get('/test-4xx-error', async function (req, res) { + res.code(400); + throw new Error('This is a 4xx error'); +}); + +app.get('/test-5xx-error', async function (req, res) { + res.code(500); + throw new Error('This is a 5xx error'); +}); + +app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.get('/test-outgoing-fetch-external-allowed', async function (req, res) { + const fetchResponse = await fetch(`http://localhost:${port2}/external-allowed`); + const data = await fetchResponse.json(); + + res.send(data); +}); + +app.get('/test-outgoing-fetch-external-disallowed', async function (req, res) { + const fetchResponse = await fetch(`http://localhost:${port2}/external-disallowed`); + const data = await fetchResponse.json(); + + res.send(data); +}); + +app.get('/test-outgoing-http-external-allowed', async function (req, res) { + const data = await makeHttpRequest(`http://localhost:${port2}/external-allowed`); + res.send(data); +}); + +app.get('/test-outgoing-http-external-disallowed', async function (req, res) { + const data = await makeHttpRequest(`http://localhost:${port2}/external-disallowed`); + res.send(data); +}); + +app.post('/test-post', function (req, res) { + res.send({ status: 'ok', body: req.body }); +}); + +app.listen({ port: port }); + +// A second app so we can test header propagation between external URLs +const app2 = fastify(); +app2.get('/external-allowed', function (req, res) { + const headers = req.headers; + + res.send({ headers, route: '/external-allowed' }); +}); + +app2.get('/external-disallowed', function (req, res) { + const headers = req.headers; + + res.send({ headers, route: '/external-disallowed' }); +}); + +app2.listen({ port: port2 }); + +function makeHttpRequest(url: string) { + return new Promise(resolve => { + const data: any[] = []; + + http + .request(url, httpRes => { + httpRes.on('data', chunk => { + data.push(chunk); + }); + httpRes.on('error', error => { + resolve({ error: error.message, url }); + }); + httpRes.on('end', () => { + try { + const json = JSON.parse(Buffer.concat(data).toString()); + resolve(json); + } catch { + resolve({ data: Buffer.concat(data).toString(), url }); + } + }); + }) + .end(); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app.ts index 7f7ac390b4b3..1c428c0486f9 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/src/app.ts @@ -17,7 +17,18 @@ console.warn = new Proxy(console.warn, { Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, - integrations: [], + integrations: [ + Sentry.fastifyIntegration({ + shouldHandleError: (error, _request, _reply) => { + if (_request.routeOptions?.url?.includes('/test-error-not-captured')) { + // Errors from this path will not be captured by Sentry + return false; + } + + return true; + }, + }), + ], tracesSampleRate: 1, tunnel: 'http://localhost:3031/', // proxy server tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], @@ -81,6 +92,11 @@ app.get('/test-error', async function (req, res) { res.send({ exceptionId }); }); +app.get('/test-error-not-captured', async function () { + // This error will not be captured by Sentry + throw new Error('This is an error that will not be captured'); +}); + app.get('/test-4xx-error', async function (req, res) { res.code(400); throw new Error('This is a 4xx error'); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/errors.test.ts index 8ecdc8975778..46453e4749e0 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-4/tests/errors.test.ts @@ -50,3 +50,18 @@ test('Does not send 4xx errors by default', async ({ baseURL }) => { const errorEvent = await serverErrorPromise; expect(errorEvent.exception?.values?.[0]?.value).toContain('This is a 5xx error'); }); + +test('Does not send error when shouldHandleError returns false', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-fastify-4', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an error that will not be captured'; + }); + + errorEventPromise.then(() => { + throw new Error('This error should not be captured'); + }); + + await fetch(`${baseURL}/test-error-not-captured`); + + // wait for a short time to ensure the error is not captured + await new Promise(resolve => setTimeout(resolve, 1000)); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json index fba10d4d2a90..0b03a26eca47 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/package.json @@ -4,11 +4,13 @@ "private": true, "scripts": { "start": "ts-node src/app.ts", + "start:override": "ts-node src/app-handle-error-override.ts", "test": "playwright test", + "test:override": "playwright test --config playwright.override.config.mjs", "clean": "npx rimraf node_modules pnpm-lock.yaml", "typecheck": "tsc", "test:build": "pnpm install && pnpm run typecheck", - "test:assert": "pnpm test" + "test:assert": "pnpm test && pnpm test:override" }, "dependencies": { "@sentry/node": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/playwright.override.config.mjs b/dev-packages/e2e-tests/test-applications/node-fastify-5/playwright.override.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/playwright.override.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app-handle-error-override.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app-handle-error-override.ts new file mode 100644 index 000000000000..217201332e17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app-handle-error-override.ts @@ -0,0 +1,178 @@ +import type * as S from '@sentry/node'; +const Sentry = require('@sentry/node') as typeof S; + +// We wrap console.warn to find out if a warning is incorrectly logged +console.warn = new Proxy(console.warn, { + apply: function (target, thisArg, argumentsList) { + const msg = argumentsList[0]; + if (typeof msg === 'string' && msg.startsWith('[Sentry]')) { + console.error(`Sentry warning was triggered: ${msg}`); + process.exit(1); + } + + return target.apply(thisArg, argumentsList); + }, +}); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + integrations: [ + Sentry.fastifyIntegration({ + shouldHandleError: (error, _request, _reply) => { + return true; + }, + }), + ], + tracesSampleRate: 1, + tunnel: 'http://localhost:3031/', // proxy server + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); + +import type * as H from 'http'; +import type * as F from 'fastify'; + +// Make sure fastify is imported after Sentry is initialized +const { fastify } = require('fastify') as typeof F; +const http = require('http') as typeof H; + +const app = fastify(); +const port = 3030; +const port2 = 3040; + +Sentry.setupFastifyErrorHandler(app, { + shouldHandleError: (error, _request, _reply) => { + // @ts-ignore // Fastify V5 is not typed correctly + if (_request.routeOptions?.url?.includes('/test-error-not-captured')) { + // Errors from this path will not be captured by Sentry + return false; + } + + return true; + }, +}); + +app.get('/test-success', function (_req, res) { + res.send({ version: 'v1' }); +}); + +app.get<{ Params: { param: string } }>('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get<{ Params: { id: string } }>('/test-inbound-headers/:id', function (req, res) { + const headers = req.headers; + + res.send({ headers, id: req.params.id }); +}); + +app.get<{ Params: { id: string } }>('/test-outgoing-http/:id', async function (req, res) { + const id = req.params.id; + const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); + + res.send(data); +}); + +app.get<{ Params: { id: string } }>('/test-outgoing-fetch/:id', async function (req, res) { + const id = req.params.id; + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + + res.send(data); +}); + +app.get('/test-transaction', async function (req, res) { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + + res.send({}); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-error-not-captured', async function () { + // This error will not be captured by Sentry + throw new Error('This is an error that will not be captured'); +}); + +app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +app.get('/test-outgoing-fetch-external-allowed', async function (req, res) { + const fetchResponse = await fetch(`http://localhost:${port2}/external-allowed`); + const data = await fetchResponse.json(); + + res.send(data); +}); + +app.get('/test-outgoing-fetch-external-disallowed', async function (req, res) { + const fetchResponse = await fetch(`http://localhost:${port2}/external-disallowed`); + const data = await fetchResponse.json(); + + res.send(data); +}); + +app.get('/test-outgoing-http-external-allowed', async function (req, res) { + const data = await makeHttpRequest(`http://localhost:${port2}/external-allowed`); + res.send(data); +}); + +app.get('/test-outgoing-http-external-disallowed', async function (req, res) { + const data = await makeHttpRequest(`http://localhost:${port2}/external-disallowed`); + res.send(data); +}); + +app.post('/test-post', function (req, res) { + res.send({ status: 'ok', body: req.body }); +}); + +app.listen({ port: port }); + +// A second app so we can test header propagation between external URLs +const app2 = fastify(); +app2.get('/external-allowed', function (req, res) { + const headers = req.headers; + + res.send({ headers, route: '/external-allowed' }); +}); + +app2.get('/external-disallowed', function (req, res) { + const headers = req.headers; + + res.send({ headers, route: '/external-disallowed' }); +}); + +app2.listen({ port: port2 }); + +function makeHttpRequest(url: string) { + return new Promise(resolve => { + const data: any[] = []; + + http + .request(url, httpRes => { + httpRes.on('data', chunk => { + data.push(chunk); + }); + httpRes.on('error', error => { + resolve({ error: error.message, url }); + }); + httpRes.on('end', () => { + try { + const json = JSON.parse(Buffer.concat(data).toString()); + resolve(json); + } catch { + resolve({ data: Buffer.concat(data).toString(), url }); + } + }); + }) + .end(); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts index db2e9bf9cc5f..83f7e53a45ce 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/src/app.ts @@ -17,7 +17,18 @@ console.warn = new Proxy(console.warn, { Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, - integrations: [], + integrations: [ + Sentry.fastifyIntegration({ + shouldHandleError: (error, _request, _reply) => { + if (_request.routeOptions?.url?.includes('/test-error-not-captured')) { + // Errors from this path will not be captured by Sentry + return false; + } + + return true; + }, + }), + ], tracesSampleRate: 1, tunnel: 'http://localhost:3031/', // proxy server tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], @@ -79,6 +90,11 @@ app.get('/test-error', async function (req, res) { res.send({ exceptionId }); }); +app.get('/test-error-not-captured', async function () { + // This error will not be captured by Sentry + throw new Error('This is an error that will not be captured'); +}); + app.get<{ Params: { id: string } }>('/test-exception/:id', async function (req, res) { throw new Error(`This is an exception with id ${req.params.id}`); }); diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/errors.test.ts index f79eb30e9b4c..cf1428b7a34a 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-fastify-5/tests/errors.test.ts @@ -28,3 +28,18 @@ test('Sends correct error event', async ({ baseURL }) => { parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), }); }); + +test('Does not send error when shouldHandleError returns false', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-fastify-5', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an error that will not be captured'; + }); + + errorEventPromise.then(() => { + throw new Error('This error should not be captured'); + }); + + await fetch(`${baseURL}/test-error-not-captured`); + + // wait for a short time to ensure the error is not captured + await new Promise(resolve => setTimeout(resolve, 1000)); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc b/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc new file mode 100644 index 000000000000..47e4665f6905 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "sentry-firebase-e2e-test-f4ed3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.gitignore b/dev-packages/e2e-tests/test-applications/node-firebase/.gitignore new file mode 100644 index 000000000000..48b1bd712db4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/.gitignore @@ -0,0 +1,58 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +test-results diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.npmrc b/dev-packages/e2e-tests/test-applications/node-firebase/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/README.md b/dev-packages/e2e-tests/test-applications/node-firebase/README.md new file mode 100644 index 000000000000..e44ee12f5268 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/README.md @@ -0,0 +1,64 @@ +## Assuming you already have installed docker desktop or orbstack etc. or any other docker software + +### Enabling / authorising firebase emulator through docker + +1. Run the docker + +```bash +pnpm docker +``` + +2. In new tab, enter the docker container by simply running + +```bash +docker exec -it sentry-firebase bash +``` + +3. Now inside docker container run + +```bash +firebase login +``` + +4. You should now see a long link to authenticate with google account, copy the link and open it using your browser +5. Choose the account you want to authenticate with +6. Once you do this you should be able to see something like "Firebase CLI Login Successful" +7. And inside docker container you should see something like "Success! Logged in as " +8. Now you can exit docker container + +```bash +exit +``` + +9. Switch back to previous tab, stop the docker container (ctrl+c). +10. You should now be able to run the test, as you have correctly authenticated the firebase emulator + +### Preparing data for CLI + +1. Please authorize the docker first - see the previous section +2. Once you do that you can generate .env file locally, to do that just run + +```bash +npm run createEnvFromConfig +``` + +3. It will create a new file called ".env" inside folder "docker" +4. View the file. There will be 2 params CONFIG_FIREBASE_TOOLS and CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS. +5. Now inside the CLI create a new variable under the name CONFIG_FIREBASE_TOOLS and + CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS - take values from mentioned .env file +6. File .env is ignored to avoid situation when developer after authorizing firebase with private account will + accidently push the tokens to github. +7. But if we want the users to still have some default to be used for authorisation (on their local development) it will + be enough to commit this file, we just have to authorize it with some "special" account. + +**Some explanation towards environment settings, the environment variable defined directly in "environments" takes +precedence over .env file, that means it will be safe to define it in CLI and still keeps the .env file.** + +### Scripts - helpers + +- createEnvFromConfig - it will use the firebase docker authentication and create .env file which will be used then by + docker whenever you run emulator +- createConfigFromEnv - it will use '.env' file in docker folder to create .config for the firebase to be used to + authenticate whenever you run docker, Docker by default loads .env file itself + +Use these scripts when testing and updating the environment settings on CLI diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json new file mode 100644 index 000000000000..05203f1d6567 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json @@ -0,0 +1,20 @@ +{ + "firestore": { + "database": "(default)", + "location": "nam5", + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "emulators": { + "firestore": { + "port": 8080 + }, + "database": { + "port": 9000 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore.indexes.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.indexes.json new file mode 100644 index 000000000000..415027e5ddaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules new file mode 100644 index 000000000000..260e089a299b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules @@ -0,0 +1,18 @@ +rules_version='2' + +service cloud.firestore { + match /databases/{database}/documents { + match /{document=**} { + // This rule allows anyone with your database reference to view, edit, + // and delete all data in your database. It is useful for getting + // started, but it is configured to expire after 30 days because it + // leaves your app open to attackers. At that time, all client + // requests to your database will be denied. + // + // Make sure to write security rules for your app before that time, or + // else all client requests to your database will be denied until you + // update your rules. + allow read, write: if request.time < timestamp.date(2025, 8, 17); + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/package.json new file mode 100644 index 000000000000..0a23fbbeef92 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/package.json @@ -0,0 +1,38 @@ +{ + "name": "node-firebase-e2e-test-app", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch", + "proxy": "node start-event-proxy.mjs", + "emulate": "firebase emulators:start &", + "start": "node ./dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm firebase emulators:exec 'pnpm test'" + }, + "dependencies": { + "@firebase/app": "^0.13.1", + "@sentry/node": "latest || *", + "@sentry/core": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@types/node": "^18.19.1", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "firebase": "^12.0.0", + "firebase-admin": "^12.0.0", + "tsconfig-paths": "^4.2.0", + "typescript": "4.9.5" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/express": "^4.17.13", + "firebase-tools": "^12.0.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-firebase/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts b/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts new file mode 100644 index 000000000000..486aa06b5ffc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts @@ -0,0 +1,71 @@ +import * as Sentry from '@sentry/node'; +import './init'; +import express from 'express'; +import type { FirebaseOptions } from '@firebase/app'; +import { initializeApp } from 'firebase/app'; +import { + addDoc, + collection, + connectFirestoreEmulator, + deleteDoc, + doc, + getDocs, + getFirestore, + setDoc, +} from 'firebase/firestore/lite'; + +const options: FirebaseOptions = { + projectId: 'sentry-15d85', + apiKey: 'sentry-fake-api-key', +}; + +const app = initializeApp(options); + +const db = getFirestore(app); +connectFirestoreEmulator(db, '127.0.0.1', 8080); +const citiesRef = collection(db, 'cities'); + +async function addCity(): Promise { + await addDoc(citiesRef, { + name: 'San Francisco', + }); +} + +async function getCities(): Promise { + const citySnapshot = await getDocs(citiesRef); + const cityList = citySnapshot.docs.map(doc => doc.data()); + return cityList; +} + +async function deleteCity(): Promise { + await deleteDoc(doc(citiesRef, 'SF')); +} + +async function setCity(): Promise { + await setDoc(doc(citiesRef, 'SF'), { + name: 'San Francisco', + state: 'CA', + country: 'USA', + capital: false, + population: 860000, + regions: ['west_coast', 'norcal'], + }); +} + +const expressApp = express(); +const port = 3030; + +expressApp.get('/test', async function (req, res) { + await Sentry.startSpan({ name: 'Test Transaction' }, async () => { + await addCity(); + await setCity(); + await getCities(); + await deleteCity(); + }); + await Sentry.flush(); + res.send({ version: 'v1' }); +}); + +expressApp.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts new file mode 100644 index 000000000000..23c3d2fa5974 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; + + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.firebaseIntegration()], + defaultIntegrations: false, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-firebase/start-event-proxy.mjs new file mode 100644 index 000000000000..d935bf3dcc0b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-firebase', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/transactions.test.ts new file mode 100644 index 000000000000..749d818aee66 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/transactions.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const spanAddDoc = expect.objectContaining({ + description: 'addDoc cities', + data: expect.objectContaining({ + 'db.collection.name': 'cities', + 'db.namespace': '[DEFAULT]', + 'db.operation.name': 'addDoc', + 'db.system.name': 'firebase.firestore', + 'firebase.firestore.options.projectId': 'sentry-15d85', + 'firebase.firestore.type': 'collection', + 'otel.kind': 'CLIENT', + 'server.address': '127.0.0.1', + 'server.port': 8080, + 'sentry.origin': 'auto.firebase.otel.firestore', + 'sentry.op': 'db.query', + }), + op: 'db.query', + origin: 'auto.firebase.otel.firestore', + parent_span_id: expect.any(String), + trace_id: expect.any(String), + span_id: expect.any(String), + timestamp: expect.any(Number), + start_timestamp: expect.any(Number), + status: 'ok', +}); + +const spanSetDocs = expect.objectContaining({ + description: 'setDoc cities', + data: expect.objectContaining({ + 'db.collection.name': 'cities', + 'db.namespace': '[DEFAULT]', + 'db.operation.name': 'setDoc', + 'db.system.name': 'firebase.firestore', + 'firebase.firestore.options.projectId': 'sentry-15d85', + 'firebase.firestore.type': 'collection', + 'otel.kind': 'CLIENT', + 'server.address': '127.0.0.1', + 'server.port': 8080, + 'sentry.origin': 'auto.firebase.otel.firestore', + 'sentry.op': 'db.query', + }), + op: 'db.query', + origin: 'auto.firebase.otel.firestore', + parent_span_id: expect.any(String), + trace_id: expect.any(String), + span_id: expect.any(String), + timestamp: expect.any(Number), + start_timestamp: expect.any(Number), + status: 'ok', +}); + +const spanGetDocs = expect.objectContaining({ + description: 'getDocs cities', + data: expect.objectContaining({ + 'db.collection.name': 'cities', + 'db.namespace': '[DEFAULT]', + 'db.operation.name': 'getDocs', + 'db.system.name': 'firebase.firestore', + 'firebase.firestore.options.projectId': 'sentry-15d85', + 'firebase.firestore.type': 'collection', + 'otel.kind': 'CLIENT', + 'server.address': '127.0.0.1', + 'server.port': 8080, + 'sentry.origin': 'auto.firebase.otel.firestore', + 'sentry.op': 'db.query', + }), + op: 'db.query', + origin: 'auto.firebase.otel.firestore', + parent_span_id: expect.any(String), + trace_id: expect.any(String), + span_id: expect.any(String), + timestamp: expect.any(Number), + start_timestamp: expect.any(Number), + status: 'ok', +}); + +const spanDeleteDoc = expect.objectContaining({ + description: 'deleteDoc cities', + data: expect.objectContaining({ + 'db.collection.name': 'cities', + 'db.namespace': '[DEFAULT]', + 'db.operation.name': 'deleteDoc', + 'db.system.name': 'firebase.firestore', + 'firebase.firestore.options.projectId': 'sentry-15d85', + 'firebase.firestore.type': 'collection', + 'otel.kind': 'CLIENT', + 'server.address': '127.0.0.1', + 'server.port': 8080, + 'sentry.origin': 'auto.firebase.otel.firestore', + 'sentry.op': 'db.query', + }), + op: 'db.query', + origin: 'auto.firebase.otel.firestore', + parent_span_id: expect.any(String), + trace_id: expect.any(String), + span_id: expect.any(String), + timestamp: expect.any(Number), + start_timestamp: expect.any(Number), + status: 'ok', +}); + +test('should add, set, get and delete document', async ({ baseURL, page }) => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'Test Transaction'; + }); + + await fetch(`${baseURL}/test`); + + const transactionEvent = await serverTransactionPromise; + + expect(transactionEvent.transaction).toEqual('Test Transaction'); + expect(transactionEvent.spans?.length).toEqual(4); + + expect(transactionEvent.spans).toEqual(expect.arrayContaining([spanAddDoc, spanSetDocs, spanGetDocs, spanDeleteDoc])); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json new file mode 100644 index 000000000000..8cb64e989ed9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018"], + "strict": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/package.json index c35bcef4da90..92770106970e 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/sdk-trace-node": "^1.25.1", + "@opentelemetry/sdk-trace-node": "^2.0.0", "@sentry/node": "latest || *", "@sentry/opentelemetry": "latest || *", "@types/express": "4.17.17", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts index d0aed916864b..de09f0965baa 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-custom-sampler/src/instrument.ts @@ -19,10 +19,9 @@ Sentry.init({ const provider = new NodeTracerProvider({ sampler: new CustomSampler(), + spanProcessors: [new SentrySpanProcessor()], }); -provider.addSpanProcessor(new SentrySpanProcessor()); - provider.register({ propagator: new SentryPropagator(), contextManager: new Sentry.SentryContextManager(), 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 c3e40d06d6b0..5ca9077634d2 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 @@ -50,7 +50,6 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'query', 'express.type': 'middleware', }, @@ -69,7 +68,6 @@ test('Sends a sampled API route transaction', async ({ baseURL }) => { data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'expressInit', 'express.type': 'middleware', }, diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json index f5013b83598a..d26dc2db5843 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -11,8 +11,8 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-node": "0.52.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", + "@opentelemetry/sdk-node": "0.203.0", + "@opentelemetry/exporter-trace-otlp-http": "0.203.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", 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 0f978f72cf57..3e12007c0d75 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 @@ -74,7 +74,6 @@ test('Sends an API route transaction', async ({ baseURL }) => { data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'query', 'express.type': 'middleware', }, @@ -93,7 +92,6 @@ test('Sends an API route transaction', async ({ baseURL }) => { data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'expressInit', 'express.type': 'middleware', }, @@ -152,7 +150,6 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'query', 'express.type': 'middleware', }, @@ -171,7 +168,6 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'expressInit', 'express.type': 'middleware', }, @@ -200,8 +196,9 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), - status: 'ok', + status: 'unknown_error', timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), + measurements: {}, }); }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json index 4e83198da45c..11b5509fb637 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json @@ -11,11 +11,11 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-trace-node": "1.26.0", - "@opentelemetry/exporter-trace-otlp-http": "0.53.0", - "@opentelemetry/instrumentation-undici": "0.6.0", - "@opentelemetry/instrumentation-http": "0.53.0", - "@opentelemetry/instrumentation": "0.53.0", + "@opentelemetry/sdk-trace-node": "2.0.0", + "@opentelemetry/exporter-trace-otlp-http": "0.203.0", + "@opentelemetry/instrumentation-undici": "0.13.2", + "@opentelemetry/instrumentation-http": "0.203.0", + "@opentelemetry/instrumentation": "0.203.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", 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 ebabd499fee5..b833af11ef83 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 @@ -20,15 +20,15 @@ Sentry.init({ }); // Create and configure NodeTracerProvider -const provider = new NodeTracerProvider({}); - -provider.addSpanProcessor( - new BatchSpanProcessor( - new OTLPTraceExporter({ - url: 'http://localhost:3032/', - }), - ), -); +const provider = new NodeTracerProvider({ + spanProcessors: [ + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], +}); // Initialize the provider provider.register({ diff --git a/dev-packages/e2e-tests/test-applications/node-otel/package.json b/dev-packages/e2e-tests/test-applications/node-otel/package.json index af285767a655..1a554ece3bf7 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel/package.json @@ -11,8 +11,8 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-node": "0.52.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", + "@opentelemetry/sdk-node": "0.203.0", + "@opentelemetry/exporter-trace-otlp-http": "0.203.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", 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 de68adf681b7..c6abde474439 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 @@ -74,7 +74,6 @@ test('Sends an API route transaction', async ({ baseURL }) => { data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'query', 'express.type': 'middleware', }, @@ -93,7 +92,6 @@ test('Sends an API route transaction', async ({ baseURL }) => { data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'expressInit', 'express.type': 'middleware', }, @@ -152,7 +150,6 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'query', 'express.type': 'middleware', }, @@ -171,7 +168,6 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) data: { 'sentry.origin': 'auto.http.otel.express', 'sentry.op': 'middleware.express', - 'http.route': '/', 'express.name': 'expressInit', 'express.type': 'middleware', }, @@ -200,8 +196,9 @@ test('Sends an API route transaction for an errored route', async ({ baseURL }) parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), - status: 'ok', + status: 'unknown_error', timestamp: expect.any(Number), trace_id: expect.stringMatching(/[a-f0-9]{32}/), + measurements: {}, }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts index 9ca836610f2f..8f6ef4516fab 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/plugins/customNitroErrorHandler.ts @@ -1,4 +1,4 @@ -import { Context, GLOBAL_OBJ, flush, logger, vercelWaitUntil } from '@sentry/core'; +import { Context, flushIfServerless } from '@sentry/core'; import * as SentryNode from '@sentry/node'; import { H3Error } from 'h3'; import type { CapturedErrorContext } from 'nitropack'; @@ -53,31 +53,3 @@ function extractErrorContext(errorContext: CapturedErrorContext): Context { return ctx; } - -async function flushIfServerless(): Promise { - const isServerless = - !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions - !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda - !!process.env.VERCEL || - !!process.env.NETLIFY; - - // @ts-expect-error This is not typed - if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { - vercelWaitUntil(flushWithTimeout()); - } else if (isServerless) { - await flushWithTimeout(); - } -} - -async function flushWithTimeout(): Promise { - const sentryClient = SentryNode.getClient(); - const isDebug = sentryClient ? sentryClient.getOptions().debug : false; - - try { - isDebug && logger.log('Flushing events...'); - await flush(2000); - isDebug && logger.log('Done flushing events'); - } catch (e) { - isDebug && logger.log('Error while flushing events:\n', e); - } -} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx index 97260755da21..738cd1515a4d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.server.tsx @@ -15,12 +15,4 @@ const handleRequest = Sentry.createSentryHandleRequest({ export default handleRequest; -export const handleError: HandleErrorFunction = (error, { request }) => { - // React Router may abort some interrupted requests, don't log those - if (!request.signal.aborted) { - Sentry.captureException(error); - - // make sure to still log the error so you can see it - console.error(error); - } -}; +export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.server.test.ts index d702f8cee597..2759bfecb67e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.server.test.ts @@ -20,7 +20,8 @@ test.describe('server-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'react-router', }, }, ], @@ -67,7 +68,8 @@ test.describe('server-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'react-router', }, }, ], diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts index 7562297b2d4d..e9b2c9409154 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/trace-propagation.test.ts @@ -31,7 +31,11 @@ test.describe('Trace propagation', () => { const clientTx = await clientTxPromise; expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); - expect(clientTx.contexts?.trace?.parent_span_id).toBe(serverTx.contexts?.trace?.span_id); + + const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'request_handler.express'); + + expect(requestHandlerSpan).toBeDefined(); + expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); }); test('should not have trace connection for prerendered pages', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/entry.server.tsx index 97260755da21..738cd1515a4d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/entry.server.tsx @@ -15,12 +15,4 @@ const handleRequest = Sentry.createSentryHandleRequest({ export default handleRequest; -export const handleError: HandleErrorFunction = (error, { request }) => { - // React Router may abort some interrupted requests, don't log those - if (!request.signal.aborted) { - Sentry.captureException(error); - - // make sure to still log the error so you can see it - console.error(error); - } -}; +export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/errors/errors.server.test.ts index d702f8cee597..2759bfecb67e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/errors/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/errors/errors.server.test.ts @@ -20,7 +20,8 @@ test.describe('server-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'react-router', }, }, ], @@ -67,7 +68,8 @@ test.describe('server-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'react-router', }, }, ], diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/trace-propagation.test.ts index 7562297b2d4d..e9b2c9409154 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/trace-propagation.test.ts @@ -31,7 +31,11 @@ test.describe('Trace propagation', () => { const clientTx = await clientTxPromise; expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); - expect(clientTx.contexts?.trace?.parent_span_id).toBe(serverTx.contexts?.trace?.span_id); + + const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'request_handler.express'); + + expect(requestHandlerSpan).toBeDefined(); + expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); }); test('should not have trace connection for prerendered pages', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx index 97260755da21..738cd1515a4d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.server.tsx @@ -15,12 +15,4 @@ const handleRequest = Sentry.createSentryHandleRequest({ export default handleRequest; -export const handleError: HandleErrorFunction = (error, { request }) => { - // React Router may abort some interrupted requests, don't log those - if (!request.signal.aborted) { - Sentry.captureException(error); - - // make sure to still log the error so you can see it - console.error(error); - } -}; +export const handleError: HandleErrorFunction = Sentry.createSentryHandleError({ logErrors: true }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts index d702f8cee597..2759bfecb67e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.server.test.ts @@ -20,7 +20,8 @@ test.describe('server-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'react-router', }, }, ], @@ -67,7 +68,8 @@ test.describe('server-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'react-router', }, }, ], diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts index 7562297b2d4d..e9b2c9409154 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/trace-propagation.test.ts @@ -31,7 +31,11 @@ test.describe('Trace propagation', () => { const clientTx = await clientTxPromise; expect(clientTx.contexts?.trace?.trace_id).toEqual(serverTx.contexts?.trace?.trace_id); - expect(clientTx.contexts?.trace?.parent_span_id).toBe(serverTx.contexts?.trace?.span_id); + + const requestHandlerSpan = serverTx.spans?.find(span => span.op === 'request_handler.express'); + + expect(requestHandlerSpan).toBeDefined(); + expect(clientTx.contexts?.trace?.parent_span_id).toBe(requestHandlerSpan?.span_id); }); test('should not have trace connection for prerendered pages', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts index 76abdb1fa6b5..c11fba897aff 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/tests/fixtures/ReplayRecordingData.ts @@ -245,25 +245,6 @@ export const ReplayRecordingData = [ }, }, }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'web-vital', - description: 'first-input-delay', - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - data: { - value: expect.any(Number), - size: expect.any(Number), - rating: expect.any(String), - nodeIds: [10], - }, - }, - }, - }, { type: 5, timestamp: expect.any(Number), diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json index 1d0cbdce8eae..530f8ada5650 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json @@ -20,7 +20,7 @@ "@remix-run/cloudflare-pages": "^2.15.2", "@sentry/cloudflare": "latest || *", "@sentry/remix": "latest || *", - "@sentry/vite-plugin": "^3.1.2", + "@sentry/vite-plugin": "^4.0.0", "@shopify/hydrogen": "2025.4.0", "@shopify/remix-oxygen": "^2.0.10", "graphql": "^16.6.0", diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 1aa77efcf44c..b27f7db9584e 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -27,12 +27,12 @@ "@nestjs/core": "10.4.6", "@nestjs/platform-express": "10.4.6", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "^0.57.2", - "@opentelemetry/instrumentation-http": "0.57.2", - "@opentelemetry/resources": "^1.30.1", - "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-http": "0.203.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@sentry/core": "9.40.0", "@sentry/node-core": "9.40.0", diff --git a/dev-packages/node-core-integration-tests/suites/winston/subject.ts b/dev-packages/node-core-integration-tests/suites/winston/subject.ts index c8840b855f9b..3c31ddb63fa5 100644 --- a/dev-packages/node-core-integration-tests/suites/winston/subject.ts +++ b/dev-packages/node-core-integration-tests/suites/winston/subject.ts @@ -8,9 +8,7 @@ const client = Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0.0', environment: 'test', - _experiments: { - enableLogs: true, - }, + enableLogs: true, transport: loggingTransport, }); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index bac4cce0e1b9..b633205756fd 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -16,11 +16,9 @@ "build:types": "tsc -p tsconfig.types.json", "clean": "rimraf -g **/node_modules && run-p clean:script", "clean:script": "node scripts/clean.js", - "express-v5-install": "cd suites/express-v5 && yarn --no-lockfile", "lint": "eslint . --format stylish", "fix": "eslint . --format stylish --fix", "type-check": "tsc", - "pretest": "yarn express-v5-install", "test": "vitest run", "test:watch": "yarn test --watch" }, @@ -71,6 +69,7 @@ "yargs": "^16.2.0" }, "devDependencies": { + "@sentry-internal/test-utils": "link:../test-utils", "@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/aws-serverless/aws-integration/s3/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/s3/test.ts index b131eb421b61..a6fdc2ce88af 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/s3/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/s3/test.ts @@ -14,7 +14,7 @@ const EXPECTED_TRANSCATION = { 'rpc.system': 'aws-api', 'rpc.method': 'PutObject', 'rpc.service': 'S3', - 'aws.region': 'us-east-1', + 'cloud.region': 'us-east-1', 'aws.s3.bucket': 'ot-demo-test', 'otel.kind': 'CLIENT', }), diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts deleted file mode 100644 index 8f594e449162..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - transport: loggingTransport, -}); - -import express from 'express'; - -const app = express(); - -Sentry.setTag('global', 'tag'); - -app.get('/test/withScope', () => { - Sentry.withScope(scope => { - scope.setTag('local', 'tag'); - throw new Error('test_error'); - }); -}); - -app.get('/test/isolationScope', () => { - Sentry.getIsolationScope().setTag('isolation-scope', 'tag'); - throw new Error('isolation_test_error'); -}); - -app.get('/test/withIsolationScope', () => { - Sentry.withIsolationScope(iScope => { - iScope.setTag('with-isolation-scope', 'tag'); - throw new Error('with_isolation_scope_test_error'); - }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts deleted file mode 100644 index 306449b09569..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/handle-error-scope-data-loss/test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -/** - * Why does this test exist? - * - * We recently discovered that errors caught by global handlers will potentially loose scope data from the active scope - * where the error was originally thrown in. The simple example in this test (see subject.ts) demonstrates this behavior - * (in a Node environment but the same behavior applies to the browser; see the test there). - * - * This test nevertheless covers the behavior so that we're aware. - */ -test('withScope scope is NOT applied to thrown error caught by global handler', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - event: { - exception: { - values: [ - { - mechanism: { - type: 'middleware', - handled: false, - }, - type: 'Error', - value: 'test_error', - stacktrace: { - frames: expect.arrayContaining([ - expect.objectContaining({ - function: expect.any(String), - lineno: expect.any(Number), - colno: expect.any(Number), - }), - ]), - }, - }, - ], - }, - // 'local' tag is not applied to the event - tags: expect.not.objectContaining({ local: expect.anything() }), - }, - }) - .start(); - runner.makeRequest('get', '/test/withScope', { expectError: true }); - await runner.completed(); -}); - -/** - * This test shows that the isolation scope set tags are applied correctly to the error. - */ -test('isolation scope is applied to thrown error caught by global handler', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - event: { - exception: { - values: [ - { - mechanism: { - type: 'middleware', - handled: false, - }, - type: 'Error', - value: 'isolation_test_error', - stacktrace: { - frames: expect.arrayContaining([ - expect.objectContaining({ - function: expect.any(String), - lineno: expect.any(Number), - colno: expect.any(Number), - }), - ]), - }, - }, - ], - }, - tags: { - global: 'tag', - 'isolation-scope': 'tag', - }, - }, - }) - .start(); - runner.makeRequest('get', '/test/isolationScope', { expectError: true }); - await runner.completed(); -}); - -/** - * This test shows that an inner isolation scope, created via `withIsolationScope`, is not applied to the error. - * - * This behaviour occurs because, just like in the test above where we use `getIsolationScope().setTag`, - * this isolation scope again is only valid as long as we're in the callback. - * - * So why _does_ the http isolation scope get applied then? Because express' error handler applies on - * a per-request basis, meaning, it's called while we're inside the isolation scope of the http request, - * created from our `httpIntegration`. - */ -test('withIsolationScope scope is NOT applied to thrown error caught by global handler', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - event: { - exception: { - values: [ - { - mechanism: { - type: 'middleware', - handled: false, - }, - type: 'Error', - value: 'with_isolation_scope_test_error', - stacktrace: { - frames: expect.arrayContaining([ - expect.objectContaining({ - function: expect.any(String), - lineno: expect.any(Number), - colno: expect.any(Number), - }), - ]), - }, - }, - ], - }, - // 'with-isolation-scope' tag is not applied to the event - tags: expect.not.objectContaining({ 'with-isolation-scope': expect.anything() }), - }, - }) - .start(); - - runner.makeRequest('get', '/test/withIsolationScope', { expectError: true }); - - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/server.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/server.ts deleted file mode 100644 index eb7044faee97..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - transport: loggingTransport, -}); - -import express from 'express'; - -const app = express(); - -app.get('/test/express/:id', req => { - throw new Error(`test_error with id ${req.params.id}`); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/test.ts b/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/test.ts deleted file mode 100644 index 899a611cdfe0..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-unset/test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should capture and send Express controller error if tracesSampleRate is not set.', async () => { - const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') - .expect({ - event: { - exception: { - values: [ - { - mechanism: { - type: 'middleware', - handled: false, - }, - type: 'Error', - value: 'test_error with id 123', - stacktrace: { - frames: expect.arrayContaining([ - expect.objectContaining({ - function: expect.any(String), - lineno: expect.any(Number), - colno: expect.any(Number), - }), - ]), - }, - }, - ], - }, - }, - }) - .start(); - runner.makeRequest('get', '/test/express/123', { expectError: true }); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-init/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-init/server.ts deleted file mode 100644 index f9952ce43a9f..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-init/server.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - // No dsn, means client is disabled - // dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - transport: loggingTransport, -}); - -// We add http integration to ensure request isolation etc. works -const initialClient = Sentry.getClient(); -initialClient?.addIntegration(Sentry.httpIntegration()); - -// Store this so we can update the client later -const initialCurrentScope = Sentry.getCurrentScope(); - -import express from 'express'; - -const app = express(); - -Sentry.setTag('global', 'tag'); - -app.get('/test/no-init', (_req, res) => { - Sentry.addBreadcrumb({ message: 'no init breadcrumb' }); - Sentry.setTag('no-init', 'tag'); - - res.send({}); -}); - -app.get('/test/init', (_req, res) => { - // Call init again, but with DSN - Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - transport: loggingTransport, - }); - // Set this on initial scope, to ensure it can be inherited - initialCurrentScope.setClient(Sentry.getClient()!); - - Sentry.addBreadcrumb({ message: 'init breadcrumb' }); - Sentry.setTag('init', 'tag'); - - res.send({}); -}); - -app.get('/test/error/:id', (req, res) => { - const id = req.params.id; - Sentry.addBreadcrumb({ message: `error breadcrumb ${id}` }); - Sentry.setTag('error', id); - - Sentry.captureException(new Error(`This is an exception ${id}`)); - - setTimeout(() => { - // We flush to ensure we are sending exceptions in a certain order - Sentry.flush(1000).then( - () => { - // We send this so we can wait for this, to know the test is ended & server can be closed - if (id === '3') { - Sentry.captureException(new Error('Final exception was captured')); - } - res.send({}); - }, - () => { - res.send({}); - }, - ); - }, 1); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-init/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-init/test.ts deleted file mode 100644 index 04d45fe557ef..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-init/test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('allows to call init multiple times', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - event: { - exception: { - values: [ - { - value: 'This is an exception 2', - }, - ], - }, - breadcrumbs: [ - { - message: 'error breadcrumb 2', - timestamp: expect.any(Number), - }, - ], - tags: { - global: 'tag', - error: '2', - }, - }, - }) - .expect({ - event: { - exception: { - values: [ - { - value: 'This is an exception 3', - }, - ], - }, - breadcrumbs: [ - { - message: 'error breadcrumb 3', - timestamp: expect.any(Number), - }, - ], - tags: { - global: 'tag', - error: '3', - }, - }, - }) - .expect({ - event: { - exception: { - values: [ - { - value: 'Final exception was captured', - }, - ], - }, - }, - }) - .start(); - - runner - .makeRequest('get', '/test/no-init') - .then(() => runner.makeRequest('get', '/test/error/1')) - .then(() => runner.makeRequest('get', '/test/init')) - .then(() => runner.makeRequest('get', '/test/error/2')) - .then(() => runner.makeRequest('get', '/test/error/3')); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/server.ts deleted file mode 100644 index 6cbe4a330463..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; - -const app = express(); - -app.use(cors()); - -const APIv1 = express.Router(); - -APIv1.get('/user/:userId', function (_req, res) { - Sentry.captureMessage('Custom Message'); - res.send('Success'); -}); - -const root = express.Router(); - -app.use('/api2/v1', root); -app.use('/api/v1', APIv1); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/test.ts deleted file mode 100644 index 85c3d0a2c353..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix-parameterized/test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { afterAll, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should construct correct url with common infixes with multiple parameterized routers.', async () => { - const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') - .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/user/:userId' } }) - .start(); - runner.makeRequest('get', '/api/v1/user/3212'); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/server.ts deleted file mode 100644 index b07226733bc6..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/server.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - debug: true, - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; - -const app = express(); - -app.use(cors()); - -const APIv1 = express.Router(); - -APIv1.get('/test', function (_req, res) { - Sentry.captureMessage('Custom Message'); - res.send('Success'); -}); - -const root = express.Router(); - -app.use('/api/v1', root); -app.use('/api2/v1', APIv1); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/test.ts deleted file mode 100644 index f6a29574e254..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-infix/test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { afterAll, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should construct correct url with common infixes with multiple routers.', async () => { - const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') - .expect({ event: { message: 'Custom Message', transaction: 'GET /api2/v1/test' } }) - .start(); - runner.makeRequest('get', '/api2/v1/test'); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/server.ts deleted file mode 100644 index 99792fe8185c..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; - -const app = express(); - -app.use(cors()); - -const APIv1 = express.Router(); - -APIv1.get('/user/:userId', function (_req, res) { - Sentry.captureMessage('Custom Message'); - res.send('Success'); -}); - -const root = express.Router(); - -app.use('/api/v1', APIv1); -app.use('/api', root); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/test.ts deleted file mode 100644 index b2b5baabd103..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized-reverse/test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { afterAll, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should construct correct urls with multiple parameterized routers (use order reversed).', async () => { - const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') - .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/user/:userId' } }) - .start(); - runner.makeRequest('get', '/api/v1/user/1234/'); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/server.ts deleted file mode 100644 index e405a5f17d27..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; - -const app = express(); - -app.use(cors()); - -const APIv1 = express.Router(); - -APIv1.get('/user/:userId', function (_req, res) { - Sentry.captureMessage('Custom Message'); - res.send('Success'); -}); - -const root = express.Router(); - -app.use('/api', root); -app.use('/api/v1', APIv1); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/test.ts deleted file mode 100644 index f362d49ddd03..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-parameterized/test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { afterAll, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should construct correct urls with multiple parameterized routers.', async () => { - const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') - .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/user/:userId' } }) - .start(); - runner.makeRequest('get', '/api/v1/user/1234/'); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/server.ts deleted file mode 100644 index 0ef9dbee681e..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; - -const app = express(); - -app.use(cors()); - -const APIv1 = express.Router(); - -APIv1.get('/:userId', function (_req, res) { - Sentry.captureMessage('Custom Message'); - res.send('Success'); -}); - -const root = express.Router(); - -app.use('/api/v1', APIv1); -app.use('/api', root); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/test.ts deleted file mode 100644 index 146e7d0625c5..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized copy/test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { afterAll, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should construct correct url with multiple parameterized routers of the same length (use order reversed).', async () => { - const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') - .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/:userId' } }) - .start(); - runner.makeRequest('get', '/api/v1/1234/'); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/server.ts deleted file mode 100644 index a5272e134c48..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; - -const app = express(); - -app.use(cors()); - -const APIv1 = express.Router(); - -APIv1.get('/:userId', function (_req, res) { - Sentry.captureMessage('Custom Message'); - res.send('Success'); -}); - -const root = express.Router(); - -app.use('/api', root); -app.use('/api/v1', APIv1); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/test.ts deleted file mode 100644 index 3209cde3ea23..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix-same-length-parameterized/test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { afterAll, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should construct correct url with multiple parameterized routers of the same length.', async () => { - const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') - .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/:userId' } }) - .start(); - runner.makeRequest('get', '/api/v1/1234/'); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/server.ts deleted file mode 100644 index 52cce3594b68..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; - -const app = express(); - -app.use(cors()); - -const APIv1 = express.Router(); - -APIv1.get('/test', function (_req, res) { - Sentry.captureMessage('Custom Message'); - res.send('Success'); -}); - -const root = express.Router(); - -app.use('/api', root); -app.use('/api/v1', APIv1); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/test.ts deleted file mode 100644 index 12c6584b2d3f..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/common-prefix/test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { afterAll, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should construct correct urls with multiple routers.', async () => { - const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') - .expect({ event: { message: 'Custom Message', transaction: 'GET /api/v1/test' } }) - .start(); - runner.makeRequest('get', '/api/v1/test'); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/server.ts deleted file mode 100644 index 7d875b47f13b..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import express from 'express'; - -const app = express(); - -const APIv1 = express.Router(); - -APIv1.use( - '/users/:userId', - APIv1.get('/posts/:postId', (_req, res) => { - Sentry.captureMessage('Custom Message'); - return res.send('Success'); - }), -); - -const router = express.Router(); - -app.use('/api', router); -app.use('/api/api/v1', APIv1.use('/sub-router', APIv1)); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/test.ts deleted file mode 100644 index fbb97cb6b1df..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/complex-router/test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { afterAll, describe, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -describe('complex-router', () => { - test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route and express used multiple middlewares with route', async () => { - const EXPECTED_TRANSACTION = { - transaction: 'GET /api/api/v1/sub-router/users/:userId/posts/:postId', - transaction_info: { - source: 'route', - }, - }; - - const runner = createRunner(__dirname, 'server.ts') - .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION as any }) - .start(); - runner.makeRequest('get', '/api/api/v1/sub-router/users/123/posts/456'); - await runner.completed(); - }); - - test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route and express used multiple middlewares with route and original url has query params', async () => { - const EXPECTED_TRANSACTION = { - transaction: 'GET /api/api/v1/sub-router/users/:userId/posts/:postId', - transaction_info: { - source: 'route', - }, - }; - - const runner = createRunner(__dirname, 'server.ts') - .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION as any }) - .start(); - runner.makeRequest('get', '/api/api/v1/sub-router/users/123/posts/456?param=1'); - await runner.completed(); - }); - - test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route and express used multiple middlewares with route and original url ends with trailing slash and has query params', async () => { - const EXPECTED_TRANSACTION = { - transaction: 'GET /api/api/v1/sub-router/users/:userId/posts/:postId', - transaction_info: { - source: 'route', - }, - }; - - const runner = createRunner(__dirname, 'server.ts') - .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION as any }) - .start(); - runner.makeRequest('get', '/api/api/v1/sub-router/users/123/posts/456/?param=1'); - await runner.completed(); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/server.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/server.ts deleted file mode 100644 index 793a84924001..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import express from 'express'; - -const app = express(); - -const APIv1 = express.Router(); - -APIv1.use( - '/users/:userId', - APIv1.get('/posts/:postId', (_req, res) => { - Sentry.captureMessage('Custom Message'); - return res.send('Success'); - }), -); - -const root = express.Router(); - -app.use('/api/v1', APIv1); -app.use('/api', root); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/test.ts b/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/test.ts deleted file mode 100644 index 6f24e03bac59..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/multiple-routers/middle-layer-parameterized/test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { afterAll, describe, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -// Before Node 16, parametrization is not working properly here -describe('middle-layer-parameterized', () => { - test('should construct correct url with multiple parameterized routers, when param is also contain in middle layer route', async () => { - const EXPECTED_TRANSACTION = { - transaction: 'GET /api/v1/users/:userId/posts/:postId', - transaction_info: { - source: 'route', - }, - }; - - const runner = createRunner(__dirname, 'server.ts') - .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION as any }) - .start(); - runner.makeRequest('get', '/api/v1/users/123/posts/456'); - await runner.completed(); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/package.json b/dev-packages/node-integration-tests/suites/express-v5/package.json deleted file mode 100644 index b3855635c556..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "express-v5", - "dependencies": { - "express": "^5.0.0" - } -} diff --git a/dev-packages/node-integration-tests/suites/express-v5/requestUser/server.js b/dev-packages/node-integration-tests/suites/express-v5/requestUser/server.js deleted file mode 100644 index d93d22905506..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/requestUser/server.js +++ /dev/null @@ -1,49 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - transport: loggingTransport, - debug: true, -}); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); - -app.use((req, _res, next) => { - // We simulate this, which would in other cases be done by some middleware - req.user = { - id: '1', - email: 'test@sentry.io', - }; - - next(); -}); - -app.get('/test1', (_req, _res) => { - throw new Error('error_1'); -}); - -app.use((_req, _res, next) => { - Sentry.setUser({ - id: '2', - email: 'test2@sentry.io', - }); - - next(); -}); - -app.get('/test2', (_req, _res) => { - throw new Error('error_2'); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/requestUser/test.ts b/dev-packages/node-integration-tests/suites/express-v5/requestUser/test.ts deleted file mode 100644 index 2605b7ed5127..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/requestUser/test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; - -describe('express user handling', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - test('ignores user from request', async () => { - expect.assertions(2); - - const runner = createRunner(__dirname, 'server.js') - .expect({ - event: event => { - expect(event.user).toBeUndefined(); - expect(event.exception?.values?.[0]?.value).toBe('error_1'); - }, - }) - .start(); - runner.makeRequest('get', '/test1', { expectError: true }); - await runner.completed(); - }); - - test('using setUser in middleware works', async () => { - const runner = createRunner(__dirname, 'server.js') - .expect({ - event: { - user: { - id: '2', - email: 'test2@sentry.io', - }, - exception: { - values: [ - { - value: 'error_2', - }, - ], - }, - }, - }) - .start(); - runner.makeRequest('get', '/test2', { expectError: true }); - await runner.completed(); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-assign/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-assign/test.ts deleted file mode 100644 index 8b8d648513e4..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-assign/test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { parseBaggageHeader } from '@sentry/core'; -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -import type { TestAPIResponse } from '../server'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('Should overwrite baggage if the incoming request already has Sentry baggage data but no sentry-trace', async () => { - const runner = createRunner(__dirname, '..', 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - baggage: 'sentry-release=2.0.0,sentry-environment=myEnv', - }, - }); - - expect(response).toBeDefined(); - expect(response).not.toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - baggage: 'sentry-release=2.0.0,sentry-environment=myEnv', - }, - }); -}); - -test('Should propagate sentry trace baggage data from an incoming to an outgoing request.', async () => { - const runner = createRunner(__dirname, '..', 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', - baggage: 'sentry-release=2.0.0,sentry-environment=myEnv,dogs=great,sentry-sample_rand=0.42', - }, - }); - - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - baggage: 'sentry-release=2.0.0,sentry-environment=myEnv,sentry-sample_rand=0.42', - }, - }); -}); - -test('Should not propagate baggage data from an incoming to an outgoing request if sentry-trace is faulty.', async () => { - const runner = createRunner(__dirname, '..', 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '', - baggage: 'sentry-release=2.0.0,sentry-environment=myEnv,dogs=great', - }, - }); - - expect(response).toBeDefined(); - expect(response).not.toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - baggage: 'sentry-release=2.0.0,sentry-environment=myEnv', - }, - }); -}); - -test('Should not propagate baggage if sentry-trace header is present in incoming request but no baggage header', async () => { - const runner = createRunner(__dirname, '..', 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', - }, - }); - - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - }, - }); -}); - -test('Should not propagate baggage and ignore original 3rd party baggage entries if sentry-trace header is present', async () => { - const runner = createRunner(__dirname, '..', 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', - baggage: 'foo=bar', - }, - }); - - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - }, - }); -}); - -test('Should populate and propagate sentry baggage if sentry-trace header does not exist', async () => { - const runner = createRunner(__dirname, '..', 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express'); - - expect(response).toBeDefined(); - - const parsedBaggage = parseBaggageHeader(response?.test_data.baggage); - - expect(response?.test_data.host).toBe('somewhere.not.sentry'); - expect(parsedBaggage).toStrictEqual({ - 'sentry-environment': 'prod', - 'sentry-release': '1.0', - 'sentry-public_key': 'public', - // TraceId changes, hence we only expect that the string contains the traceid key - 'sentry-trace_id': expect.stringMatching(/[\S]*/), - 'sentry-sample_rand': expect.stringMatching(/[\S]*/), - 'sentry-sample_rate': '1', - 'sentry-sampled': 'true', - 'sentry-transaction': 'GET /test/express', - }); -}); - -test('Should populate Sentry and ignore 3rd party content if sentry-trace header does not exist', async () => { - const runner = createRunner(__dirname, '..', 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - baggage: 'foo=bar,bar=baz', - }, - }); - - expect(response).toBeDefined(); - expect(response?.test_data.host).toBe('somewhere.not.sentry'); - - const parsedBaggage = parseBaggageHeader(response?.test_data.baggage); - expect(parsedBaggage).toStrictEqual({ - 'sentry-environment': 'prod', - 'sentry-release': '1.0', - 'sentry-public_key': 'public', - // TraceId changes, hence we only expect that the string contains the traceid key - 'sentry-trace_id': expect.stringMatching(/[\S]*/), - 'sentry-sample_rand': expect.stringMatching(/[\S]*/), - 'sentry-sample_rate': '1', - 'sentry-sampled': 'true', - 'sentry-transaction': 'GET /test/express', - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/server.ts deleted file mode 100644 index a66661c50bd2..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - environment: 'prod', - tracePropagationTargets: [/^(?!.*express).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; -import http from 'http'; - -const app = express(); - -Sentry.setUser({ id: 'user123' }); - -app.use(cors()); - -app.get('/test/express', (_req, res) => { - const span = Sentry.getActiveSpan(); - const traceId = span?.spanContext().traceId; - const headers = http.get('http://somewhere.not.sentry/').getHeaders(); - if (traceId) { - headers['baggage'] = (headers['baggage'] as string).replace(traceId, '__SENTRY_TRACE_ID__'); - } - // Responding with the headers outgoing request headers back to the assertions. - res.send({ test_data: headers }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/test.ts deleted file mode 100644 index 913fcd5e2038..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-header-out/test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -import type { TestAPIResponse } from './server'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should attach a baggage header to an outgoing request.', async () => { - const runner = createRunner(__dirname, 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express'); - - expect(response).toBeDefined(); - - const baggage = response?.test_data.baggage?.split(','); - - [ - 'sentry-environment=prod', - 'sentry-public_key=public', - 'sentry-release=1.0', - 'sentry-sample_rate=1', - 'sentry-sampled=true', - 'sentry-trace_id=__SENTRY_TRACE_ID__', - 'sentry-transaction=GET%20%2Ftest%2Fexpress', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ].forEach(item => { - expect(baggage).toContainEqual(item); - }); - - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - }, - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/server.ts deleted file mode 100644 index 372d413a158e..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/server.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - environment: 'prod', - // disable requests to /express - tracePropagationTargets: [/^(?!.*express).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; -import * as http from 'http'; - -const app = express(); - -app.use(cors()); - -app.get('/test/express', (_req, res) => { - // simulate setting a "third party" baggage header which the Sentry SDK should merge with Sentry DSC entries - const headers = http - .get({ - hostname: 'somewhere.not.sentry', - headers: { - baggage: - 'other=vendor,foo=bar,third=party,sentry-release=9.9.9,sentry-environment=staging,sentry-sample_rate=0.54,last=item', - }, - }) - .getHeaders(); - - // Responding with the headers outgoing request headers back to the assertions. - res.send({ test_data: headers }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts deleted file mode 100644 index 2d2074be773c..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -import type { TestAPIResponse } from '../server'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should ignore sentry-values in `baggage` header of a third party vendor and overwrite them with incoming DSC', async () => { - const runner = createRunner(__dirname, 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', - baggage: 'sentry-release=2.1.0,sentry-environment=myEnv', - }, - }); - - expect(response).toBeDefined(); - - const baggage = response?.test_data.baggage?.split(',').sort(); - - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - }, - }); - - expect(baggage).toEqual([ - 'foo=bar', - 'last=item', - 'other=vendor', - 'sentry-environment=myEnv', - 'sentry-release=2.1.0', - expect.stringMatching(/sentry-sample_rand=[0-9]+/), - 'sentry-sample_rate=0.54', - 'third=party', - ]); -}); - -test('should ignore sentry-values in `baggage` header of a third party vendor and overwrite them with new DSC', async () => { - const runner = createRunner(__dirname, 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express'); - - expect(response).toBeDefined(); - - const baggage = response?.test_data.baggage?.split(',').sort(); - - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - }, - }); - - expect(baggage).toEqual([ - 'foo=bar', - 'last=item', - 'other=vendor', - 'sentry-environment=prod', - 'sentry-public_key=public', - 'sentry-release=1.0', - expect.stringMatching(/sentry-sample_rand=[0-9]+/), - 'sentry-sample_rate=1', - 'sentry-sampled=true', - expect.stringMatching(/sentry-trace_id=[0-9a-f]{32}/), - 'sentry-transaction=GET%20%2Ftest%2Fexpress', - 'third=party', - ]); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/server.ts deleted file mode 100644 index 7aea36941e99..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - environment: 'prod', - // disable requests to /express - tracePropagationTargets: [/^(?!.*express).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; -import http from 'http'; - -const app = express(); - -app.use(cors()); - -app.get('/test/express', (_req, res) => { - // simulate setting a "third party" baggage header which the Sentry SDK should merge with Sentry DSC entries - const headers = http - .get({ hostname: 'somewhere.not.sentry', headers: { baggage: 'other=vendor,foo=bar,third=party' } }) - .getHeaders(); - - // Responding with the headers outgoing request headers back to the assertions. - res.send({ test_data: headers }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/test.ts deleted file mode 100644 index beb118944408..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-other-vendors/test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -import type { TestAPIResponse } from './server'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should merge `baggage` header of a third party vendor with the Sentry DSC baggage items', async () => { - const runner = createRunner(__dirname, 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', - baggage: 'sentry-release=2.0.0,sentry-environment=myEnv,sentry-sample_rand=0.42', - }, - }); - - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - baggage: 'other=vendor,foo=bar,third=party,sentry-release=2.0.0,sentry-environment=myEnv,sentry-sample_rand=0.42', - }, - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/server.ts deleted file mode 100644 index ef455fb9ec52..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - environment: 'prod', - // disable requests to /express - tracePropagationTargets: [/^(?!.*express).*$/], - tracesSampleRate: 1.0, - // TODO: We're rethinking the mechanism for including Pii data in DSC, hence commenting out sendDefaultPii for now - // sendDefaultPii: true, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; -import http from 'http'; - -const app = express(); - -Sentry.setUser({ id: 'user123' }); - -app.use(cors()); - -app.get('/test/express', (_req, res) => { - const headers = http.get('http://somewhere.not.sentry/').getHeaders(); - // Responding with the headers outgoing request headers back to the assertions. - res.send({ test_data: headers }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/test.ts deleted file mode 100644 index 436a8ea9b4da..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/baggage-transaction-name/test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -import type { TestAPIResponse } from '../server'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('Includes transaction in baggage if the transaction name is parameterized', async () => { - const runner = createRunner(__dirname, 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express'); - - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - baggage: expect.stringContaining('sentry-transaction=GET%20%2Ftest%2Fexpress'), - }, - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/server.ts deleted file mode 100644 index cc8d5657dc7f..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/server.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - environment: 'prod', - tracePropagationTargets: [/^(?!.*express).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; -import http from 'http'; - -const app = express(); - -app.use(cors()); - -app.get('/test/express', (_req, res) => { - const headers = http.get('http://somewhere.not.sentry/').getHeaders(); - - // Responding with the headers outgoing request headers back to the assertions. - res.send({ test_data: headers }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/server.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/server.ts deleted file mode 100644 index 8aa9ed85d3be..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/server.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } }; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - environment: 'prod', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import cors from 'cors'; -import express from 'express'; -import http from 'http'; - -const app = express(); - -app.use(cors()); - -app.get('/test/express', (_req, res) => { - const headers = http.get('http://somewhere.not.sentry/').getHeaders(); - - // Responding with the headers outgoing request headers back to the assertions. - res.send({ test_data: headers }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/test.ts deleted file mode 100644 index 7d0a729dc4ff..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-assign/test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { TRACEPARENT_REGEXP } from '@sentry/core'; -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -import type { TestAPIResponse } from '../server'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('Should assign `sentry-trace` header which sets parent trace id of an outgoing request.', async () => { - const runner = createRunner(__dirname, 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express', { - headers: { - 'sentry-trace': '12312012123120121231201212312012-1121201211212012-0', - }, - }); - - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - 'sentry-trace': expect.stringContaining('12312012123120121231201212312012-'), - }, - }); - - expect(TRACEPARENT_REGEXP.test(response?.test_data['sentry-trace'] || '')).toBe(true); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-out/test.ts b/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-out/test.ts deleted file mode 100644 index 8ed4d08bba55..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/sentry-trace/trace-header-out/test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { TRACEPARENT_REGEXP } from '@sentry/core'; -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; -import type { TestAPIResponse } from '../server'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('should attach a `sentry-trace` header to an outgoing request.', async () => { - const runner = createRunner(__dirname, '..', 'server.ts').start(); - - const response = await runner.makeRequest('get', '/test/express'); - - expect(response).toBeDefined(); - expect(response).toMatchObject({ - test_data: { - host: 'somewhere.not.sentry', - 'sentry-trace': expect.any(String), - }, - }); - - expect(TRACEPARENT_REGEXP.test(response?.test_data['sentry-trace'] || '')).toBe(true); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/server.js b/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/server.js deleted file mode 100644 index 0e73923cf88a..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/server.js +++ /dev/null @@ -1,33 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - transport: loggingTransport, -}); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); - -app.get('/test1', (_req, _res) => { - throw new Error('error_1'); -}); - -app.get('/test2', (_req, _res) => { - throw new Error('error_2'); -}); - -Sentry.setupExpressErrorHandler(app, { - shouldHandleError: error => { - return error.message === 'error_2'; - }, -}); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/test.ts b/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/test.ts deleted file mode 100644 index 571ffc52e224..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/setupExpressErrorHandler/test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { afterAll, describe, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; - -describe('express setupExpressErrorHandler', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - describe('CJS', () => { - test('allows to pass options to setupExpressErrorHandler', async () => { - const runner = createRunner(__dirname, 'server.js') - .expect({ - event: { - exception: { - values: [ - { - value: 'error_2', - }, - ], - }, - }, - }) - .start(); - - // this error is filtered & ignored - runner.makeRequest('get', '/test1', { expectError: true }); - // this error is actually captured - runner.makeRequest('get', '/test2', { expectError: true }); - await runner.completed(); - }); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/server.ts b/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/server.ts deleted file mode 100644 index 3e4f9d0de62b..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -import express from 'express'; - -const app = express(); - -Sentry.setTag('global', 'tag'); - -app.get('/test/isolationScope', (_req, res) => { - // eslint-disable-next-line no-console - console.log('This is a test log.'); - Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); - Sentry.setTag('isolation-scope', 'tag'); - - res.send({}); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/test.ts b/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/test.ts deleted file mode 100644 index f8c7c11378d5..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/span-isolationScope/test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { afterAll, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -test('correctly applies isolation scope to span', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - transaction: { - transaction: 'GET /test/isolationScope', - breadcrumbs: [ - { - category: 'console', - level: 'log', - message: expect.stringMatching(/\{"port":(\d+)\}/), - timestamp: expect.any(Number), - }, - { - category: 'console', - level: 'log', - message: 'This is a test log.', - timestamp: expect.any(Number), - }, - { - message: 'manual breadcrumb', - timestamp: expect.any(Number), - }, - ], - tags: { - global: 'tag', - 'isolation-scope': 'tag', - }, - }, - }) - .start(); - runner.makeRequest('get', '/test/isolationScope'); - await runner.completed(); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs deleted file mode 100644 index c53a72951970..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; -import express from 'express'; - -const app = express(); - -app.get('/', (_req, res) => { - res.send({ response: 'response 0' }); -}); - -app.get('/401', (_req, res) => { - res.status(401).send({ response: 'response 401' }); -}); - -app.get('/402', (_req, res) => { - res.status(402).send({ response: 'response 402' }); -}); - -app.get('/403', (_req, res) => { - res.status(403).send({ response: 'response 403' }); -}); - -app.get('/499', (_req, res) => { - res.status(499).send({ response: 'response 499' }); -}); - -app.get('/300', (_req, res) => { - res.status(300).send({ response: 'response 300' }); -}); - -app.get('/399', (_req, res) => { - res.status(399).send({ response: 'response 399' }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs deleted file mode 100644 index b0aebcbe8a79..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs +++ /dev/null @@ -1,52 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; -import bodyParser from 'body-parser'; -import cors from 'cors'; -import express from 'express'; - -const app = express(); - -app.use(cors()); -app.use(bodyParser.json()); -app.use(bodyParser.text()); -app.use(bodyParser.raw()); - -app.get('/', (_req, res) => { - res.send({ response: 'response 0' }); -}); - -app.get('/401', (_req, res) => { - res.status(401).send({ response: 'response 401' }); -}); - -app.get('/402', (_req, res) => { - res.status(402).send({ response: 'response 402' }); -}); - -app.get('/403', (_req, res) => { - res.status(403).send({ response: 'response 403' }); -}); - -app.get('/test/express', (_req, res) => { - res.send({ response: 'response 1' }); -}); - -app.get(/\/test\/regex/, (_req, res) => { - res.send({ response: 'response 2' }); -}); - -app.get(['/test/array1', /\/test\/array[2-9]/], (_req, res) => { - res.send({ response: 'response 3' }); -}); - -app.get(['/test/arr/:id', /\/test\/arr[0-9]*\/required(path)?(\/optionalPath)?\/(lastParam)?/], (_req, res) => { - res.send({ response: 'response 4' }); -}); - -app.post('/test-post', function (req, res) { - res.send({ status: 'ok', body: req.body }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts deleted file mode 100644 index 5ed3878572b8..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { afterAll, describe, expect } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; - -describe('express v5 tracing', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('should create and send transactions for Express routes and spans for middlewares.', async () => { - const runner = createRunner() - .expect({ - transaction: { - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - url: expect.stringMatching(/\/test\/express$/), - 'http.response.status_code': 200, - }, - op: 'http.server', - status: 'ok', - }, - }, - spans: expect.arrayContaining([ - expect.objectContaining({ - data: expect.objectContaining({ - 'express.name': 'corsMiddleware', - 'express.type': 'middleware', - }), - description: 'corsMiddleware', - op: 'middleware.express', - origin: 'auto.http.otel.express', - }), - expect.objectContaining({ - data: expect.objectContaining({ - 'express.name': '/test/express', - 'express.type': 'request_handler', - }), - description: '/test/express', - op: 'request_handler.express', - origin: 'auto.http.otel.express', - }), - ]), - }, - }) - .start(); - runner.makeRequest('get', '/test/express'); - await runner.completed(); - }); - - test('should set a correct transaction name for routes specified in RegEx', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /\\/test\\/regex/', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - data: { - url: expect.stringMatching(/\/test\/regex$/), - 'http.response.status_code': 200, - }, - op: 'http.server', - status: 'ok', - }, - }, - }, - }) - .start(); - runner.makeRequest('get', '/test/regex'); - await runner.completed(); - }); - - test('handles root route correctly', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /', - }, - }) - .start(); - runner.makeRequest('get', '/'); - await runner.completed(); - }); - - test.each(['/401', '/402', '/403', '/does-not-exist'])('ignores %s route by default', async (url: string) => { - const runner = createRunner() - .expect({ - // No transaction is sent for the 401, 402, 403, 404 routes - transaction: { - transaction: 'GET /', - }, - }) - .start(); - runner.makeRequest('get', url, { expectError: true }); - runner.makeRequest('get', '/'); - await runner.completed(); - }); - - test.each([['array1'], ['array5']])( - 'should set a correct transaction name for routes consisting of arrays of routes for %p', - async (segment: string) => { - const runner = await createRunner() - .expect({ - transaction: { - transaction: 'GET /test/array1,/\\/test\\/array[2-9]/', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - data: { - url: expect.stringMatching(`/test/${segment}$`), - 'http.response.status_code': 200, - }, - op: 'http.server', - status: 'ok', - }, - }, - }, - }) - .start(); - await runner.makeRequest('get', `/test/${segment}`); - await runner.completed(); - }, - ); - - test.each([ - ['arr/545'], - ['arr/required'], - ['arr/required'], - ['arr/requiredPath'], - ['arr/required/lastParam'], - ['arr55/required/lastParam'], - ])('should handle more complex regexes in route arrays correctly for %p', async (segment: string) => { - const runner = await createRunner() - .expect({ - transaction: { - transaction: 'GET /test/arr/:id,/\\/test\\/arr[0-9]*\\/required(path)?(\\/optionalPath)?\\/(lastParam)?/', - transaction_info: { - source: 'route', - }, - contexts: { - trace: { - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - data: { - url: expect.stringMatching(`/test/${segment}$`), - 'http.response.status_code': 200, - }, - op: 'http.server', - status: 'ok', - }, - }, - }, - }) - .start(); - await runner.makeRequest('get', `/test/${segment}`); - await runner.completed(); - }); - - describe('request data', () => { - test('correctly captures JSON request data', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'application/json', - }, - data: JSON.stringify({ - foo: 'bar', - other: 1, - }), - }, - }, - }) - .start(); - - runner.makeRequest('post', '/test-post', { - data: JSON.stringify({ foo: 'bar', other: 1 }), - headers: { - 'Content-Type': 'application/json', - }, - }); - await runner.completed(); - }); - - test('correctly captures plain text request data', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'text/plain', - }, - data: 'some plain text', - }, - }, - }) - .start(); - - runner.makeRequest('post', '/test-post', { - headers: { 'Content-Type': 'text/plain' }, - data: 'some plain text', - }); - await runner.completed(); - }); - - test('correctly captures text buffer request data', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'application/octet-stream', - }, - data: 'some plain text in buffer', - }, - }, - }) - .start(); - - runner.makeRequest('post', '/test-post', { - headers: { 'Content-Type': 'application/octet-stream' }, - data: Buffer.from('some plain text in buffer'), - }); - await runner.completed(); - }); - - test('correctly captures non-text buffer request data', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'application/octet-stream', - }, - // This is some non-ascii string representation - data: expect.any(String), - }, - }, - }) - .start(); - - const body = new Uint8Array([1, 2, 3, 4, 5]).buffer; - - runner.makeRequest('post', '/test-post', { - headers: { 'Content-Type': 'application/octet-stream' }, - data: body, - }); - await runner.completed(); - }); - }); - }); - - describe('filter status codes', () => { - createEsmAndCjsTests( - __dirname, - 'scenario-filterStatusCode.mjs', - 'instrument-filterStatusCode.mjs', - (createRunner, test) => { - // We opt-out of the default [401, 404] fitering in order to test how these spans are handled - test.each([ - { status_code: 401, url: '/401', status: 'unauthenticated' }, - { status_code: 402, url: '/402', status: 'invalid_argument' }, - { status_code: 403, url: '/403', status: 'permission_denied' }, - { status_code: 404, url: '/does-not-exist', status: 'not_found' }, - ])( - 'handles %s route correctly', - async ({ status_code, url, status }: { status_code: number; url: string; status: string }) => { - const runner = createRunner() - .expect({ - transaction: { - transaction: `GET ${url}`, - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'http.response.status_code': status_code, - url: expect.stringMatching(url), - 'http.method': 'GET', - 'http.url': expect.stringMatching(url), - 'http.target': url, - }, - op: 'http.server', - status, - }, - }, - }, - }) - .start(); - runner.makeRequest('get', url, { expectError: true }); - await runner.completed(); - }, - ); - - test('filters defined status codes', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /', - }, - }) - .start(); - await runner.makeRequest('get', '/499', { expectError: true }); - await runner.makeRequest('get', '/300', { expectError: true }); - await runner.makeRequest('get', '/399', { expectError: true }); - await runner.makeRequest('get', '/'); - await runner.completed(); - }); - }, - ); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/scenario-normalizedRequest.js b/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/scenario-normalizedRequest.js deleted file mode 100644 index da31780f2c5f..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/scenario-normalizedRequest.js +++ /dev/null @@ -1,34 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - transport: loggingTransport, - tracesSampler: samplingContext => { - // The sampling decision is based on whether the data in `normalizedRequest` is available --> this is what we want to test for - return ( - samplingContext.normalizedRequest.url.includes('/test-normalized-request?query=123') && - samplingContext.normalizedRequest.method && - samplingContext.normalizedRequest.query_string === 'query=123' && - !!samplingContext.normalizedRequest.headers - ); - }, -}); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); - -app.get('/test-normalized-request', (_req, res) => { - res.send('Success'); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/server.js b/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/server.js deleted file mode 100644 index b60ea07b636f..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/server.js +++ /dev/null @@ -1,39 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - transport: loggingTransport, - tracesSampler: samplingContext => { - // The name we get here is inferred at span creation time - // At this point, we sadly do not have a http.route attribute yet, - // so we infer the name from the unparameterized route instead - return ( - samplingContext.name === 'GET /test/123' && - samplingContext.attributes['sentry.op'] === 'http.server' && - samplingContext.attributes['http.method'] === 'GET' - ); - }, -}); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); - -app.get('/test/:id', (_req, res) => { - res.send('Success'); -}); - -app.get('/test2', (_req, res) => { - res.send('Success'); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/test.ts deleted file mode 100644 index 1b644ada387a..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/tracesSampler/test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterAll, describe, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -describe('express tracesSampler', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - describe('CJS', () => { - test('correctly samples & passes data to tracesSampler', async () => { - const runner = createRunner(__dirname, 'server.js') - .expect({ - transaction: { - transaction: 'GET /test/:id', - }, - }) - .start(); - - // This is not sampled - runner.makeRequest('get', '/test2?q=1'); - // This is sampled - runner.makeRequest('get', '/test/123?q=1'); - await runner.completed(); - }); - }); -}); - -describe('express tracesSampler includes normalizedRequest data', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - describe('CJS', () => { - test('correctly samples & passes data to tracesSampler', async () => { - const runner = createRunner(__dirname, 'scenario-normalizedRequest.js') - .expect({ - transaction: { - transaction: 'GET /test-normalized-request', - }, - }) - .start(); - - runner.makeRequest('get', '/test-normalized-request?query=123'); - await runner.completed(); - }); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/server.js b/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/server.js deleted file mode 100644 index c98e17276d92..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/server.js +++ /dev/null @@ -1,58 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - // disable attaching headers to /test/* endpoints - tracePropagationTargets: [/^(?!.*test).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const bodyParser = require('body-parser'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); -app.use(bodyParser.json()); -app.use(bodyParser.text()); -app.use(bodyParser.raw()); - -app.get('/test/:id/span-updateName', (_req, res) => { - const span = Sentry.getActiveSpan(); - const rootSpan = Sentry.getRootSpan(span); - rootSpan.updateName('new-name'); - res.send({ response: 'response 1' }); -}); - -app.get('/test/:id/span-updateName-source', (_req, res) => { - const span = Sentry.getActiveSpan(); - const rootSpan = Sentry.getRootSpan(span); - rootSpan.updateName('new-name'); - rootSpan.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'custom'); - res.send({ response: 'response 2' }); -}); - -app.get('/test/:id/updateSpanName', (_req, res) => { - const span = Sentry.getActiveSpan(); - const rootSpan = Sentry.getRootSpan(span); - Sentry.updateSpanName(rootSpan, 'new-name'); - res.send({ response: 'response 3' }); -}); - -app.get('/test/:id/updateSpanNameAndSource', (_req, res) => { - const span = Sentry.getActiveSpan(); - const rootSpan = Sentry.getRootSpan(span); - Sentry.updateSpanName(rootSpan, 'new-name'); - rootSpan.setAttribute(Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component'); - res.send({ response: 'response 4' }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts deleted file mode 100644 index f8cbf3c2bd57..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/updateName/test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/node'; -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -describe('express v5 tracing', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - describe('CJS', () => { - // This test documents the unfortunate behaviour of using `span.updateName` on the server-side. - // For http.server root spans (which is the root span on the server 99% of the time), Otel's http instrumentation - // calls `span.updateName` and overwrites whatever the name was set to before (by us or by users). - test("calling just `span.updateName` doesn't update the final name in express (missing source)", async () => { - const runner = createRunner(__dirname, 'server.js') - .expect({ - transaction: { - transaction: 'GET /test/:id/span-updateName', - transaction_info: { - source: 'route', - }, - }, - }) - .start(); - runner.makeRequest('get', '/test/123/span-updateName'); - await runner.completed(); - }); - - // Also calling `updateName` AND setting a source doesn't change anything - Otel has no concept of source, this is sentry-internal. - // Therefore, only the source is updated but the name is still overwritten by Otel. - test("calling `span.updateName` and setting attribute source doesn't update the final name in express but it updates the source", async () => { - const runner = createRunner(__dirname, 'server.js') - .expect({ - transaction: { - transaction: 'GET /test/:id/span-updateName-source', - transaction_info: { - source: 'custom', - }, - }, - }) - .start(); - runner.makeRequest('get', '/test/123/span-updateName-source'); - await runner.completed(); - }); - - // This test documents the correct way to update the span name (and implicitly the source) in Node: - test('calling `Sentry.updateSpanName` updates the final name and source in express', async () => { - const runner = createRunner(__dirname, 'server.js') - .expect({ - transaction: txnEvent => { - expect(txnEvent).toMatchObject({ - transaction: 'new-name', - transaction_info: { - source: 'custom', - }, - contexts: { - trace: { - op: 'http.server', - data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom' }, - }, - }, - }); - // ensure we delete the internal attribute once we're done with it - expect(txnEvent.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]).toBeUndefined(); - }, - }) - .start(); - runner.makeRequest('get', '/test/123/updateSpanName'); - await runner.completed(); - }); - }); - - // This test documents the correct way to update the span name (and implicitly the source) in Node: - test('calling `Sentry.updateSpanName` and setting source subsequently updates the final name and sets correct source', async () => { - const runner = createRunner(__dirname, 'server.js') - .expect({ - transaction: txnEvent => { - expect(txnEvent).toMatchObject({ - transaction: 'new-name', - transaction_info: { - source: 'component', - }, - contexts: { - trace: { - op: 'http.server', - data: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component' }, - }, - }, - }); - // ensure we delete the internal attribute once we're done with it - expect(txnEvent.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]).toBeUndefined(); - }, - }) - .start(); - runner.makeRequest('get', '/test/123/updateSpanNameAndSource'); - await runner.completed(); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/server.js b/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/server.js deleted file mode 100644 index d9ccc80fb7ad..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/server.js +++ /dev/null @@ -1,30 +0,0 @@ -const { loggingTransport } = require('@sentry-internal/node-integration-tests'); -const Sentry = require('@sentry/node'); - -Sentry.init({ - debug: true, - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - // disable attaching headers to /test/* endpoints - tracePropagationTargets: [/^(?!.*test).*$/], - tracesSampleRate: 1.0, - transport: loggingTransport, -}); - -// express must be required after Sentry is initialized -const express = require('express'); -const cors = require('cors'); -const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); - -const app = express(); - -app.use(cors()); - -app.get('/test/:id1/:id2', (_req, res) => { - Sentry.captureException(new Error('error_1')); - res.send('Success'); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/test.ts deleted file mode 100644 index 34d8cd515ec3..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/withError/test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { afterAll, describe, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; - -describe('express v5 tracing', () => { - afterAll(() => { - cleanupChildProcesses(); - }); - - describe('CJS', () => { - test('should apply the scope transactionName to error events', async () => { - const runner = createRunner(__dirname, 'server.js') - .ignore('transaction') - .expect({ - event: { - exception: { - values: [ - { - value: 'error_1', - }, - ], - }, - transaction: 'GET /test/:id1/:id2', - }, - }) - .start(); - runner.makeRequest('get', '/test/123/abc?q=1'); - await runner.completed(); - }); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tsconfig.test.json b/dev-packages/node-integration-tests/suites/express-v5/tsconfig.test.json deleted file mode 100644 index 3c43903cfdd1..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/tsconfig.test.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../tsconfig.json" -} diff --git a/dev-packages/node-integration-tests/suites/express-v5/without-tracing/server.ts b/dev-packages/node-integration-tests/suites/express-v5/without-tracing/server.ts deleted file mode 100644 index 222566bc945b..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/without-tracing/server.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - transport: loggingTransport, -}); - -import bodyParser from 'body-parser'; -import express from 'express'; - -const app = express(); - -app.use(bodyParser.json()); -app.use(bodyParser.text()); -app.use(bodyParser.raw()); - -Sentry.setTag('global', 'tag'); - -app.get('/test/isolationScope/:id', (req, res) => { - const id = req.params.id; - Sentry.setTag('isolation-scope', 'tag'); - Sentry.setTag(`isolation-scope-${id}`, id); - - Sentry.captureException(new Error('This is an exception')); - - res.send({}); -}); - -app.post('/test-post', function (req, res) { - Sentry.captureException(new Error('This is an exception')); - - res.send({ status: 'ok', body: req.body }); -}); - -Sentry.setupExpressErrorHandler(app); - -startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express-v5/without-tracing/test.ts b/dev-packages/node-integration-tests/suites/express-v5/without-tracing/test.ts deleted file mode 100644 index 5286ab8d2953..000000000000 --- a/dev-packages/node-integration-tests/suites/express-v5/without-tracing/test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; - -afterAll(() => { - cleanupChildProcesses(); -}); - -describe('express without tracing', () => { - test('correctly applies isolation scope even without tracing', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - event: { - transaction: 'GET /test/isolationScope/1', - tags: { - global: 'tag', - 'isolation-scope': 'tag', - 'isolation-scope-1': '1', - }, - // Request is correctly set - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test\/isolationScope\/1$/), - method: 'GET', - headers: { - 'user-agent': expect.stringContaining(''), - }, - }, - }, - }) - .start(); - - runner.makeRequest('get', '/test/isolationScope/1'); - await runner.completed(); - }); - - describe('request data', () => { - test('correctly captures JSON request data', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - event: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'application/json', - }, - data: JSON.stringify({ - foo: 'bar', - other: 1, - }), - }, - }, - }) - .start(); - - runner.makeRequest('post', '/test-post', { - headers: { - 'Content-Type': 'application/json', - }, - data: JSON.stringify({ foo: 'bar', other: 1 }), - }); - await runner.completed(); - }); - - test('correctly captures plain text request data', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - event: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'text/plain', - }, - data: 'some plain text', - }, - }, - }) - .start(); - - runner.makeRequest('post', '/test-post', { - headers: { - 'Content-Type': 'text/plain', - }, - data: 'some plain text', - }); - await runner.completed(); - }); - - test('correctly captures text buffer request data', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - event: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'application/octet-stream', - }, - data: 'some plain text in buffer', - }, - }, - }) - .start(); - - runner.makeRequest('post', '/test-post', { - headers: { 'Content-Type': 'application/octet-stream' }, - data: Buffer.from('some plain text in buffer'), - }); - await runner.completed(); - }); - - test('correctly captures non-text buffer request data', async () => { - const runner = createRunner(__dirname, 'server.ts') - .expect({ - event: { - transaction: 'POST /test-post', - request: { - url: expect.stringMatching(/^http:\/\/localhost:(\d+)\/test-post$/), - method: 'POST', - headers: { - 'user-agent': expect.stringContaining(''), - 'content-type': 'application/octet-stream', - }, - // This is some non-ascii string representation - data: expect.any(String), - }, - }, - }) - .start(); - - const body = new Uint8Array([1, 2, 3, 4, 5]).buffer; - - runner.makeRequest('post', '/test-post', { headers: { 'Content-Type': 'application/octet-stream' }, data: body }); - await runner.completed(); - }); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts index 323093ce38e0..329d658d905a 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/server.ts @@ -5,7 +5,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', transport: loggingTransport, - tracesSampleRate: 1, + tracesSampleRate: 0, }); import express from 'express'; diff --git a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts index b6bc5de97cdb..1f434ebc0971 100644 --- a/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error-tracesSampleRate-0/test.ts @@ -7,7 +7,6 @@ afterAll(() => { test('should capture and send Express controller error with txn name if tracesSampleRate is 0', async () => { const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') .expect({ event: { exception: { diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/server.ts b/dev-packages/node-integration-tests/suites/express/handle-error/server.ts similarity index 100% rename from dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/server.ts rename to dev-packages/node-integration-tests/suites/express/handle-error/server.ts index 323093ce38e0..ba8fb32cc108 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/server.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error/server.ts @@ -4,8 +4,8 @@ import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', - transport: loggingTransport, tracesSampleRate: 1, + transport: loggingTransport, }); import express from 'express'; diff --git a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/test.ts b/dev-packages/node-integration-tests/suites/express/handle-error/test.ts similarity index 88% rename from dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/test.ts rename to dev-packages/node-integration-tests/suites/express/handle-error/test.ts index b6bc5de97cdb..0db624160959 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/handle-error-tracesSampleRate-0/test.ts +++ b/dev-packages/node-integration-tests/suites/express/handle-error/test.ts @@ -5,9 +5,13 @@ afterAll(() => { cleanupChildProcesses(); }); -test('should capture and send Express controller error with txn name if tracesSampleRate is 0', async () => { +test('should capture and send Express controller error with txn name if tracesSampleRate is 1', async () => { const runner = createRunner(__dirname, 'server.ts') - .ignore('transaction') + .expect({ + transaction: { + transaction: 'GET /test/express/:id', + }, + }) .expect({ event: { exception: { diff --git a/dev-packages/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index f5a772dbf096..4476c76a3933 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -83,6 +83,22 @@ describe('express tracing', () => { .expect({ transaction: { transaction: 'GET /', + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'http.response.status_code': 200, + url: expect.stringMatching(/\/$/), + 'http.method': 'GET', + 'http.url': expect.stringMatching(/\/$/), + 'http.route': '/', + 'http.target': '/', + }, + op: 'http.server', + status: 'ok', + }, + }, }, }) .start(); @@ -327,8 +343,7 @@ describe('express tracing', () => { const runner = createRunner() .expect({ transaction: { - // TODO(v10): This is incorrect on OpenTelemetry v1 but can be fixed in v2 - transaction: `GET ${status_code === 404 ? '/' : url}`, + transaction: `GET ${url}`, contexts: { trace: { span_id: expect.stringMatching(/[a-f0-9]{16}/), diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span-ended.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span-ended.ts new file mode 100644 index 000000000000..72d83d70ec72 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span-ended.ts @@ -0,0 +1,46 @@ +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, + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +recordSpan(async () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + doSomething(); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + doSomethingWithError(); +}); + +async function doSomething(): Promise { + return Promise.resolve(); +} + +async function doSomethingWithError(): Promise { + await new Promise(resolve => setTimeout(resolve, 100)); + throw new Error('test error'); +} + +// Duplicating some code from vercel-ai to verify how things work in more complex/weird scenarios +function recordSpan(fn: (span: unknown) => Promise): Promise { + return Sentry.startSpanManual({ name: 'test-span' }, async span => { + try { + const result = await fn(span); + span.end(); + return result; + } catch (error) { + try { + span.setStatus({ code: 2 }); + } finally { + // always stop the span when there is an error: + span.end(); + } + + throw error; + } + }); +} diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span.ts new file mode 100644 index 000000000000..edff30f114ca --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/scenario-with-span.ts @@ -0,0 +1,14 @@ +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, + transport: loggingTransport, +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test-span' }, async () => { + throw new Error('test error'); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts index 2f4a22c835a4..468e66a058ca 100644 --- a/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/onUnhandledRejectionIntegration/test.ts @@ -1,3 +1,4 @@ +import type { Event } from '@sentry/node'; import * as childProcess from 'child_process'; import * as path from 'path'; import { afterAll, describe, expect, test } from 'vitest'; @@ -123,4 +124,58 @@ test rejection`); .start() .completed(); }); + + test('handles unhandled rejection in spans', async () => { + let transactionEvent: Event | undefined; + let errorEvent: Event | undefined; + + await createRunner(__dirname, 'scenario-with-span.ts') + .expect({ + transaction: transaction => { + transactionEvent = transaction; + }, + }) + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start() + .completed(); + + expect(transactionEvent).toBeDefined(); + expect(errorEvent).toBeDefined(); + + expect(transactionEvent!.transaction).toBe('test-span'); + + expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id); + expect(transactionEvent!.contexts!.trace!.span_id).toBe(errorEvent!.contexts!.trace!.span_id); + }); + + test('handles unhandled rejection in spans that are ended early', async () => { + let transactionEvent: Event | undefined; + let errorEvent: Event | undefined; + + await createRunner(__dirname, 'scenario-with-span-ended.ts') + .expect({ + transaction: transaction => { + transactionEvent = transaction; + }, + }) + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start() + .completed(); + + expect(transactionEvent).toBeDefined(); + expect(errorEvent).toBeDefined(); + + expect(transactionEvent!.transaction).toBe('test-span'); + + expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id); + expect(transactionEvent!.contexts!.trace!.span_id).toBe(errorEvent!.contexts!.trace!.span_id); + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts index 6e03921c4b73..176d947e1ecf 100644 --- a/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/kafkajs/test.ts @@ -15,14 +15,14 @@ describe('kafkajs', () => { }) .expect({ transaction: { - transaction: 'test-topic', + transaction: 'send test-topic', contexts: { trace: expect.objectContaining({ op: 'message', status: 'ok', data: expect.objectContaining({ 'messaging.system': 'kafka', - 'messaging.destination': 'test-topic', + 'messaging.destination.name': 'test-topic', 'otel.kind': 'PRODUCER', 'sentry.op': 'message', 'sentry.origin': 'auto.kafkajs.otel.producer', @@ -33,14 +33,14 @@ describe('kafkajs', () => { }) .expect({ transaction: { - transaction: 'test-topic', + transaction: 'process test-topic', contexts: { trace: expect.objectContaining({ op: 'message', status: 'ok', data: expect.objectContaining({ 'messaging.system': 'kafka', - 'messaging.destination': 'test-topic', + 'messaging.destination.name': 'test-topic', 'otel.kind': 'CONSUMER', 'sentry.op': 'message', 'sentry.origin': 'auto.kafkajs.otel.consumer', diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/instrument-filterStatusCode.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument-with-pii.mjs similarity index 67% rename from dev-packages/node-integration-tests/suites/express-v5/tracing/instrument-filterStatusCode.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument-with-pii.mjs index 31473a90df73..a53a13af7738 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/instrument-filterStatusCode.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument-with-pii.mjs @@ -5,10 +5,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, + sendDefaultPii: true, transport: loggingTransport, - integrations: [ - Sentry.httpIntegration({ - dropSpansForIncomingRequestStatusCodes: [499, [300, 399]], - }), - ], + integrations: [Sentry.openAIIntegration()], }); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument.mjs similarity index 72% rename from dev-packages/node-integration-tests/suites/express-v5/tracing/instrument.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument.mjs index 5cade6bb7ba1..f3fbac9d1274 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/instrument.mjs @@ -4,8 +4,8 @@ import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', - // disable attaching headers to /test/* endpoints - tracePropagationTargets: [/^(?!.*test).*$/], tracesSampleRate: 1.0, + sendDefaultPii: false, transport: loggingTransport, + integrations: [Sentry.openAIIntegration()], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/scenario.mjs new file mode 100644 index 000000000000..6a323c3adaee --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/scenario.mjs @@ -0,0 +1,297 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAIToolCalls { + constructor(config) { + this.apiKey = config.apiKey; + + this.chat = { + completions: { + create: async params => { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + // If stream is requested, return an async generator + if (params.stream) { + return this._createChatCompletionToolCallsStream(params); + } + + // Non-streaming tool calls response + return { + id: 'chatcmpl-tools-123', + object: 'chat.completion', + created: 1677652300, + model: params.model, + system_fingerprint: 'fp_tools_123', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'call_12345xyz', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"latitude":48.8566,"longitude":2.3522}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { + prompt_tokens: 15, + completion_tokens: 25, + total_tokens: 40, + }, + }; + }, + }, + }; + + this.responses = { + create: async params => { + await new Promise(resolve => setTimeout(resolve, 10)); + + // If stream is requested, return an async generator + if (params.stream) { + return this._createResponsesApiToolCallsStream(params); + } + + // Non-streaming tool calls response + return { + id: 'resp_tools_789', + object: 'response', + created_at: 1677652320, + model: params.model, + input_text: Array.isArray(params.input) ? JSON.stringify(params.input) : params.input, + status: 'completed', + output: [ + { + type: 'function_call', + id: 'fc_12345xyz', + call_id: 'call_12345xyz', + name: 'get_weather', + arguments: '{"latitude":48.8566,"longitude":2.3522}', + }, + ], + usage: { + input_tokens: 8, + output_tokens: 12, + total_tokens: 20, + }, + }; + }, + }; + } + + // Create a mock streaming response for chat completions with tool calls + async *_createChatCompletionToolCallsStream(params) { + // First chunk with tool call initialization + yield { + id: 'chatcmpl-stream-tools-123', + object: 'chat.completion.chunk', + created: 1677652305, + model: params.model, + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: 'call_12345xyz', + type: 'function', + function: { name: 'get_weather', arguments: '' }, + }, + ], + }, + finish_reason: null, + }, + ], + }; + + // Second chunk with arguments delta + yield { + id: 'chatcmpl-stream-tools-123', + object: 'chat.completion.chunk', + created: 1677652305, + model: params.model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: '{"latitude":48.8566,"longitude":2.3522}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 15, completion_tokens: 25, total_tokens: 40 }, + }; + } + + // Create a mock streaming response for responses API with tool calls + async *_createResponsesApiToolCallsStream(params) { + const responseId = 'resp_stream_tools_789'; + + // Response created event + yield { + type: 'response.created', + response: { + id: responseId, + object: 'response', + created_at: 1677652310, + model: params.model, + status: 'in_progress', + output: [], + usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, + }, + sequence_number: 1, + }; + + // Function call output item added + yield { + type: 'response.output_item.added', + response_id: responseId, + output_index: 0, + item: { + type: 'function_call', + id: 'fc_12345xyz', + call_id: 'call_12345xyz', + name: 'get_weather', + arguments: '', + }, + sequence_number: 2, + }; + + // Function call arguments delta events + yield { + type: 'response.function_call_arguments.delta', + response_id: responseId, + item_id: 'fc_12345xyz', + output_index: 0, + delta: '{"latitude":48.8566,"longitude":2.3522}', + sequence_number: 3, + }; + + // Function call arguments done + yield { + type: 'response.function_call_arguments.done', + response_id: responseId, + item_id: 'fc_12345xyz', + output_index: 0, + arguments: '{"latitude":48.8566,"longitude":2.3522}', + sequence_number: 4, + }; + + // Output item done + yield { + type: 'response.output_item.done', + response_id: responseId, + output_index: 0, + item: { + type: 'function_call', + id: 'fc_12345xyz', + call_id: 'call_12345xyz', + name: 'get_weather', + arguments: '{"latitude":48.8566,"longitude":2.3522}', + }, + sequence_number: 5, + }; + + // Response completed event + yield { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + created_at: 1677652310, + model: params.model, + status: 'completed', + output: [ + { + type: 'function_call', + id: 'fc_12345xyz', + call_id: 'call_12345xyz', + name: 'get_weather', + arguments: '{"latitude":48.8566,"longitude":2.3522}', + }, + ], + usage: { input_tokens: 8, output_tokens: 12, total_tokens: 20 }, + }, + sequence_number: 6, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAIToolCalls({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + const weatherTool = { + type: 'function', + function: { + name: 'get_weather', + description: 'Get the current weather in a given location', + parameters: { + type: 'object', + properties: { + latitude: { type: 'number', description: 'The latitude of the location' }, + longitude: { type: 'number', description: 'The longitude of the location' }, + }, + required: ['latitude', 'longitude'], + }, + }, + }; + + const message = { role: 'user', content: 'What is the weather like in Paris today?' }; + + // Test 1: Chat completion with tools (non-streaming) + await client.chat.completions.create({ + model: 'gpt-4', + messages: [message], + tools: [weatherTool], + }); + + // Test 2: Chat completion with tools (streaming) + const stream1 = await client.chat.completions.create({ + model: 'gpt-4', + messages: [message], + tools: [weatherTool], + stream: true, + }); + for await (const chunk of stream1) void chunk; + + // Test 3: Responses API with tools (non-streaming) + await client.responses.create({ + model: 'gpt-4', + input: [message], + tools: [weatherTool], + }); + + // Test 4: Responses API with tools (streaming) + const stream2 = await client.responses.create({ + model: 'gpt-4', + input: [message], + tools: [weatherTool], + stream: true, + }); + for await (const chunk of stream2) void chunk; + }); +} + +run(); 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 new file mode 100644 index 000000000000..c5fd4fc97a72 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/openai-tool-calls/test.ts @@ -0,0 +1,316 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('OpenAI Tool Calls integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const WEATHER_TOOL_DEFINITION = JSON.stringify([ + { + type: 'function', + function: { + name: 'get_weather', + description: 'Get the current weather in a given location', + parameters: { + type: 'object', + properties: { + latitude: { type: 'number', description: 'The latitude of the location' }, + longitude: { type: 'number', description: 'The longitude of the location' }, + }, + required: ['latitude', 'longitude'], + }, + }, + }, + ]); + + const CHAT_TOOL_CALLS = JSON.stringify([ + { + id: 'call_12345xyz', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"latitude":48.8566,"longitude":2.3522}', + }, + }, + ]); + + const CHAT_STREAM_TOOL_CALLS = JSON.stringify([ + { + index: 0, + id: 'call_12345xyz', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"latitude":48.8566,"longitude":2.3522}', + }, + }, + ]); + + const RESPONSES_TOOL_CALLS = JSON.stringify([ + { + type: 'function_call', + id: 'fc_12345xyz', + call_id: 'call_12345xyz', + name: 'get_weather', + arguments: '{"latitude":48.8566,"longitude":2.3522}', + }, + ]); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chat completion with tools (non-streaming) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'chatcmpl-tools-123', + 'gen_ai.response.finish_reasons': '["tool_calls"]', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'openai.response.id': 'chatcmpl-tools-123', + 'openai.response.model': 'gpt-4', + 'openai.response.timestamp': '2023-03-01T06:31:40.000Z', + 'openai.usage.completion_tokens': 25, + 'openai.usage.prompt_tokens': 15, + }, + description: 'chat gpt-4', + op: 'gen_ai.chat', + origin: 'manual', + status: 'ok', + }), + // Second span - chat completion with tools and streaming + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'chatcmpl-stream-tools-123', + 'gen_ai.response.finish_reasons': '["tool_calls"]', + 'gen_ai.response.streaming': true, + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'openai.response.id': 'chatcmpl-stream-tools-123', + 'openai.response.model': 'gpt-4', + 'openai.response.timestamp': '2023-03-01T06:31:45.000Z', + 'openai.usage.completion_tokens': 25, + 'openai.usage.prompt_tokens': 15, + }, + description: 'chat gpt-4 stream-response', + op: 'gen_ai.chat', + origin: 'manual', + status: 'ok', + }), + // Third span - responses API with tools (non-streaming) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'resp_tools_789', + 'gen_ai.response.finish_reasons': '["completed"]', + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + 'openai.response.id': 'resp_tools_789', + 'openai.response.model': 'gpt-4', + 'openai.response.timestamp': '2023-03-01T06:32:00.000Z', + 'openai.usage.completion_tokens': 12, + 'openai.usage.prompt_tokens': 8, + }, + description: 'responses gpt-4', + op: 'gen_ai.responses', + origin: 'manual', + status: 'ok', + }), + // Fourth span - responses API with tools and streaming + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'resp_stream_tools_789', + 'gen_ai.response.finish_reasons': '["in_progress","completed"]', + 'gen_ai.response.streaming': true, + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + 'openai.response.id': 'resp_stream_tools_789', + 'openai.response.model': 'gpt-4', + 'openai.response.timestamp': '2023-03-01T06:31:50.000Z', + 'openai.usage.completion_tokens': 12, + 'openai.usage.prompt_tokens': 8, + }, + description: 'responses gpt-4 stream-response', + op: 'gen_ai.responses', + origin: 'manual', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chat completion with tools (non-streaming) with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', + 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'chatcmpl-tools-123', + 'gen_ai.response.finish_reasons': '["tool_calls"]', + 'gen_ai.response.text': '[""]', + 'gen_ai.response.tool_calls': CHAT_TOOL_CALLS, + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'openai.response.id': 'chatcmpl-tools-123', + 'openai.response.model': 'gpt-4', + 'openai.response.timestamp': '2023-03-01T06:31:40.000Z', + 'openai.usage.completion_tokens': 25, + 'openai.usage.prompt_tokens': 15, + }, + description: 'chat gpt-4', + op: 'gen_ai.chat', + origin: 'manual', + status: 'ok', + }), + // Second span - chat completion with tools and streaming with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', + 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'chatcmpl-stream-tools-123', + 'gen_ai.response.finish_reasons': '["tool_calls"]', + 'gen_ai.response.streaming': true, + 'gen_ai.response.tool_calls': CHAT_STREAM_TOOL_CALLS, + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'openai.response.id': 'chatcmpl-stream-tools-123', + 'openai.response.model': 'gpt-4', + 'openai.response.timestamp': '2023-03-01T06:31:45.000Z', + 'openai.usage.completion_tokens': 25, + 'openai.usage.prompt_tokens': 15, + }, + description: 'chat gpt-4 stream-response', + op: 'gen_ai.chat', + origin: 'manual', + status: 'ok', + }), + // Third span - responses API with tools (non-streaming) with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', + 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'resp_tools_789', + 'gen_ai.response.finish_reasons': '["completed"]', + 'gen_ai.response.tool_calls': RESPONSES_TOOL_CALLS, + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + 'openai.response.id': 'resp_tools_789', + 'openai.response.model': 'gpt-4', + 'openai.response.timestamp': '2023-03-01T06:32:00.000Z', + 'openai.usage.completion_tokens': 12, + 'openai.usage.prompt_tokens': 8, + }, + description: 'responses gpt-4', + op: 'gen_ai.responses', + origin: 'manual', + status: 'ok', + }), + // Fourth span - responses API with tools and streaming with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather like in Paris today?"}]', + 'gen_ai.request.available_tools': WEATHER_TOOL_DEFINITION, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'resp_stream_tools_789', + 'gen_ai.response.finish_reasons': '["in_progress","completed"]', + 'gen_ai.response.streaming': true, + 'gen_ai.response.tool_calls': RESPONSES_TOOL_CALLS, + 'gen_ai.usage.input_tokens': 8, + 'gen_ai.usage.output_tokens': 12, + 'gen_ai.usage.total_tokens': 20, + 'openai.response.id': 'resp_stream_tools_789', + 'openai.response.model': 'gpt-4', + 'openai.response.timestamp': '2023-03-01T06:31:50.000Z', + 'openai.usage.completion_tokens': 12, + 'openai.usage.prompt_tokens': 8, + }, + description: 'responses gpt-4 stream-response', + op: 'gen_ai.responses', + origin: 'manual', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates openai tool calls related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates openai tool calls related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs index 3958517bea40..fde651c3c1ff 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs @@ -18,6 +18,11 @@ class MockOpenAI { throw error; } + // If stream is requested, return an async generator + if (params.stream) { + return this._createChatCompletionStream(params); + } + return { id: 'chatcmpl-mock123', object: 'chat.completion', @@ -48,14 +53,19 @@ class MockOpenAI { create: async params => { await new Promise(resolve => setTimeout(resolve, 10)); + // If stream is requested, return an async generator + if (params.stream) { + return this._createResponsesApiStream(params); + } + return { id: 'resp_mock456', object: 'response', - created: 1677652290, + created_at: 1677652290, model: params.model, input_text: params.input, output_text: `Response to: ${params.input}`, - finish_reason: 'stop', + status: 'completed', usage: { input_tokens: 5, output_tokens: 8, @@ -65,6 +75,161 @@ class MockOpenAI { }, }; } + + // Create a mock streaming response for chat completions + async *_createChatCompletionStream(params) { + // First chunk with basic info + yield { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: params.model, + system_fingerprint: 'fp_stream_123', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + content: 'Hello', + }, + finish_reason: null, + }, + ], + }; + + // Second chunk with more content + yield { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: params.model, + system_fingerprint: 'fp_stream_123', + choices: [ + { + index: 0, + delta: { + content: ' from OpenAI streaming!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 18, + total_tokens: 30, + completion_tokens_details: { + accepted_prediction_tokens: 0, + audio_tokens: 0, + reasoning_tokens: 0, + rejected_prediction_tokens: 0, + }, + prompt_tokens_details: { + audio_tokens: 0, + cached_tokens: 0, + }, + }, + }; + } + + // Create a mock streaming response for responses API + async *_createResponsesApiStream(params) { + // Response created event + yield { + type: 'response.created', + response: { + id: 'resp_stream_456', + object: 'response', + created_at: 1677652310, + model: params.model, + status: 'in_progress', + error: null, + incomplete_details: null, + instructions: params.instructions, + max_output_tokens: 1000, + parallel_tool_calls: false, + previous_response_id: null, + reasoning: { + effort: null, + summary: null, + }, + store: false, + temperature: 0.7, + text: { + format: { + type: 'text', + }, + }, + tool_choice: 'auto', + top_p: 1.0, + truncation: 'disabled', + user: null, + metadata: {}, + output: [], + output_text: '', + usage: { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + }, + }, + sequence_number: 1, + }; + + // Response in progress with output text delta + yield { + type: 'response.output_text.delta', + delta: 'Streaming response to: ', + sequence_number: 2, + }; + + yield { + type: 'response.output_text.delta', + delta: params.input, + sequence_number: 3, + }; + + // Response completed event + yield { + type: 'response.completed', + response: { + id: 'resp_stream_456', + object: 'response', + created_at: 1677652310, + model: params.model, + status: 'completed', + error: null, + incomplete_details: null, + instructions: params.instructions, + max_output_tokens: 1000, + parallel_tool_calls: false, + previous_response_id: null, + reasoning: { + effort: null, + summary: null, + }, + store: false, + temperature: 0.7, + text: { + format: { + type: 'text', + }, + }, + tool_choice: 'auto', + top_p: 1.0, + truncation: 'disabled', + user: null, + metadata: {}, + output: [], + output_text: params.input, + usage: { + input_tokens: 6, + output_tokens: 10, + total_tokens: 16, + }, + }, + sequence_number: 4, + }; + } } async function run() { @@ -93,7 +258,7 @@ async function run() { instructions: 'You are a translator', }); - // Third test: error handling + // Third test: error handling in chat completions try { await client.chat.completions.create({ model: 'error-model', @@ -102,6 +267,51 @@ async function run() { } 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 + } }); } 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 ec6f97a6aa00..e72d0144d36c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -38,23 +38,25 @@ describe('OpenAI integration', () => { // Second span - responses API expect.objectContaining({ data: { - 'gen_ai.operation.name': 'chat', - 'sentry.op': 'gen_ai.chat', + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', 'sentry.origin': 'manual', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.response.model': 'gpt-3.5-turbo', 'gen_ai.response.id': 'resp_mock456', + 'gen_ai.response.finish_reasons': '["completed"]', 'gen_ai.usage.input_tokens': 5, 'gen_ai.usage.output_tokens': 8, 'gen_ai.usage.total_tokens': 13, 'openai.response.id': 'resp_mock456', 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:30.000Z', 'openai.usage.completion_tokens': 8, 'openai.usage.prompt_tokens': 5, }, - description: 'chat gpt-3.5-turbo', - op: 'gen_ai.chat', + description: 'responses gpt-3.5-turbo', + op: 'gen_ai.responses', origin: 'manual', status: 'ok', }), @@ -72,6 +74,76 @@ describe('OpenAI integration', () => { origin: 'manual', status: 'unknown_error', }), + // Fourth span - chat completions streaming + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.stream': true, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'chatcmpl-stream-123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 18, + 'gen_ai.usage.total_tokens': 30, + 'openai.response.id': 'chatcmpl-stream-123', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:40.000Z', + 'openai.usage.completion_tokens': 18, + 'openai.usage.prompt_tokens': 12, + }, + description: 'chat gpt-4 stream-response', + op: 'gen_ai.chat', + origin: 'manual', + status: 'ok', + }), + // Fifth span - responses API streaming + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'resp_stream_456', + 'gen_ai.response.finish_reasons': '["in_progress","completed"]', + 'gen_ai.usage.input_tokens': 6, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 16, + 'openai.response.id': 'resp_stream_456', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:50.000Z', + 'openai.usage.completion_tokens': 10, + 'openai.usage.prompt_tokens': 6, + }, + description: 'responses gpt-4 stream-response', + op: 'gen_ai.responses', + origin: 'manual', + status: 'ok', + }), + // Sixth span - error handling in streaming context + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.stream': true, + 'gen_ai.system': 'openai', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'manual', + }, + description: 'chat error-model stream-response', + op: 'gen_ai.chat', + origin: 'manual', + status: 'internal_error', + }), ]), }; @@ -110,13 +182,14 @@ describe('OpenAI integration', () => { // Second span - responses API with PII expect.objectContaining({ data: { - 'gen_ai.operation.name': 'chat', - 'sentry.op': 'gen_ai.chat', + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', 'sentry.origin': 'manual', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', 'gen_ai.request.messages': '"Translate this to French: Hello"', 'gen_ai.response.text': 'Response to: Translate this to French: Hello', + 'gen_ai.response.finish_reasons': '["completed"]', 'gen_ai.response.model': 'gpt-3.5-turbo', 'gen_ai.response.id': 'resp_mock456', 'gen_ai.usage.input_tokens': 5, @@ -124,11 +197,12 @@ describe('OpenAI integration', () => { 'gen_ai.usage.total_tokens': 13, 'openai.response.id': 'resp_mock456', 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:30.000Z', 'openai.usage.completion_tokens': 8, 'openai.usage.prompt_tokens': 5, }, - description: 'chat gpt-3.5-turbo', - op: 'gen_ai.chat', + description: 'responses gpt-3.5-turbo', + op: 'gen_ai.responses', origin: 'manual', status: 'ok', }), @@ -147,6 +221,82 @@ describe('OpenAI integration', () => { origin: 'manual', status: 'unknown_error', }), + // Fourth span - chat completions streaming with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': + '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Tell me about streaming"}]', + 'gen_ai.response.text': 'Hello from OpenAI streaming!', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.response.id': 'chatcmpl-stream-123', + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 18, + 'gen_ai.usage.total_tokens': 30, + 'openai.response.id': 'chatcmpl-stream-123', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:40.000Z', + 'openai.usage.completion_tokens': 18, + 'openai.usage.prompt_tokens': 12, + }), + description: 'chat gpt-4 stream-response', + op: 'gen_ai.chat', + origin: 'manual', + status: 'ok', + }), + // Fifth span - responses API streaming with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'manual', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': '"Test streaming responses API"', + 'gen_ai.response.text': 'Streaming response to: Test streaming responses APITest streaming responses API', + 'gen_ai.response.finish_reasons': '["in_progress","completed"]', + 'gen_ai.response.id': 'resp_stream_456', + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.usage.input_tokens': 6, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 16, + 'openai.response.id': 'resp_stream_456', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:50.000Z', + 'openai.usage.completion_tokens': 10, + 'openai.usage.prompt_tokens': 6, + }), + description: 'responses gpt-4 stream-response', + op: 'gen_ai.responses', + origin: 'manual', + status: 'ok', + }), + // Sixth span - error handling in streaming context with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', + 'gen_ai.system': 'openai', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'manual', + }, + description: 'chat error-model stream-response', + op: 'gen_ai.chat', + origin: 'manual', + status: 'internal_error', + }), ]), }; @@ -160,24 +310,44 @@ describe('OpenAI integration', () => { 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true }), }), + // Check that custom options are respected for streaming + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true + 'gen_ai.request.stream': true, // Should be marked as stream + }), + }), ]), }; createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai related spans with sendDefaultPii: false', async () => { - await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }).start().completed(); + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); }); }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai related spans with sendDefaultPii: true', async () => { - await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }).start().completed(); + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .start() + .completed(); }); }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates openai related spans with custom options', async () => { - await createRunner().expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }).start().completed(); + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }) + .start() + .completed(); }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs index d69f7dca5feb..b798e21228f5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs @@ -7,5 +7,5 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, transport: loggingTransport, - integrations: [Sentry.vercelAIIntegration({ force: true })], + integrations: [Sentry.vercelAIIntegration()], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs index e4cd7b9cabd7..5e898ee1949d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs @@ -6,5 +6,5 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, - integrations: [Sentry.vercelAIIntegration({ force: true })], + integrations: [Sentry.vercelAIIntegration()], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool-express.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool-express.mjs new file mode 100644 index 000000000000..82bfe3c35445 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool-express.mjs @@ -0,0 +1,50 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import express from 'express'; +import { z } from 'zod'; + +const app = express(); + +app.get('/test/error-in-tool', async (_req, res, next) => { + Sentry.setTag('test-tag', 'test-value'); + + try { + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + } catch (error) { + next(error); + return; + } + + res.send({ message: 'OK' }); +}); +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..4185d972da4d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-error-in-tool.mjs @@ -0,0 +1,40 @@ +import * as Sentry from '@sentry/node'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + Sentry.setTag('test-tag', 'test-value'); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index f9b853aa4946..5353f53f42e3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -1,7 +1,7 @@ +import type { Event } from '@sentry/node'; import { afterAll, describe, expect } from 'vitest'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; -// `ai` SDK only support Node 18+ describe('Vercel AI integration', () => { afterAll(() => { cleanupChildProcesses(); @@ -416,4 +416,247 @@ describe('Vercel AI integration', () => { await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }).start().completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-error-in-tool.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures error in tool', async () => { + const expectedTransaction = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'unknown_error', + }), + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + expect.objectContaining({ + data: { + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'unknown_error', + }), + ]), + + tags: { + 'test-tag': 'test-value', + }, + }; + + let traceId: string = 'unset-trace-id'; + let spanId: string = 'unset-span-id'; + + const expectedError = { + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool getWeather: Error in tool', + }), + ]), + }, + tags: { + 'test-tag': 'test-value', + }, + }; + + await createRunner() + .expect({ + transaction: transaction => { + expect(transaction).toMatchObject(expectedTransaction); + traceId = transaction.contexts!.trace!.trace_id; + spanId = transaction.contexts!.trace!.span_id; + }, + }) + .expect({ + event: event => { + expect(event).toMatchObject(expectedError); + expect(event.contexts!.trace!.trace_id).toBe(traceId); + expect(event.contexts!.trace!.span_id).toBe(spanId); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-error-in-tool-express.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures error in tool in express server', async () => { + const expectedTransaction = { + transaction: 'GET /test/error-in-tool', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'unknown_error', + }), + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + expect.objectContaining({ + data: { + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'unknown_error', + }), + ]), + + tags: { + 'test-tag': 'test-value', + }, + }; + + const expectedError = { + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + exception: { + values: expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool getWeather: Error in tool', + }), + ]), + }, + tags: { + 'test-tag': 'test-value', + }, + }; + + let transactionEvent: Event | undefined; + let errorEvent: Event | undefined; + + const runner = await createRunner() + .expect({ + transaction: transaction => { + transactionEvent = transaction; + }, + }) + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start(); + + await runner.makeRequest('get', '/test/error-in-tool', { expectError: true }); + await runner.completed(); + + expect(transactionEvent).toBeDefined(); + expect(errorEvent).toBeDefined(); + + expect(transactionEvent).toMatchObject(expectedTransaction); + + expect(errorEvent).toMatchObject(expectedError); + expect(errorEvent!.contexts!.trace!.trace_id).toBe(transactionEvent!.contexts!.trace!.trace_id); + expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); + }); + }); }); diff --git a/dev-packages/node-integration-tests/suites/winston/subject.ts b/dev-packages/node-integration-tests/suites/winston/subject.ts index ac70abc50d80..1047f2f1cd47 100644 --- a/dev-packages/node-integration-tests/suites/winston/subject.ts +++ b/dev-packages/node-integration-tests/suites/winston/subject.ts @@ -7,9 +7,7 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0.0', environment: 'test', - _experiments: { - enableLogs: true, - }, + enableLogs: true, transport: loggingTransport, }); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index 44fc82c220a0..44118747c45c 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -12,6 +12,7 @@ import type { TransactionEvent, } from '@sentry/core'; import { normalize } from '@sentry/core'; +import { createBasicSentryServer } from '@sentry-internal/test-utils'; import { execSync, spawn, spawnSync } from 'child_process'; import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; import { join } from 'path'; @@ -27,7 +28,6 @@ import { assertSentrySessions, assertSentryTransaction, } from './assertions'; -import { createBasicSentryServer } from './server'; const CLEANUP_STEPS = new Set(); diff --git a/dev-packages/node-integration-tests/utils/server.ts b/dev-packages/node-integration-tests/utils/server.ts index 92e0477c845c..a1ba3f522fb1 100644 --- a/dev-packages/node-integration-tests/utils/server.ts +++ b/dev-packages/node-integration-tests/utils/server.ts @@ -1,43 +1,6 @@ -import type { Envelope } from '@sentry/core'; -import { parseEnvelope } from '@sentry/core'; import express from 'express'; import type { AddressInfo } from 'net'; -/** - * Creates a basic Sentry server that accepts POST to the envelope endpoint - * - * This does no checks on the envelope, it just calls the callback if it managed to parse an envelope from the raw POST - * body data. - */ -export function createBasicSentryServer(onEnvelope: (env: Envelope) => void): Promise<[number, () => void]> { - const app = express(); - - app.use(express.raw({ type: () => true, inflate: true, limit: '100mb' })); - app.post('/api/:id/envelope/', (req, res) => { - try { - const env = parseEnvelope(req.body as Buffer); - onEnvelope(env); - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - } - - res.status(200).send(); - }); - - return new Promise(resolve => { - const server = app.listen(0, () => { - const address = server.address() as AddressInfo; - resolve([ - address.port, - () => { - server.close(); - }, - ]); - }); - }); -} - type HeaderAssertCallback = (headers: Record) => void; /** Creates a test server that can be used to check headers */ diff --git a/dev-packages/opentelemetry-v2-tests/.eslintrc.js b/dev-packages/opentelemetry-v2-tests/.eslintrc.js deleted file mode 100644 index fdb9952bae52..000000000000 --- a/dev-packages/opentelemetry-v2-tests/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - env: { - node: true, - }, - extends: ['../../.eslintrc.js'], -}; diff --git a/dev-packages/opentelemetry-v2-tests/README.md b/dev-packages/opentelemetry-v2-tests/README.md deleted file mode 100644 index e5ae255c830c..000000000000 --- a/dev-packages/opentelemetry-v2-tests/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# OpenTelemetry v2 Tests - -This package contains tests for `@sentry/opentelemetry` when using OpenTelemetry v2. It is used to ensure compatibility with OpenTelemetry v2 APIs. - -## Running Tests - -To run the tests: - -```bash -yarn test -``` - -## Structure - -The tests are copied from `packages/opentelemetry/test` with adjusted imports to work with OpenTelemetry v2 dependencies. The main differences are: - -1. Uses OpenTelemetry v2 as devDependencies -2. Imports from `@sentry/opentelemetry` instead of relative paths -3. Tests the same functionality but with v2 APIs diff --git a/dev-packages/opentelemetry-v2-tests/package.json b/dev-packages/opentelemetry-v2-tests/package.json deleted file mode 100644 index 5d091acc5674..000000000000 --- a/dev-packages/opentelemetry-v2-tests/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@sentry-internal/opentelemetry-v2-tests", - "version": "9.40.0", - "private": true, - "description": "Tests for @sentry/opentelemetry with OpenTelemetry v2", - "engines": { - "node": ">=18" - }, - "scripts": { - "lint": "eslint . --format stylish", - "fix": "eslint . --format stylish --fix", - "test": "vitest run", - "test:watch": "vitest --watch" - }, - "devDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.0.0", - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.203.0", - "@opentelemetry/sdk-trace-base": "^2.0.0", - "@opentelemetry/semantic-conventions": "^1.34.0" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts b/dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts deleted file mode 100644 index 0df183362633..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/asyncContextStrategy.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import type { Scope } from '@sentry/core'; -import { - getCurrentScope, - getIsolationScope, - Scope as ScopeClass, - setAsyncContextStrategy, - withIsolationScope, - withScope, -} from '@sentry/core'; -import { afterAll, afterEach, beforeEach, describe, expect, it, test } from 'vitest'; -import { setOpenTelemetryContextAsyncContextStrategy } from '../../../packages/opentelemetry/src/asyncContextStrategy'; -import { setupOtel } from './helpers/initOtel'; -import { cleanupOtel } from './helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from './helpers/TestClient'; - -describe('asyncContextStrategy', () => { - let provider: BasicTracerProvider | undefined; - - beforeEach(() => { - getCurrentScope().clear(); - getIsolationScope().clear(); - - const options = getDefaultTestClientOptions(); - const client = new TestClient(options); - [provider] = setupOtel(client); - setOpenTelemetryContextAsyncContextStrategy(); - }); - - afterEach(() => { - cleanupOtel(provider); - }); - - afterAll(() => { - // clear the strategy - setAsyncContextStrategy(undefined); - }); - - test('scope inheritance', () => { - const initialScope = getCurrentScope(); - const initialIsolationScope = getIsolationScope(); - - initialScope.setExtra('a', 'a'); - initialIsolationScope.setExtra('aa', 'aa'); - - withIsolationScope(() => { - const scope1 = getCurrentScope(); - const isolationScope1 = getIsolationScope(); - - expect(scope1).not.toBe(initialScope); - expect(isolationScope1).not.toBe(initialIsolationScope); - - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); - expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); - - scope1.setExtra('b', 'b'); - isolationScope1.setExtra('bb', 'bb'); - - withScope(() => { - const scope2 = getCurrentScope(); - const isolationScope2 = getIsolationScope(); - - expect(scope2).not.toBe(scope1); - expect(isolationScope2).toBe(isolationScope1); - - expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); - - scope2.setExtra('c', 'c'); - - expect(scope2.getScopeData().extra).toEqual({ - a: 'a', - b: 'b', - c: 'c', - }); - - expect(isolationScope2.getScopeData().extra).toEqual({ - aa: 'aa', - bb: 'bb', - }); - }); - }); - }); - - test('async scope inheritance', async () => { - const initialScope = getCurrentScope(); - const initialIsolationScope = getIsolationScope(); - - async function asyncSetExtra(scope: Scope, key: string, value: string): Promise { - await new Promise(resolve => setTimeout(resolve, 1)); - scope.setExtra(key, value); - } - - initialScope.setExtra('a', 'a'); - initialIsolationScope.setExtra('aa', 'aa'); - - await withIsolationScope(async () => { - const scope1 = getCurrentScope(); - const isolationScope1 = getIsolationScope(); - - expect(scope1).not.toBe(initialScope); - expect(isolationScope1).not.toBe(initialIsolationScope); - - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); - expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); - - await asyncSetExtra(scope1, 'b', 'b'); - await asyncSetExtra(isolationScope1, 'bb', 'bb'); - - await withScope(async () => { - const scope2 = getCurrentScope(); - const isolationScope2 = getIsolationScope(); - - expect(scope2).not.toBe(scope1); - expect(isolationScope2).toBe(isolationScope1); - - expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); - - await asyncSetExtra(scope2, 'c', 'c'); - - expect(scope2.getScopeData().extra).toEqual({ - a: 'a', - b: 'b', - c: 'c', - }); - - expect(isolationScope2.getScopeData().extra).toEqual({ - aa: 'aa', - bb: 'bb', - }); - }); - }); - }); - - test('concurrent scope contexts', () => { - const initialScope = getCurrentScope(); - const initialIsolationScope = getIsolationScope(); - - initialScope.setExtra('a', 'a'); - initialIsolationScope.setExtra('aa', 'aa'); - - withIsolationScope(() => { - const scope1 = getCurrentScope(); - const isolationScope1 = getIsolationScope(); - - expect(scope1).not.toBe(initialScope); - expect(isolationScope1).not.toBe(initialIsolationScope); - - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); - expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); - - scope1.setExtra('b', 'b'); - isolationScope1.setExtra('bb', 'bb'); - - withScope(() => { - const scope2 = getCurrentScope(); - const isolationScope2 = getIsolationScope(); - - expect(scope2).not.toBe(scope1); - expect(isolationScope2).toBe(isolationScope1); - - expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); - - scope2.setExtra('c', 'c'); - - expect(scope2.getScopeData().extra).toEqual({ - a: 'a', - b: 'b', - c: 'c', - }); - - expect(isolationScope2.getScopeData().extra).toEqual({ - aa: 'aa', - bb: 'bb', - }); - }); - }); - - withIsolationScope(() => { - const scope1 = getCurrentScope(); - const isolationScope1 = getIsolationScope(); - - expect(scope1).not.toBe(initialScope); - expect(isolationScope1).not.toBe(initialIsolationScope); - - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); - expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); - - scope1.setExtra('b2', 'b'); - isolationScope1.setExtra('bb2', 'bb'); - - withScope(() => { - const scope2 = getCurrentScope(); - const isolationScope2 = getIsolationScope(); - - expect(scope2).not.toBe(scope1); - expect(isolationScope2).toBe(isolationScope1); - - expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); - - scope2.setExtra('c2', 'c'); - - expect(scope2.getScopeData().extra).toEqual({ - a: 'a', - b2: 'b', - c2: 'c', - }); - - expect(isolationScope2.getScopeData().extra).toEqual({ - aa: 'aa', - bb2: 'bb', - }); - }); - }); - }); - - test('concurrent async scope contexts', async () => { - const initialScope = getCurrentScope(); - const initialIsolationScope = getIsolationScope(); - - async function asyncSetExtra(scope: Scope, key: string, value: string): Promise { - await new Promise(resolve => setTimeout(resolve, 1)); - scope.setExtra(key, value); - } - - initialScope.setExtra('a', 'a'); - initialIsolationScope.setExtra('aa', 'aa'); - - await withIsolationScope(async () => { - const scope1 = getCurrentScope(); - const isolationScope1 = getIsolationScope(); - - expect(scope1).not.toBe(initialScope); - expect(isolationScope1).not.toBe(initialIsolationScope); - - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); - expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); - - await asyncSetExtra(scope1, 'b', 'b'); - await asyncSetExtra(isolationScope1, 'bb', 'bb'); - - await withScope(async () => { - const scope2 = getCurrentScope(); - const isolationScope2 = getIsolationScope(); - - expect(scope2).not.toBe(scope1); - expect(isolationScope2).toBe(isolationScope1); - - expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); - - await asyncSetExtra(scope2, 'c', 'c'); - - expect(scope2.getScopeData().extra).toEqual({ - a: 'a', - b: 'b', - c: 'c', - }); - - expect(isolationScope2.getScopeData().extra).toEqual({ - aa: 'aa', - bb: 'bb', - }); - }); - }); - - await withIsolationScope(async () => { - const scope1 = getCurrentScope(); - const isolationScope1 = getIsolationScope(); - - expect(scope1).not.toBe(initialScope); - expect(isolationScope1).not.toBe(initialIsolationScope); - - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); - expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); - - scope1.setExtra('b2', 'b'); - isolationScope1.setExtra('bb2', 'bb'); - - await withScope(async () => { - const scope2 = getCurrentScope(); - const isolationScope2 = getIsolationScope(); - - expect(scope2).not.toBe(scope1); - expect(isolationScope2).toBe(isolationScope1); - - expect(scope2.getScopeData()).toEqual(scope1.getScopeData()); - - scope2.setExtra('c2', 'c'); - - expect(scope2.getScopeData().extra).toEqual({ - a: 'a', - b2: 'b', - c2: 'c', - }); - - expect(isolationScope2.getScopeData().extra).toEqual({ - aa: 'aa', - bb2: 'bb', - }); - }); - }); - }); - - describe('withScope()', () => { - it('will make the passed scope the active scope within the callback', () => - new Promise(done => { - withScope(scope => { - expect(getCurrentScope()).toBe(scope); - done(); - }); - })); - - it('will pass a scope that is different from the current active isolation scope', () => - new Promise(done => { - withScope(scope => { - expect(getIsolationScope()).not.toBe(scope); - done(); - }); - })); - - it('will always make the inner most passed scope the current scope when nesting calls', () => - new Promise(done => { - withIsolationScope(_scope1 => { - withIsolationScope(scope2 => { - expect(getIsolationScope()).toBe(scope2); - done(); - }); - }); - })); - - it('forks the scope when not passing any scope', () => - new Promise(done => { - const initialScope = getCurrentScope(); - initialScope.setTag('aa', 'aa'); - - withScope(scope => { - expect(getCurrentScope()).toBe(scope); - scope.setTag('bb', 'bb'); - expect(scope).not.toBe(initialScope); - expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); - done(); - }); - })); - - it('forks the scope when passing undefined', () => - new Promise(done => { - const initialScope = getCurrentScope(); - initialScope.setTag('aa', 'aa'); - - withScope(undefined, scope => { - expect(getCurrentScope()).toBe(scope); - scope.setTag('bb', 'bb'); - expect(scope).not.toBe(initialScope); - expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); - done(); - }); - })); - - it('sets the passed in scope as active scope', () => - new Promise(done => { - const initialScope = getCurrentScope(); - initialScope.setTag('aa', 'aa'); - - const customScope = new ScopeClass(); - - withScope(customScope, scope => { - expect(getCurrentScope()).toBe(customScope); - expect(scope).toBe(customScope); - done(); - }); - })); - }); - - describe('withIsolationScope()', () => { - it('will make the passed isolation scope the active isolation scope within the callback', () => - new Promise(done => { - withIsolationScope(scope => { - expect(getIsolationScope()).toBe(scope); - done(); - }); - })); - - it('will pass an isolation scope that is different from the current active scope', () => - new Promise(done => { - withIsolationScope(scope => { - expect(getCurrentScope()).not.toBe(scope); - done(); - }); - })); - - it('will always make the inner most passed scope the current scope when nesting calls', () => - new Promise(done => { - withIsolationScope(_scope1 => { - withIsolationScope(scope2 => { - expect(getIsolationScope()).toBe(scope2); - done(); - }); - }); - })); - - it('forks the isolation scope when not passing any isolation scope', () => - new Promise(done => { - const initialScope = getIsolationScope(); - initialScope.setTag('aa', 'aa'); - - withIsolationScope(scope => { - expect(getIsolationScope()).toBe(scope); - scope.setTag('bb', 'bb'); - expect(scope).not.toBe(initialScope); - expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); - done(); - }); - })); - - it('forks the isolation scope when passing undefined', () => - new Promise(done => { - const initialScope = getIsolationScope(); - initialScope.setTag('aa', 'aa'); - - withIsolationScope(undefined, scope => { - expect(getIsolationScope()).toBe(scope); - scope.setTag('bb', 'bb'); - expect(scope).not.toBe(initialScope); - expect(scope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); - done(); - }); - })); - - it('sets the passed in isolation scope as active isolation scope', () => - new Promise(done => { - const initialScope = getIsolationScope(); - initialScope.setTag('aa', 'aa'); - - const customScope = new ScopeClass(); - - withIsolationScope(customScope, scope => { - expect(getIsolationScope()).toBe(customScope); - expect(scope).toBe(customScope); - done(); - }); - })); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts b/dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts deleted file mode 100644 index b39f45d4919e..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/custom/client.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ProxyTracer } from '@opentelemetry/api'; -import { describe, expect, it } from 'vitest'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -describe('OpenTelemetryClient', () => { - it('exposes a tracer', () => { - const options = getDefaultTestClientOptions(); - const client = new TestClient(options); - - const tracer = client.tracer; - expect(tracer).toBeDefined(); - expect(tracer).toBeInstanceOf(ProxyTracer); - - // Ensure we always get the same tracer instance - const tracer2 = client.tracer; - - expect(tracer2).toBe(tracer); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts deleted file mode 100644 index f67cc361d73e..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/helpers/TestClient.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { ClientOptions, Event, Options, SeverityLevel } from '@sentry/core'; -import { Client, createTransport, getCurrentScope, resolvedSyncPromise } from '@sentry/core'; -import { wrapClientClass } from '../../../../packages/opentelemetry/src/custom/client'; -import type { OpenTelemetryClient } from '../../../../packages/opentelemetry/src/types'; - -class BaseTestClient extends Client { - public constructor(options: ClientOptions) { - super(options); - } - - public eventFromException(exception: any): PromiseLike { - return resolvedSyncPromise({ - exception: { - values: [ - { - type: exception.name, - value: exception.message, - }, - ], - }, - }); - } - - public eventFromMessage(message: string, level: SeverityLevel = 'info'): PromiseLike { - return resolvedSyncPromise({ message, level }); - } -} - -export const TestClient = wrapClientClass(BaseTestClient); - -export type TestClientInterface = Client & OpenTelemetryClient; - -export function init(options: Partial = {}): void { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1, ...options })); - - // The client is on the current scope, from where it generally is inherited - getCurrentScope().setClient(client); - client.init(); -} - -export function getDefaultTestClientOptions(options: Partial = {}): ClientOptions { - return { - integrations: [], - transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), - stackParser: () => [], - ...options, - } as ClientOptions; -} diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts deleted file mode 100644 index b45e49e28d79..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/helpers/initOtel.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { context, diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'; -import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION, - SEMRESATTRS_SERVICE_NAMESPACE, -} from '@opentelemetry/semantic-conventions'; -import { debug as debugLogger, getClient, SDK_VERSION } from '@sentry/core'; -import { wrapContextManagerClass } from '../../../../packages/opentelemetry/src/contextManager'; -import { DEBUG_BUILD } from '../../../../packages/opentelemetry/src/debug-build'; -import { SentryPropagator } from '../../../../packages/opentelemetry/src/propagator'; -import { SentrySampler } from '../../../../packages/opentelemetry/src/sampler'; -import { setupEventContextTrace } from '../../../../packages/opentelemetry/src/setupEventContextTrace'; -import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; -import { enhanceDscWithOpenTelemetryRootSpanName } from '../../../../packages/opentelemetry/src/utils/enhanceDscWithOpenTelemetryRootSpanName'; -import type { TestClientInterface } from './TestClient'; - -/** - * Initialize OpenTelemetry for Node. - */ -export function initOtel(): void { - const client = getClient(); - - if (!client) { - DEBUG_BUILD && - debugLogger.warn( - 'No client available, skipping OpenTelemetry setup. This probably means that `Sentry.init()` was not called before `initOtel()`.', - ); - return; - } - - if (client.getOptions().debug) { - diag.setLogger( - { - error: debugLogger.error, - warn: debugLogger.warn, - info: debugLogger.log, - debug: debugLogger.log, - verbose: debugLogger.log, - }, - DiagLogLevel.DEBUG, - ); - } - - setupEventContextTrace(client); - enhanceDscWithOpenTelemetryRootSpanName(client); - - const [provider, spanProcessor] = setupOtel(client); - client.traceProvider = provider; - client.spanProcessor = spanProcessor; -} - -/** Just exported for tests. */ -export function setupOtel(client: TestClientInterface): [BasicTracerProvider, SentrySpanProcessor] { - const spanProcessor = new SentrySpanProcessor(); - // Create and configure NodeTracerProvider - const provider = new BasicTracerProvider({ - sampler: new SentrySampler(client), - resource: defaultResource().merge( - resourceFromAttributes({ - [ATTR_SERVICE_NAME]: 'opentelemetry-test', - // eslint-disable-next-line deprecation/deprecation - [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', - [ATTR_SERVICE_VERSION]: SDK_VERSION, - }), - ), - forceFlushTimeoutMillis: 500, - spanProcessors: [spanProcessor], - }); - - // We use a custom context manager to keep context in sync with sentry scope - const SentryContextManager = wrapContextManagerClass(AsyncLocalStorageContextManager); - - trace.setGlobalTracerProvider(provider); - propagation.setGlobalPropagator(new SentryPropagator()); - context.setGlobalContextManager(new SentryContextManager()); - - return [provider, spanProcessor]; -} diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts b/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts deleted file mode 100644 index 12372f60ea85..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/helpers/mockSdkInit.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { context, propagation, ProxyTracerProvider, trace } from '@opentelemetry/api'; -import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import type { ClientOptions, Options } from '@sentry/core'; -import { flush, getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; -import { setOpenTelemetryContextAsyncContextStrategy } from '../../../../packages/opentelemetry/src/asyncContextStrategy'; -import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; -import type { OpenTelemetryClient } from '../../../../packages/opentelemetry/src/types'; -import { clearOpenTelemetrySetupCheck } from '../../../../packages/opentelemetry/src/utils/setupCheck'; -import { initOtel } from './initOtel'; -import { init as initTestClient } from './TestClient'; - -const PUBLIC_DSN = 'https://username@domain/123'; - -/** - * Initialize Sentry for Node. - */ -function init(options: Partial | undefined = {}): void { - setOpenTelemetryContextAsyncContextStrategy(); - initTestClient(options); - initOtel(); -} - -function resetGlobals(): void { - getCurrentScope().clear(); - getCurrentScope().setClient(undefined); - getIsolationScope().clear(); - getGlobalScope().clear(); - delete (global as any).__SENTRY__; -} - -export function mockSdkInit(options?: Partial) { - resetGlobals(); - - init({ dsn: PUBLIC_DSN, ...options }); -} - -export async function cleanupOtel(_provider?: BasicTracerProvider): Promise { - clearOpenTelemetrySetupCheck(); - - const provider = getProvider(_provider); - - if (provider) { - await provider.forceFlush(); - await provider.shutdown(); - } - - // Disable all globally registered APIs - trace.disable(); - context.disable(); - propagation.disable(); - - await flush(); -} - -export function getSpanProcessor(): SentrySpanProcessor | undefined { - const client = getClient(); - if (!client) { - return undefined; - } - - const spanProcessor = client.spanProcessor; - if (spanProcessor instanceof SentrySpanProcessor) { - return spanProcessor; - } - - return undefined; -} - -export function getProvider(_provider?: BasicTracerProvider): BasicTracerProvider | undefined { - let provider = _provider || getClient()?.traceProvider || trace.getTracerProvider(); - - if (provider instanceof ProxyTracerProvider) { - provider = provider.getDelegate(); - } - - if (!(provider instanceof BasicTracerProvider)) { - return undefined; - } - - return provider; -} diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts deleted file mode 100644 index 800c2dbbeba1..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/integration/breadcrumbs.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { addBreadcrumb, captureException, getClient, withIsolationScope, withScope } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { startSpan } from '../../../../packages/opentelemetry/src/trace'; -import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; -import type { TestClientInterface } from '../helpers/TestClient'; - -describe('Integration | breadcrumbs', () => { - const beforeSendTransaction = vi.fn(() => null); - - afterEach(async () => { - await cleanupOtel(); - }); - - describe('without tracing', () => { - it('correctly adds & retrieves breadcrumbs', async () => { - const beforeSend = vi.fn(() => null); - const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); - - mockSdkInit({ beforeSend, beforeBreadcrumb }); - - const client = getClient() as TestClientInterface; - - addBreadcrumb({ timestamp: 123456, message: 'test1' }); - addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); - addBreadcrumb({ timestamp: 123455, message: 'test3' }); - - const error = new Error('test'); - captureException(error); - - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - expect(beforeBreadcrumb).toHaveBeenCalledTimes(3); - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - breadcrumbs: [ - { message: 'test1', timestamp: 123456 }, - { data: { nested: 'yes' }, message: 'test2', timestamp: 123457 }, - { message: 'test3', timestamp: 123455 }, - ], - }), - { - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }, - ); - }); - - it('handles parallel isolation scopes', async () => { - const beforeSend = vi.fn(() => null); - const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); - - mockSdkInit({ beforeSend, beforeBreadcrumb }); - - const client = getClient() as TestClientInterface; - - const error = new Error('test'); - - addBreadcrumb({ timestamp: 123456, message: 'test0' }); - - withIsolationScope(() => { - addBreadcrumb({ timestamp: 123456, message: 'test1' }); - }); - - withIsolationScope(() => { - addBreadcrumb({ timestamp: 123456, message: 'test2' }); - captureException(error); - }); - - withIsolationScope(() => { - addBreadcrumb({ timestamp: 123456, message: 'test3' }); - }); - - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - expect(beforeBreadcrumb).toHaveBeenCalledTimes(4); - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - breadcrumbs: [ - { message: 'test0', timestamp: 123456 }, - { message: 'test2', timestamp: 123456 }, - ], - }), - { - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }, - ); - }); - }); - - it('correctly adds & retrieves breadcrumbs', async () => { - const beforeSend = vi.fn(() => null); - const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); - - mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); - - const client = getClient() as TestClientInterface; - - const error = new Error('test'); - - startSpan({ name: 'test' }, () => { - addBreadcrumb({ timestamp: 123456, message: 'test1' }); - - startSpan({ name: 'inner1' }, () => { - addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); - }); - - startSpan({ name: 'inner2' }, () => { - addBreadcrumb({ timestamp: 123455, message: 'test3' }); - }); - - captureException(error); - }); - - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - expect(beforeBreadcrumb).toHaveBeenCalledTimes(3); - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - breadcrumbs: [ - { message: 'test1', timestamp: 123456 }, - { data: { nested: 'yes' }, message: 'test2', timestamp: 123457 }, - { message: 'test3', timestamp: 123455 }, - ], - }), - { - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }, - ); - }); - - it('correctly adds & retrieves breadcrumbs for the current isolation scope only', async () => { - const beforeSend = vi.fn(() => null); - const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); - - mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); - - const client = getClient() as TestClientInterface; - - const error = new Error('test'); - - withIsolationScope(() => { - startSpan({ name: 'test1' }, () => { - addBreadcrumb({ timestamp: 123456, message: 'test1-a' }); - - startSpan({ name: 'inner1' }, () => { - addBreadcrumb({ timestamp: 123457, message: 'test1-b' }); - }); - }); - }); - - withIsolationScope(() => { - startSpan({ name: 'test2' }, () => { - addBreadcrumb({ timestamp: 123456, message: 'test2-a' }); - - startSpan({ name: 'inner2' }, () => { - addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); - }); - - captureException(error); - }); - }); - - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - expect(beforeBreadcrumb).toHaveBeenCalledTimes(4); - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - breadcrumbs: [ - { message: 'test2-a', timestamp: 123456 }, - { message: 'test2-b', timestamp: 123457 }, - ], - }), - { - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }, - ); - }); - - it('ignores scopes inside of root span', async () => { - const beforeSend = vi.fn(() => null); - const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); - - mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); - - const client = getClient() as TestClientInterface; - - const error = new Error('test'); - - startSpan({ name: 'test1' }, () => { - withScope(() => { - addBreadcrumb({ timestamp: 123456, message: 'test1' }); - }); - startSpan({ name: 'inner1' }, () => { - addBreadcrumb({ timestamp: 123457, message: 'test2' }); - }); - - captureException(error); - }); - - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - expect(beforeBreadcrumb).toHaveBeenCalledTimes(2); - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - breadcrumbs: [ - { message: 'test1', timestamp: 123456 }, - { message: 'test2', timestamp: 123457 }, - ], - }), - { - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }, - ); - }); - - it('handles deep nesting of scopes', async () => { - const beforeSend = vi.fn(() => null); - const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); - - mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); - - const client = getClient() as TestClientInterface; - - const error = new Error('test'); - - startSpan({ name: 'test1' }, () => { - withScope(() => { - addBreadcrumb({ timestamp: 123456, message: 'test1' }); - }); - startSpan({ name: 'inner1' }, () => { - addBreadcrumb({ timestamp: 123457, message: 'test2' }); - - startSpan({ name: 'inner2' }, () => { - addBreadcrumb({ timestamp: 123457, message: 'test3' }); - - startSpan({ name: 'inner3' }, () => { - addBreadcrumb({ timestamp: 123457, message: 'test4' }); - - captureException(error); - - startSpan({ name: 'inner4' }, () => { - addBreadcrumb({ timestamp: 123457, message: 'test5' }); - }); - - addBreadcrumb({ timestamp: 123457, message: 'test6' }); - }); - }); - }); - - addBreadcrumb({ timestamp: 123456, message: 'test99' }); - }); - - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - breadcrumbs: [ - { message: 'test1', timestamp: 123456 }, - { message: 'test2', timestamp: 123457 }, - { message: 'test3', timestamp: 123457 }, - { message: 'test4', timestamp: 123457 }, - ], - }), - { - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }, - ); - }); - - it('correctly adds & retrieves breadcrumbs in async isolation scopes', async () => { - const beforeSend = vi.fn(() => null); - const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); - - mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, tracesSampleRate: 1 }); - - const client = getClient() as TestClientInterface; - - const error = new Error('test'); - - const promise1 = withIsolationScope(() => { - return startSpan({ name: 'test' }, async () => { - addBreadcrumb({ timestamp: 123456, message: 'test1' }); - - await startSpan({ name: 'inner1' }, async () => { - addBreadcrumb({ timestamp: 123457, message: 'test2' }); - }); - - await startSpan({ name: 'inner2' }, async () => { - addBreadcrumb({ timestamp: 123455, message: 'test3' }); - }); - - await new Promise(resolve => setTimeout(resolve, 10)); - - captureException(error); - }); - }); - - const promise2 = withIsolationScope(() => { - return startSpan({ name: 'test-b' }, async () => { - addBreadcrumb({ timestamp: 123456, message: 'test1-b' }); - - await startSpan({ name: 'inner1' }, async () => { - addBreadcrumb({ timestamp: 123457, message: 'test2-b' }); - }); - - await startSpan({ name: 'inner2' }, async () => { - addBreadcrumb({ timestamp: 123455, message: 'test3-b' }); - }); - }); - }); - - await Promise.all([promise1, promise2]); - - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - expect(beforeBreadcrumb).toHaveBeenCalledTimes(6); - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - breadcrumbs: [ - { message: 'test1', timestamp: 123456 }, - { message: 'test2', timestamp: 123457 }, - { message: 'test3', timestamp: 123455 }, - ], - }), - { - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }, - ); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts deleted file mode 100644 index 3e237b749d5e..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/integration/scope.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { - captureException, - getCapturedScopesOnSpan, - getClient, - getCurrentScope, - getIsolationScope, - setTag, - withIsolationScope, - withScope, -} from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { startSpan } from '../../../../packages/opentelemetry/src/trace'; -import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; -import type { TestClientInterface } from '../helpers/TestClient'; - -describe('Integration | Scope', () => { - afterEach(async () => { - await cleanupOtel(); - }); - - describe.each([ - ['with tracing', true], - ['without tracing', false], - ])('%s', (_name, tracingEnabled) => { - it('correctly syncs OTEL context & Sentry hub/scope', async () => { - const beforeSend = vi.fn(() => null); - const beforeSendTransaction = vi.fn(() => null); - - mockSdkInit({ - tracesSampleRate: tracingEnabled ? 1 : 0, - beforeSend, - beforeSendTransaction, - }); - - const client = getClient() as TestClientInterface; - - const rootScope = getCurrentScope(); - - const error = new Error('test error'); - let spanId: string | undefined; - let traceId: string | undefined; - - rootScope.setTag('tag1', 'val1'); - - withScope(scope1 => { - scope1.setTag('tag2', 'val2'); - - withScope(scope2b => { - scope2b.setTag('tag3-b', 'val3-b'); - }); - - withScope(scope2 => { - scope2.setTag('tag3', 'val3'); - - startSpan({ name: 'outer' }, span => { - expect(getCapturedScopesOnSpan(span).scope).toBe(tracingEnabled ? scope2 : undefined); - - spanId = span.spanContext().spanId; - traceId = span.spanContext().traceId; - - setTag('tag4', 'val4'); - - captureException(error); - }); - }); - }); - - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - - if (spanId) { - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - contexts: { - trace: { - span_id: spanId, - trace_id: traceId, - }, - }, - }), - { - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }, - ); - } - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - tags: { - tag1: 'val1', - tag2: 'val2', - tag3: 'val3', - tag4: 'val4', - }, - }), - { - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }, - ); - - if (tracingEnabled) { - expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - // Note: Scope for transaction is taken at `start` time, not `finish` time - expect(beforeSendTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: { - data: { - 'sentry.origin': 'manual', - 'sentry.source': 'custom', - 'sentry.sample_rate': 1, - }, - span_id: spanId, - status: 'ok', - trace_id: traceId, - origin: 'manual', - }, - }), - spans: [], - start_timestamp: expect.any(Number), - tags: { - tag1: 'val1', - tag2: 'val2', - tag3: 'val3', - tag4: 'val4', - }, - timestamp: expect.any(Number), - transaction_info: { source: 'custom' }, - type: 'transaction', - }), - { - event_id: expect.any(String), - }, - ); - } - }); - - it('isolates parallel scopes', async () => { - const beforeSend = vi.fn(() => null); - const beforeSendTransaction = vi.fn(() => null); - - mockSdkInit({ tracesSampleRate: tracingEnabled ? 1 : 0, beforeSend, beforeSendTransaction }); - - const client = getClient() as TestClientInterface; - const rootScope = getCurrentScope(); - - const error1 = new Error('test error 1'); - const error2 = new Error('test error 2'); - let spanId1: string | undefined; - let spanId2: string | undefined; - let traceId1: string | undefined; - let traceId2: string | undefined; - - rootScope.setTag('tag1', 'val1'); - - const initialIsolationScope = getIsolationScope(); - - withScope(scope1 => { - scope1.setTag('tag2', 'val2a'); - - expect(getIsolationScope()).toBe(initialIsolationScope); - - withScope(scope2 => { - scope2.setTag('tag3', 'val3a'); - - startSpan({ name: 'outer' }, span => { - expect(getIsolationScope()).toBe(initialIsolationScope); - - spanId1 = span.spanContext().spanId; - traceId1 = span.spanContext().traceId; - - setTag('tag4', 'val4a'); - - captureException(error1); - }); - }); - }); - - withScope(scope1 => { - scope1.setTag('tag2', 'val2b'); - - expect(getIsolationScope()).toBe(initialIsolationScope); - - withScope(scope2 => { - scope2.setTag('tag3', 'val3b'); - - startSpan({ name: 'outer' }, span => { - expect(getIsolationScope()).toBe(initialIsolationScope); - - spanId2 = span.spanContext().spanId; - traceId2 = span.spanContext().traceId; - - setTag('tag4', 'val4b'); - - captureException(error2); - }); - }); - }); - - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(2); - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: spanId1 - ? { - span_id: spanId1, - trace_id: traceId1, - } - : expect.any(Object), - }), - tags: { - tag1: 'val1', - tag2: 'val2a', - tag3: 'val3a', - tag4: 'val4a', - }, - }), - { - event_id: expect.any(String), - originalException: error1, - syntheticException: expect.any(Error), - }, - ); - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: spanId2 - ? { - span_id: spanId2, - trace_id: traceId2, - } - : expect.any(Object), - }), - tags: { - tag1: 'val1', - tag2: 'val2b', - tag3: 'val3b', - tag4: 'val4b', - }, - }), - { - event_id: expect.any(String), - originalException: error2, - syntheticException: expect.any(Error), - }, - ); - - if (tracingEnabled) { - expect(beforeSendTransaction).toHaveBeenCalledTimes(2); - } - }); - - it('isolates parallel isolation scopes', async () => { - const beforeSend = vi.fn(() => null); - const beforeSendTransaction = vi.fn(() => null); - - mockSdkInit({ tracesSampleRate: tracingEnabled ? 1 : 0, beforeSend, beforeSendTransaction }); - - const client = getClient() as TestClientInterface; - const rootScope = getCurrentScope(); - - const error1 = new Error('test error 1'); - const error2 = new Error('test error 2'); - let spanId1: string | undefined; - let spanId2: string | undefined; - let traceId1: string | undefined; - let traceId2: string | undefined; - - rootScope.setTag('tag1', 'val1'); - - const initialIsolationScope = getIsolationScope(); - initialIsolationScope.setTag('isolationTag1', 'val1'); - - withIsolationScope(scope1 => { - scope1.setTag('tag2', 'val2a'); - - expect(getIsolationScope()).not.toBe(initialIsolationScope); - getIsolationScope().setTag('isolationTag2', 'val2'); - - withScope(scope2 => { - scope2.setTag('tag3', 'val3a'); - - startSpan({ name: 'outer' }, span => { - expect(getIsolationScope()).not.toBe(initialIsolationScope); - - spanId1 = span.spanContext().spanId; - traceId1 = span.spanContext().traceId; - - setTag('tag4', 'val4a'); - - captureException(error1); - }); - }); - }); - - withIsolationScope(scope1 => { - scope1.setTag('tag2', 'val2b'); - - expect(getIsolationScope()).not.toBe(initialIsolationScope); - getIsolationScope().setTag('isolationTag2', 'val2b'); - - withScope(scope2 => { - scope2.setTag('tag3', 'val3b'); - - startSpan({ name: 'outer' }, span => { - expect(getIsolationScope()).not.toBe(initialIsolationScope); - - spanId2 = span.spanContext().spanId; - traceId2 = span.spanContext().traceId; - - setTag('tag4', 'val4b'); - - captureException(error2); - }); - }); - }); - - await client.flush(); - - expect(spanId1).toBeDefined(); - expect(spanId2).toBeDefined(); - expect(traceId1).toBeDefined(); - expect(traceId2).toBeDefined(); - - expect(beforeSend).toHaveBeenCalledTimes(2); - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: { - span_id: spanId1, - trace_id: traceId1, - }, - }), - tags: { - tag1: 'val1', - tag2: 'val2a', - tag3: 'val3a', - tag4: 'val4a', - isolationTag1: 'val1', - isolationTag2: 'val2', - }, - }), - { - event_id: expect.any(String), - originalException: error1, - syntheticException: expect.any(Error), - }, - ); - - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: { - span_id: spanId2, - trace_id: traceId2, - }, - }), - tags: { - tag1: 'val1', - tag2: 'val2b', - tag3: 'val3b', - tag4: 'val4b', - isolationTag1: 'val1', - isolationTag2: 'val2b', - }, - }), - { - event_id: expect.any(String), - originalException: error2, - syntheticException: expect.any(Error), - }, - ); - - if (tracingEnabled) { - expect(beforeSendTransaction).toHaveBeenCalledTimes(2); - } - }); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts b/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts deleted file mode 100644 index 4c9909e09d9f..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/integration/transactions.test.ts +++ /dev/null @@ -1,724 +0,0 @@ -import type { SpanContext } from '@opentelemetry/api'; -import { context, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; -import { TraceState } from '@opentelemetry/core'; -import type { Event, TransactionEvent } from '@sentry/core'; -import { - addBreadcrumb, - debug, - getClient, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - setTag, - startSpanManual, - withIsolationScope, -} from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SENTRY_TRACE_STATE_DSC } from '../../../../packages/opentelemetry/src/constants'; -import { startInactiveSpan, startSpan } from '../../../../packages/opentelemetry/src/trace'; -import { makeTraceState } from '../../../../packages/opentelemetry/src/utils/makeTraceState'; -import { cleanupOtel, getSpanProcessor, mockSdkInit } from '../helpers/mockSdkInit'; -import type { TestClientInterface } from '../helpers/TestClient'; - -describe('Integration | Transactions', () => { - afterEach(async () => { - vi.restoreAllMocks(); - vi.useRealTimers(); - await cleanupOtel(); - }); - - it('correctly creates transaction & spans', async () => { - const transactions: TransactionEvent[] = []; - const beforeSendTransaction = vi.fn(event => { - transactions.push(event); - return null; - }); - - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction, - release: '8.0.0', - }); - - const client = getClient() as TestClientInterface; - - addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); - setTag('outer.tag', 'test value'); - - startSpan( - { - op: 'test op', - name: 'test name', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', - }, - }, - span => { - addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); - - span.setAttributes({ - 'test.outer': 'test value', - }); - - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); - - setTag('test.tag', 'test value'); - - startSpan({ name: 'inner span 2' }, innerSpan => { - addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); - - innerSpan.setAttributes({ - 'test.inner': 'test value', - }); - }); - }, - ); - - await client.flush(); - - expect(transactions).toHaveLength(1); - const transaction = transactions[0]!; - - expect(transaction.breadcrumbs).toEqual([ - { message: 'test breadcrumb 1', timestamp: 123456 }, - { message: 'test breadcrumb 2', timestamp: 123456 }, - { message: 'test breadcrumb 3', timestamp: 123456 }, - ]); - - expect(transaction.contexts?.otel).toEqual({ - resource: { - 'service.name': 'opentelemetry-test', - 'service.namespace': 'sentry', - 'service.version': expect.any(String), - 'telemetry.sdk.language': 'nodejs', - 'telemetry.sdk.name': 'opentelemetry', - 'telemetry.sdk.version': expect.any(String), - }, - }); - - expect(transaction.contexts?.trace).toEqual({ - data: { - 'sentry.op': 'test op', - 'sentry.origin': 'auto.test', - 'sentry.source': 'task', - 'sentry.sample_rate': 1, - 'test.outer': 'test value', - }, - op: 'test op', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.test', - }); - - expect(transaction.sdkProcessingMetadata?.sampleRate).toEqual(1); - expect(transaction.sdkProcessingMetadata?.dynamicSamplingContext).toEqual({ - environment: 'production', - public_key: expect.any(String), - sample_rate: '1', - sampled: 'true', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - transaction: 'test name', - release: '8.0.0', - sample_rand: expect.any(String), - }); - - expect(transaction.environment).toEqual('production'); - expect(transaction.event_id).toEqual(expect.any(String)); - expect(transaction.start_timestamp).toEqual(expect.any(Number)); - expect(transaction.timestamp).toEqual(expect.any(Number)); - expect(transaction.transaction).toEqual('test name'); - - expect(transaction.tags).toEqual({ - 'outer.tag': 'test value', - 'test.tag': 'test value', - }); - expect(transaction.transaction_info).toEqual({ source: 'task' }); - expect(transaction.type).toEqual('transaction'); - - expect(transaction.spans).toHaveLength(2); - const spans = transaction.spans || []; - - // note: Currently, spans do not have any context/span added to them - // This is the same behavior as for the "regular" SDKs - expect(spans).toEqual([ - { - data: { - 'sentry.origin': 'manual', - }, - description: 'inner span 1', - origin: 'manual', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - { - data: { - 'test.inner': 'test value', - 'sentry.origin': 'manual', - }, - description: 'inner span 2', - origin: 'manual', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - ]); - }); - - it('correctly creates concurrent transaction & spans', async () => { - const beforeSendTransaction = vi.fn(() => null); - - mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); - - const client = getClient() as TestClientInterface; - - addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); - - withIsolationScope(() => { - startSpan( - { - op: 'test op', - name: 'test name', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - }, - }, - span => { - addBreadcrumb({ message: 'test breadcrumb 2', timestamp: 123456 }); - - span.setAttributes({ - 'test.outer': 'test value', - }); - - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); - - setTag('test.tag', 'test value'); - - startSpan({ name: 'inner span 2' }, innerSpan => { - addBreadcrumb({ message: 'test breadcrumb 3', timestamp: 123456 }); - - innerSpan.setAttributes({ - 'test.inner': 'test value', - }); - }); - }, - ); - }); - - withIsolationScope(() => { - startSpan({ op: 'test op b', name: 'test name b' }, span => { - addBreadcrumb({ message: 'test breadcrumb 2b', timestamp: 123456 }); - - span.setAttributes({ - 'test.outer': 'test value b', - }); - - const subSpan = startInactiveSpan({ name: 'inner span 1b' }); - subSpan.end(); - - setTag('test.tag', 'test value b'); - - startSpan({ name: 'inner span 2b' }, innerSpan => { - addBreadcrumb({ message: 'test breadcrumb 3b', timestamp: 123456 }); - - innerSpan.setAttributes({ - 'test.inner': 'test value b', - }); - }); - }); - }); - - await client.flush(); - - expect(beforeSendTransaction).toHaveBeenCalledTimes(2); - expect(beforeSendTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - breadcrumbs: [ - { message: 'test breadcrumb 1', timestamp: 123456 }, - { message: 'test breadcrumb 2', timestamp: 123456 }, - { message: 'test breadcrumb 3', timestamp: 123456 }, - ], - contexts: expect.objectContaining({ - trace: { - data: { - 'sentry.op': 'test op', - 'sentry.origin': 'auto.test', - 'sentry.source': 'task', - 'test.outer': 'test value', - 'sentry.sample_rate': 1, - }, - op: 'test op', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.test', - }, - }), - spans: [expect.any(Object), expect.any(Object)], - start_timestamp: expect.any(Number), - tags: { - 'test.tag': 'test value', - }, - timestamp: expect.any(Number), - transaction: 'test name', - transaction_info: { source: 'task' }, - type: 'transaction', - }), - { - event_id: expect.any(String), - }, - ); - - expect(beforeSendTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - breadcrumbs: [ - { message: 'test breadcrumb 1', timestamp: 123456 }, - { message: 'test breadcrumb 2b', timestamp: 123456 }, - { message: 'test breadcrumb 3b', timestamp: 123456 }, - ], - contexts: expect.objectContaining({ - trace: { - data: { - 'sentry.op': 'test op b', - 'sentry.origin': 'manual', - 'sentry.source': 'custom', - 'test.outer': 'test value b', - 'sentry.sample_rate': 1, - }, - op: 'test op b', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', - }, - }), - spans: [expect.any(Object), expect.any(Object)], - start_timestamp: expect.any(Number), - tags: { - 'test.tag': 'test value b', - }, - timestamp: expect.any(Number), - transaction: 'test name b', - transaction_info: { source: 'custom' }, - type: 'transaction', - }), - { - event_id: expect.any(String), - }, - ); - }); - - it('correctly creates transaction & spans with a trace header data', async () => { - const beforeSendTransaction = vi.fn(() => null); - - const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; - const parentSpanId = '6e0c63257de34c92'; - - const traceState = makeTraceState({ - dsc: undefined, - sampled: true, - }); - - const spanContext: SpanContext = { - traceId, - spanId: parentSpanId, - isRemote: true, - traceFlags: TraceFlags.SAMPLED, - traceState, - }; - - mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); - - const client = getClient() as TestClientInterface; - - // We simulate the correct context we'd normally get from the SentryPropagator - context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { - startSpan( - { - op: 'test op', - name: 'test name', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - }, - }, - () => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); - startSpan({ name: 'inner span 2' }, () => {}); - }, - ); - }); - - await client.flush(); - - expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(beforeSendTransaction).toHaveBeenLastCalledWith( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: { - data: { - 'sentry.op': 'test op', - 'sentry.origin': 'auto.test', - 'sentry.source': 'task', - }, - op: 'test op', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - parent_span_id: parentSpanId, - status: 'ok', - trace_id: traceId, - origin: 'auto.test', - }, - }), - // spans are circular (they have a reference to the transaction), which leads to jest choking on this - // instead we compare them in detail below - spans: [expect.any(Object), expect.any(Object)], - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - transaction: 'test name', - transaction_info: { source: 'task' }, - type: 'transaction', - }), - { - event_id: expect.any(String), - }, - ); - - // Checking the spans here, as they are circular to the transaction... - const runArgs = beforeSendTransaction.mock.calls[0] as unknown as [TransactionEvent, unknown]; - const spans = runArgs[0].spans || []; - - // note: Currently, spans do not have any context/span added to them - // This is the same behavior as for the "regular" SDKs - expect(spans).toEqual([ - { - data: { - 'sentry.origin': 'manual', - }, - description: 'inner span 1', - origin: 'manual', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - }, - { - data: { - 'sentry.origin': 'manual', - }, - description: 'inner span 2', - origin: 'manual', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - }, - ]); - }); - - it('cleans up spans that are not flushed for over 5 mins', async () => { - const beforeSendTransaction = vi.fn(() => null); - - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); - - const logs: unknown[] = []; - vi.spyOn(debug, 'log').mockImplementation(msg => logs.push(msg)); - - mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); - - const spanProcessor = getSpanProcessor(); - - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } - - void startSpan({ name: 'test name' }, async () => { - startInactiveSpan({ name: 'inner span 1' }).end(); - startInactiveSpan({ name: 'inner span 2' }).end(); - - // Pretend this is pending for 10 minutes - await new Promise(resolve => setTimeout(resolve, 10 * 60 * 1000)); - }); - - // Child-spans have been added to the exporter, but they are pending since they are waiting for their parent - const finishedSpans1 = []; - exporter['_finishedSpanBuckets'].forEach(bucket => { - if (bucket) { - finishedSpans1.push(...bucket.spans); - } - }); - expect(finishedSpans1.length).toBe(2); - expect(beforeSendTransaction).toHaveBeenCalledTimes(0); - - // Now wait for 5 mins - vi.advanceTimersByTime(5 * 60 * 1_000 + 1); - - // Adding another span will trigger the cleanup - startSpan({ name: 'other span' }, () => {}); - - vi.advanceTimersByTime(1); - - // Old spans have been cleared away - const finishedSpans2 = []; - exporter['_finishedSpanBuckets'].forEach(bucket => { - if (bucket) { - finishedSpans2.push(...bucket.spans); - } - }); - expect(finishedSpans2.length).toBe(0); - - // Called once for the 'other span' - expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - - expect(logs).toEqual( - expect.arrayContaining([ - 'SpanExporter dropped 2 spans because they were pending for more than 300 seconds.', - 'SpanExporter exported 1 spans, 0 spans are waiting for their parent spans to finish', - ]), - ); - }); - - it('includes child spans that are finished in the same tick but after their parent span', async () => { - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); - - const logs: unknown[] = []; - vi.spyOn(debug, 'log').mockImplementation(msg => logs.push(msg)); - - const transactions: Event[] = []; - - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction: event => { - transactions.push(event); - return null; - }, - }); - - const spanProcessor = getSpanProcessor(); - - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } - - startSpanManual({ name: 'test name' }, async span => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); - - const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); - - span.end(); - subSpan2.end(); - }); - - vi.advanceTimersByTime(1); - - expect(transactions).toHaveLength(1); - expect(transactions[0]?.spans).toHaveLength(2); - - // No spans are pending - const finishedSpans = []; - exporter['_finishedSpanBuckets'].forEach(bucket => { - if (bucket) { - finishedSpans.push(...bucket.spans); - } - }); - expect(finishedSpans.length).toBe(0); - }); - - it('collects child spans that are finished within 5 minutes their parent span has been sent', async () => { - const timeout = 5 * 60 * 1000; - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); - - const logs: unknown[] = []; - vi.spyOn(debug, 'log').mockImplementation(msg => logs.push(msg)); - - const transactions: Event[] = []; - - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction: event => { - transactions.push(event); - return null; - }, - }); - - const spanProcessor = getSpanProcessor(); - - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } - - startSpanManual({ name: 'test name' }, async span => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); - - const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); - - span.end(); - - setTimeout(() => { - subSpan2.end(); - }, timeout - 2); - }); - - vi.advanceTimersByTime(timeout - 1); - - expect(transactions).toHaveLength(2); - expect(transactions[0]?.spans).toHaveLength(1); - - const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => - bucket ? Array.from(bucket.spans) : [], - ); - expect(finishedSpans.length).toBe(0); - }); - - it('discards child spans that are finished after 5 minutes their parent span has been sent', async () => { - const timeout = 5 * 60 * 1000; - const now = Date.now(); - vi.useFakeTimers(); - vi.setSystemTime(now); - - const logs: unknown[] = []; - vi.spyOn(debug, 'log').mockImplementation(msg => logs.push(msg)); - - const transactions: Event[] = []; - - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction: event => { - transactions.push(event); - return null; - }, - }); - - const spanProcessor = getSpanProcessor(); - - const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; - - if (!exporter) { - throw new Error('No exporter found, aborting test...'); - } - - startSpanManual({ name: 'test name' }, async span => { - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - subSpan.end(); - - const subSpan2 = startInactiveSpan({ name: 'inner span 2' }); - - span.end(); - - setTimeout(() => { - subSpan2.end(); - }, timeout + 1); - }); - - vi.advanceTimersByTime(timeout + 2); - - expect(transactions).toHaveLength(1); - expect(transactions[0]?.spans).toHaveLength(1); - - // subSpan2 is pending (and will eventually be cleaned up) - const finishedSpans: any = []; - exporter['_finishedSpanBuckets'].forEach(bucket => { - if (bucket) { - finishedSpans.push(...bucket.spans); - } - }); - expect(finishedSpans.length).toBe(1); - expect(finishedSpans[0]?.name).toBe('inner span 2'); - }); - - it('uses & inherits DSC on span trace state', async () => { - const transactionEvents: Event[] = []; - const beforeSendTransaction = vi.fn(event => { - transactionEvents.push(event); - return null; - }); - - const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; - const parentSpanId = '6e0c63257de34c92'; - - const dscString = `sentry-transaction=other-transaction,sentry-environment=other,sentry-release=8.0.0,sentry-public_key=public,sentry-trace_id=${traceId},sentry-sampled=true`; - - const spanContext: SpanContext = { - traceId, - spanId: parentSpanId, - isRemote: true, - traceFlags: TraceFlags.SAMPLED, - traceState: new TraceState().set(SENTRY_TRACE_STATE_DSC, dscString), - }; - - mockSdkInit({ - tracesSampleRate: 1, - beforeSendTransaction, - release: '7.0.0', - }); - - const client = getClient() as TestClientInterface; - - // We simulate the correct context we'd normally get from the SentryPropagator - context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { - startSpan( - { - op: 'test op', - name: 'test name', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - }, - }, - span => { - expect(span.spanContext().traceState?.get(SENTRY_TRACE_STATE_DSC)).toEqual(dscString); - - const subSpan = startInactiveSpan({ name: 'inner span 1' }); - - expect(subSpan.spanContext().traceState?.get(SENTRY_TRACE_STATE_DSC)).toEqual(dscString); - - subSpan.end(); - - startSpan({ name: 'inner span 2' }, subSpan => { - expect(subSpan.spanContext().traceState?.get(SENTRY_TRACE_STATE_DSC)).toEqual(dscString); - }); - }, - ); - }); - - await client.flush(); - - expect(transactionEvents).toHaveLength(1); - expect(transactionEvents[0]?.sdkProcessingMetadata?.dynamicSamplingContext).toEqual({ - environment: 'other', - public_key: 'public', - release: '8.0.0', - sampled: 'true', - trace_id: traceId, - transaction: 'other-transaction', - }); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/propagator.test.ts b/dev-packages/opentelemetry-v2-tests/test/propagator.test.ts deleted file mode 100644 index 8e3f85b38250..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/propagator.test.ts +++ /dev/null @@ -1,670 +0,0 @@ -import { - context, - defaultTextMapGetter, - defaultTextMapSetter, - propagation, - ROOT_CONTEXT, - trace, - TraceFlags, -} from '@opentelemetry/api'; -import { suppressTracing } from '@opentelemetry/core'; -import { getCurrentScope, withScope } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - SENTRY_BAGGAGE_HEADER, - SENTRY_SCOPES_CONTEXT_KEY, - SENTRY_TRACE_HEADER, -} from '../../../packages/opentelemetry/src/constants'; -import { SentryPropagator } from '../../../packages/opentelemetry/src/propagator'; -import { getSamplingDecision } from '../../../packages/opentelemetry/src/utils/getSamplingDecision'; -import { makeTraceState } from '../../../packages/opentelemetry/src/utils/makeTraceState'; -import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; - -describe('SentryPropagator', () => { - const propagator = new SentryPropagator(); - let carrier: { [key: string]: unknown }; - - beforeEach(() => { - carrier = {}; - mockSdkInit({ - environment: 'production', - release: '1.0.0', - tracesSampleRate: 1, - dsn: 'https://abc@domain/123', - }); - }); - - afterEach(async () => { - await cleanupOtel(); - }); - - it('returns fields set', () => { - expect(propagator.fields()).toEqual([SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER]); - }); - - describe('inject', () => { - describe('without active local span', () => { - it('uses scope propagation context without DSC if no span is found', () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - parentSpanId: '6e0c63257de34c93', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ].sort(), - ); - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); - }); - }); - - it('uses scope propagation context with DSC if no span is found', () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - parentSpanId: '6e0c63257de34c93', - sampled: true, - sampleRand: Math.random(), - dsc: { - transaction: 'sampled-transaction', - sampled: 'false', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sampled=false', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ].sort(), - ); - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); - }); - }); - - it('uses propagation data from current scope if no scope & span is found', () => { - const scope = getCurrentScope(); - const traceId = scope.getPropagationContext().traceId; - - const ctx = trace.deleteSpan(ROOT_CONTEXT).deleteValue(SENTRY_SCOPES_CONTEXT_KEY); - propagator.inject(ctx, carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual([ - 'sentry-environment=production', - 'sentry-public_key=abc', - 'sentry-release=1.0.0', - `sentry-trace_id=${traceId}`, - ]); - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(traceId); - }); - }); - - describe('with active span', () => { - it.each([ - [ - 'continues a remote trace without dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=test', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - true, - ], - [ - 'continues a remote trace with dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - traceState: makeTraceState({ - dsc: { - transaction: 'sampled-transaction', - sampled: 'true', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }), - }, - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sampled=true', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - true, - ], - [ - 'continues an unsampled remote trace without dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=test', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - undefined, - ], - [ - 'continues an unsampled remote trace with sampled trace state & without dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ - sampled: false, - }), - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-sampled=false', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', - false, - ], - [ - 'continues an unsampled remote trace with dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ - dsc: { - transaction: 'sampled-transaction', - sampled: 'false', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }), - }, - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sampled=false', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', - false, - ], - [ - 'continues an unsampled remote trace with dsc & sampled trace state', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ - sampled: false, - dsc: { - transaction: 'sampled-transaction', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }), - }, - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', - false, - ], - [ - 'starts a new trace without existing dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - true, - ], - ])('%s', (_name, spanContext, baggage, sentryTrace, samplingDecision) => { - expect(getSamplingDecision(spanContext)).toBe(samplingDecision); - - context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { - trace.getTracer('test').startActiveSpan('test', span => { - propagator.inject(context.active(), carrier, defaultTextMapSetter); - baggage.forEach(baggageItem => { - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(baggageItem); - }); - expect(carrier[SENTRY_TRACE_HEADER]).toBe(sentryTrace.replace('{{spanId}}', span.spanContext().spanId)); - }); - }); - }); - - it('uses local span over propagation context', () => { - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - }), - () => { - trace.getTracer('test').startActiveSpan('test', span => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'TRACE_ID', - parentSpanId: 'PARENT_SPAN_ID', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=test', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ].forEach(item => { - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(item); - }); - expect(carrier[SENTRY_TRACE_HEADER]).toBe( - `d4cda95b652f4a1592b449d5929fda1b-${span.spanContext().spanId}-1`, - ); - }); - }); - }, - ); - }); - - it('uses remote span with deferred sampling decision over propagation context', () => { - const carrier: Record = {}; - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - }), - () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'TRACE_ID', - parentSpanId: 'PARENT_SPAN_ID', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ].sort(), - ); - // Used spanId is a random ID, not from the remote span - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}/); - expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'); - }); - }, - ); - }); - - it('uses remote span over propagation context', () => { - const carrier: Record = {}; - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ sampled: false }), - }), - () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'TRACE_ID', - parentSpanId: 'PARENT_SPAN_ID', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=false', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ].sort(), - ); - // Used spanId is a random ID, not from the remote span - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-0/); - expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'); - }); - }, - ); - }); - }); - - it('should include existing baggage', () => { - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); - - it('should include existing baggage header', () => { - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - - const carrier = { - other: 'header', - baggage: 'foo=bar,other=yes', - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage(); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'other=yes', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); - - it('should include existing baggage array header', () => { - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - - const carrier = { - other: 'header', - baggage: ['foo=bar,other=yes', 'other2=no'], - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage(); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'other=yes', - 'other2=no', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); - - it('should overwrite existing sentry baggage header', () => { - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - - const carrier = { - baggage: 'foo=bar,other=yes,sentry-release=9.9.9,sentry-other=yes', - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage(); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'other=yes', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-other=yes', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); - - it('should create baggage without propagation context', () => { - const scope = getCurrentScope(); - const traceId = scope.getPropagationContext().traceId; - - const context = ROOT_CONTEXT; - const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe( - `foo=bar,sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=${traceId}`, - ); - }); - - it('should NOT set baggage and sentry-trace header if instrumentation is suppressed', () => { - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - - const context = suppressTracing(trace.setSpanContext(ROOT_CONTEXT, spanContext)); - propagator.inject(context, carrier, defaultTextMapSetter); - expect(carrier[SENTRY_TRACE_HEADER]).toBe(undefined); - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); - }); - }); - - describe('extract', () => { - it('sets data from sentry trace header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({}), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); - }); - - it('sets data from negative sampled sentry trace header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({ sampled: false }), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(false); - }); - - it('sets data from not sampled sentry trace header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({}), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(undefined); - }); - - it('handles undefined sentry trace header', () => { - const sentryTraceHeader = undefined; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual(undefined); - expect(getCurrentScope().getPropagationContext()).toEqual({ - traceId: expect.stringMatching(/[a-f0-9]{32}/), - sampleRand: expect.any(Number), - }); - }); - - it('sets data from baggage header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; - const baggage = - 'sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-transaction=dsc-transaction,sentry-sample_rand=0.123'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - carrier[SENTRY_BAGGAGE_HEADER] = baggage; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({ - dsc: { - environment: 'production', - release: '1.0.0', - public_key: 'abc', - trace_id: 'd4cda95b652f4a1592b449d5929fda1b', - transaction: 'dsc-transaction', - sample_rand: '0.123', - }, - }), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); - }); - - it('handles empty dsc baggage header', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; - const baggage = ''; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - carrier[SENTRY_BAGGAGE_HEADER] = baggage; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({}), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); - }); - - it('handles when sentry-trace is an empty array', () => { - carrier[SENTRY_TRACE_HEADER] = []; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual(undefined); - expect(getCurrentScope().getPropagationContext()).toEqual({ - traceId: expect.stringMatching(/[a-f0-9]{32}/), - sampleRand: expect.any(Number), - }); - }); - }); -}); - -function baggageToArray(baggage: unknown): string[] { - return typeof baggage === 'string' ? baggage.split(',').sort() : []; -} diff --git a/dev-packages/opentelemetry-v2-tests/test/sampler.test.ts b/dev-packages/opentelemetry-v2-tests/test/sampler.test.ts deleted file mode 100644 index 86cf7b135f97..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/sampler.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { context, SpanKind, trace } from '@opentelemetry/api'; -import { TraceState } from '@opentelemetry/core'; -import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; -import { ATTR_HTTP_REQUEST_METHOD } from '@opentelemetry/semantic-conventions'; -import { generateSpanId, generateTraceId } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING } from '../../../packages/opentelemetry/src/constants'; -import { SentrySampler } from '../../../packages/opentelemetry/src/sampler'; -import { cleanupOtel } from './helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from './helpers/TestClient'; - -describe('SentrySampler', () => { - afterEach(async () => { - await cleanupOtel(); - }); - - it('works with tracesSampleRate=0', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); - const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - const sampler = new SentrySampler(client); - - const ctx = context.active(); - const traceId = generateTraceId(); - const spanName = 'test'; - const spanKind = SpanKind.INTERNAL; - const spanAttributes = {}; - const links = undefined; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); - expect(actual).toEqual( - expect.objectContaining({ - decision: SamplingDecision.NOT_RECORD, - attributes: { 'sentry.sample_rate': 0 }, - }), - ); - expect(actual.traceState?.get('sentry.sampled_not_recording')).toBe('1'); - expect(actual.traceState?.get('sentry.sample_rand')).toEqual(expect.any(String)); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(1); - expect(spyOnDroppedEvent).toHaveBeenCalledWith('sample_rate', 'transaction'); - - spyOnDroppedEvent.mockReset(); - }); - - it('works with tracesSampleRate=0 & for a child span', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); - const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - const sampler = new SentrySampler(client); - - const traceId = generateTraceId(); - const ctx = trace.setSpanContext(context.active(), { - spanId: generateSpanId(), - traceId, - traceFlags: 0, - traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), - }); - const spanName = 'test'; - const spanKind = SpanKind.INTERNAL; - const spanAttributes = {}; - const links = undefined; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); - expect(actual).toEqual({ - decision: SamplingDecision.NOT_RECORD, - traceState: new TraceState().set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1'), - }); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); - - spyOnDroppedEvent.mockReset(); - }); - - it('works with tracesSampleRate=1', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - const sampler = new SentrySampler(client); - - const ctx = context.active(); - const traceId = generateTraceId(); - const spanName = 'test'; - const spanKind = SpanKind.INTERNAL; - const spanAttributes = {}; - const links = undefined; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); - expect(actual).toEqual( - expect.objectContaining({ - decision: SamplingDecision.RECORD_AND_SAMPLED, - attributes: { 'sentry.sample_rate': 1 }, - }), - ); - expect(actual.traceState?.constructor.name).toBe('TraceState'); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); - - spyOnDroppedEvent.mockReset(); - }); - - it('works with traceSampleRate=undefined', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: undefined })); - const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - const sampler = new SentrySampler(client); - - const ctx = context.active(); - const traceId = generateTraceId(); - const spanName = 'test'; - const spanKind = SpanKind.INTERNAL; - const spanAttributes = {}; - const links = undefined; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); - expect(actual).toEqual({ - decision: SamplingDecision.NOT_RECORD, - traceState: new TraceState(), - }); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); - - spyOnDroppedEvent.mockReset(); - }); - - it('ignores local http client root spans', () => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 0 })); - const spyOnDroppedEvent = vi.spyOn(client, 'recordDroppedEvent'); - const sampler = new SentrySampler(client); - - const ctx = context.active(); - const traceId = generateTraceId(); - const spanName = 'test'; - const spanKind = SpanKind.CLIENT; - const spanAttributes = { - [ATTR_HTTP_REQUEST_METHOD]: 'GET', - }; - const links = undefined; - - const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); - expect(actual).toEqual({ - decision: SamplingDecision.NOT_RECORD, - traceState: new TraceState(), - }); - expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); - - spyOnDroppedEvent.mockReset(); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts b/dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts deleted file mode 100644 index 5a1782c89e7b..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/spanExporter.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { ATTR_HTTP_RESPONSE_STATUS_CODE } from '@opentelemetry/semantic-conventions'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, startInactiveSpan, startSpanManual } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createTransactionForOtelSpan } from '../../../packages/opentelemetry/src/spanExporter'; -import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; - -describe('createTransactionForOtelSpan', () => { - beforeEach(() => { - mockSdkInit({ - tracesSampleRate: 1, - }); - }); - - afterEach(async () => { - await cleanupOtel(); - }); - - it('works with a basic span', () => { - const span = startInactiveSpan({ name: 'test', startTime: 1733821670000 }); - span.end(1733821672000); - - const event = createTransactionForOtelSpan(span as any); - // we do not care about this here - delete event.sdkProcessingMetadata; - - expect(event).toEqual({ - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.source': 'custom', - 'sentry.sample_rate': 1, - 'sentry.origin': 'manual', - }, - origin: 'manual', - status: 'ok', - }, - otel: { - resource: { - 'service.name': 'opentelemetry-test', - 'telemetry.sdk.language': 'nodejs', - 'telemetry.sdk.name': 'opentelemetry', - 'telemetry.sdk.version': expect.any(String), - 'service.namespace': 'sentry', - 'service.version': SDK_VERSION, - }, - }, - }, - spans: [], - start_timestamp: 1733821670, - timestamp: 1733821672, - transaction: 'test', - type: 'transaction', - transaction_info: { source: 'custom' }, - }); - }); - - it('works with a http.server span', () => { - const span = startInactiveSpan({ - name: 'test', - startTime: 1733821670000, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', - [ATTR_HTTP_RESPONSE_STATUS_CODE]: 200, - }, - }); - span.end(1733821672000); - - const event = createTransactionForOtelSpan(span as any); - // we do not care about this here - delete event.sdkProcessingMetadata; - - expect(event).toEqual({ - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'sentry.source': 'custom', - 'sentry.sample_rate': 1, - 'sentry.origin': 'manual', - 'sentry.op': 'http.server', - 'http.response.status_code': 200, - }, - origin: 'manual', - status: 'ok', - op: 'http.server', - }, - otel: { - resource: { - 'service.name': 'opentelemetry-test', - 'telemetry.sdk.language': 'nodejs', - 'telemetry.sdk.name': 'opentelemetry', - 'telemetry.sdk.version': expect.any(String), - 'service.namespace': 'sentry', - 'service.version': SDK_VERSION, - }, - }, - response: { - status_code: 200, - }, - }, - spans: [], - start_timestamp: 1733821670, - timestamp: 1733821672, - transaction: 'test', - type: 'transaction', - transaction_info: { source: 'custom' }, - }); - }); - - it('adds span link to the trace context when adding with addLink()', () => { - const span = startInactiveSpan({ name: 'parent1' }); - span.end(); - - startSpanManual({ name: 'rootSpan' }, rootSpan => { - rootSpan.addLink({ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }); - rootSpan.end(); - - const prevTraceId = span.spanContext().traceId; - const prevSpanId = span.spanContext().spanId; - const event = createTransactionForOtelSpan(rootSpan as any); - - expect(event.contexts?.trace).toEqual( - expect.objectContaining({ - links: [ - expect.objectContaining({ - attributes: { 'sentry.link.type': 'previous_trace' }, - sampled: true, - trace_id: expect.stringMatching(prevTraceId), - span_id: expect.stringMatching(prevSpanId), - }), - ], - }), - ); - }); - }); - - it('adds span link to the trace context when linked in span options', () => { - const span = startInactiveSpan({ name: 'parent1' }); - - const prevTraceId = span.spanContext().traceId; - const prevSpanId = span.spanContext().spanId; - - const linkedSpan = startInactiveSpan({ - name: 'parent2', - links: [{ context: span.spanContext(), attributes: { 'sentry.link.type': 'previous_trace' } }], - }); - - span.end(); - linkedSpan.end(); - - const event = createTransactionForOtelSpan(linkedSpan as any); - - expect(event.contexts?.trace).toEqual( - expect.objectContaining({ - links: [ - expect.objectContaining({ - attributes: { 'sentry.link.type': 'previous_trace' }, - sampled: true, - trace_id: expect.stringMatching(prevTraceId), - span_id: expect.stringMatching(prevSpanId), - }), - ], - }), - ); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/trace.test.ts b/dev-packages/opentelemetry-v2-tests/test/trace.test.ts deleted file mode 100644 index 52d5e67477d0..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/trace.test.ts +++ /dev/null @@ -1,1935 +0,0 @@ -/* eslint-disable deprecation/deprecation */ -import type { Span, TimeInput } from '@opentelemetry/api'; -import { context, ROOT_CONTEXT, SpanKind, trace, TraceFlags } from '@opentelemetry/api'; -import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { SEMATTRS_HTTP_METHOD } from '@opentelemetry/semantic-conventions'; -import type { Event, Scope } from '@sentry/core'; -import { - getClient, - getCurrentScope, - getDynamicSamplingContextFromClient, - getDynamicSamplingContextFromSpan, - getRootSpan, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - spanIsSampled, - spanToJSON, - suppressTracing, - withScope, -} from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - continueTrace, - startInactiveSpan, - startSpan, - startSpanManual, -} from '../../../packages/opentelemetry/src/trace'; -import type { AbstractSpan } from '../../../packages/opentelemetry/src/types'; -import { getActiveSpan } from '../../../packages/opentelemetry/src/utils/getActiveSpan'; -import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId'; -import { getSamplingDecision } from '../../../packages/opentelemetry/src/utils/getSamplingDecision'; -import { getSpanKind } from '../../../packages/opentelemetry/src/utils/getSpanKind'; -import { makeTraceState } from '../../../packages/opentelemetry/src/utils/makeTraceState'; -import { spanHasAttributes, spanHasName } from '../../../packages/opentelemetry/src/utils/spanTypes'; -import { isSpan } from './helpers/isSpan'; -import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; - -describe('trace', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - afterEach(async () => { - await cleanupOtel(); - }); - - describe('startSpan', () => { - it('works with a sync callback', () => { - const spans: Span[] = []; - - expect(getActiveSpan()).toEqual(undefined); - - const res = startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - spans.push(outerSpan); - - expect(getSpanName(outerSpan)).toEqual('outer'); - expect(getActiveSpan()).toEqual(outerSpan); - - startSpan({ name: 'inner' }, innerSpan => { - expect(innerSpan).toBeDefined(); - spans.push(innerSpan); - - expect(getSpanName(innerSpan)).toEqual('inner'); - expect(getActiveSpan()).toEqual(innerSpan); - }); - - return 'test value'; - }); - - expect(res).toEqual('test value'); - - expect(getActiveSpan()).toEqual(undefined); - expect(spans).toHaveLength(2); - const [outerSpan, innerSpan] = spans as [Span, Span]; - - expect(getSpanName(outerSpan)).toEqual('outer'); - expect(getSpanName(innerSpan)).toEqual('inner'); - - expect(getSpanEndTime(outerSpan)).not.toEqual([0, 0]); - expect(getSpanEndTime(innerSpan)).not.toEqual([0, 0]); - }); - - it('works with an async callback', async () => { - const spans: Span[] = []; - - expect(getActiveSpan()).toEqual(undefined); - - const res = await startSpan({ name: 'outer' }, async outerSpan => { - expect(outerSpan).toBeDefined(); - spans.push(outerSpan); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(getSpanName(outerSpan)).toEqual('outer'); - expect(getActiveSpan()).toEqual(outerSpan); - - await startSpan({ name: 'inner' }, async innerSpan => { - expect(innerSpan).toBeDefined(); - spans.push(innerSpan); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(getSpanName(innerSpan)).toEqual('inner'); - expect(getActiveSpan()).toEqual(innerSpan); - }); - - return 'test value'; - }); - - expect(res).toEqual('test value'); - - expect(getActiveSpan()).toEqual(undefined); - expect(spans).toHaveLength(2); - const [outerSpan, innerSpan] = spans as [Span, Span]; - - expect(getSpanName(outerSpan)).toEqual('outer'); - expect(getSpanName(innerSpan)).toEqual('inner'); - - expect(getSpanEndTime(outerSpan)).not.toEqual([0, 0]); - expect(getSpanEndTime(innerSpan)).not.toEqual([0, 0]); - }); - - it('works with multiple parallel calls', () => { - const spans1: Span[] = []; - const spans2: Span[] = []; - - expect(getActiveSpan()).toEqual(undefined); - - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - spans1.push(outerSpan); - - expect(getSpanName(outerSpan)).toEqual('outer'); - expect(getActiveSpan()).toEqual(outerSpan); - - startSpan({ name: 'inner' }, innerSpan => { - expect(innerSpan).toBeDefined(); - spans1.push(innerSpan); - - expect(getSpanName(innerSpan)).toEqual('inner'); - expect(getActiveSpan()).toEqual(innerSpan); - }); - }); - - startSpan({ name: 'outer2' }, outerSpan => { - expect(outerSpan).toBeDefined(); - spans2.push(outerSpan); - - expect(getSpanName(outerSpan)).toEqual('outer2'); - expect(getActiveSpan()).toEqual(outerSpan); - - startSpan({ name: 'inner2' }, innerSpan => { - expect(innerSpan).toBeDefined(); - spans2.push(innerSpan); - - expect(getSpanName(innerSpan)).toEqual('inner2'); - expect(getActiveSpan()).toEqual(innerSpan); - }); - }); - - expect(getActiveSpan()).toEqual(undefined); - expect(spans1).toHaveLength(2); - expect(spans2).toHaveLength(2); - }); - - it('works with multiple parallel async calls', async () => { - const spans1: Span[] = []; - const spans2: Span[] = []; - - expect(getActiveSpan()).toEqual(undefined); - - const promise1 = startSpan({ name: 'outer' }, async outerSpan => { - expect(outerSpan).toBeDefined(); - spans1.push(outerSpan); - - expect(getSpanName(outerSpan)).toEqual('outer'); - expect(getActiveSpan()).toEqual(outerSpan); - expect(getRootSpan(outerSpan)).toEqual(outerSpan); - - await new Promise(resolve => setTimeout(resolve, 10)); - - await startSpan({ name: 'inner' }, async innerSpan => { - expect(innerSpan).toBeDefined(); - spans1.push(innerSpan); - - expect(getSpanName(innerSpan)).toEqual('inner'); - expect(getActiveSpan()).toEqual(innerSpan); - expect(getRootSpan(innerSpan)).toEqual(outerSpan); - }); - }); - - const promise2 = startSpan({ name: 'outer2' }, async outerSpan => { - expect(outerSpan).toBeDefined(); - spans2.push(outerSpan); - - expect(getSpanName(outerSpan)).toEqual('outer2'); - expect(getActiveSpan()).toEqual(outerSpan); - expect(getRootSpan(outerSpan)).toEqual(outerSpan); - - await new Promise(resolve => setTimeout(resolve, 10)); - - await startSpan({ name: 'inner2' }, async innerSpan => { - expect(innerSpan).toBeDefined(); - spans2.push(innerSpan); - - expect(getSpanName(innerSpan)).toEqual('inner2'); - expect(getActiveSpan()).toEqual(innerSpan); - expect(getRootSpan(innerSpan)).toEqual(outerSpan); - }); - }); - - await Promise.all([promise1, promise2]); - - expect(getActiveSpan()).toEqual(undefined); - expect(spans1).toHaveLength(2); - expect(spans2).toHaveLength(2); - }); - - it('allows to pass context arguments', () => { - startSpan( - { - name: 'outer', - }, - span => { - expect(span).toBeDefined(); - expect(getSpanAttributes(span)).toEqual({ - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - }); - }, - ); - - startSpan( - { - name: 'outer', - op: 'my-op', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', - }, - }, - span => { - expect(span).toBeDefined(); - expect(getSpanAttributes(span)).toEqual({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'my-op', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - }); - }, - ); - }); - - it('allows to pass base SpanOptions', () => { - const date = [5000, 0] as TimeInput; - - startSpan( - { - name: 'outer', - kind: SpanKind.CLIENT, - attributes: { - test1: 'test 1', - test2: 2, - }, - startTime: date, - }, - span => { - expect(span).toBeDefined(); - expect(getSpanName(span)).toEqual('outer'); - expect(getSpanStartTime(span)).toEqual(date); - expect(getSpanAttributes(span)).toEqual({ - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - test1: 'test 1', - test2: 2, - }); - expect(getSpanKind(span)).toEqual(SpanKind.CLIENT); - }, - ); - }); - - it('allows to pass a startTime in seconds', () => { - const startTime = 1708504860.961; - const start = startSpan({ name: 'outer', startTime: startTime }, span => { - return getSpanStartTime(span); - }); - - expect(start).toEqual([1708504860, 961000000]); - }); - - it('allows to pass a scope', () => { - const initialScope = getCurrentScope(); - - let manualScope: Scope; - let parentSpan: Span; - - // "hack" to create a manual scope with a parent span - startSpanManual({ name: 'detached' }, span => { - parentSpan = span; - manualScope = getCurrentScope(); - manualScope.setTag('manual', 'tag'); - }); - - expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag' }); - expect(getCurrentScope()).not.toBe(manualScope!); - - getCurrentScope().setTag('outer', 'tag'); - - startSpan({ name: 'GET users/[id]', scope: manualScope! }, span => { - // the current scope in the callback is a fork of the manual scope - expect(getCurrentScope()).not.toBe(initialScope); - expect(getCurrentScope()).not.toBe(manualScope); - expect(getCurrentScope().getScopeData().tags).toEqual({ manual: 'tag' }); - - // getActiveSpan returns the correct span - expect(getActiveSpan()).toBe(span); - - // span hierarchy is correct - expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId); - - // scope data modifications are isolated between original and forked manual scope - getCurrentScope().setTag('inner', 'tag'); - manualScope!.setTag('manual-scope-inner', 'tag'); - - expect(getCurrentScope().getScopeData().tags).toEqual({ manual: 'tag', inner: 'tag' }); - expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag', 'manual-scope-inner': 'tag' }); - }); - - // manualScope modifications remain set outside the callback - expect(manualScope!.getScopeData().tags).toEqual({ manual: 'tag', 'manual-scope-inner': 'tag' }); - - // current scope is reset back to initial scope - expect(getCurrentScope()).toBe(initialScope); - expect(getCurrentScope().getScopeData().tags).toEqual({ outer: 'tag' }); - - // although the manual span is still running, it's no longer active due to being outside of the callback - expect(getActiveSpan()).toBe(undefined); - }); - - it('allows to pass a parentSpan', () => { - let parentSpan: Span; - - startSpanManual({ name: 'detached' }, span => { - parentSpan = span; - }); - - startSpan({ name: 'GET users/[id]', parentSpan: parentSpan! }, span => { - expect(getActiveSpan()).toBe(span); - expect(spanToJSON(span).parent_span_id).toBe(parentSpan.spanContext().spanId); - }); - - expect(getActiveSpan()).toBe(undefined); - }); - - it('allows to pass parentSpan=null', () => { - startSpan({ name: 'GET users/[id' }, () => { - startSpan({ name: 'child', parentSpan: null }, span => { - expect(spanToJSON(span).parent_span_id).toBe(undefined); - }); - }); - }); - - it('allows to add span links', () => { - const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); - - // @ts-expect-error links exists on span - expect(rawSpan1?.links).toEqual([]); - - const span1JSON = spanToJSON(rawSpan1); - - startSpan({ name: '/users/:id' }, rawSpan2 => { - rawSpan2.addLink({ - context: rawSpan1.spanContext(), - attributes: { - 'sentry.link.type': 'previous_trace', - }, - }); - - const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; - - expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); - expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); - expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); - }); - }); - - it('allows to pass span links in span options', () => { - const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); - - // @ts-expect-error links exists on span - expect(rawSpan1?.links).toEqual([]); - - const span1JSON = spanToJSON(rawSpan1); - - startSpan( - { - name: '/users/:id', - links: [ - { - context: rawSpan1.spanContext(), - attributes: { 'sentry.link.type': 'previous_trace' }, - }, - ], - }, - rawSpan2 => { - const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; - - expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); - expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); - expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); - }, - ); - }); - - it('allows to force a transaction with forceTransaction=true', async () => { - const client = getClient()!; - const transactionEvents: Event[] = []; - - client.getOptions().beforeSendTransaction = event => { - transactionEvents.push({ - ...event, - sdkProcessingMetadata: { - dynamicSamplingContext: event.sdkProcessingMetadata?.dynamicSamplingContext, - }, - }); - return event; - }; - - startSpan({ name: 'outer transaction' }, () => { - startSpan({ name: 'inner span' }, () => { - startSpan({ name: 'inner transaction', forceTransaction: true }, () => { - startSpan({ name: 'inner span 2' }, () => { - // all good - }); - }); - }); - }); - - await client.flush(); - - const normalizedTransactionEvents = transactionEvents.map(event => { - return { - ...event, - spans: event.spans?.map(span => ({ name: span.description, id: span.span_id })), - }; - }); - - expect(normalizedTransactionEvents).toHaveLength(2); - - const outerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'outer transaction'); - const innerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'inner transaction'); - - const outerTraceId = outerTransaction?.contexts?.trace?.trace_id; - // The inner transaction should be a child of the last span of the outer transaction - const innerParentSpanId = outerTransaction?.spans?.[0]?.id; - const innerSpanId = innerTransaction?.contexts?.trace?.span_id; - - expect(outerTraceId).toBeDefined(); - expect(innerParentSpanId).toBeDefined(); - expect(innerSpanId).toBeDefined(); - // inner span ID should _not_ be the parent span ID, but the id of the new span - expect(innerSpanId).not.toEqual(innerParentSpanId); - - expect(outerTransaction?.contexts?.trace).toEqual({ - data: { - 'sentry.source': 'custom', - 'sentry.sample_rate': 1, - 'sentry.origin': 'manual', - }, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', - status: 'ok', - }); - expect(outerTransaction?.spans).toEqual([{ name: 'inner span', id: expect.any(String) }]); - expect(outerTransaction?.transaction).toEqual('outer transaction'); - expect(outerTransaction?.sdkProcessingMetadata).toEqual({ - dynamicSamplingContext: { - environment: 'production', - public_key: 'username', - trace_id: outerTraceId, - sample_rate: '1', - transaction: 'outer transaction', - sampled: 'true', - sample_rand: expect.any(String), - }, - }); - - expect(innerTransaction?.contexts?.trace).toEqual({ - data: { - 'sentry.source': 'custom', - 'sentry.origin': 'manual', - }, - parent_span_id: innerParentSpanId, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: outerTraceId, - origin: 'manual', - status: 'ok', - }); - expect(innerTransaction?.spans).toEqual([{ name: 'inner span 2', id: expect.any(String) }]); - expect(innerTransaction?.transaction).toEqual('inner transaction'); - expect(innerTransaction?.sdkProcessingMetadata).toEqual({ - dynamicSamplingContext: { - environment: 'production', - public_key: 'username', - trace_id: outerTraceId, - sample_rate: '1', - transaction: 'outer transaction', - sampled: 'true', - sample_rand: expect.any(String), - }, - }); - }); - - // TODO: propagation scope is not picked up by spans... - - describe('onlyIfParent', () => { - it('does not create a span if there is no parent', () => { - const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { - return span; - }); - - expect(isSpan(span)).toBe(false); - }); - - it('creates a span if there is a parent', () => { - const span = startSpan({ name: 'parent span' }, () => { - const span = startSpan({ name: 'test span', onlyIfParent: true }, span => { - return span; - }); - - return span; - }); - - expect(isSpan(span)).toBe(true); - }); - }); - }); - - describe('startInactiveSpan', () => { - it('works at the root', () => { - const span = startInactiveSpan({ name: 'test' }); - - expect(span).toBeDefined(); - expect(getSpanName(span)).toEqual('test'); - expect(getSpanEndTime(span)).toEqual([0, 0]); - expect(getActiveSpan()).toBeUndefined(); - - span.end(); - - expect(getSpanEndTime(span)).not.toEqual([0, 0]); - expect(getActiveSpan()).toBeUndefined(); - }); - - it('works as a child span', () => { - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - expect(getActiveSpan()).toEqual(outerSpan); - - const innerSpan = startInactiveSpan({ name: 'test' }); - - expect(innerSpan).toBeDefined(); - expect(getSpanName(innerSpan)).toEqual('test'); - expect(getSpanEndTime(innerSpan)).toEqual([0, 0]); - expect(getActiveSpan()).toEqual(outerSpan); - - innerSpan.end(); - - expect(getSpanEndTime(innerSpan)).not.toEqual([0, 0]); - expect(getActiveSpan()).toEqual(outerSpan); - }); - }); - - it('allows to pass context arguments', () => { - const span = startInactiveSpan({ - name: 'outer', - }); - - expect(span).toBeDefined(); - expect(getSpanAttributes(span)).toEqual({ - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - }); - - const span2 = startInactiveSpan({ - name: 'outer', - op: 'my-op', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', - }, - }); - - expect(span2).toBeDefined(); - expect(getSpanAttributes(span2)).toEqual({ - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.test.origin', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'my-op', - }); - }); - - it('allows to pass base SpanOptions', () => { - const date = [5000, 0] as TimeInput; - - const span = startInactiveSpan({ - name: 'outer', - kind: SpanKind.CLIENT, - attributes: { - test1: 'test 1', - test2: 2, - }, - startTime: date, - }); - - expect(span).toBeDefined(); - expect(getSpanName(span)).toEqual('outer'); - expect(getSpanStartTime(span)).toEqual(date); - expect(getSpanAttributes(span)).toEqual({ - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - test1: 'test 1', - test2: 2, - }); - expect(getSpanKind(span)).toEqual(SpanKind.CLIENT); - }); - - it('allows to pass a startTime in seconds', () => { - const startTime = 1708504860.961; - const span = startInactiveSpan({ name: 'outer', startTime: startTime }); - - expect(getSpanStartTime(span)).toEqual([1708504860, 961000000]); - }); - - it('allows to pass a scope', () => { - const initialScope = getCurrentScope(); - - let manualScope: Scope; - - const parentSpan = startSpanManual({ name: 'detached' }, span => { - manualScope = getCurrentScope(); - manualScope.setTag('manual', 'tag'); - return span; - }); - - getCurrentScope().setTag('outer', 'tag'); - - const span = startInactiveSpan({ name: 'GET users/[id]', scope: manualScope! }); - expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId); - - expect(getCurrentScope()).toBe(initialScope); - expect(getActiveSpan()).toBe(undefined); - }); - - it('allows to pass a parentSpan', () => { - let parentSpan: Span; - - startSpanManual({ name: 'detached' }, span => { - parentSpan = span; - }); - - const span = startInactiveSpan({ name: 'GET users/[id]', parentSpan: parentSpan! }); - - expect(getActiveSpan()).toBe(undefined); - expect(spanToJSON(span).parent_span_id).toBe(parentSpan!.spanContext().spanId); - - expect(getActiveSpan()).toBe(undefined); - }); - - it('allows to pass parentSpan=null', () => { - startSpan({ name: 'outer' }, () => { - const span = startInactiveSpan({ name: 'test span', parentSpan: null }); - expect(spanToJSON(span).parent_span_id).toBe(undefined); - span.end(); - }); - }); - - it('allows to pass span links in span options', () => { - const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); - - // @ts-expect-error links exists on span - expect(rawSpan1?.links).toEqual([]); - - const rawSpan2 = startInactiveSpan({ - name: 'GET users/[id]', - links: [ - { - context: rawSpan1.spanContext(), - attributes: { 'sentry.link.type': 'previous_trace' }, - }, - ], - }); - - const span1JSON = spanToJSON(rawSpan1); - const span2JSON = spanToJSON(rawSpan2); - const span2LinkJSON = span2JSON.links?.[0]; - - expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); - expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); - expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); - - // sampling decision is inherited - expect(span2LinkJSON?.sampled).toBe(Boolean(spanToJSON(rawSpan1).data['sentry.sample_rate'])); - }); - - it('allows to force a transaction with forceTransaction=true', async () => { - const client = getClient()!; - const transactionEvents: Event[] = []; - - client.getOptions().beforeSendTransaction = event => { - transactionEvents.push({ - ...event, - sdkProcessingMetadata: { - dynamicSamplingContext: event.sdkProcessingMetadata?.dynamicSamplingContext, - }, - }); - return event; - }; - - startSpan({ name: 'outer transaction' }, () => { - startSpan({ name: 'inner span' }, () => { - const innerTransaction = startInactiveSpan({ name: 'inner transaction', forceTransaction: true }); - innerTransaction.end(); - }); - }); - - await client.flush(); - - const normalizedTransactionEvents = transactionEvents.map(event => { - return { - ...event, - spans: event.spans?.map(span => ({ name: span.description, id: span.span_id })), - }; - }); - - expect(normalizedTransactionEvents).toHaveLength(2); - - const outerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'outer transaction'); - const innerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'inner transaction'); - - const outerTraceId = outerTransaction?.contexts?.trace?.trace_id; - // The inner transaction should be a child of the last span of the outer transaction - const innerParentSpanId = outerTransaction?.spans?.[0]?.id; - const innerSpanId = innerTransaction?.contexts?.trace?.span_id; - - expect(outerTraceId).toBeDefined(); - expect(innerParentSpanId).toBeDefined(); - expect(innerSpanId).toBeDefined(); - // inner span ID should _not_ be the parent span ID, but the id of the new span - expect(innerSpanId).not.toEqual(innerParentSpanId); - - expect(outerTransaction?.contexts?.trace).toEqual({ - data: { - 'sentry.source': 'custom', - 'sentry.sample_rate': 1, - 'sentry.origin': 'manual', - }, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', - status: 'ok', - }); - expect(outerTransaction?.spans).toEqual([{ name: 'inner span', id: expect.any(String) }]); - expect(outerTransaction?.transaction).toEqual('outer transaction'); - expect(outerTransaction?.sdkProcessingMetadata).toEqual({ - dynamicSamplingContext: { - environment: 'production', - public_key: 'username', - trace_id: outerTraceId, - sample_rate: '1', - transaction: 'outer transaction', - sampled: 'true', - sample_rand: expect.any(String), - }, - }); - - expect(innerTransaction?.contexts?.trace).toEqual({ - data: { - 'sentry.source': 'custom', - 'sentry.origin': 'manual', - }, - parent_span_id: innerParentSpanId, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: outerTraceId, - origin: 'manual', - status: 'ok', - }); - expect(innerTransaction?.spans).toEqual([]); - expect(innerTransaction?.transaction).toEqual('inner transaction'); - expect(innerTransaction?.sdkProcessingMetadata).toEqual({ - dynamicSamplingContext: { - environment: 'production', - public_key: 'username', - trace_id: outerTraceId, - sample_rate: '1', - transaction: 'outer transaction', - sampled: 'true', - sample_rand: expect.any(String), - }, - }); - }); - - describe('onlyIfParent', () => { - it('does not create a span if there is no parent', () => { - const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); - - expect(isSpan(span)).toBe(false); - }); - - it('creates a span if there is a parent', () => { - const span = startSpan({ name: 'parent span' }, () => { - const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); - - return span; - }); - - expect(isSpan(span)).toBe(true); - }); - }); - - it('includes the scope at the time the span was started when finished', async () => { - const beforeSendTransaction = vi.fn(event => event); - - const client = getClient()!; - - client.getOptions().beforeSendTransaction = beforeSendTransaction; - - let span: Span; - - const scope = getCurrentScope(); - scope.setTag('outer', 'foo'); - - withScope(scope => { - scope.setTag('scope', 1); - span = startInactiveSpan({ name: 'my-span' }); - scope.setTag('scope_after_span', 2); - }); - - withScope(scope => { - scope.setTag('scope', 2); - span.end(); - }); - - await client.flush(); - - expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(beforeSendTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - tags: expect.objectContaining({ - outer: 'foo', - scope: 1, - scope_after_span: 2, - }), - }), - expect.anything(), - ); - }); - }); - - describe('startSpanManual', () => { - it('does not automatically finish the span', () => { - expect(getActiveSpan()).toEqual(undefined); - - let _outerSpan: Span | undefined; - let _innerSpan: Span | undefined; - - const res = startSpanManual({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - _outerSpan = outerSpan; - - expect(getSpanName(outerSpan)).toEqual('outer'); - expect(getActiveSpan()).toEqual(outerSpan); - - startSpanManual({ name: 'inner' }, innerSpan => { - expect(innerSpan).toBeDefined(); - _innerSpan = innerSpan; - - expect(getSpanName(innerSpan)).toEqual('inner'); - expect(getActiveSpan()).toEqual(innerSpan); - }); - - expect(getSpanEndTime(_innerSpan!)).toEqual([0, 0]); - - _innerSpan!.end(); - - expect(getSpanEndTime(_innerSpan!)).not.toEqual([0, 0]); - - return 'test value'; - }); - - expect(getSpanEndTime(_outerSpan!)).toEqual([0, 0]); - - _outerSpan!.end(); - - expect(getSpanEndTime(_outerSpan!)).not.toEqual([0, 0]); - - expect(res).toEqual('test value'); - - expect(getActiveSpan()).toEqual(undefined); - }); - - it('allows to pass base SpanOptions', () => { - const date = [5000, 0] as TimeInput; - - startSpanManual( - { - name: 'outer', - kind: SpanKind.CLIENT, - attributes: { - test1: 'test 1', - test2: 2, - }, - startTime: date, - }, - span => { - expect(span).toBeDefined(); - expect(getSpanName(span)).toEqual('outer'); - expect(getSpanStartTime(span)).toEqual(date); - expect(getSpanAttributes(span)).toEqual({ - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - test1: 'test 1', - test2: 2, - }); - expect(getSpanKind(span)).toEqual(SpanKind.CLIENT); - }, - ); - }); - - it('allows to pass a startTime in seconds', () => { - const startTime = 1708504860.961; - const start = startSpanManual({ name: 'outer', startTime: startTime }, span => { - const start = getSpanStartTime(span); - span.end(); - return start; - }); - - expect(start).toEqual([1708504860, 961000000]); - }); - - it('allows to pass a scope', () => { - const initialScope = getCurrentScope(); - - let manualScope: Scope; - let parentSpan: Span; - - startSpanManual({ name: 'detached' }, span => { - parentSpan = span; - manualScope = getCurrentScope(); - manualScope.setTag('manual', 'tag'); - }); - - getCurrentScope().setTag('outer', 'tag'); - - startSpanManual({ name: 'GET users/[id]', scope: manualScope! }, span => { - expect(getCurrentScope()).not.toBe(initialScope); - - expect(getCurrentScope()).toEqual(manualScope); - expect(getActiveSpan()).toBe(span); - - expect(getSpanParentSpanId(span)).toBe(parentSpan.spanContext().spanId); - - span.end(); - }); - - expect(getCurrentScope()).toBe(initialScope); - expect(getActiveSpan()).toBe(undefined); - }); - - it('allows to pass a parentSpan', () => { - let parentSpan: Span; - - startSpanManual({ name: 'detached' }, span => { - parentSpan = span; - }); - - startSpanManual({ name: 'GET users/[id]', parentSpan: parentSpan! }, span => { - expect(getActiveSpan()).toBe(span); - expect(spanToJSON(span).parent_span_id).toBe(parentSpan.spanContext().spanId); - - span.end(); - }); - - expect(getActiveSpan()).toBe(undefined); - }); - - it('allows to pass parentSpan=null', () => { - startSpan({ name: 'outer' }, () => { - startSpanManual({ name: 'GET users/[id]', parentSpan: null }, span => { - expect(spanToJSON(span).parent_span_id).toBe(undefined); - span.end(); - }); - }); - }); - - it('allows to add span links', () => { - const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); - - // @ts-expect-error links exists on span - expect(rawSpan1?.links).toEqual([]); - - const span1JSON = spanToJSON(rawSpan1); - - startSpanManual({ name: '/users/:id' }, rawSpan2 => { - rawSpan2.addLink({ - context: rawSpan1.spanContext(), - attributes: { - 'sentry.link.type': 'previous_trace', - }, - }); - - const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; - - expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); - expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); - expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); - }); - }); - - it('allows to pass span links in span options', () => { - const rawSpan1 = startInactiveSpan({ name: 'pageload_span' }); - - // @ts-expect-error links exists on span - expect(rawSpan1?.links).toEqual([]); - - const span1JSON = spanToJSON(rawSpan1); - - startSpanManual( - { - name: '/users/:id', - links: [ - { - context: rawSpan1.spanContext(), - attributes: { 'sentry.link.type': 'previous_trace' }, - }, - ], - }, - rawSpan2 => { - const span2LinkJSON = spanToJSON(rawSpan2).links?.[0]; - - expect(span2LinkJSON?.attributes?.['sentry.link.type']).toBe('previous_trace'); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(rawSpan1._spanContext.traceId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.traceId).toEqual(span1JSON.trace_id); - expect(span2LinkJSON?.trace_id).toBe(span1JSON.trace_id); - - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(rawSpan1?._spanContext.spanId); - // @ts-expect-error links and _spanContext exist on span - expect(rawSpan2?.links?.[0].context.spanId).toEqual(span1JSON.span_id); - expect(span2LinkJSON?.span_id).toBe(span1JSON.span_id); - }, - ); - }); - - it('allows to force a transaction with forceTransaction=true', async () => { - const client = getClient()!; - const transactionEvents: Event[] = []; - - client.getOptions().beforeSendTransaction = event => { - transactionEvents.push({ - ...event, - sdkProcessingMetadata: { - dynamicSamplingContext: event.sdkProcessingMetadata?.dynamicSamplingContext, - }, - }); - return event; - }; - - startSpanManual({ name: 'outer transaction' }, span => { - startSpanManual({ name: 'inner span' }, span => { - startSpanManual({ name: 'inner transaction', forceTransaction: true }, span => { - startSpanManual({ name: 'inner span 2' }, span => { - // all good - span.end(); - }); - span.end(); - }); - span.end(); - }); - span.end(); - }); - - await client.flush(); - - const normalizedTransactionEvents = transactionEvents.map(event => { - return { - ...event, - spans: event.spans?.map(span => ({ name: span.description, id: span.span_id })), - }; - }); - - expect(normalizedTransactionEvents).toHaveLength(2); - - const outerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'outer transaction'); - const innerTransaction = normalizedTransactionEvents.find(event => event.transaction === 'inner transaction'); - - const outerTraceId = outerTransaction?.contexts?.trace?.trace_id; - // The inner transaction should be a child of the last span of the outer transaction - const innerParentSpanId = outerTransaction?.spans?.[0]?.id; - const innerSpanId = innerTransaction?.contexts?.trace?.span_id; - - expect(outerTraceId).toBeDefined(); - expect(innerParentSpanId).toBeDefined(); - expect(innerSpanId).toBeDefined(); - // inner span ID should _not_ be the parent span ID, but the id of the new span - expect(innerSpanId).not.toEqual(innerParentSpanId); - - expect(outerTransaction?.contexts?.trace).toEqual({ - data: { - 'sentry.source': 'custom', - 'sentry.sample_rate': 1, - 'sentry.origin': 'manual', - }, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'manual', - status: 'ok', - }); - expect(outerTransaction?.spans).toEqual([{ name: 'inner span', id: expect.any(String) }]); - expect(outerTransaction?.transaction).toEqual('outer transaction'); - expect(outerTransaction?.sdkProcessingMetadata).toEqual({ - dynamicSamplingContext: { - environment: 'production', - public_key: 'username', - trace_id: outerTraceId, - sample_rate: '1', - transaction: 'outer transaction', - sampled: 'true', - sample_rand: expect.any(String), - }, - }); - - expect(innerTransaction?.contexts?.trace).toEqual({ - data: { - 'sentry.source': 'custom', - 'sentry.origin': 'manual', - }, - parent_span_id: innerParentSpanId, - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: outerTraceId, - origin: 'manual', - status: 'ok', - }); - expect(innerTransaction?.spans).toEqual([{ name: 'inner span 2', id: expect.any(String) }]); - expect(innerTransaction?.transaction).toEqual('inner transaction'); - expect(innerTransaction?.sdkProcessingMetadata).toEqual({ - dynamicSamplingContext: { - environment: 'production', - public_key: 'username', - trace_id: outerTraceId, - sample_rate: '1', - transaction: 'outer transaction', - sampled: 'true', - sample_rand: expect.any(String), - }, - }); - }); - - describe('onlyIfParent', () => { - it('does not create a span if there is no parent', () => { - const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { - return span; - }); - - expect(isSpan(span)).toBe(false); - }); - - it('creates a span if there is a parent', () => { - const span = startSpan({ name: 'parent span' }, () => { - const span = startSpanManual({ name: 'test span', onlyIfParent: true }, span => { - return span; - }); - - return span; - }); - - expect(isSpan(span)).toBe(true); - }); - }); - }); - - describe('propagation', () => { - it('starts new trace, if there is no parent', () => { - withScope(scope => { - const propagationContext = scope.getPropagationContext(); - const span = startInactiveSpan({ name: 'test span' }); - - expect(span).toBeDefined(); - const traceId = spanToJSON(span).trace_id; - expect(traceId).toMatch(/[a-f0-9]{32}/); - expect(spanToJSON(span).parent_span_id).toBe(undefined); - expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); - - expect(getDynamicSamplingContextFromSpan(span)).toEqual({ - trace_id: traceId, - environment: 'production', - public_key: 'username', - sample_rate: '1', - sampled: 'true', - transaction: 'test span', - sample_rand: expect.any(String), - }); - }); - }); - - // Note: This _should_ never happen, when we have an incoming trace, we should always have a parent span - it('starts new trace, ignoring parentSpanId, if there is no parent', () => { - withScope(scope => { - const propagationContext = scope.getPropagationContext(); - propagationContext.parentSpanId = '1121201211212012'; - const span = startInactiveSpan({ name: 'test span' }); - - expect(span).toBeDefined(); - const traceId = spanToJSON(span).trace_id; - expect(traceId).toMatch(/[a-f0-9]{32}/); - expect(spanToJSON(span).parent_span_id).toBe(undefined); - expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); - - expect(getDynamicSamplingContextFromSpan(span)).toEqual({ - environment: 'production', - public_key: 'username', - trace_id: traceId, - sample_rate: '1', - sampled: 'true', - transaction: 'test span', - sample_rand: expect.any(String), - }); - }); - }); - - it('picks up the trace context from the parent without DSC', () => { - withScope(scope => { - const propagationContext = scope.getPropagationContext(); - - startSpan({ name: 'parent span' }, parentSpan => { - const span = startInactiveSpan({ name: 'test span' }); - - expect(span).toBeDefined(); - expect(spanToJSON(span).trace_id).toEqual(parentSpan.spanContext().traceId); - expect(spanToJSON(span).parent_span_id).toEqual(parentSpan.spanContext().spanId); - expect(getDynamicSamplingContextFromSpan(span)).toEqual({ - ...getDynamicSamplingContextFromClient(propagationContext.traceId, getClient()!), - trace_id: parentSpan.spanContext().traceId, - transaction: 'parent span', - sampled: 'true', - sample_rate: '1', - sample_rand: expect.any(String), - }); - }); - }); - }); - - it('picks up the trace context from the parent with DSC', () => { - withScope(() => { - const ctx = trace.setSpanContext(ROOT_CONTEXT, { - traceId: '12312012123120121231201212312012', - spanId: '1121201211212012', - isRemote: false, - traceFlags: TraceFlags.SAMPLED, - traceState: makeTraceState({ - dsc: { - release: '1.0', - environment: 'production', - }, - }), - }); - - context.with(ctx, () => { - const span = startInactiveSpan({ name: 'test span' }); - - expect(span).toBeDefined(); - expect(spanToJSON(span).trace_id).toEqual('12312012123120121231201212312012'); - expect(spanToJSON(span).parent_span_id).toEqual('1121201211212012'); - expect(getDynamicSamplingContextFromSpan(span)).toEqual({ - release: '1.0', - environment: 'production', - }); - }); - }); - }); - - it('picks up the trace context from a remote parent', () => { - withScope(() => { - const ctx = trace.setSpanContext(ROOT_CONTEXT, { - traceId: '12312012123120121231201212312012', - spanId: '1121201211212012', - isRemote: true, - traceFlags: TraceFlags.SAMPLED, - traceState: makeTraceState({ - dsc: { - release: '1.0', - environment: 'production', - }, - }), - }); - - context.with(ctx, () => { - const span = startInactiveSpan({ name: 'test span' }); - - expect(span).toBeDefined(); - expect(spanToJSON(span).trace_id).toEqual('12312012123120121231201212312012'); - expect(spanToJSON(span).parent_span_id).toEqual('1121201211212012'); - expect(getDynamicSamplingContextFromSpan(span)).toEqual({ - release: '1.0', - environment: 'production', - }); - }); - }); - }); - }); -}); - -describe('trace (tracing disabled)', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 0 }); - }); - - afterEach(async () => { - await cleanupOtel(); - }); - - it('startSpan calls callback without span', () => { - const val = startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - expect(outerSpan.isRecording()).toBe(false); - - return 'test value'; - }); - - expect(val).toEqual('test value'); - }); - - it('startInactiveSpan returns a NonRecordinSpan', () => { - const span = startInactiveSpan({ name: 'test' }); - - expect(span).toBeDefined(); - expect(span.isRecording()).toBe(false); - }); -}); - -describe('trace (sampling)', () => { - afterEach(async () => { - await cleanupOtel(); - vi.clearAllMocks(); - }); - - it('samples with a tracesSampleRate, when Math.random() > tracesSampleRate', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.6); - - mockSdkInit({ tracesSampleRate: 0.5 }); - - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - expect(outerSpan.isRecording()).toBe(false); - - startSpan({ name: 'inner' }, innerSpan => { - expect(innerSpan).toBeDefined(); - expect(innerSpan.isRecording()).toBe(false); - }); - }); - }); - - it('samples with a tracesSampleRate, when Math.random() < tracesSampleRate', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.4); - - mockSdkInit({ tracesSampleRate: 0.5 }); - - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - expect(outerSpan.isRecording()).toBe(true); - // All fields are empty for NonRecordingSpan - expect(getSpanName(outerSpan)).toBe('outer'); - - startSpan({ name: 'inner' }, innerSpan => { - expect(innerSpan).toBeDefined(); - expect(innerSpan.isRecording()).toBe(true); - expect(getSpanName(innerSpan)).toBe('inner'); - }); - }); - }); - - it('positive parent sampling takes precedence over tracesSampleRate', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.6); - - mockSdkInit({ tracesSampleRate: 1 }); - - // This will def. be sampled because of the tracesSampleRate - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - expect(outerSpan.isRecording()).toBe(true); - expect(getSpanName(outerSpan)).toBe('outer'); - - // Now let's mutate the tracesSampleRate so that the next entry _should_ not be sampled - // but it will because of parent sampling - const client = getClient(); - client!.getOptions().tracesSampleRate = 0.5; - - startSpan({ name: 'inner' }, innerSpan => { - expect(innerSpan).toBeDefined(); - expect(innerSpan.isRecording()).toBe(true); - expect(getSpanName(innerSpan)).toBe('inner'); - }); - }); - }); - - it('negative parent sampling takes precedence over tracesSampleRate', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.6); - - mockSdkInit({ tracesSampleRate: 0.5 }); - - // This will def. be unsampled because of the tracesSampleRate - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - expect(outerSpan.isRecording()).toBe(false); - - // Now let's mutate the tracesSampleRate so that the next entry _should_ be sampled - // but it will remain unsampled because of parent sampling - const client = getClient(); - client!.getOptions().tracesSampleRate = 1; - - startSpan({ name: 'inner' }, innerSpan => { - expect(innerSpan).toBeDefined(); - expect(innerSpan.isRecording()).toBe(false); - }); - }); - }); - - it('positive remote parent sampling takes precedence over tracesSampleRate', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.6); - - mockSdkInit({ tracesSampleRate: 0.5 }); - - const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; - const parentSpanId = '6e0c63257de34c92'; - - const spanContext = { - traceId, - spanId: parentSpanId, - sampled: true, - isRemote: true, - traceFlags: TraceFlags.SAMPLED, - }; - - // We simulate the correct context we'd normally get from the SentryPropagator - context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { - // This will def. be sampled because of the tracesSampleRate - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - expect(outerSpan.isRecording()).toBe(true); - expect(getSpanName(outerSpan)).toBe('outer'); - }); - }); - }); - - it('negative remote parent sampling takes precedence over tracesSampleRate', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.6); - - mockSdkInit({ tracesSampleRate: 0.5 }); - - const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; - const parentSpanId = '6e0c63257de34c92'; - - const spanContext = { - traceId, - spanId: parentSpanId, - sampled: false, - isRemote: true, - traceFlags: TraceFlags.NONE, - }; - - // We simulate the correct context we'd normally get from the SentryPropagator - context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { - // This will def. be sampled because of the tracesSampleRate - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - expect(outerSpan.isRecording()).toBe(false); - }); - }); - }); - - it('samples with a tracesSampler returning a boolean', () => { - let tracesSamplerResponse: boolean = true; - - const tracesSampler = vi.fn(() => { - return tracesSamplerResponse; - }); - - mockSdkInit({ tracesSampler }); - - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan).toBeDefined(); - }); - - expect(tracesSampler).toBeCalledTimes(1); - expect(tracesSampler).toHaveBeenLastCalledWith({ - parentSampled: undefined, - name: 'outer', - attributes: {}, - inheritOrSampleWith: expect.any(Function), - }); - - // Now return `false`, it should not sample - tracesSamplerResponse = false; - - startSpan({ name: 'outer2' }, outerSpan => { - expect(outerSpan.isRecording()).toBe(false); - - startSpan({ name: 'inner2' }, innerSpan => { - expect(innerSpan.isRecording()).toBe(false); - }); - }); - - expect(tracesSampler).toHaveBeenCalledTimes(2); - expect(tracesSampler).toHaveBeenCalledWith( - expect.objectContaining({ - parentSampled: undefined, - name: 'outer', - attributes: {}, - }), - ); - expect(tracesSampler).toHaveBeenCalledWith( - expect.objectContaining({ - parentSampled: undefined, - name: 'outer2', - attributes: {}, - }), - ); - - // Only root spans should go through the sampler - expect(tracesSampler).not.toHaveBeenLastCalledWith({ - name: 'inner2', - }); - }); - - it('samples with a tracesSampler returning a number', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.6); - - let tracesSamplerResponse: number = 1; - - const tracesSampler = vi.fn(() => { - return tracesSamplerResponse; - }); - - mockSdkInit({ tracesSampler }); - - startSpan( - { - name: 'outer', - op: 'test.op', - attributes: { attr1: 'yes', attr2: 1 }, - }, - outerSpan => { - expect(outerSpan).toBeDefined(); - }, - ); - - expect(tracesSampler).toHaveBeenCalledTimes(1); - expect(tracesSampler).toHaveBeenLastCalledWith({ - parentSampled: undefined, - name: 'outer', - attributes: { - attr1: 'yes', - attr2: 1, - 'sentry.op': 'test.op', - }, - inheritOrSampleWith: expect.any(Function), - }); - - // Now return `0`, it should not sample - tracesSamplerResponse = 0; - - startSpan({ name: 'outer2' }, outerSpan => { - expect(outerSpan.isRecording()).toBe(false); - - startSpan({ name: 'inner2' }, innerSpan => { - expect(innerSpan.isRecording()).toBe(false); - }); - }); - - expect(tracesSampler).toHaveBeenCalledTimes(2); - expect(tracesSampler).toHaveBeenCalledWith( - expect.objectContaining({ - parentSampled: undefined, - name: 'outer2', - attributes: {}, - }), - ); - - // Only root spans should be passed to tracesSampler - expect(tracesSampler).not.toHaveBeenLastCalledWith( - expect.objectContaining({ - name: 'inner2', - }), - ); - - // Now return `0.4`, it should not sample - tracesSamplerResponse = 0.4; - - startSpan({ name: 'outer3' }, outerSpan => { - expect(outerSpan.isRecording()).toBe(false); - }); - - expect(tracesSampler).toHaveBeenCalledTimes(3); - expect(tracesSampler).toHaveBeenLastCalledWith({ - parentSampled: undefined, - name: 'outer3', - attributes: {}, - inheritOrSampleWith: expect.any(Function), - }); - }); - - it('samples with a tracesSampler even if parent is remotely sampled', () => { - const tracesSampler = vi.fn(() => { - return false; - }); - - mockSdkInit({ tracesSampler }); - const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; - const parentSpanId = '6e0c63257de34c92'; - - const spanContext = { - traceId, - spanId: parentSpanId, - sampled: true, - isRemote: true, - traceFlags: TraceFlags.SAMPLED, - }; - - // We simulate the correct context we'd normally get from the SentryPropagator - context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { - // This will def. be sampled because of the tracesSampleRate - startSpan({ name: 'outer' }, outerSpan => { - expect(outerSpan.isRecording()).toBe(false); - }); - }); - - expect(tracesSampler).toBeCalledTimes(1); - expect(tracesSampler).toHaveBeenLastCalledWith({ - parentSampled: true, - name: 'outer', - attributes: {}, - inheritOrSampleWith: expect.any(Function), - }); - }); - - it('ignores parent span context if it is invalid', () => { - mockSdkInit({ tracesSampleRate: 1 }); - const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; - - const spanContext = { - traceId, - spanId: 'INVALID', - traceFlags: TraceFlags.SAMPLED, - }; - - context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { - startSpan({ name: 'outer' }, span => { - expect(span.isRecording()).toBe(true); - expect(span.spanContext().spanId).not.toBe('INVALID'); - expect(span.spanContext().spanId).toMatch(/[a-f0-9]{16}/); - expect(span.spanContext().traceId).not.toBe(traceId); - expect(span.spanContext().traceId).toMatch(/[a-f0-9]{32}/); - }); - }); - }); -}); - -describe('HTTP methods (sampling)', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - afterEach(async () => { - await cleanupOtel(); - }); - - it('does sample when HTTP method is other than OPTIONS or HEAD', () => { - const spanGET = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'GET' } }, span => { - return span; - }); - expect(spanIsSampled(spanGET)).toBe(true); - expect(getSamplingDecision(spanGET.spanContext())).toBe(true); - - const spanPOST = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'POST' } }, span => { - return span; - }); - expect(spanIsSampled(spanPOST)).toBe(true); - expect(getSamplingDecision(spanPOST.spanContext())).toBe(true); - - const spanPUT = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'PUT' } }, span => { - return span; - }); - expect(spanIsSampled(spanPUT)).toBe(true); - expect(getSamplingDecision(spanPUT.spanContext())).toBe(true); - - const spanDELETE = startSpanManual( - { name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'DELETE' } }, - span => { - return span; - }, - ); - expect(spanIsSampled(spanDELETE)).toBe(true); - expect(getSamplingDecision(spanDELETE.spanContext())).toBe(true); - }); - - it('does not sample when HTTP method is OPTIONS', () => { - const span = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'OPTIONS' } }, span => { - return span; - }); - expect(spanIsSampled(span)).toBe(false); - expect(getSamplingDecision(span.spanContext())).toBe(false); - }); - - it('does not sample when HTTP method is HEAD', () => { - const span = startSpanManual({ name: 'test span', attributes: { [SEMATTRS_HTTP_METHOD]: 'HEAD' } }, span => { - return span; - }); - expect(spanIsSampled(span)).toBe(false); - expect(getSamplingDecision(span.spanContext())).toBe(false); - }); -}); - -describe('continueTrace', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - afterEach(async () => { - await cleanupOtel(); - }); - - it('works without trace & baggage data', () => { - const scope = continueTrace({ sentryTrace: undefined, baggage: undefined }, () => { - const span = getActiveSpan()!; - expect(span).toBeUndefined(); - return getCurrentScope(); - }); - - expect(scope.getPropagationContext()).toEqual({ - traceId: expect.any(String), - sampleRand: expect.any(Number), - }); - - expect(scope.getScopeData().sdkProcessingMetadata).toEqual({}); - }); - - it('works with trace data', () => { - continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-0', - baggage: undefined, - }, - () => { - const span = getActiveSpan()!; - expect(span).toBeDefined(); - expect(spanToJSON(span)).toEqual({ - span_id: '1121201211212012', - trace_id: '12312012123120121231201212312012', - data: {}, - start_timestamp: 0, - }); - expect(getSamplingDecision(span.spanContext())).toBe(false); - expect(spanIsSampled(span)).toBe(false); - }, - ); - }); - - it('works with trace & baggage data', () => { - continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-1', - baggage: 'sentry-version=1.0,sentry-environment=production', - }, - () => { - const span = getActiveSpan()!; - expect(span).toBeDefined(); - expect(spanToJSON(span)).toEqual({ - span_id: '1121201211212012', - trace_id: '12312012123120121231201212312012', - data: {}, - start_timestamp: 0, - }); - expect(getSamplingDecision(span.spanContext())).toBe(true); - expect(spanIsSampled(span)).toBe(true); - }, - ); - }); - - it('works with trace & 3rd party baggage data', () => { - continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-1', - baggage: 'sentry-version=1.0,sentry-environment=production,dogs=great,cats=boring', - }, - () => { - const span = getActiveSpan()!; - expect(span).toBeDefined(); - expect(spanToJSON(span)).toEqual({ - span_id: '1121201211212012', - trace_id: '12312012123120121231201212312012', - data: {}, - start_timestamp: 0, - }); - expect(getSamplingDecision(span.spanContext())).toBe(true); - expect(spanIsSampled(span)).toBe(true); - }, - ); - }); - - it('returns response of callback', () => { - const result = continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-0', - baggage: undefined, - }, - () => { - return 'aha'; - }, - ); - - expect(result).toEqual('aha'); - }); -}); - -describe('suppressTracing', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - afterEach(async () => { - await cleanupOtel(); - }); - - it('works for a root span', () => { - const span = suppressTracing(() => { - return startInactiveSpan({ name: 'span' }); - }); - - expect(span.isRecording()).toBe(false); - expect(spanIsSampled(span)).toBe(false); - }); - - it('works for a child span', () => { - startSpan({ name: 'outer' }, span => { - expect(span.isRecording()).toBe(true); - expect(spanIsSampled(span)).toBe(true); - - const child1 = startInactiveSpan({ name: 'inner1' }); - - expect(child1.isRecording()).toBe(true); - expect(spanIsSampled(child1)).toBe(true); - - const child2 = suppressTracing(() => { - return startInactiveSpan({ name: 'span' }); - }); - - expect(child2.isRecording()).toBe(false); - expect(spanIsSampled(child2)).toBe(false); - }); - }); - - it('works for a child span with forceTransaction=true', () => { - startSpan({ name: 'outer' }, span => { - expect(span.isRecording()).toBe(true); - expect(spanIsSampled(span)).toBe(true); - - const child = suppressTracing(() => { - return startInactiveSpan({ name: 'span', forceTransaction: true }); - }); - - expect(child.isRecording()).toBe(false); - expect(spanIsSampled(child)).toBe(false); - }); - }); -}); - -function getSpanName(span: AbstractSpan): string | undefined { - return spanHasName(span) ? span.name : undefined; -} - -function getSpanEndTime(span: AbstractSpan): [number, number] | undefined { - return (span as ReadableSpan).endTime; -} - -function getSpanStartTime(span: AbstractSpan): [number, number] | undefined { - return (span as ReadableSpan).startTime; -} - -function getSpanAttributes(span: AbstractSpan): Record | undefined { - return spanHasAttributes(span) ? span.attributes : undefined; -} - -function getSpanParentSpanId(span: AbstractSpan): string | undefined { - return getParentSpanId(span as ReadableSpan); -} diff --git a/dev-packages/opentelemetry-v2-tests/test/tsconfig.json b/dev-packages/opentelemetry-v2-tests/test/tsconfig.json deleted file mode 100644 index 38ca0b13bcdd..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../tsconfig.test.json" -} diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts deleted file mode 100644 index c91e49ea5f84..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/getActiveSpan.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { trace } from '@opentelemetry/api'; -import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { getRootSpan } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getActiveSpan } from '../../../../packages/opentelemetry/src/utils/getActiveSpan'; -import { setupOtel } from '../helpers/initOtel'; -import { cleanupOtel } from '../helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -describe('getActiveSpan', () => { - let provider: BasicTracerProvider | undefined; - - beforeEach(() => { - const client = new TestClient(getDefaultTestClientOptions()); - [provider] = setupOtel(client); - }); - - afterEach(() => { - cleanupOtel(provider); - }); - - it('returns undefined if no span is active', () => { - const span = getActiveSpan(); - expect(span).toBeUndefined(); - }); - - it('returns undefined if no provider is active', async () => { - await provider?.forceFlush(); - await provider?.shutdown(); - provider = undefined; - - const span = getActiveSpan(); - expect(span).toBeUndefined(); - }); - - it('returns currently active span', () => { - const tracer = trace.getTracer('test'); - - expect(getActiveSpan()).toBeUndefined(); - - tracer.startActiveSpan('test', span => { - expect(getActiveSpan()).toBe(span); - - const inner1 = tracer.startSpan('inner1'); - - expect(getActiveSpan()).toBe(span); - - inner1.end(); - - tracer.startActiveSpan('inner2', inner2 => { - expect(getActiveSpan()).toBe(inner2); - - inner2.end(); - }); - - expect(getActiveSpan()).toBe(span); - - span.end(); - }); - - expect(getActiveSpan()).toBeUndefined(); - }); - - it('returns currently active span in concurrent spans', () => { - const tracer = trace.getTracer('test'); - - expect(getActiveSpan()).toBeUndefined(); - - tracer.startActiveSpan('test1', span => { - expect(getActiveSpan()).toBe(span); - - tracer.startActiveSpan('inner1', inner1 => { - expect(getActiveSpan()).toBe(inner1); - inner1.end(); - }); - - span.end(); - }); - - tracer.startActiveSpan('test2', span => { - expect(getActiveSpan()).toBe(span); - - tracer.startActiveSpan('inner2', inner => { - expect(getActiveSpan()).toBe(inner); - inner.end(); - }); - - span.end(); - }); - - expect(getActiveSpan()).toBeUndefined(); - }); -}); - -describe('getRootSpan', () => { - let provider: BasicTracerProvider | undefined; - - beforeEach(() => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - [provider] = setupOtel(client); - }); - - afterEach(async () => { - await provider?.forceFlush(); - await provider?.shutdown(); - }); - - it('returns currently active root span', () => { - const tracer = trace.getTracer('test'); - - tracer.startActiveSpan('test', span => { - expect(getRootSpan(span)).toBe(span); - - const inner1 = tracer.startSpan('inner1'); - - expect(getRootSpan(inner1)).toBe(span); - - inner1.end(); - - tracer.startActiveSpan('inner2', inner2 => { - expect(getRootSpan(inner2)).toBe(span); - - inner2.end(); - }); - - span.end(); - }); - }); - - it('returns currently active root span in concurrent spans', () => { - const tracer = trace.getTracer('test'); - - tracer.startActiveSpan('test1', span => { - expect(getRootSpan(span)).toBe(span); - - tracer.startActiveSpan('inner1', inner1 => { - expect(getRootSpan(inner1)).toBe(span); - inner1.end(); - }); - - span.end(); - }); - - tracer.startActiveSpan('test2', span => { - expect(getRootSpan(span)).toBe(span); - - tracer.startActiveSpan('inner2', inner => { - expect(getRootSpan(inner)).toBe(span); - inner.end(); - }); - - span.end(); - }); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts deleted file mode 100644 index 3f0914b6afb7..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/getRequestSpanData.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* eslint-disable deprecation/deprecation */ -import type { Span } from '@opentelemetry/api'; -import { trace } from '@opentelemetry/api'; -import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { SEMATTRS_HTTP_METHOD, SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { getRequestSpanData } from '../../../../packages/opentelemetry/src/utils/getRequestSpanData'; -import { setupOtel } from '../helpers/initOtel'; -import { cleanupOtel } from '../helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -describe('getRequestSpanData', () => { - let provider: BasicTracerProvider | undefined; - - beforeEach(() => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - [provider] = setupOtel(client); - }); - - afterEach(() => { - cleanupOtel(provider); - }); - - function createSpan(name: string): Span { - return trace.getTracer('test').startSpan(name); - } - - it('works with basic span', () => { - const span = createSpan('test-span'); - const data = getRequestSpanData(span); - - expect(data).toEqual({}); - }); - - it('works with http span', () => { - const span = createSpan('test-span'); - span.setAttributes({ - [SEMATTRS_HTTP_URL]: 'http://example.com?foo=bar#baz', - [SEMATTRS_HTTP_METHOD]: 'GET', - }); - - const data = getRequestSpanData(span); - - expect(data).toEqual({ - url: 'http://example.com', - 'http.method': 'GET', - 'http.query': '?foo=bar', - 'http.fragment': '#baz', - }); - }); - - it('works without method', () => { - const span = createSpan('test-span'); - span.setAttributes({ - [SEMATTRS_HTTP_URL]: 'http://example.com', - }); - - const data = getRequestSpanData(span); - - expect(data).toEqual({ - url: 'http://example.com', - 'http.method': 'GET', - }); - }); - - it('works with incorrect URL', () => { - const span = createSpan('test-span'); - span.setAttributes({ - [SEMATTRS_HTTP_URL]: 'malformed-url-here', - [SEMATTRS_HTTP_METHOD]: 'GET', - }); - - const data = getRequestSpanData(span); - - expect(data).toEqual({ - url: 'malformed-url-here', - 'http.method': 'GET', - }); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts deleted file mode 100644 index 16dacdafe8ee..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/getSpanKind.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Span } from '@opentelemetry/api'; -import { SpanKind } from '@opentelemetry/api'; -import { describe, expect, it } from 'vitest'; -import { getSpanKind } from '../../../../packages/opentelemetry/src/utils/getSpanKind'; - -describe('getSpanKind', () => { - it('works', () => { - expect(getSpanKind({} as Span)).toBe(SpanKind.INTERNAL); - expect(getSpanKind({ kind: SpanKind.CLIENT } as unknown as Span)).toBe(SpanKind.CLIENT); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts deleted file mode 100644 index 136b6251523d..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/getTraceData.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { context, trace } from '@opentelemetry/api'; -import { getCurrentScope, setAsyncContextStrategy } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getTraceData } from '../../../../packages/opentelemetry/src/utils/getTraceData'; -import { makeTraceState } from '../../../../packages/opentelemetry/src/utils/makeTraceState'; -import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; - -describe('getTraceData', () => { - beforeEach(() => { - setAsyncContextStrategy(undefined); - mockSdkInit(); - }); - - afterEach(async () => { - await cleanupOtel(); - vi.clearAllMocks(); - }); - - it('returns the tracing data from the span, if a span is available', () => { - const ctx = trace.setSpanContext(context.active(), { - traceId: '12345678901234567890123456789012', - spanId: '1234567890123456', - traceFlags: 1, - }); - - context.with(ctx, () => { - const data = getTraceData(); - - expect(data).toEqual({ - 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', - baggage: - 'sentry-environment=production,sentry-public_key=username,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', - }); - }); - }); - - it('allows to pass a span directly', () => { - const ctx = trace.setSpanContext(context.active(), { - traceId: '12345678901234567890123456789012', - spanId: '1234567890123456', - traceFlags: 1, - }); - - const span = trace.getSpan(ctx)!; - - const data = getTraceData({ span }); - - expect(data).toEqual({ - 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', - baggage: - 'sentry-environment=production,sentry-public_key=username,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', - }); - }); - - it('returns propagationContext DSC data if no span is available', () => { - getCurrentScope().setPropagationContext({ - traceId: '12345678901234567890123456789012', - sampleRand: Math.random(), - sampled: true, - dsc: { - environment: 'staging', - public_key: 'key', - trace_id: '12345678901234567890123456789012', - }, - }); - - const traceData = getTraceData(); - - expect(traceData['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); - expect(traceData.baggage).toEqual( - 'sentry-environment=staging,sentry-public_key=key,sentry-trace_id=12345678901234567890123456789012', - ); - }); - - it('works with an span with frozen DSC in traceState', () => { - const ctx = trace.setSpanContext(context.active(), { - traceId: '12345678901234567890123456789012', - spanId: '1234567890123456', - traceFlags: 1, - traceState: makeTraceState({ - dsc: { environment: 'test-dev', public_key: '456', trace_id: '12345678901234567890123456789088' }, - }), - }); - - context.with(ctx, () => { - const data = getTraceData(); - - expect(data).toEqual({ - 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', - baggage: 'sentry-environment=test-dev,sentry-public_key=456,sentry-trace_id=12345678901234567890123456789088', - }); - }); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts deleted file mode 100644 index 87d7daa4a43a..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/groupSpansWithParents.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { trace } from '@opentelemetry/api'; -import type { BasicTracerProvider, ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import type { Span } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { withActiveSpan } from '../../../../packages/opentelemetry/src/trace'; -import { groupSpansWithParents } from '../../../../packages/opentelemetry/src/utils/groupSpansWithParents'; -import { setupOtel } from '../helpers/initOtel'; -import { cleanupOtel } from '../helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -describe('groupSpansWithParents', () => { - let provider: BasicTracerProvider | undefined; - - beforeEach(() => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - [provider] = setupOtel(client); - }); - - afterEach(() => { - cleanupOtel(provider); - }); - - it('works with no spans', () => { - const actual = groupSpansWithParents([]); - expect(actual).toEqual([]); - }); - - it('works with a single root span & in-order spans', () => { - const tracer = trace.getTracer('test'); - const rootSpan = tracer.startSpan('root') as unknown as ReadableSpan; - const parentSpan1 = withActiveSpan( - rootSpan as unknown as Span, - () => tracer.startSpan('parent1') as unknown as ReadableSpan, - ); - const parentSpan2 = withActiveSpan( - rootSpan as unknown as Span, - () => tracer.startSpan('parent2') as unknown as ReadableSpan, - ); - const child1 = withActiveSpan( - parentSpan1 as unknown as Span, - () => tracer.startSpan('child1') as unknown as ReadableSpan, - ); - - const actual = groupSpansWithParents([rootSpan, parentSpan1, parentSpan2, child1]); - expect(actual).toHaveLength(4); - - // Ensure parent & span is correctly set - const rootRef = actual.find(ref => ref.span === rootSpan); - const parent1Ref = actual.find(ref => ref.span === parentSpan1); - const parent2Ref = actual.find(ref => ref.span === parentSpan2); - const child1Ref = actual.find(ref => ref.span === child1); - - expect(rootRef).toBeDefined(); - expect(parent1Ref).toBeDefined(); - expect(parent2Ref).toBeDefined(); - expect(child1Ref).toBeDefined(); - - expect(rootRef?.parentNode).toBeUndefined(); - expect(rootRef?.children).toEqual([parent1Ref, parent2Ref]); - - expect(parent1Ref?.span).toBe(parentSpan1); - expect(parent2Ref?.span).toBe(parentSpan2); - - expect(parent1Ref?.parentNode).toBe(rootRef); - expect(parent2Ref?.parentNode).toBe(rootRef); - - expect(parent1Ref?.children).toEqual([child1Ref]); - expect(parent2Ref?.children).toEqual([]); - - expect(child1Ref?.parentNode).toBe(parent1Ref); - expect(child1Ref?.children).toEqual([]); - }); - - it('works with a spans with missing root span', () => { - const tracer = trace.getTracer('test'); - - // We create this root span here, but we do not pass it to `groupSpansWithParents` below - const rootSpan = tracer.startSpan('root') as unknown as ReadableSpan; - const parentSpan1 = withActiveSpan( - rootSpan as unknown as Span, - () => tracer.startSpan('parent1') as unknown as ReadableSpan, - ); - const parentSpan2 = withActiveSpan( - rootSpan as unknown as Span, - () => tracer.startSpan('parent2') as unknown as ReadableSpan, - ); - const child1 = withActiveSpan( - parentSpan1 as unknown as Span, - () => tracer.startSpan('child1') as unknown as ReadableSpan, - ); - - const actual = groupSpansWithParents([parentSpan1, parentSpan2, child1]); - expect(actual).toHaveLength(4); - - // Ensure parent & span is correctly set - const rootRef = actual.find(ref => ref.id === rootSpan.spanContext().spanId); - const parent1Ref = actual.find(ref => ref.span === parentSpan1); - const parent2Ref = actual.find(ref => ref.span === parentSpan2); - const child1Ref = actual.find(ref => ref.span === child1); - - expect(rootRef).toBeDefined(); - expect(parent1Ref).toBeDefined(); - expect(parent2Ref).toBeDefined(); - expect(child1Ref).toBeDefined(); - - expect(rootRef?.parentNode).toBeUndefined(); - expect(rootRef?.span).toBeUndefined(); - expect(rootRef?.children).toEqual([parent1Ref, parent2Ref]); - - expect(parent1Ref?.span).toBe(parentSpan1); - expect(parent2Ref?.span).toBe(parentSpan2); - - expect(parent1Ref?.parentNode).toBe(rootRef); - expect(parent2Ref?.parentNode).toBe(rootRef); - - expect(parent1Ref?.children).toEqual([child1Ref]); - expect(parent2Ref?.children).toEqual([]); - - expect(child1Ref?.parentNode).toBe(parent1Ref); - expect(child1Ref?.children).toEqual([]); - }); - - it('works with multiple root spans & out-of-order spans', () => { - const tracer = trace.getTracer('test'); - const rootSpan1 = tracer.startSpan('root1') as unknown as ReadableSpan; - const rootSpan2 = tracer.startSpan('root2') as unknown as ReadableSpan; - const parentSpan1 = withActiveSpan( - rootSpan1 as unknown as Span, - () => tracer.startSpan('parent1') as unknown as ReadableSpan, - ); - const parentSpan2 = withActiveSpan( - rootSpan2 as unknown as Span, - () => tracer.startSpan('parent2') as unknown as ReadableSpan, - ); - const childSpan1 = withActiveSpan( - parentSpan1 as unknown as Span, - () => tracer.startSpan('child1') as unknown as ReadableSpan, - ); - - const actual = groupSpansWithParents([childSpan1, parentSpan1, parentSpan2, rootSpan2, rootSpan1]); - expect(actual).toHaveLength(5); - - // Ensure parent & span is correctly set - const root1Ref = actual.find(ref => ref.span === rootSpan1); - const root2Ref = actual.find(ref => ref.span === rootSpan2); - const parent1Ref = actual.find(ref => ref.span === parentSpan1); - const parent2Ref = actual.find(ref => ref.span === parentSpan2); - const child1Ref = actual.find(ref => ref.span === childSpan1); - - expect(root1Ref).toBeDefined(); - expect(root2Ref).toBeDefined(); - expect(parent1Ref).toBeDefined(); - expect(parent2Ref).toBeDefined(); - expect(child1Ref).toBeDefined(); - - expect(root1Ref?.parentNode).toBeUndefined(); - expect(root1Ref?.children).toEqual([parent1Ref]); - - expect(root2Ref?.parentNode).toBeUndefined(); - expect(root2Ref?.children).toEqual([parent2Ref]); - - expect(parent1Ref?.span).toBe(parentSpan1); - expect(parent2Ref?.span).toBe(parentSpan2); - - expect(parent1Ref?.parentNode).toBe(root1Ref); - expect(parent2Ref?.parentNode).toBe(root2Ref); - - expect(parent1Ref?.children).toEqual([child1Ref]); - expect(parent2Ref?.children).toEqual([]); - - expect(child1Ref?.parentNode).toBe(parent1Ref); - expect(child1Ref?.children).toEqual([]); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts deleted file mode 100644 index b479da0d61ad..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/mapStatus.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* eslint-disable deprecation/deprecation */ -import type { Span } from '@opentelemetry/api'; -import { trace } from '@opentelemetry/api'; -import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { SEMATTRS_HTTP_STATUS_CODE, SEMATTRS_RPC_GRPC_STATUS_CODE } from '@opentelemetry/semantic-conventions'; -import type { SpanStatus } from '@sentry/core'; -import { SPAN_STATUS_ERROR, SPAN_STATUS_OK } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { mapStatus } from '../../../../packages/opentelemetry/src/utils/mapStatus'; -import { setupOtel } from '../helpers/initOtel'; -import { cleanupOtel } from '../helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -describe('mapStatus', () => { - let provider: BasicTracerProvider | undefined; - - beforeEach(() => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - [provider] = setupOtel(client); - }); - - afterEach(() => { - cleanupOtel(provider); - }); - - function createSpan(name: string): Span { - return trace.getTracer('test').startSpan(name); - } - - const statusTestTable: [undefined | number | string, undefined | string, SpanStatus][] = [ - // http codes - [400, undefined, { code: SPAN_STATUS_ERROR, message: 'invalid_argument' }], - [401, undefined, { code: SPAN_STATUS_ERROR, message: 'unauthenticated' }], - [403, undefined, { code: SPAN_STATUS_ERROR, message: 'permission_denied' }], - [404, undefined, { code: SPAN_STATUS_ERROR, message: 'not_found' }], - [409, undefined, { code: SPAN_STATUS_ERROR, message: 'already_exists' }], - [429, undefined, { code: SPAN_STATUS_ERROR, message: 'resource_exhausted' }], - [499, undefined, { code: SPAN_STATUS_ERROR, message: 'cancelled' }], - [500, undefined, { code: SPAN_STATUS_ERROR, message: 'internal_error' }], - [501, undefined, { code: SPAN_STATUS_ERROR, message: 'unimplemented' }], - [503, undefined, { code: SPAN_STATUS_ERROR, message: 'unavailable' }], - [504, undefined, { code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }], - [999, undefined, { code: SPAN_STATUS_ERROR, message: 'unknown_error' }], - - // grpc codes - [undefined, '1', { code: SPAN_STATUS_ERROR, message: 'cancelled' }], - [undefined, '2', { code: SPAN_STATUS_ERROR, message: 'unknown_error' }], - [undefined, '3', { code: SPAN_STATUS_ERROR, message: 'invalid_argument' }], - [undefined, '4', { code: SPAN_STATUS_ERROR, message: 'deadline_exceeded' }], - [undefined, '5', { code: SPAN_STATUS_ERROR, message: 'not_found' }], - [undefined, '6', { code: SPAN_STATUS_ERROR, message: 'already_exists' }], - [undefined, '7', { code: SPAN_STATUS_ERROR, message: 'permission_denied' }], - [undefined, '8', { code: SPAN_STATUS_ERROR, message: 'resource_exhausted' }], - [undefined, '9', { code: SPAN_STATUS_ERROR, message: 'failed_precondition' }], - [undefined, '10', { code: SPAN_STATUS_ERROR, message: 'aborted' }], - [undefined, '11', { code: SPAN_STATUS_ERROR, message: 'out_of_range' }], - [undefined, '12', { code: SPAN_STATUS_ERROR, message: 'unimplemented' }], - [undefined, '13', { code: SPAN_STATUS_ERROR, message: 'internal_error' }], - [undefined, '14', { code: SPAN_STATUS_ERROR, message: 'unavailable' }], - [undefined, '15', { code: SPAN_STATUS_ERROR, message: 'data_loss' }], - [undefined, '16', { code: SPAN_STATUS_ERROR, message: 'unauthenticated' }], - [undefined, '999', { code: SPAN_STATUS_ERROR, message: 'unknown_error' }], - - // http takes precedence over grpc - [400, '2', { code: SPAN_STATUS_ERROR, message: 'invalid_argument' }], - ]; - - it.each(statusTestTable)('works with httpCode=%s, grpcCode=%s', (httpCode, grpcCode, expected) => { - const span = createSpan('test-span'); - span.setStatus({ code: 0 }); // UNSET - - if (httpCode) { - span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, httpCode); - } - - if (grpcCode) { - span.setAttribute(SEMATTRS_RPC_GRPC_STATUS_CODE, grpcCode); - } - - const actual = mapStatus(span); - expect(actual).toEqual(expected); - }); - - it('works with string SEMATTRS_HTTP_STATUS_CODE', () => { - const span = createSpan('test-span'); - - span.setStatus({ code: 0 }); // UNSET - span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, '400'); - - const actual = mapStatus(span); - expect(actual).toEqual({ code: SPAN_STATUS_ERROR, message: 'invalid_argument' }); - }); - - it('returns ok span status when is UNSET present on span', () => { - const span = createSpan('test-span'); - span.setStatus({ code: 0 }); // UNSET - expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_OK }); - }); - - it('returns ok span status when already present on span', () => { - const span = createSpan('test-span'); - span.setStatus({ code: 1 }); // OK - expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_OK }); - }); - - it('returns error status when span already has error status', () => { - const span = createSpan('test-span'); - span.setStatus({ code: 2, message: 'invalid_argument' }); // ERROR - expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'invalid_argument' }); - }); - - it('returns error status when span already has error status without message', () => { - const span = createSpan('test-span'); - span.setStatus({ code: 2 }); // ERROR - expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'unknown_error' }); - }); - - it('infers error status form attributes when span already has error status without message', () => { - const span = createSpan('test-span'); - span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, 500); - span.setStatus({ code: 2 }); // ERROR - expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - }); - - it('returns unknown error status when code is unknown', () => { - const span = createSpan('test-span'); - span.setStatus({ code: -1 as 0 }); - expect(mapStatus(span)).toEqual({ code: SPAN_STATUS_ERROR, message: 'unknown_error' }); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts deleted file mode 100644 index 56d50a3b2fbc..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/parseSpanDescription.test.ts +++ /dev/null @@ -1,690 +0,0 @@ -/* eslint-disable deprecation/deprecation */ -import type { Span } from '@opentelemetry/api'; -import { SpanKind } from '@opentelemetry/api'; -import { - ATTR_HTTP_ROUTE, - SEMATTRS_DB_STATEMENT, - SEMATTRS_DB_SYSTEM, - SEMATTRS_FAAS_TRIGGER, - SEMATTRS_HTTP_HOST, - SEMATTRS_HTTP_METHOD, - SEMATTRS_HTTP_STATUS_CODE, - SEMATTRS_HTTP_TARGET, - SEMATTRS_HTTP_URL, - SEMATTRS_MESSAGING_SYSTEM, - SEMATTRS_RPC_SERVICE, -} from '@opentelemetry/semantic-conventions'; -import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import { describe, expect, it } from 'vitest'; -import { - descriptionForHttpMethod, - getSanitizedUrl, - getUserUpdatedNameAndSource, - parseSpanDescription, -} from '../../../../packages/opentelemetry/src/utils/parseSpanDescription'; - -describe('parseSpanDescription', () => { - it.each([ - [ - 'works without attributes & name', - undefined, - undefined, - undefined, - { - description: '', - op: undefined, - source: 'custom', - }, - ], - [ - 'works with empty attributes', - {}, - 'test name', - SpanKind.CLIENT, - { - description: 'test name', - op: undefined, - source: 'custom', - }, - ], - [ - 'works with deprecated http method', - { - [SEMATTRS_HTTP_METHOD]: 'GET', - }, - 'test name', - SpanKind.CLIENT, - { - description: 'test name', - op: 'http.client', - source: 'custom', - }, - ], - [ - 'works with http method', - { - 'http.request.method': 'GET', - }, - 'test name', - SpanKind.CLIENT, - { - description: 'test name', - op: 'http.client', - source: 'custom', - }, - ], - [ - 'works with db system', - { - [SEMATTRS_DB_SYSTEM]: 'mysql', - [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', - }, - 'test name', - SpanKind.CLIENT, - { - description: 'SELECT * from users', - op: 'db', - source: 'task', - }, - ], - [ - 'works with db system and custom source', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMATTRS_DB_SYSTEM]: 'mysql', - [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', - }, - 'test name', - SpanKind.CLIENT, - { - description: 'test name', - op: 'db', - source: 'custom', - }, - ], - [ - 'works with db system and custom source and custom name', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMATTRS_DB_SYSTEM]: 'mysql', - [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - SpanKind.CLIENT, - { - description: 'custom name', - op: 'db', - source: 'custom', - }, - ], - [ - 'works with db system and component source and custom name', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMATTRS_DB_SYSTEM]: 'mysql', - [SEMATTRS_DB_STATEMENT]: 'SELECT * from users', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - SpanKind.CLIENT, - { - description: 'custom name', - op: 'db', - source: 'component', - }, - ], - [ - 'works with db system without statement', - { - [SEMATTRS_DB_SYSTEM]: 'mysql', - }, - 'test name', - SpanKind.CLIENT, - { - description: 'test name', - op: 'db', - source: 'task', - }, - ], - [ - 'works with rpc service', - { - [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', - }, - 'test name', - undefined, - { - description: 'test name', - op: 'rpc', - source: 'route', - }, - ], - [ - 'works with rpc service and custom source', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', - }, - 'test name', - undefined, - { - description: 'test name', - op: 'rpc', - source: 'custom', - }, - ], - [ - 'works with rpc service and custom source and custom name', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - undefined, - { - description: 'custom name', - op: 'rpc', - source: 'custom', - }, - ], - [ - 'works with rpc service and component source and custom name', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMATTRS_RPC_SERVICE]: 'rpc-test-service', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - undefined, - { - description: 'custom name', - op: 'rpc', - source: 'component', - }, - ], - [ - 'works with messaging system', - { - [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', - }, - 'test name', - undefined, - { - description: 'test name', - op: 'message', - source: 'route', - }, - ], - [ - 'works with messaging system and custom source', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', - }, - 'test name', - undefined, - { - description: 'test name', - op: 'message', - source: 'custom', - }, - ], - [ - 'works with messaging system and custom source and custom name', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - undefined, - { - description: 'custom name', - op: 'message', - source: 'custom', - }, - ], - [ - 'works with messaging system and component source and custom name', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMATTRS_MESSAGING_SYSTEM]: 'test-messaging-system', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - undefined, - { - description: 'custom name', - op: 'message', - source: 'component', - }, - ], - [ - 'works with faas trigger', - { - [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', - }, - 'test name', - undefined, - { - description: 'test name', - op: 'test-faas-trigger', - source: 'route', - }, - ], - [ - 'works with faas trigger and custom source', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', - }, - 'test name', - undefined, - { - description: 'test name', - op: 'test-faas-trigger', - source: 'custom', - }, - ], - [ - 'works with faas trigger and custom source and custom name', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - undefined, - { - description: 'custom name', - op: 'test-faas-trigger', - source: 'custom', - }, - ], - [ - 'works with faas trigger and component source and custom name', - { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMATTRS_FAAS_TRIGGER]: 'test-faas-trigger', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - undefined, - { - description: 'custom name', - op: 'test-faas-trigger', - source: 'component', - }, - ], - ])('%s', (_, attributes, name, kind, expected) => { - const actual = parseSpanDescription({ attributes, kind, name } as unknown as Span); - expect(actual).toEqual(expected); - }); -}); - -describe('descriptionForHttpMethod', () => { - it.each([ - [ - 'works without attributes', - 'GET', - {}, - 'test name', - SpanKind.CLIENT, - { - op: 'http.client', - description: 'test name', - source: 'custom', - }, - ], - [ - 'works with basic client GET', - 'GET', - { - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', - [SEMATTRS_HTTP_TARGET]: '/my-path', - }, - 'test name', - SpanKind.CLIENT, - { - op: 'http.client', - description: 'GET https://www.example.com/my-path', - data: { - url: 'https://www.example.com/my-path', - }, - source: 'url', - }, - ], - [ - 'works with prefetch request', - 'GET', - { - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', - [SEMATTRS_HTTP_TARGET]: '/my-path', - 'sentry.http.prefetch': true, - }, - 'test name', - SpanKind.CLIENT, - { - op: 'http.client.prefetch', - description: 'GET https://www.example.com/my-path', - data: { - url: 'https://www.example.com/my-path', - }, - source: 'url', - }, - ], - [ - 'works with basic server POST', - 'POST', - { - [SEMATTRS_HTTP_METHOD]: 'POST', - [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', - [SEMATTRS_HTTP_TARGET]: '/my-path', - }, - 'test name', - SpanKind.SERVER, - { - op: 'http.server', - description: 'POST /my-path', - data: { - url: 'https://www.example.com/my-path', - }, - source: 'url', - }, - ], - [ - 'works with client GET with route', - 'GET', - { - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', - [SEMATTRS_HTTP_TARGET]: '/my-path/123', - [ATTR_HTTP_ROUTE]: '/my-path/:id', - }, - 'test name', - SpanKind.CLIENT, - { - op: 'http.client', - description: 'GET /my-path/:id', - data: { - url: 'https://www.example.com/my-path/123', - }, - source: 'route', - }, - ], - [ - 'works with basic client GET with SpanKind.INTERNAL', - 'GET', - { - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path', - [SEMATTRS_HTTP_TARGET]: '/my-path', - }, - 'test name', - SpanKind.INTERNAL, - { - op: 'http', - description: 'test name', - data: { - url: 'https://www.example.com/my-path', - }, - source: 'custom', - }, - ], - [ - "doesn't overwrite span name with source custom", - 'GET', - { - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', - [SEMATTRS_HTTP_TARGET]: '/my-path/123', - [ATTR_HTTP_ROUTE]: '/my-path/:id', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - }, - 'test name', - SpanKind.CLIENT, - { - op: 'http.client', - description: 'test name', - data: { - url: 'https://www.example.com/my-path/123', - }, - source: 'custom', - }, - ], - [ - 'takes user-passed span name (with source custom)', - 'GET', - { - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', - [SEMATTRS_HTTP_TARGET]: '/my-path/123', - [ATTR_HTTP_ROUTE]: '/my-path/:id', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'custom', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - SpanKind.CLIENT, - { - op: 'http.client', - description: 'custom name', - data: { - url: 'https://www.example.com/my-path/123', - }, - source: 'custom', - }, - ], - [ - 'takes user-passed span name (with source component)', - 'GET', - { - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_URL]: 'https://www.example.com/my-path/123', - [SEMATTRS_HTTP_TARGET]: '/my-path/123', - [ATTR_HTTP_ROUTE]: '/my-path/:id', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - }, - 'test name', - SpanKind.CLIENT, - { - op: 'http.client', - description: 'custom name', - data: { - url: 'https://www.example.com/my-path/123', - }, - source: 'component', - }, - ], - ])('%s', (_, httpMethod, attributes, name, kind, expected) => { - const actual = descriptionForHttpMethod({ attributes, kind, name }, httpMethod); - expect(actual).toEqual(expected); - }); -}); - -describe('getSanitizedUrl', () => { - it.each([ - [ - 'works without attributes', - {}, - SpanKind.CLIENT, - { - urlPath: undefined, - url: undefined, - fragment: undefined, - query: undefined, - hasRoute: false, - }, - ], - [ - 'uses url without query for client request', - { - [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_TARGET]: '/?what=true', - [SEMATTRS_HTTP_HOST]: 'example.com:80', - [SEMATTRS_HTTP_STATUS_CODE]: 200, - }, - SpanKind.CLIENT, - { - urlPath: 'http://example.com/', - url: 'http://example.com/', - fragment: undefined, - query: '?what=true', - hasRoute: false, - }, - ], - [ - 'uses url without hash for client request', - { - [SEMATTRS_HTTP_URL]: 'http://example.com/sub#hash', - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_TARGET]: '/sub#hash', - [SEMATTRS_HTTP_HOST]: 'example.com:80', - [SEMATTRS_HTTP_STATUS_CODE]: 200, - }, - SpanKind.CLIENT, - { - urlPath: 'http://example.com/sub', - url: 'http://example.com/sub', - fragment: '#hash', - query: undefined, - hasRoute: false, - }, - ], - [ - 'uses route if available for client request', - { - [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_TARGET]: '/?what=true', - [ATTR_HTTP_ROUTE]: '/my-route', - [SEMATTRS_HTTP_HOST]: 'example.com:80', - [SEMATTRS_HTTP_STATUS_CODE]: 200, - }, - SpanKind.CLIENT, - { - urlPath: '/my-route', - url: 'http://example.com/', - fragment: undefined, - query: '?what=true', - hasRoute: true, - }, - ], - [ - 'falls back to target for client request if url not available', - { - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_TARGET]: '/?what=true', - [SEMATTRS_HTTP_HOST]: 'example.com:80', - [SEMATTRS_HTTP_STATUS_CODE]: 200, - }, - SpanKind.CLIENT, - { - urlPath: '/', - url: undefined, - fragment: undefined, - query: undefined, - hasRoute: false, - }, - ], - [ - 'uses target without query for server request', - { - [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_TARGET]: '/?what=true', - [SEMATTRS_HTTP_HOST]: 'example.com:80', - [SEMATTRS_HTTP_STATUS_CODE]: 200, - }, - SpanKind.SERVER, - { - urlPath: '/', - url: 'http://example.com/', - fragment: undefined, - query: '?what=true', - hasRoute: false, - }, - ], - [ - 'uses target without hash for server request', - { - [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_TARGET]: '/sub#hash', - [SEMATTRS_HTTP_HOST]: 'example.com:80', - [SEMATTRS_HTTP_STATUS_CODE]: 200, - }, - SpanKind.SERVER, - { - urlPath: '/sub', - url: 'http://example.com/', - fragment: undefined, - query: '?what=true', - hasRoute: false, - }, - ], - [ - 'uses route for server request if available', - { - [SEMATTRS_HTTP_URL]: 'http://example.com/?what=true', - [SEMATTRS_HTTP_METHOD]: 'GET', - [SEMATTRS_HTTP_TARGET]: '/?what=true', - [ATTR_HTTP_ROUTE]: '/my-route', - [SEMATTRS_HTTP_HOST]: 'example.com:80', - [SEMATTRS_HTTP_STATUS_CODE]: 200, - }, - SpanKind.SERVER, - { - urlPath: '/my-route', - url: 'http://example.com/', - fragment: undefined, - query: '?what=true', - hasRoute: true, - }, - ], - ])('%s', (_, attributes, kind, expected) => { - const actual = getSanitizedUrl(attributes, kind); - - expect(actual).toEqual(expected); - }); -}); - -describe('getUserUpdatedNameAndSource', () => { - it('returns param name if `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute is not set', () => { - expect(getUserUpdatedNameAndSource('base name', {})).toEqual({ description: 'base name', source: 'custom' }); - }); - - it('returns param name with custom fallback source if `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute is not set', () => { - expect(getUserUpdatedNameAndSource('base name', {}, 'route')).toEqual({ - description: 'base name', - source: 'route', - }); - }); - - it('returns param name if `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute is not a string', () => { - expect(getUserUpdatedNameAndSource('base name', { [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 123 })).toEqual({ - description: 'base name', - source: 'custom', - }); - }); - - it.each(['custom', 'task', 'url', 'route'])( - 'returns `SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME` attribute if is a string and source is %s', - source => { - expect( - getUserUpdatedNameAndSource('base name', { - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: 'custom name', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, - }), - ).toEqual({ - description: 'custom name', - source, - }); - }, - ); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts deleted file mode 100644 index 8f453bb9792c..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/setupCheck.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { SentrySampler } from '../../../../packages/opentelemetry/src/sampler'; -import { SentrySpanProcessor } from '../../../../packages/opentelemetry/src/spanProcessor'; -import { openTelemetrySetupCheck } from '../../../../packages/opentelemetry/src/utils/setupCheck'; -import { setupOtel } from '../helpers/initOtel'; -import { cleanupOtel } from '../helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -describe('openTelemetrySetupCheck', () => { - let provider: BasicTracerProvider | undefined; - - beforeEach(() => { - cleanupOtel(provider); - }); - - afterEach(() => { - cleanupOtel(provider); - }); - - it('returns empty array by default', () => { - const setup = openTelemetrySetupCheck(); - expect(setup).toEqual([]); - }); - - it('returns all setup parts', () => { - const client = new TestClient(getDefaultTestClientOptions()); - [provider] = setupOtel(client); - - const setup = openTelemetrySetupCheck(); - expect(setup).toEqual(['SentrySpanProcessor', 'SentrySampler', 'SentryPropagator', 'SentryContextManager']); - }); - - it('returns partial setup parts', () => { - const client = new TestClient(getDefaultTestClientOptions()); - provider = new BasicTracerProvider({ - sampler: new SentrySampler(client), - spanProcessors: [new SentrySpanProcessor()], - }); - - const setup = openTelemetrySetupCheck(); - expect(setup).toEqual(['SentrySampler', 'SentrySpanProcessor']); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts deleted file mode 100644 index fbf6e1b69991..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/setupEventContextTrace.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { captureException, setCurrentClient } from '@sentry/core'; -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setupEventContextTrace } from '../../../../packages/opentelemetry/src/setupEventContextTrace'; -import { setupOtel } from '../helpers/initOtel'; -import { cleanupOtel } from '../helpers/mockSdkInit'; -import type { TestClientInterface } from '../helpers/TestClient'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -const PUBLIC_DSN = 'https://username@domain/123'; - -describe('setupEventContextTrace', () => { - const beforeSend = vi.fn(() => null); - let client: TestClientInterface; - let provider: BasicTracerProvider | undefined; - - beforeEach(() => { - client = new TestClient( - getDefaultTestClientOptions({ - sampleRate: 1, - tracesSampleRate: 1, - beforeSend, - debug: true, - dsn: PUBLIC_DSN, - }), - ); - - setCurrentClient(client); - client.init(); - - setupEventContextTrace(client); - [provider] = setupOtel(client); - }); - - afterEach(() => { - beforeSend.mockReset(); - cleanupOtel(provider); - }); - - afterAll(() => { - vi.clearAllMocks(); - }); - - it('works with no active span', async () => { - const error = new Error('test'); - captureException(error); - await client.flush(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }, - }), - }), - expect.objectContaining({ - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }), - ); - }); - - it('works with active span', async () => { - const error = new Error('test'); - - let outerId: string | undefined; - let innerId: string | undefined; - let traceId: string | undefined; - - client.tracer.startActiveSpan('outer', outerSpan => { - outerId = outerSpan.spanContext().spanId; - traceId = outerSpan.spanContext().traceId; - - client.tracer.startActiveSpan('inner', innerSpan => { - innerId = innerSpan.spanContext().spanId; - captureException(error); - }); - }); - - await client.flush(); - - expect(outerId).toBeDefined(); - expect(innerId).toBeDefined(); - expect(traceId).toBeDefined(); - - expect(beforeSend).toHaveBeenCalledTimes(1); - expect(beforeSend).toHaveBeenCalledWith( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: { - span_id: innerId, - parent_span_id: outerId, - trace_id: traceId, - }, - }), - }), - expect.objectContaining({ - event_id: expect.any(String), - originalException: error, - syntheticException: expect.any(Error), - }), - ); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts deleted file mode 100644 index c1f9fe2a18c7..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/spanToJSON.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Span, SpanOptions } from '@opentelemetry/api'; -import { trace } from '@opentelemetry/api'; -import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - spanToJSON, -} from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { setupOtel } from '../helpers/initOtel'; -import { cleanupOtel } from '../helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -describe('spanToJSON', () => { - describe('OpenTelemetry Span', () => { - let provider: BasicTracerProvider | undefined; - - beforeEach(() => { - const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - [provider] = setupOtel(client); - }); - - afterEach(() => { - cleanupOtel(provider); - }); - - function createSpan(name: string, params?: SpanOptions): Span { - return trace.getTracer('test').startSpan(name, params); - } - - it('works with a simple span', () => { - const span = createSpan('test span', { startTime: [123, 0] }); - - expect(spanToJSON(span)).toEqual({ - span_id: span.spanContext().spanId, - trace_id: span.spanContext().traceId, - start_timestamp: 123, - description: 'test span', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - }, - }); - }); - - it('works with a full span', () => { - const span = createSpan('test span', { startTime: [123, 0] }); - - span.setAttributes({ - attr1: 'value1', - attr2: 2, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', - }); - - span.setStatus({ code: 2, message: 'unknown_error' }); - span.end([456, 0]); - - expect(spanToJSON(span)).toEqual({ - span_id: span.spanContext().spanId, - trace_id: span.spanContext().traceId, - start_timestamp: 123, - timestamp: 456, - description: 'test span', - op: 'test op', - origin: 'auto', - data: { - attr1: 'value1', - attr2: 2, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - }, - status: 'unknown_error', - }); - }); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts b/dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts deleted file mode 100644 index 00c9eccdf98e..000000000000 --- a/dev-packages/opentelemetry-v2-tests/test/utils/spanTypes.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Span } from '@opentelemetry/api'; -import { describe, expect, it } from 'vitest'; -import { - spanHasAttributes, - spanHasEvents, - spanHasKind, - spanHasParentId, -} from '../../../../packages/opentelemetry/src/utils/spanTypes'; - -describe('spanTypes', () => { - describe('spanHasAttributes', () => { - it.each([ - [{}, false], - [{ attributes: null }, false], - [{ attributes: {} }, true], - ])('works with %p', (span, expected) => { - const castSpan = span as unknown as Span; - const actual = spanHasAttributes(castSpan); - - expect(actual).toBe(expected); - - if (actual) { - expect(castSpan.attributes).toBeDefined(); - } - }); - }); - - describe('spanHasKind', () => { - it.each([ - [{}, false], - [{ kind: null }, false], - [{ kind: 0 }, true], - [{ kind: 5 }, true], - [{ kind: 'TEST_KIND' }, false], - ])('works with %p', (span, expected) => { - const castSpan = span as unknown as Span; - const actual = spanHasKind(castSpan); - - expect(actual).toBe(expected); - - if (actual) { - expect(castSpan.kind).toBeDefined(); - } - }); - }); - - describe('spanHasParentId', () => { - it.each([ - [{}, false], - [{ parentSpanId: null }, false], - [{ parentSpanId: 'TEST_PARENT_ID' }, true], - ])('works with %p', (span, expected) => { - const castSpan = span as unknown as Span; - const actual = spanHasParentId(castSpan); - - expect(actual).toBe(expected); - - if (actual) { - expect(castSpan.parentSpanId).toBeDefined(); - } - }); - }); - - describe('spanHasEvents', () => { - it.each([ - [{}, false], - [{ events: null }, false], - [{ events: [] }, true], - ])('works with %p', (span, expected) => { - const castSpan = span as unknown as Span; - const actual = spanHasEvents(castSpan); - - expect(actual).toBe(expected); - - if (actual) { - expect(castSpan.events).toBeDefined(); - } - }); - }); -}); diff --git a/dev-packages/opentelemetry-v2-tests/tsconfig.json b/dev-packages/opentelemetry-v2-tests/tsconfig.json deleted file mode 100644 index b9f9b425c7df..000000000000 --- a/dev-packages/opentelemetry-v2-tests/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./build", - "types": ["node", "vitest/globals"] - }, - "include": ["test/**/*", "vite.config.ts"] -} diff --git a/dev-packages/opentelemetry-v2-tests/tsconfig.test.json b/dev-packages/opentelemetry-v2-tests/tsconfig.test.json deleted file mode 100644 index ca7dbeb3be94..000000000000 --- a/dev-packages/opentelemetry-v2-tests/tsconfig.test.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - - "include": ["test/**/*", "vite.config.ts"], - - "compilerOptions": { - // should include all types from `./tsconfig.json` plus types for all test frameworks used - "types": ["node"] - - // other package-specific, test-specific options - } -} diff --git a/dev-packages/opentelemetry-v2-tests/vite.config.ts b/dev-packages/opentelemetry-v2-tests/vite.config.ts deleted file mode 100644 index d7ea407dfac7..000000000000 --- a/dev-packages/opentelemetry-v2-tests/vite.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import baseConfig from '../../vite/vite.config'; - -export default { - ...baseConfig, - test: { - ...baseConfig.test, - coverage: { - enabled: false, - }, - }, -}; diff --git a/dev-packages/rollup-utils/bundleHelpers.mjs b/dev-packages/rollup-utils/bundleHelpers.mjs index f80b0b7c2e50..1099cb6b6549 100644 --- a/dev-packages/rollup-utils/bundleHelpers.mjs +++ b/dev-packages/rollup-utils/bundleHelpers.mjs @@ -90,32 +90,6 @@ export function makeBaseBundleConfig(options) { plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin], }; - // used by `@sentry/aws-serverless`, when creating the lambda layer - const awsLambdaBundleConfig = { - output: { - format: 'cjs', - }, - plugins: [ - jsonPlugin, - commonJSPlugin, - // Temporary fix for the lambda layer SDK bundle. - // This is necessary to apply to our lambda layer bundle because calling `new ImportInTheMiddle()` will throw an - // that `ImportInTheMiddle` is not a constructor. Instead we modify the code to call `new ImportInTheMiddle.default()` - // TODO: Remove this plugin once the weird import-in-the-middle exports are fixed, released and we use the respective - // version in our SDKs. See: https://github.com/getsentry/sentry-javascript/issues/12009#issuecomment-2126211967 - { - name: 'aws-serverless-lambda-layer-fix', - transform: code => { - if (code.includes('ImportInTheMiddle')) { - return code.replaceAll(/new\s+(ImportInTheMiddle.*)\(/gm, 'new $1.default('); - } - }, - }, - ], - // Don't bundle any of Node's core modules - external: builtinModules, - }; - const workerBundleConfig = { output: { format: 'esm', @@ -143,7 +117,6 @@ export function makeBaseBundleConfig(options) { const bundleTypeConfigMap = { standalone: standAloneBundleConfig, addon: addOnBundleConfig, - 'aws-lambda': awsLambdaBundleConfig, 'node-worker': workerBundleConfig, }; diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 30a70a3a837c..40e679cf0e25 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -43,6 +43,9 @@ "peerDependencies": { "@playwright/test": "~1.53.2" }, + "dependencies": { + "express": "^4.21.1" + }, "devDependencies": { "@playwright/test": "~1.53.2", "@sentry/core": "9.40.0" diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 6cfbe61d9306..e9ae76f592ed 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -10,3 +10,4 @@ export { } from './event-proxy-server'; export { getPlaywrightConfig } from './playwright-config'; +export { createBasicSentryServer } from './server'; diff --git a/dev-packages/test-utils/src/server.ts b/dev-packages/test-utils/src/server.ts new file mode 100644 index 000000000000..b8941b4b0c32 --- /dev/null +++ b/dev-packages/test-utils/src/server.ts @@ -0,0 +1,39 @@ +import type { Envelope } from '@sentry/core'; +import { parseEnvelope } from '@sentry/core'; +import express from 'express'; +import type { AddressInfo } from 'net'; + +/** + * Creates a basic Sentry server that accepts POST to the envelope endpoint + * + * This does no checks on the envelope, it just calls the callback if it managed to parse an envelope from the raw POST + * body data. + */ +export function createBasicSentryServer(onEnvelope: (env: Envelope) => void): Promise<[number, () => void]> { + const app = express(); + + app.use(express.raw({ type: () => true, inflate: true, limit: '100mb' })); + app.post('/api/:id/envelope/', (req, res) => { + try { + const env = parseEnvelope(req.body as Buffer); + onEnvelope(env); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + + res.status(200).send(); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + const address = server.address() as AddressInfo; + resolve([ + address.port, + () => { + server.close(); + }, + ]); + }); + }); +} diff --git a/dev-packages/test-utils/tsconfig.json b/dev-packages/test-utils/tsconfig.json index 7b0fa87fc45b..43f50e435628 100644 --- a/dev-packages/test-utils/tsconfig.json +++ b/dev-packages/test-utils/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../../tsconfig.json", "compilerOptions": { "target": "ES2022", - "module": "ES2022" + "module": "ES2022", + // package-specific options + "esModuleInterop": true, }, "include": ["src/**/*.ts"] } diff --git a/docs/migration/v8-to-v9.md b/docs/migration/v8-to-v9.md new file mode 100644 index 000000000000..46223d201928 --- /dev/null +++ b/docs/migration/v8-to-v9.md @@ -0,0 +1,534 @@ +# Deprecations in 9.x + +## Deprecated `@sentry/core` SDK internal `logger` export + +The internal SDK `logger` export from `@sentry/core` has been deprecated in favor of the `debug` export. `debug` only exposes `log`, `warn`, and `error` methods but is otherwise identical to `logger`. Note that this deprecation does not affect the `logger` export from other packages (like `@sentry/browser` or `@sentry/node`) which is used for Sentry Logging. + +```js +import { logger, debug } from '@sentry/core'; + +// before +logger.info('This is an info message'); + +// after +debug.log('This is an info message'); +``` + +# Upgrading from 8.x to 9.x + +Version 9 of the Sentry JavaScript SDK primarily introduces API cleanup and version support changes. +This update contains behavioral changes that will not be caught by type checkers, linters, or tests, so we recommend carefully reading through the entire migration guide instead of relying on automatic tooling. + +Version 9 of the SDK is compatible with Sentry self-hosted versions 24.4.2 or higher (unchanged from v8). +Lower versions may continue to work, but may not support all features. + +## 1. Version Support Changes: + +Version 9 of the Sentry SDK has new compatibility ranges for runtimes and frameworks. + +### General Runtime Support Changes + +**ECMAScript Version:** All the JavaScript code in the Sentry SDK packages may now contain ECMAScript 2020 features. +This includes features like Nullish Coalescing (`??`), Optional Chaining (`?.`), `String.matchAll()`, Logical Assignment Operators (`&&=`, `||=`, `??=`), and `Promise.allSettled()`. + +If you observe failures due to syntax or features listed above, it may indicate that your current runtime does not support ES2020. +If your runtime does not support ES2020, we recommend transpiling the SDK using Babel or similar tooling. + +**Node.js:** The minimum supported Node.js version is **18.0.0** (Released Apr 19, 2022), except for ESM-only SDKs (`@sentry/astro`, `@sentry/nuxt`, `@sentry/sveltekit`) which require Node.js version **18.19.1** (Released Feb 14, 2024) or higher. + +**Browsers:** Due to SDK code now including ES2020 features, the minimum supported browser list now looks as follows: + +- Chrome 80 (Released Feb 5, 2020) +- Edge 80 (Released Feb 7, 2020) +- Safari 14, iOS Safari 14.4 (Released Sep 16, 2020) +- Firefox 74 (Released Mar 10, 2020) +- Opera 67 (Released Mar 12, 2020) +- Samsung Internet 13.0 (Released Nov 20, 2020) + +If you need to support older browsers, we recommend transpiling your code using SWC, Babel or similar tooling. + +**Deno:** The minimum supported Deno version is now **2.0.0**. + +### Framework and Library Support Changes + +Support for the following frameworks and library versions are dropped: + +- **Remix**: Version `1.x` +- **TanStack Router**: Version `1.63.0` and lower (relevant when using `tanstackRouterBrowserTracingIntegration`) +- **SvelteKit**: Version `1.x` +- **Ember.js**: Version `3.x` and lower (minimum supported version is `4.x`) +- **Prisma**: Version `5.x` + +### TypeScript Version Policy + +In preparation for v2 of the OpenTelemetry SDK, the minimum required TypeScript version is increased to version `5.0.4`. + +Additionally, like the OpenTelemetry SDK, the Sentry JavaScript SDK will follow [DefinitelyType's version support policy](https://github.com/DefinitelyTyped/DefinitelyTyped#support-window) which has a support time frame of 2 years for any released version of TypeScript. + +Older TypeScript versions _may_ continue to be compatible, but no guarantees apply. + +### AWS Lambda Layer Changes + +A new AWS Lambda Layer for version 9 will be published as `SentryNodeServerlessSDKv9`. +The ARN will be published in the [Sentry docs](https://docs.sentry.io/platforms/javascript/guides/aws-lambda/install/cjs-layer/) once available. + +The previous `SentryNodeServerlessSDK` layer will not receive new updates anymore. + +Updates and fixes for version 8 will be published as `SentryNodeServerlessSDKv8`. +The ARN will be published in the [Sentry docs](https://docs.sentry.io/platforms/javascript/guides/aws-lambda/install/cjs-layer/) once available. + +## 2. Behavior Changes + +### `@sentry/core` / All SDKs + +- Dropping spans in the `beforeSendSpan` hook is no longer possible. + This means you can no longer return `null` from the `beforeSendSpan` hook. + This hook is intended to be used to add additional data to spans or remove unwanted attributes (for example for PII stripping). + To control which spans are recorded, we recommend configuring [integrations](https://docs.sentry.io/platforms/javascript/configuration/integrations/) instead. + +- The `beforeSendSpan` hook now receives the root span as well as the child spans. + We recommend checking your `beforeSendSpan` to account for this change. + +- The `request` property on the `samplingContext` argument passed to the `tracesSampler` and `profilesSampler` options has been removed. + `samplingContext.normalizedRequest` can be used instead. + Note that the type of `normalizedRequest` differs from `request`. + +- The `startSpan` behavior was changed if you pass a custom `scope`: + While in v8, the passed scope was set active directly on the passed scope, in v9, the scope is cloned. This behavior change does not apply to `@sentry/node` where the scope was already cloned. + This change was made to ensure that the span only remains active within the callback and to align behavior between `@sentry/node` and all other SDKs. + As a result of the change, span hierarchy should be more accurate. + However, modifying the scope (for example, setting tags) within the `startSpan` callback behaves a bit differently now. + + ```js + startSpan({ name: 'example', scope: customScope }, () => { + getCurrentScope().setTag('tag-a', 'a'); // this tag will only remain within the callback + // set the tag directly on customScope in addition, if you want to to persist the tag outside of the callback + customScope.setTag('tag-a', 'a'); + }); + ``` + +- Passing `undefined` as a `tracesSampleRate` option value will now be treated the same as if the attribute was not defined at all. + In previous versions, it was checked whether the `tracesSampleRate` property existed in the SDK options to decide if trace data should be propagated for tracing. + Consequentially, this sometimes caused the SDK to propagate negative sampling decisions when `tracesSampleRate: undefined` was passed. + This is no longer the case and sampling decisions will be deferred to downstream SDKs for distributed tracing. + This is more of a bugfix rather than a breaking change, however, depending on the setup of your SDKs, an increase in sampled traces may be observed. + +- If you use the optional `captureConsoleIntegration` and set `attachStackTrace: true` in your `Sentry.init` call, console messages will no longer be marked as unhandled (`handled: false`) but as handled (`handled: true`). + If you want to keep sending them as unhandled, configure the `handled` option when adding the integration: + + ```js + Sentry.init({ + integrations: [Sentry.captureConsoleIntegration({ handled: false })], + attachStackTrace: true, + }); + ``` + +### `@sentry/browser` / All SDKs running in the browser + +- The SDK no longer instructs the Sentry backend to automatically infer IP addresses by default. + Depending on the version of the Sentry backend (self-hosted), this may lead to IP addresses no longer showing up in Sentry, and events being grouped to "anonymous users". + At the time of writing, the Sentry SaaS solution will still continue to infer IP addresses, but this will change in the near future. + Set `sendDefaultPii: true` in `Sentry.init()` to instruct the Sentry backend to always infer IP addresses. + +### `@sentry/node` / All SDKs running in Node.js + +- The `tracesSampler` hook will no longer be called for _every_ span. + Root spans may however have incoming trace data from a different service, for example when using distributed tracing. + +- The `requestDataIntegration` will no longer automatically set the user from `request.user` when `express` is used. + Starting in v9, you'll need to manually call `Sentry.setUser()` e.g. in a middleware to set the user on Sentry events. + +- The `processThreadBreadcrumbIntegration` was renamed to `childProcessIntegration`. + +- The `childProcessIntegration`'s (previously `processThreadBreadcrumbIntegration`) `name` value has been changed from `"ProcessAndThreadBreadcrumbs"` to `"ChildProcess"`. + Any filtering logic for registered integrations should be updated to account for the changed name. + +- The `vercelAIIntegration`'s `name` value has been changed from `"vercelAI"` to `"VercelAI"` (capitalized). + Any filtering logic for registered integrations should be updated to account for the changed name. + +- The Prisma integration no longer supports Prisma v5 and supports Prisma v6 by default. As per Prisma v6, the `previewFeatures = ["tracing"]` client generator option in your Prisma Schema is no longer required to use tracing with the Prisma integration. + + For performance instrumentation using other/older Prisma versions: + + 1. Install the `@prisma/instrumentation` package with the desired version. + 1. Pass a `new PrismaInstrumentation()` instance as exported from `@prisma/instrumentation` to the `prismaInstrumentation` option of this integration: + + ```js + import { PrismaInstrumentation } from '@prisma/instrumentation'; + Sentry.init({ + integrations: [ + prismaIntegration({ + // Override the default instrumentation that Sentry uses + prismaInstrumentation: new PrismaInstrumentation(), + }), + ], + }); + ``` + + The passed instrumentation instance will override the default instrumentation instance the integration would use, while the `prismaIntegration` will still ensure data compatibility for the various Prisma versions. + + 1. Depending on your Prisma version (prior to Prisma version 6), add `previewFeatures = ["tracing"]` to the client generator block of your Prisma schema: + + ``` + generator client { + provider = "prisma-client-js" + previewFeatures = ["tracing"] + } + ``` + +- When `skipOpenTelemetrySetup: true` is configured, `httpIntegration({ spans: false })` will be configured by default. + You no longer have to specify this manually. + With this change, no spans are emitted once `skipOpenTelemetrySetup: true` is configured, without any further configuration being needed. + +### All Meta-Framework SDKs (`@sentry/nextjs`, `@sentry/nuxt`, `@sentry/sveltekit`, `@sentry/astro`, `@sentry/solidstart`) + +- SDKs no longer transform user-provided values for source map generation in build configurations (like Vite config, Rollup config, or `next.config.js`). + + If source maps are explicitly disabled, the SDK will not enable them. If source maps are explicitly enabled, the SDK will not change how they are emitted. **However,** the SDK will also _not_ delete source maps after uploading them. If source map generation is not configured, the SDK will turn it on and delete them after the upload. + + To customize which files are deleted after upload, define the `filesToDeleteAfterUpload` array with globs. + +### `@sentry/react` + +- The `componentStack` field in the `ErrorBoundary` component is now typed as `string` instead of `string | null | undefined` for the `onError` and `onReset` lifecycle methods. This more closely matches the actual behavior of React, which always returns a `string` whenever a component stack is available. + + In the `onUnmount` lifecycle method, the `componentStack` field is now typed as `string | null`. The `componentStack` is `null` when no error has been thrown at time of unmount. + +### `@sentry/nextjs` + +- By default, client-side source maps will now be automatically deleted after being uploaded to Sentry during the build. + You can opt out of this behavior by explicitly setting `sourcemaps.deleteSourcemapsAfterUpload` to `false` in your Sentry config. + +- The Sentry Next.js SDK will no longer use the Next.js Build ID as fallback identifier for releases. + The SDK will continue to attempt to read CI-provider-specific environment variables and the current git SHA to automatically determine a release name. + If you examine that you no longer see releases created in Sentry, it is recommended to manually provide a release name to `withSentryConfig` via the `release.name` option. + + This behavior was changed because the Next.js Build ID is non-deterministic, causing build artifacts to be non-deterministic, because the release name is injected into client bundles. + +- Source maps are now automatically enabled for both client and server builds unless explicitly disabled via `sourcemaps.disable`. + Client builds use `hidden-source-map` while server builds use `source-map` as their webpack `devtool` setting unless any other value than `false` or `undefined` has been assigned already. + +- The `sentry` property on the Next.js config object has officially been discontinued. + Pass options to `withSentryConfig` directly. + +## 3. Package Removals + +The `@sentry/utils` package will no longer be published. + +The `@sentry/types` package will continue to be published, however, it is deprecated and its API will not be extended. +It will not be published as part of future major versions. + +All exports and APIs of `@sentry/utils` and `@sentry/types` (except for the ones that are explicitly called out in this migration guide to be removed) have been moved into the `@sentry/core` package. + +## 4. Removed APIs + +### `@sentry/core` / All SDKs + +- **The metrics API has been removed from the SDK.** + + The Sentry metrics beta has ended and the metrics API has been removed from the SDK. Learn more in the Sentry [help center docs](https://sentry.zendesk.com/hc/en-us/articles/26369339769883-Metrics-Beta-Ended-on-October-7th). + +- The `transactionContext` property on the `samplingContext` argument passed to the `tracesSampler` and `profilesSampler` options has been removed. + All object attributes are available in the top-level of `samplingContext`: + + ```diff + Sentry.init({ + // Custom traces sampler + tracesSampler: samplingContext => { + - if (samplingContext.transactionContext.name === '/health-check') { + + if (samplingContext.name === '/health-check') { + return 0; + } else { + return 0.5; + } + }, + + // Custom profiles sampler + profilesSampler: samplingContext => { + - if (samplingContext.transactionContext.name === '/health-check') { + + if (samplingContext.name === '/health-check') { + return 0; + } else { + return 0.5; + } + }, + }) + ``` + +- The `enableTracing` option was removed. + Instead, set `tracesSampleRate: 1` or `tracesSampleRate: 0`. + +- The `autoSessionTracking` option was removed. + + To enable session tracking, ensure that either, in browser environments the `browserSessionIntegration` is added, or in server environments the `httpIntegration` is added. (both are added by default) + + To disable session tracking, remove the `browserSessionIntegration` in browser environments, or in server environments configure the `httpIntegration` with the `trackIncomingRequestsAsSessions` option set to `false`. + Additionally, in Node.js environments, a session was automatically created for every node process when `autoSessionTracking` was set to `true`. This behavior has been replaced by the `processSessionIntegration` which is configured by default. + +- The `getCurrentHub()`, `Hub` and `getCurrentHubShim()` APIs have been removed. They were on compatibility life support since the release of v8 and have now been fully removed from the SDK. + +- The `addOpenTelemetryInstrumentation` method has been removed. Use the `openTelemetryInstrumentations` option in `Sentry.init()` or your custom Sentry Client instead. + + ```js + import * as Sentry from '@sentry/node'; + + // before + Sentry.addOpenTelemetryInstrumentation(new GenericPoolInstrumentation()); + + // after + Sentry.init({ + openTelemetryInstrumentations: [new GenericPoolInstrumentation()], + }); + ``` + +- The `debugIntegration` has been removed. To log outgoing events, use [Hook Options](https://docs.sentry.io/platforms/javascript/configuration/options/#hooks) (`beforeSend`, `beforeSendTransaction`, ...). + +- The `sessionTimingIntegration` has been removed. To capture session durations alongside events, use [Context](https://docs.sentry.io/platforms/javascript/enriching-events/context/) (`Sentry.setContext()`). + +### Server-side SDKs (`@sentry/node` and all dependents) + +- The `addOpenTelemetryInstrumentation` method was removed. + Use the `openTelemetryInstrumentations` option in `Sentry.init()` or your custom Sentry Client instead. + +- `registerEsmLoaderHooks` now only accepts `true | false | undefined`. + The SDK will default to wrapping modules that are used as part of OpenTelemetry Instrumentation. + +- The `nestIntegration` was removed. + Use the NestJS SDK (`@sentry/nestjs`) instead. + +- The `setupNestErrorHandler` was removed. + Use the NestJS SDK (`@sentry/nestjs`) instead. + +### `@sentry/browser` + +- The `captureUserFeedback` method has been removed. + Use the `captureFeedback` method instead and update the `comments` field to `message`. + +### `@sentry/nextjs` + +- The `hideSourceMaps` option was removed without replacements. + The SDK emits hidden sourcemaps by default. + +### `@sentry/solidstart` + +- The `sentrySolidStartVite` plugin is no longer exported. Instead, wrap the SolidStart config with `withSentry` and + provide Sentry options as the second parameter. + + ```ts + // app.config.ts + import { defineConfig } from '@solidjs/start/config'; + import { withSentry } from '@sentry/solidstart'; + + export default defineConfig( + withSentry( + { + /* SolidStart config */ + }, + { + /* Sentry build-time config (like project and org) */ + }, + ), + ); + ``` + +### `@sentry/nestjs` + +- Removed `@WithSentry` decorator. + Use the `@SentryExceptionCaptured` decorator as a drop-in replacement. + +- Removed `SentryService`. + + - If you are using `@sentry/nestjs` you can safely remove any references to the `SentryService`. + - If you are using another package migrate to `@sentry/nestjs` and remove the `SentryService` afterward. + +- Removed `SentryTracingInterceptor`. + + - If you are using `@sentry/nestjs` you can safely remove any references to the `SentryTracingInterceptor`. + - If you are using another package migrate to `@sentry/nestjs` and remove the `SentryTracingInterceptor` afterward. + +- Removed `SentryGlobalGenericFilter`. + Use the `SentryGlobalFilter` as a drop-in replacement. + +- Removed `SentryGlobalGraphQLFilter`. + Use the `SentryGlobalFilter` as a drop-in replacement. + +### `@sentry/react` + +- The `wrapUseRoutes` method has been removed. + Depending on what version of react router you are using, use the `wrapUseRoutesV6` or `wrapUseRoutesV7` methods instead. + +- The `wrapCreateBrowserRouter` method has been removed. + Depending on what version of react router you are using, use the `wrapCreateBrowserRouterV6` or `wrapCreateBrowserRouterV7` methods instead. + +### `@sentry/vue` + +- The options `tracingOptions`, `trackComponents`, `timeout`, `hooks` have been removed everywhere except in the `tracingOptions` option of `vueIntegration()`. + + These options should now be configured as follows: + + ```js + import * as Sentry from '@sentry/vue'; + + Sentry.init({ + integrations: [ + Sentry.vueIntegration({ + tracingOptions: { + trackComponents: true, + timeout: 1000, + hooks: ['mount', 'update', 'unmount'], + }, + }), + ], + }); + ``` + +- The option `logErrors` in the `vueIntegration` has been removed. The Sentry Vue error handler will always propagate the error to a user-defined error handler or re-throw the error (which will log the error without modifying). + +- The option `stateTransformer` in `createSentryPiniaPlugin()` now receives the full state from all stores as its parameter. + The top-level keys of the state object are the store IDs. + +### `@sentry/nuxt` + +- The `tracingOptions` option in `Sentry.init()` was removed in favor of passing the `vueIntegration()` to `Sentry.init({ integrations: [...] })` and setting `tracingOptions` there. + +- The option `stateTransformer` in the `piniaIntegration` now receives the full state from all stores as its parameter. + The top-level keys of the state object are the store IDs. + +### `@sentry/vue` and `@sentry/nuxt` + +- When component tracking is enabled, "update" spans are no longer created by default. + + Add an `"update"` item to the `tracingOptions.hooks` option via the `vueIntegration()` to restore this behavior. + + ```ts + Sentry.init({ + integrations: [ + Sentry.vueIntegration({ + tracingOptions: { + trackComponents: true, + hooks: [ + 'mount', + 'update', // add this line to re-enable update spans + 'unmount', + ], + }, + }), + ], + }); + ``` + +### `@sentry/remix` + +- The `autoInstrumentRemix` option was removed. + The SDK now always behaves as if the option were set to `true`. + +### `@sentry/sveltekit` + +- The `fetchProxyScriptNonce` option in `sentryHandle()` was removed due to security concerns. If you previously specified this option for your CSP policy, specify a [script hash](https://docs.sentry.io/platforms/javascript/guides/sveltekit/manual-setup/#configure-csp-for-client-side-fetch-instrumentation) in your CSP config or [disable](https://docs.sentry.io/platforms/javascript/guides/sveltekit/manual-setup/#disable-client-side-fetch-proxy-script) the injection of the script entirely. + +### `@sentry/core` + +- A `sampleRand` field on `PropagationContext` is now required. This is relevant if you used `scope.setPropagationContext(...)` + +- The `DEFAULT_USER_INCLUDES` constant has been removed. There is no replacement. + +- The `BAGGAGE_HEADER_NAME` export has been removed. Use a `"baggage"` string constant directly instead. + +- The `extractRequestData` method has been removed. Manually extract relevant data of request objects instead. + +- The `addRequestDataToEvent` method has been removed. Use `httpRequestToRequestData` instead and put the resulting object directly on `event.request`. + +- The `addNormalizedRequestDataToEvent` method has been removed. Use `httpRequestToRequestData` instead and put the resulting object directly on `event.request`. + +- The `generatePropagationContext()` method was removed. + Use `generateTraceId()` directly. + +- The `spanId` field on `propagationContext` was removed. + It was replaced with an **optional** field `propagationSpanId` having the same semantics but only being defined when a unit of execution should be associated with a particular span ID. + +- The `initSessionFlusher` method on the `ServerRuntimeClient` was removed without replacements. + Any mechanisms creating sessions will flush themselves. + +- The `IntegrationClass` type was removed. + Instead, use `Integration` or `IntegrationFn`. + +- The following exports have been removed without replacement: + + - `getNumberOfUrlSegments` + - `validSeverityLevels` + - `makeFifoCache` + - `arrayify` + - `flatten` + - `urlEncode` + - `getDomElement` + - `memoBuilder` + - `extractPathForTransaction` + - `_browserPerformanceTimeOriginMode` + - `addTracingHeadersToFetchRequest` + - `SessionFlusher` + +- The following types have been removed without replacement: + + - `Request` + `RequestEventData` + - `TransactionNamingScheme` + - `RequestDataIntegrationOptions` + - `SessionFlusherLike` + - `RequestSession` + - `RequestSessionStatus` + +### `@sentry/opentelemetry` + +- Removed `getPropagationContextFromSpan` without replacement. +- Removed `generateSpanContextForPropagationContext` without replacement. + +#### Other/Internal Changes + +The following changes are unlikely to affect users of the SDK. They are listed here only for completion sake, and to alert users that may be relying on internal behavior. + +- `client._prepareEvent()` now requires both `currentScope` and `isolationScope` to be passed as arguments. +- `client.recordDroppedEvent()` no longer accepts an `event` as third argument. + The event was no longer used for some time, instead you can (optionally) pass a count of dropped events as third argument. + +## 5. Build Changes + +- The CJS code for the SDK now only contains compatibility statements for CJS/ESM in modules that have default exports: + + ```js + Object.defineProperty(exports, '__esModule', { value: true }); + ``` + + Let us know if this is causing issues in your setup by opening an issue on GitHub. + +- `@sentry/deno` is no longer published on the `deno.land` registry so you'll need to import the SDK from npm: + + ```javascript + import * as Sentry from 'npm:@sentry/deno'; + + Sentry.init({ + dsn: '__DSN__', + // ... + }); + ``` + +## 6. Type Changes + +- `Scope` usages now always expect `Scope` instances + +- `Client` usages now always expect `BaseClient` instances. + The abstract `Client` class was removed. + Client classes now have to extend from `BaseClient`. + +These changes should not affect most users unless you relied on passing things with a similar shape to internal methods. + +In v8, interfaces have been exported from `@sentry/types`, while implementations have been exported from other packages. + +## No Version Support Timeline + +Version support timelines are stressful for everybody using the SDK, so we won't be defining one. +Instead, we will be applying bug fixes and features to older versions as long as there is demand. + +Additionally, we hold ourselves accountable to any security issues, meaning that if any vulnerabilities are found, we will in almost all cases backport them. + +Note, that it is decided on a case-per-case basis, what gets backported or not. +If you need a fix or feature in a previous version of the SDK, please reach out via a GitHub Issue. diff --git a/package.json b/package.json index f2cbefa5faa4..330343d570ab 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "dedupe-deps:check": "yarn-deduplicate yarn.lock --list --fail", "dedupe-deps:fix": "yarn-deduplicate yarn.lock", "postpublish": "lerna run --stream --concurrency 1 postpublish", - "test": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests}\" test", - "test:unit": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests}\" test:unit", + "test": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\" test", + "test:unit": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\" test:unit", "test:update-snapshots": "lerna run test:update-snapshots", - "test:pr": "nx affected -t test --exclude \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests}\"", + "test:pr": "nx affected -t test --exclude \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests,cloudflare-integration-tests}\"", "test:pr:browser": "UNIT_TEST_ENV=browser ts-node ./scripts/ci-unit-tests.ts --affected", "test:pr:node": "UNIT_TEST_ENV=node ts-node ./scripts/ci-unit-tests.ts --affected", "test:ci:browser": "UNIT_TEST_ENV=browser ts-node ./scripts/ci-unit-tests.ts", @@ -95,13 +95,13 @@ "dev-packages/bundle-analyzer-scenarios", "dev-packages/e2e-tests", "dev-packages/node-integration-tests", + "dev-packages/cloudflare-integration-tests", "dev-packages/node-core-integration-tests", "dev-packages/test-utils", "dev-packages/size-limit-gh-action", "dev-packages/clear-cache-gh-action", "dev-packages/external-contributor-gh-action", - "dev-packages/rollup-utils", - "dev-packages/opentelemetry-v2-tests" + "dev-packages/rollup-utils" ], "devDependencies": { "@rollup/plugin-commonjs": "^25.0.7", diff --git a/packages/astro/package.json b/packages/astro/package.json index 97d212bd5bc5..44d7ef45c679 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -59,7 +59,7 @@ "@sentry/browser": "9.40.0", "@sentry/core": "9.40.0", "@sentry/node": "9.40.0", - "@sentry/vite-plugin": "^2.22.6" + "@sentry/vite-plugin": "^4.0.0" }, "devDependencies": { "astro": "^3.5.0", diff --git a/packages/astro/src/client/browserTracingIntegration.ts b/packages/astro/src/client/browserTracingIntegration.ts new file mode 100644 index 000000000000..7f8576671635 --- /dev/null +++ b/packages/astro/src/client/browserTracingIntegration.ts @@ -0,0 +1,52 @@ +import { browserTracingIntegration as originalBrowserTracingIntegration, WINDOW } from '@sentry/browser'; +import type { Integration, TransactionSource } from '@sentry/core'; +import { debug, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +/** + * Returns the value of a meta-tag + */ +function getMetaContent(metaName: string): string | undefined { + const optionalDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined; + const metaTag = optionalDocument?.querySelector(`meta[name=${metaName}]`); + return metaTag?.getAttribute('content') || undefined; +} + +/** + * A custom browser tracing integrations for Astro. + */ +export function browserTracingIntegration( + options: Parameters[0] = {}, +): Integration { + const integration = originalBrowserTracingIntegration(options); + + return { + ...integration, + setup(client) { + // Original integration setup call + integration.setup?.(client); + + client.on('afterStartPageLoadSpan', pageLoadSpan => { + const routeNameFromMetaTags = getMetaContent('sentry-route-name'); + + if (routeNameFromMetaTags) { + let decodedRouteName; + try { + decodedRouteName = decodeURIComponent(routeNameFromMetaTags); + } catch { + // We ignore errors here, e.g. if the value cannot be URL decoded. + return; + } + + DEBUG_BUILD && debug.log(`[Tracing] Using route name from Sentry HTML meta-tag: ${decodedRouteName}`); + + pageLoadSpan.updateName(decodedRouteName); + pageLoadSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route' as TransactionSource, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.astro', + }); + } + }); + }, + }; +} diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index f04725d1ef1e..21c5770f255f 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -1,11 +1,8 @@ import type { BrowserOptions } from '@sentry/browser'; -import { - browserTracingIntegration, - getDefaultIntegrations as getBrowserDefaultIntegrations, - init as initBrowserSdk, -} from '@sentry/browser'; +import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBrowserSdk } from '@sentry/browser'; import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; +import { browserTracingIntegration } from './browserTracingIntegration'; // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; diff --git a/packages/astro/src/debug-build.ts b/packages/astro/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/astro/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/astro/src/index.client.ts b/packages/astro/src/index.client.ts index 2b85c05c3af1..55a6c8d3915b 100644 --- a/packages/astro/src/index.client.ts +++ b/packages/astro/src/index.client.ts @@ -1,3 +1,6 @@ export * from '@sentry/browser'; +// Override the browserTracingIntegration with the custom Astro version +export { browserTracingIntegration } from './client/browserTracingIntegration'; + export { init } from './client/sdk'; diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 0b92c8a4a6f8..a9e81aee7db5 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -39,6 +39,7 @@ export { expressIntegration, extraErrorDataIntegration, fastifyIntegration, + firebaseIntegration, flush, fsIntegration, functionToStringIntegration, diff --git a/packages/astro/src/integration/types.ts b/packages/astro/src/integration/types.ts index 08a8635889fe..aed2b7e1d193 100644 --- a/packages/astro/src/integration/types.ts +++ b/packages/astro/src/integration/types.ts @@ -1,4 +1,5 @@ import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; +import type { RouteData } from 'astro'; type SdkInitPaths = { /** @@ -224,3 +225,27 @@ export type SentryOptions = SdkInitPaths & debug?: boolean; // eslint-disable-next-line deprecation/deprecation } & DeprecatedRuntimeOptions; + +/** + * Routes inside 'astro:routes:resolved' hook (Astro v5+) + * + * Inline type for official `IntegrationResolvedRoute`. + * The type includes more properties, but we only need some of them. + * + * @see https://github.com/withastro/astro/blob/04e60119afee668264a2ff6665c19a32150f4c91/packages/astro/src/types/public/integrations.ts#L287 + */ +export type IntegrationResolvedRoute = { + isPrerendered: RouteData['prerender']; + pattern: RouteData['route']; + patternRegex: RouteData['pattern']; + segments: RouteData['segments']; +}; + +/** + * Internal type for Astro routes, as we store an additional `patternCaseSensitive` property alongside the + * lowercased parametrized `pattern` of each Astro route. + */ +export type ResolvedRouteWithCasedPattern = IntegrationResolvedRoute & { + patternRegex: string; // RegEx gets stringified + patternCaseSensitive: string; +}; diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index fb2f2e572fa4..fbf6720c23b8 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -1,17 +1,15 @@ import type { RequestEventData, Scope, SpanAttributes } from '@sentry/core'; import { addNonEnumerableProperty, - debug, extractQueryParamsFromUrl, + flushIfServerless, objectify, stripUrlQueryAndFragment, - vercelWaitUntil, winterCGRequestToRequestData, } from '@sentry/core'; import { captureException, continueTrace, - flush, getActiveSpan, getClient, getCurrentScope, @@ -22,7 +20,7 @@ import { startSpan, withIsolationScope, } from '@sentry/node'; -import type { APIContext, MiddlewareResponseHandler } from 'astro'; +import type { APIContext, MiddlewareResponseHandler, RoutePart } from 'astro'; type MiddlewareOptions = { /** @@ -128,8 +126,26 @@ async function instrumentRequest( } try { - const interpolatedRoute = interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params); - const source = interpolatedRoute ? 'route' : 'url'; + // `routePattern` is available after Astro 5 + const contextWithRoutePattern = ctx as Parameters[0] & { routePattern?: string }; + const rawRoutePattern = contextWithRoutePattern.routePattern; + + // @ts-expect-error Implicit any on Symbol.for (This is available in Astro 5) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const routesFromManifest = ctx?.[Symbol.for('context.routes')]?.manifest?.routes; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const matchedRouteSegmentsFromManifest = routesFromManifest?.find( + (route: { routeData?: { route?: string } }) => route?.routeData?.route === rawRoutePattern, + )?.routeData?.segments; + + const parametrizedRoute = + // Astro v5 - Joining the segments to get the correct casing of the parametrized route + (matchedRouteSegmentsFromManifest && joinRouteSegments(matchedRouteSegmentsFromManifest)) || + // Fallback (Astro v4 and earlier) + interpolateRouteFromUrlAndParams(ctx.url.pathname, ctx.params); + + const source = parametrizedRoute ? 'route' : 'url'; // storing res in a variable instead of directly returning is necessary to // invoke the catch block if next() throws @@ -148,12 +164,12 @@ async function instrumentRequest( attributes['http.fragment'] = ctx.url.hash; } - isolationScope?.setTransactionName(`${method} ${interpolatedRoute || ctx.url.pathname}`); + isolationScope?.setTransactionName(`${method} ${parametrizedRoute || ctx.url.pathname}`); const res = await startSpan( { attributes, - name: `${method} ${interpolatedRoute || ctx.url.pathname}`, + name: `${method} ${parametrizedRoute || ctx.url.pathname}`, op: 'http.server', }, async span => { @@ -202,7 +218,7 @@ async function instrumentRequest( try { for await (const chunk of bodyReporter()) { const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); - const modifiedHtml = addMetaTagToHead(html); + const modifiedHtml = addMetaTagToHead(html, parametrizedRoute); controller.enqueue(new TextEncoder().encode(modifiedHtml)); } } catch (e) { @@ -222,16 +238,7 @@ async function instrumentRequest( ); return res; } finally { - vercelWaitUntil( - (async () => { - // Flushes pending Sentry events with a 2-second timeout and in a way that cannot create unhandled promise rejections. - try { - await flush(2000); - } catch (e) { - debug.log('Error while flushing events:\n', e); - } - })(), - ); + await flushIfServerless(); } // TODO: flush if serverless (first extract function) }, @@ -242,11 +249,13 @@ async function instrumentRequest( * This function optimistically assumes that the HTML coming in chunks will not be split * within the tag. If this still happens, we simply won't replace anything. */ -function addMetaTagToHead(htmlChunk: string): string { +function addMetaTagToHead(htmlChunk: string, parametrizedRoute?: string): string { if (typeof htmlChunk !== 'string') { return htmlChunk; } - const metaTags = getTraceMetaTags(); + const metaTags = parametrizedRoute + ? `${getTraceMetaTags()}\n\n` + : getTraceMetaTags(); if (!metaTags) { return htmlChunk; @@ -306,26 +315,30 @@ export function interpolateRouteFromUrlAndParams( return acc.replace(key, `[${valuesToMultiSegmentParams[key]}]`); }, decodedUrlPathname); - return urlWithReplacedMultiSegmentParams - .split('/') - .map(segment => { - if (!segment) { - return ''; - } + return ( + urlWithReplacedMultiSegmentParams + .split('/') + .map(segment => { + if (!segment) { + return ''; + } - if (valuesToParams[segment]) { - return replaceWithParamName(segment); - } + if (valuesToParams[segment]) { + return replaceWithParamName(segment); + } - // astro permits multiple params in a single path segment, e.g. /[foo]-[bar]/ - const segmentParts = segment.split('-'); - if (segmentParts.length > 1) { - return segmentParts.map(part => replaceWithParamName(part)).join('-'); - } + // astro permits multiple params in a single path segment, e.g. /[foo]-[bar]/ + const segmentParts = segment.split('-'); + if (segmentParts.length > 1) { + return segmentParts.map(part => replaceWithParamName(part)).join('-'); + } - return segment; - }) - .join('/'); + return segment; + }) + .join('/') + // Remove trailing slash (only if it's not the only segment) + .replace(/^(.+?)\/$/, '$1') + ); } function tryDecodeUrl(url: string): string | undefined { @@ -348,3 +361,18 @@ function checkIsDynamicPageRequest(context: Parameters + segment.map(routePart => (routePart.dynamic ? `[${routePart.content}]` : routePart.content)).join(''), + ); + + return `/${parthArray.join('/')}`; +} diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index e8c16fa570d3..6430a5f47eb7 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -482,4 +482,11 @@ describe('interpolateRouteFromUrlAndParams', () => { const expectedRoute = '/usernames/user'; expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute); }); + + it('removes trailing slashes from the route', () => { + const rawUrl = '/users/123/'; + const params = { id: '123' }; + const expectedRoute = '/users/[id]'; + expect(interpolateRouteFromUrlAndParams(rawUrl, params)).toEqual(expectedRoute); + }); }); diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 4e04a83226ee..fcd3b16f74dd 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -15,6 +15,7 @@ "/build/loader-hook.mjs" ], "main": "build/npm/cjs/index.js", + "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", "exports": { "./package.json": "./package.json", @@ -65,19 +66,19 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.57.2", - "@opentelemetry/instrumentation-aws-lambda": "0.50.3", - "@opentelemetry/instrumentation-aws-sdk": "0.49.1", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-aws-lambda": "0.54.0", + "@opentelemetry/instrumentation-aws-sdk": "0.56.0", "@sentry/core": "9.40.0", "@sentry/node": "9.40.0", "@types/aws-lambda": "^8.10.62" }, "devDependencies": { - "@types/node": "^18.19.1" + "@types/node": "^18.19.1", + "@vercel/nft": "^0.29.4" }, "scripts": { - "build": "run-p build:transpile build:types build:bundle", - "build:bundle": "yarn build:layer", + "build": "run-p build:transpile build:types", "build:layer": "yarn ts-node scripts/buildLambdaLayer.ts", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.mjs && yarn build:layer", @@ -104,12 +105,17 @@ "sideEffects": false, "nx": { "targets": { - "build:bundle": { + "build:transpile": { + "inputs": [ + "production", + "^production" + ], "dependsOn": [ - "build:transpile", - "build:types" + "^build:transpile" ], "outputs": [ + "{projectRoot}/build/npm/esm", + "{projectRoot}/build/npm/cjs", "{projectRoot}/build/aws" ] } diff --git a/packages/aws-serverless/rollup.aws.config.mjs b/packages/aws-serverless/rollup.aws.config.mjs deleted file mode 100644 index d9f0720886ef..000000000000 --- a/packages/aws-serverless/rollup.aws.config.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { makeBaseBundleConfig, makeBaseNPMConfig, makeBundleConfigVariants } from '@sentry-internal/rollup-utils'; - -export default [ - // The SDK - ...makeBundleConfigVariants( - makeBaseBundleConfig({ - // this automatically sets it to be CJS - bundleType: 'aws-lambda', - entrypoints: ['src/index.ts'], - licenseTitle: '@sentry/aws-serverless', - outputFileBase: () => 'index', - packageSpecificConfig: { - output: { - dir: 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs', - sourcemap: false, - }, - }, - }), - // We only need one copy of the SDK, and we pick the minified one because there's a cap on how big a lambda function - // plus its dependencies can be, and we might as well take up as little of that space as is necessary. We'll rename - // it to be `index.js` in the build script, since it's standing in for the index file of the npm package. - { variants: ['.debug.min.js'] }, - ), - makeBaseNPMConfig({ - entrypoints: ['src/awslambda-auto.ts'], - packageSpecificConfig: { - // Normally `makeNPMConfigVariants` sets both of these values for us, but we don't actually want the ESM variant, - // and the directory structure is different than normal, so we have to do it ourselves. - output: { - format: 'cjs', - dir: 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs', - sourcemap: false, - }, - // We only want `awslambda-auto.js`, not the modules that it imports, because they're all included in the bundle - // we generate above - external: ['./index'], - }, - }), -]; diff --git a/packages/aws-serverless/scripts/buildLambdaLayer.ts b/packages/aws-serverless/scripts/buildLambdaLayer.ts index 52c3b50009c5..a918e6bbae18 100644 --- a/packages/aws-serverless/scripts/buildLambdaLayer.ts +++ b/packages/aws-serverless/scripts/buildLambdaLayer.ts @@ -1,6 +1,8 @@ /* eslint-disable no-console */ +import { nodeFileTrace } from '@vercel/nft'; import * as childProcess from 'child_process'; import * as fs from 'fs'; +import * as path from 'path'; import { version } from '../package.json'; /** @@ -11,21 +13,21 @@ function run(cmd: string, options?: childProcess.ExecSyncOptions): string { return String(childProcess.execSync(cmd, { stdio: 'inherit', ...options })); } +/** + * Build the AWS lambda layer by first installing the local package into `build/aws/dist-serverless/nodejs`. + * Then, prune the node_modules directory to remove unused files by first getting all necessary files with + * `@vercel/nft` and then deleting all other files inside `node_modules`. + * Finally, create a zip file of the layer. + */ async function buildLambdaLayer(): Promise { - // Create the main SDK bundle - run('yarn rollup --config rollup.aws.config.mjs'); - - // We build a minified bundle, but it's standing in for the regular `index.js` file listed in `package.json`'s `main` - // property, so we have to rename it so it's findable. - fs.renameSync( - 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/index.debug.min.js', - 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/index.js', - ); + console.log('Building Lambda layer.'); + buildPackageJson(); + console.log('Installing local @sentry/aws-serverless into build/aws/dist-serverless/nodejs.'); + run('yarn install --prod --cwd ./build/aws/dist-serverless/nodejs'); - // We're creating a bundle for the SDK, but still using it in a Node context, so we need to copy in `package.json`, - // purely for its `main` property. - console.log('Copying `package.json` into lambda layer.'); - fs.copyFileSync('package.json', 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/package.json'); + await pruneNodeModules(); + fs.rmSync('./build/aws/dist-serverless/nodejs/package.json', { force: true }); + fs.rmSync('./build/aws/dist-serverless/nodejs/yarn.lock', { force: true }); // The layer also includes `awslambda-auto.js`, a helper file which calls `Sentry.init()` and wraps the lambda // handler. It gets run when Node is launched inside the lambda, using the environment variable @@ -41,6 +43,8 @@ async function buildLambdaLayer(): Promise { 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/dist/awslambda-auto.js', ); + replaceSDKSource(); + const zipFilename = `sentry-node-serverless-${version}.zip`; console.log(`Creating final layer zip file ${zipFilename}.`); // need to preserve the symlink above with -y @@ -59,5 +63,147 @@ buildLambdaLayer(); */ function fsForceMkdirSync(path: string): void { fs.rmSync(path, { recursive: true, force: true }); - fs.mkdirSync(path); + fs.mkdirSync(path, { recursive: true }); +} + +async function pruneNodeModules(): Promise { + const entrypoints = [ + './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/esm/index.js', + './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/index.js', + './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/awslambda-auto.js', + './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/esm/awslambda-auto.js', + ]; + + const { fileList } = await nodeFileTrace(entrypoints); + + const allFiles = getAllFiles('./build/aws/dist-serverless/nodejs/node_modules'); + + const filesToDelete = allFiles.filter(file => !fileList.has(file)); + console.log(`Removing ${filesToDelete.length} unused files from node_modules.`); + + for (const file of filesToDelete) { + try { + fs.unlinkSync(file); + } catch { + console.error(`Error deleting ${file}`); + } + } + + console.log('Cleaning up empty directories.'); + + removeEmptyDirs('./build/aws/dist-serverless/nodejs/node_modules'); +} + +function removeEmptyDirs(dir: string): void { + try { + const entries = fs.readdirSync(dir); + + for (const entry of entries) { + const fullPath = path.join(dir, entry); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + removeEmptyDirs(fullPath); + } + } + + const remainingEntries = fs.readdirSync(dir); + + if (remainingEntries.length === 0) { + fs.rmdirSync(dir); + } + } catch { + // Directory might not exist or might not be empty, that's ok + } +} + +function getAllFiles(dir: string): string[] { + const files: string[] = []; + + function walkDirectory(currentPath: string): void { + try { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + const relativePath = path.relative(process.cwd(), fullPath); + + if (entry.isDirectory()) { + walkDirectory(fullPath); + } else { + files.push(relativePath); + } + } + } catch { + console.log(`Skipping directory ${currentPath}`); + } + } + + walkDirectory(dir); + return files; +} + +function buildPackageJson(): void { + console.log('Building package.json'); + const packagesDir = path.resolve(__dirname, '../..'); + const packageDirs = fs + .readdirSync(packagesDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name) + .filter(name => !name.startsWith('.')) // Skip hidden directories + .sort(); + + const resolutions: Record = {}; + + for (const packageDir of packageDirs) { + const packageJsonPath = path.join(packagesDir, packageDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageContent = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { name?: string }; + const packageName = packageContent.name; + if (typeof packageName === 'string' && packageName) { + resolutions[packageName] = `file:../../../../../../packages/${packageDir}`; + } + } catch { + console.warn(`Warning: Could not read package.json for ${packageDir}`); + } + } + } + + const packageJson = { + dependencies: { + '@sentry/aws-serverless': 'file:../../../../../../packages/aws-serverless', + }, + resolutions, + }; + + fsForceMkdirSync('./build/aws/dist-serverless/nodejs'); + const packageJsonPath = './build/aws/dist-serverless/nodejs/package.json'; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); +} + +function replaceSDKSource(): void { + console.log('Replacing SDK source.'); + + const envFiles = [ + './build/aws/dist-serverless/nodejs/node_modules/@sentry/core/build/cjs/utils/env.js', + './build/aws/dist-serverless/nodejs/node_modules/@sentry/core/build/esm/utils/env.js', + ]; + + for (const envFile of envFiles) { + try { + let content = fs.readFileSync(envFile, 'utf-8'); + + // Replace the line marked with __SENTRY_SDK_SOURCE__ comment + // Change from 'npm' to 'aws-lambda-layer' to identify that this is the AWS Lambda layer + content = content.replace( + "/* __SENTRY_SDK_SOURCE__ */ return 'npm';", + "/* __SENTRY_SDK_SOURCE__ */ return 'aws-lambda-layer';", + ); + + fs.writeFileSync(envFile, content); + console.log(`Updated SDK source in ${envFile}`); + } catch { + console.warn(`Warning: Could not update SDK source in ${envFile}`); + } + } } diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 7cf8e17f0dd7..b99c481fd1d3 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -89,6 +89,7 @@ export { connectIntegration, setupConnectErrorHandler, fastifyIntegration, + firebaseIntegration, fsIntegration, genericPoolIntegration, graphqlIntegration, diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index dafaf780ed99..f64b62b9a373 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -2,6 +2,7 @@ import type { Integration, Options, Scope, Span } from '@sentry/core'; import { applySdkMetadata, debug, + getSDKSource, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; @@ -81,7 +82,7 @@ export function init(options: NodeOptions = {}): NodeClient | undefined { ...options, }; - applySdkMetadata(opts, 'aws-serverless'); + applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], getSDKSource()); return initWithoutDefaultIntegrations(opts); } diff --git a/packages/aws-serverless/tsconfig.json b/packages/aws-serverless/tsconfig.json index a2731860dfa0..fd68e15254db 100644 --- a/packages/aws-serverless/tsconfig.json +++ b/packages/aws-serverless/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["src/**/*"], + "include": ["src/**/*", "scripts/**/*"], "compilerOptions": { // package-specific options diff --git a/packages/aws-serverless/tsconfig.types.json b/packages/aws-serverless/tsconfig.types.json index 4c51bd21e64b..03b57fcaa2a7 100644 --- a/packages/aws-serverless/tsconfig.types.json +++ b/packages/aws-serverless/tsconfig.types.json @@ -3,7 +3,7 @@ // We don't ship this in the npm package (it exists purely for controlling what ends up in the AWS lambda layer), so // no need to build types for it - "exclude": ["src/index.awslambda.ts"], + "exclude": ["src/index.awslambda.ts", "scripts/**/*"], "compilerOptions": { "declaration": true, diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 0a2d9e85ade9..bf6605f3f399 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -1,7 +1,6 @@ export { addPerformanceInstrumentationHandler, addClsInstrumentationHandler, - addFidInstrumentationHandler, addTtfbInstrumentationHandler, addLcpInstrumentationHandler, addInpInstrumentationHandler, diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index e9fa822a431e..870558ada39d 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -17,7 +17,6 @@ import { trackClsAsStandaloneSpan } from './cls'; import { type PerformanceLongAnimationFrameTiming, addClsInstrumentationHandler, - addFidInstrumentationHandler, addLcpInstrumentationHandler, addPerformanceInstrumentationHandler, addTtfbInstrumentationHandler, @@ -103,13 +102,11 @@ export function startTrackingWebVitals({ if (performance.mark) { WINDOW.performance.mark('sentry-tracing-init'); } - const fidCleanupCallback = _trackFID(); const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP(); const ttfbCleanupCallback = _trackTtfb(); const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS(); return (): void => { - fidCleanupCallback(); lcpCleanupCallback?.(); ttfbCleanupCallback(); clsCleanupCallback?.(); @@ -277,21 +274,6 @@ function _trackLCP(): () => void { }, true); } -/** Starts tracking the First Input Delay on the current page. */ -function _trackFID(): () => void { - return addFidInstrumentationHandler(({ metric }) => { - const entry = metric.entries[metric.entries.length - 1]; - if (!entry) { - return; - } - - const timeOrigin = msToSec(browserPerformanceTimeOrigin() as number); - const startTime = msToSec(entry.startTime); - _measurements['fid'] = { value: metric.value, unit: 'millisecond' }; - _measurements['mark.fid'] = { value: timeOrigin + startTime, unit: 'second' }; - }); -} - function _trackTtfb(): () => void { return addTtfbInstrumentationHandler(({ metric }) => { const entry = metric.entries[metric.entries.length - 1]; @@ -415,25 +397,8 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries if (op === 'pageload') { _addTtfbRequestTimeToMeasurements(_measurements); - const fidMark = _measurements['mark.fid']; - if (fidMark && _measurements['fid']) { - // create span for FID - startAndEndSpan(span, fidMark.value, fidMark.value + msToSec(_measurements['fid'].value), { - name: 'first input delay', - op: 'ui.action', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics', - }, - }); - - // Delete mark.fid as we don't want it to be part of final payload - delete _measurements['mark.fid']; - } - - // If FCP is not recorded we should not record the cls value - // according to the new definition of CLS. - // TODO: Check if the first condition is still necessary: `onCLS` already only fires once `onFCP` was called. - if (!('fcp' in _measurements) || !options.recordClsOnPageloadSpan) { + // If CLS standalone spans are enabled, don't record CLS as a measurement + if (!options.recordClsOnPageloadSpan) { delete _measurements.cls; } diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 23bbf57367d3..8017bd4c89e1 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -1,7 +1,6 @@ import { debug, getFunctionName } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { onCLS } from './web-vitals/getCLS'; -import { onFID } from './web-vitals/getFID'; import { onINP } from './web-vitals/getINP'; import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; @@ -13,10 +12,11 @@ type InstrumentHandlerTypePerformanceObserver = | 'navigation' | 'paint' | 'resource' - | 'first-input' - | 'element'; + | 'element' + // fist-input is still needed for INP + | 'first-input'; -type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb' | 'inp'; +type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'ttfb' | 'inp'; // We provide this here manually instead of relying on a global, as this is not available in non-browser environements // And we do not want to expose such types @@ -51,7 +51,7 @@ interface Metric { /** * The name of the metric (in acronym form). */ - name: 'CLS' | 'FCP' | 'FID' | 'INP' | 'LCP' | 'TTFB'; + name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB'; /** * The current value of the metric. @@ -111,7 +111,6 @@ const handlers: { [key in InstrumentHandlerType]?: InstrumentHandlerCallback[] } const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; let _previousCls: Metric | undefined; -let _previousFid: Metric | undefined; let _previousLcp: Metric | undefined; let _previousTtfb: Metric | undefined; let _previousInp: Metric | undefined; @@ -145,15 +144,7 @@ export function addLcpInstrumentationHandler( } /** - * Add a callback that will be triggered when a FID metric is available. - * Returns a cleanup callback which can be called to remove the instrumentation handler. - */ -export function addFidInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback { - return addMetricObserver('fid', callback, instrumentFid, _previousFid); -} - -/** - * Add a callback that will be triggered when a FID metric is available. + * Add a callback that will be triggered when a TTFD metric is available. */ export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric }) => void): CleanupHandlerCallback { return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb); @@ -236,15 +227,6 @@ function instrumentCls(): StopListening { ); } -function instrumentFid(): void { - return onFID(metric => { - triggerHandlers('fid', { - metric, - }); - _previousFid = metric; - }); -} - function instrumentLcp(): StopListening { return onLCP( metric => { diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md index 4f9d29e5f02f..a57937246cdd 100644 --- a/packages/browser-utils/src/metrics/web-vitals/README.md +++ b/packages/browser-utils/src/metrics/web-vitals/README.md @@ -27,6 +27,10 @@ web-vitals only report once per pageload. ## CHANGELOG +https://github.com/getsentry/sentry-javascript/pull/17076 + +- Removed FID-related code with v10 of the SDK + https://github.com/getsentry/sentry-javascript/pull/16492 - Bumped from Web Vitals 4.2.5 to 5.0.2 diff --git a/packages/browser-utils/src/metrics/web-vitals/getFID.ts b/packages/browser-utils/src/metrics/web-vitals/getFID.ts deleted file mode 100644 index b549f4c07c7c..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals/getFID.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * // Sentry: web-vitals removed FID reporting from v5. We're keeping it around - * for the time being. - * // TODO(v10): Remove FID reporting! - */ - -import { bindReporter } from './lib/bindReporter'; -import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; -import { initMetric } from './lib/initMetric'; -import { observe } from './lib/observe'; -import { onHidden } from './lib/onHidden'; -import { runOnce } from './lib/runOnce'; -import { whenActivated } from './lib/whenActivated'; -import type { FIDMetric, MetricRatingThresholds, ReportOpts } from './types'; - -/** Thresholds for FID. See https://web.dev/articles/fid#what_is_a_good_fid_score */ -export const FIDThresholds: MetricRatingThresholds = [100, 300]; - -/** - * Calculates the [FID](https://web.dev/articles/fid) value for the current page and - * calls the `callback` function once the value is ready, along with the - * relevant `first-input` performance entry used to determine the value. The - * reported value is a `DOMHighResTimeStamp`. - * - * _**Important:** since FID is only reported after the user interacts with the - * page, it's possible that it will not be reported for some page loads._ - */ -export const onFID = (onReport: (metric: FIDMetric) => void, opts: ReportOpts = {}) => { - whenActivated(() => { - const visibilityWatcher = getVisibilityWatcher(); - const metric = initMetric('FID'); - // eslint-disable-next-line prefer-const - let report: ReturnType; - - const handleEntry = (entry: PerformanceEventTiming): void => { - // Only report if the page wasn't hidden prior to the first input. - if (entry.startTime < visibilityWatcher.firstHiddenTime) { - metric.value = entry.processingStart - entry.startTime; - metric.entries.push(entry); - report(true); - } - }; - - const handleEntries = (entries: FIDMetric['entries']) => { - (entries as PerformanceEventTiming[]).forEach(handleEntry); - }; - - const po = observe('first-input', handleEntries); - - report = bindReporter(onReport, metric, FIDThresholds, opts.reportAllChanges); - - if (po) { - // sentry: TODO: Figure out if we can use new whinIdleOrHidden insteard of onHidden - onHidden( - runOnce(() => { - handleEntries(po.takeRecords() as FIDMetric['entries']); - po.disconnect(); - }), - ); - } - }); -}; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/interactions.ts b/packages/browser-utils/src/metrics/web-vitals/lib/interactions.ts deleted file mode 100644 index 69ca920ddb67..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals/lib/interactions.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { getInteractionCount } from './polyfills/interactionCountPolyfill'; - -interface Interaction { - id: number; - latency: number; - entries: PerformanceEventTiming[]; -} - -interface EntryPreProcessingHook { - (entry: PerformanceEventTiming): void; -} - -// A list of longest interactions on the page (by latency) sorted so the -// longest one is first. The list is at most MAX_INTERACTIONS_TO_CONSIDER long. -export const longestInteractionList: Interaction[] = []; - -// A mapping of longest interactions by their interaction ID. -// This is used for faster lookup. -export const longestInteractionMap: Map = new Map(); - -// The default `durationThreshold` used across this library for observing -// `event` entries via PerformanceObserver. -export const DEFAULT_DURATION_THRESHOLD = 40; - -// Used to store the interaction count after a bfcache restore, since p98 -// interaction latencies should only consider the current navigation. -let prevInteractionCount = 0; - -/** - * Returns the interaction count since the last bfcache restore (or for the - * full page lifecycle if there were no bfcache restores). - */ -const getInteractionCountForNavigation = () => { - return getInteractionCount() - prevInteractionCount; -}; - -export const resetInteractions = () => { - prevInteractionCount = getInteractionCount(); - longestInteractionList.length = 0; - longestInteractionMap.clear(); -}; - -/** - * Returns the estimated p98 longest interaction based on the stored - * interaction candidates and the interaction count for the current page. - */ -export const estimateP98LongestInteraction = () => { - const candidateInteractionIndex = Math.min( - longestInteractionList.length - 1, - Math.floor(getInteractionCountForNavigation() / 50), - ); - - return longestInteractionList[candidateInteractionIndex]; -}; - -// To prevent unnecessary memory usage on pages with lots of interactions, -// store at most 10 of the longest interactions to consider as INP candidates. -const MAX_INTERACTIONS_TO_CONSIDER = 10; - -/** - * A list of callback functions to run before each entry is processed. - * Exposing this list allows the attribution build to hook into the - * entry processing pipeline. - */ -export const entryPreProcessingCallbacks: EntryPreProcessingHook[] = []; - -/** - * Takes a performance entry and adds it to the list of worst interactions - * if its duration is long enough to make it among the worst. If the - * entry is part of an existing interaction, it is merged and the latency - * and entries list is updated as needed. - */ -export const processInteractionEntry = (entry: PerformanceEventTiming) => { - entryPreProcessingCallbacks.forEach(cb => cb(entry)); - - // Skip further processing for entries that cannot be INP candidates. - if (!(entry.interactionId || entry.entryType === 'first-input')) return; - - // The least-long of the 10 longest interactions. - const minLongestInteraction = longestInteractionList[longestInteractionList.length - 1]; - - const existingInteraction = longestInteractionMap.get(entry.interactionId!); - - // Only process the entry if it's possibly one of the ten longest, - // or if it's part of an existing interaction. - if ( - existingInteraction || - longestInteractionList.length < MAX_INTERACTIONS_TO_CONSIDER || - (minLongestInteraction && entry.duration > minLongestInteraction.latency) - ) { - // If the interaction already exists, update it. Otherwise create one. - if (existingInteraction) { - // If the new entry has a longer duration, replace the old entries, - // otherwise add to the array. - if (entry.duration > existingInteraction.latency) { - existingInteraction.entries = [entry]; - existingInteraction.latency = entry.duration; - } else if ( - entry.duration === existingInteraction.latency && - entry.startTime === existingInteraction.entries[0]?.startTime - ) { - existingInteraction.entries.push(entry); - } - } else { - const interaction = { - id: entry.interactionId!, - latency: entry.duration, - entries: [entry], - }; - longestInteractionMap.set(interaction.id, interaction); - longestInteractionList.push(interaction); - } - - // Sort the entries by latency (descending) and keep only the top ten. - longestInteractionList.sort((a, b) => b.latency - a.latency); - if (longestInteractionList.length > MAX_INTERACTIONS_TO_CONSIDER) { - longestInteractionList.splice(MAX_INTERACTIONS_TO_CONSIDER).forEach(i => longestInteractionMap.delete(i.id)); - } - } -}; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts index 1844a616a479..5a3c1b4fc810 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts @@ -27,7 +27,7 @@ export interface OnHiddenCallback { // The PR removed listening to the `pagehide` event, in favour of only listening to `visibilitychange` event. // This is "more correct" but some browsers we still support (Safari <14.4) don't fully support `visibilitychange` // or have known bugs w.r.t the `visibilitychange` event. -// TODO (v10): If we decide to drop support for Safari 14.4, we can use the logic from web-vitals 4.2.4 +// TODO (v11): If we decide to drop support for Safari 14.4, we can use the logic from web-vitals 4.2.4 // In this case, we also need to update the integration tests that currently trigger the `pagehide` event to // simulate the page being hidden. export const onHidden = (cb: OnHiddenCallback) => { diff --git a/packages/browser-utils/src/metrics/web-vitals/types.ts b/packages/browser-utils/src/metrics/web-vitals/types.ts index 033fbee09926..8146849182b5 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types.ts @@ -15,11 +15,9 @@ */ export * from './types/base'; -export * from './types/polyfills'; export * from './types/cls'; export * from './types/fcp'; -export * from './types/fid'; // FIX was removed in 5.0.2 but we keep it around for now export * from './types/inp'; export * from './types/lcp'; export * from './types/ttfb'; diff --git a/packages/browser-utils/src/metrics/web-vitals/types/base.ts b/packages/browser-utils/src/metrics/web-vitals/types/base.ts index d8315b817f4a..02cb566011ac 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/base.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/base.ts @@ -16,7 +16,6 @@ import type { CLSMetric, CLSMetricWithAttribution } from './cls'; import type { FCPMetric, FCPMetricWithAttribution } from './fcp'; -import type { FIDMetric, FIDMetricWithAttribution } from './fid'; import type { INPMetric, INPMetricWithAttribution } from './inp'; import type { LCPMetric, LCPMetricWithAttribution } from './lcp'; import type { TTFBMetric, TTFBMetricWithAttribution } from './ttfb'; @@ -24,9 +23,8 @@ import type { TTFBMetric, TTFBMetricWithAttribution } from './ttfb'; export interface Metric { /** * The name of the metric (in acronym form). - * // sentry: re-added FID here since we continue supporting it for now */ - name: 'CLS' | 'FCP' | 'FID' | 'INP' | 'LCP' | 'TTFB'; + name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB'; /** * The current value of the metric. @@ -79,14 +77,12 @@ export interface Metric { } /** The union of supported metric types. */ -// sentry: re-added FIDMetric here since we continue supporting it for now -export type MetricType = CLSMetric | FCPMetric | FIDMetric | INPMetric | LCPMetric | TTFBMetric; +export type MetricType = CLSMetric | FCPMetric | INPMetric | LCPMetric | TTFBMetric; /** The union of supported metric attribution types. */ export type MetricWithAttribution = | CLSMetricWithAttribution | FCPMetricWithAttribution - | FIDMetricWithAttribution | INPMetricWithAttribution | LCPMetricWithAttribution | TTFBMetricWithAttribution; diff --git a/packages/browser-utils/src/metrics/web-vitals/types/fid.ts b/packages/browser-utils/src/metrics/web-vitals/types/fid.ts deleted file mode 100644 index 953607adff98..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals/types/fid.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2022 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { LoadState, Metric } from './base'; - -/** - * An FID-specific version of the Metric object. - */ -export interface FIDMetric extends Metric { - name: 'FID'; - entries: PerformanceEventTiming[]; -} - -/** - * An object containing potentially-helpful debugging information that - * can be sent along with the FID value for the current page visit in order - * to help identify issues happening to real-users in the field. - */ -export interface FIDAttribution { - /** - * A selector identifying the element that the user interacted with. This - * element will be the `target` of the `event` dispatched. - */ - eventTarget: string; - /** - * The time when the user interacted. This time will match the `timeStamp` - * value of the `event` dispatched. - */ - eventTime: number; - /** - * The `type` of the `event` dispatched from the user interaction. - */ - eventType: string; - /** - * The `PerformanceEventTiming` entry corresponding to FID. - */ - eventEntry: PerformanceEventTiming; - /** - * The loading state of the document at the time when the first interaction - * occurred (see `LoadState` for details). If the first interaction occurred - * while the document was loading and executing script (e.g. usually in the - * `dom-interactive` phase) it can result in long input delays. - */ - loadState: LoadState; -} - -/** - * An FID-specific version of the Metric object with attribution. - */ -export interface FIDMetricWithAttribution extends FIDMetric { - attribution: FIDAttribution; -} diff --git a/packages/browser-utils/src/metrics/web-vitals/types/polyfills.ts b/packages/browser-utils/src/metrics/web-vitals/types/polyfills.ts deleted file mode 100644 index c4314c0697fa..000000000000 --- a/packages/browser-utils/src/metrics/web-vitals/types/polyfills.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2022 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export type FirstInputPolyfillEntry = Omit; - -export interface FirstInputPolyfillCallback { - (entry: FirstInputPolyfillEntry): void; -} diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 5c50e53e708b..b6c05afe70f7 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -85,8 +85,7 @@ export class BrowserClient extends Client { super(opts); - const { sendDefaultPii, sendClientReports, _experiments } = this._options; - const enableLogs = _experiments?.enableLogs; + const { sendDefaultPii, sendClientReports, enableLogs } = this._options; if (WINDOW.document && (sendClientReports || enableLogs)) { WINDOW.document.addEventListener('visibilitychange', () => { diff --git a/packages/browser/src/log.ts b/packages/browser/src/log.ts index ef2614b81f55..c21477e378b3 100644 --- a/packages/browser/src/log.ts +++ b/packages/browser/src/log.ts @@ -19,7 +19,7 @@ function captureLog( } /** - * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `trace` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `debug` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `info` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `warn` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `error` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `fatal` level. Requires the `enableLogs` option to be enabled. * * @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/browser/test/client.test.ts b/packages/browser/test/client.test.ts index c0f4e649501a..2197b6ed03c0 100644 --- a/packages/browser/test/client.test.ts +++ b/packages/browser/test/client.test.ts @@ -27,7 +27,6 @@ describe('BrowserClient', () => { it('does not flush logs when logs are disabled', () => { client = new BrowserClient( getDefaultBrowserClientOptions({ - _experiments: { enableLogs: false }, sendClientReports: true, }), ); @@ -50,7 +49,7 @@ describe('BrowserClient', () => { vi.useFakeTimers(); client = new BrowserClient( getDefaultBrowserClientOptions({ - _experiments: { enableLogs: true }, + enableLogs: true, sendClientReports: true, }), ); diff --git a/packages/browser/test/log.test.ts b/packages/browser/test/log.test.ts index 68c87069966c..0967d38531dd 100644 --- a/packages/browser/test/log.test.ts +++ b/packages/browser/test/log.test.ts @@ -42,9 +42,7 @@ describe('Logger', () => { init({ dsn, transport: makeSimpleTransport, - _experiments: { - enableLogs: true, - }, + enableLogs: true, }); }); diff --git a/packages/browser/test/tracekit/chromium.test.ts b/packages/browser/test/tracekit/chromium.test.ts index 998f67e829e6..71cb950c147d 100644 --- a/packages/browser/test/tracekit/chromium.test.ts +++ b/packages/browser/test/tracekit/chromium.test.ts @@ -617,7 +617,7 @@ describe('Tracekit - Chrome Tests', () => { }); }); - it('should drop frames that are over 1kb', () => { + it('should truncate frames that are over 1kb', () => { const LONG_STR = 'A'.repeat(1040); const LONG_FRAME = { @@ -637,6 +637,12 @@ describe('Tracekit - Chrome Tests', () => { stacktrace: { frames: [ { filename: 'http://localhost:5000/', function: '?', lineno: 50, colno: 19, in_app: true }, + { + filename: + 'http://localhost:5000/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + function: 'Foo.testMethod', + in_app: true, + }, { filename: 'http://localhost:5000/', function: 'aha', lineno: 39, colno: 5, in_app: true }, ], }, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 024e3e3af5e8..b9af910eb0f1 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -107,6 +107,7 @@ export { setupExpressErrorHandler, fastifyIntegration, setupFastifyErrorHandler, + firebaseIntegration, koaIntegration, setupKoaErrorHandler, connectIntegration, diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index c9de8763750c..4efaf33c9b1c 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { + type Scope, captureException, flush, getClient, + isThenable, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, @@ -46,7 +48,8 @@ function wrapMethodWithSentry( const currentClient = getClient(); // if a client is already set, use withScope, otherwise use withIsolationScope const sentryWithScope = currentClient ? withScope : withIsolationScope; - return sentryWithScope(async scope => { + + const wrappedFunction = (scope: Scope): unknown => { // In certain situations, the passed context can become undefined. // For example, for Astro while prerendering pages at build time. // see: https://github.com/getsentry/sentry-javascript/issues/13217 @@ -65,7 +68,29 @@ function wrapMethodWithSentry( if (callback) { callback(...args); } - return await Reflect.apply(target, thisArg, args); + const result = Reflect.apply(target, thisArg, args); + + if (isThenable(result)) { + return result.then( + (res: unknown) => { + waitUntil?.(flush(2000)); + return res; + }, + (e: unknown) => { + captureException(e, { + mechanism: { + type: 'cloudflare_durableobject', + handled: false, + }, + }); + waitUntil?.(flush(2000)); + throw e; + }, + ); + } else { + waitUntil?.(flush(2000)); + return result; + } } catch (e) { captureException(e, { mechanism: { @@ -73,9 +98,8 @@ function wrapMethodWithSentry( handled: false, }, }); - throw e; - } finally { waitUntil?.(flush(2000)); + throw e; } } @@ -87,9 +111,31 @@ function wrapMethodWithSentry( : {}; // Only create these spans if they have a parent span. - return startSpan({ name: wrapperOptions.spanName, attributes, onlyIfParent: true }, async () => { + return startSpan({ name: wrapperOptions.spanName, attributes, onlyIfParent: true }, () => { try { - return await Reflect.apply(target, thisArg, args); + const result = Reflect.apply(target, thisArg, args); + + if (isThenable(result)) { + return result.then( + (res: unknown) => { + waitUntil?.(flush(2000)); + return res; + }, + (e: unknown) => { + captureException(e, { + mechanism: { + type: 'cloudflare_durableobject', + handled: false, + }, + }); + waitUntil?.(flush(2000)); + throw e; + }, + ); + } else { + waitUntil?.(flush(2000)); + return result; + } } catch (e) { captureException(e, { mechanism: { @@ -97,12 +143,13 @@ function wrapMethodWithSentry( handled: false, }, }); - throw e; - } finally { waitUntil?.(flush(2000)); + throw e; } }); - }); + }; + + return sentryWithScope(wrappedFunction); }, }); } diff --git a/packages/cloudflare/src/logs/exports.ts b/packages/cloudflare/src/logs/exports.ts index ef2614b81f55..c21477e378b3 100644 --- a/packages/cloudflare/src/logs/exports.ts +++ b/packages/cloudflare/src/logs/exports.ts @@ -19,7 +19,7 @@ function captureLog( } /** - * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `trace` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `debug` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `info` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `warn` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `error` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `fatal` level. Requires the `enableLogs` option to be enabled. * * @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/cloudflare/src/opentelemetry/tracer.ts b/packages/cloudflare/src/opentelemetry/tracer.ts index 94dc917c5070..a180346f7cce 100644 --- a/packages/cloudflare/src/opentelemetry/tracer.ts +++ b/packages/cloudflare/src/opentelemetry/tracer.ts @@ -71,8 +71,7 @@ class SentryCloudflareTracer implements Tracer { ? context : typeof fn === 'function' ? fn - : // eslint-disable-next-line @typescript-eslint/no-empty-function - () => {} + : () => {} ) as F; // In OTEL the semantic matches `startSpanManual` because spans are not auto-ended diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 022f0040893a..7ca31d00bbd5 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -24,14 +24,24 @@ import { init } from './sdk'; const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; -function propagationContextFromInstanceId(instanceId: string): PropagationContext { - // Validate and normalize traceId - should be a valid UUID with or without hyphens - if (!UUID_REGEX.test(instanceId)) { - throw new Error("Invalid 'instanceId' for workflow: Sentry requires random UUIDs for instanceId."); - } +/** + * Hashes a string to a UUID using SHA-1. + */ +export async function deterministicTraceIdFromInstanceId(instanceId: string): Promise { + const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(instanceId)); + return ( + Array.from(new Uint8Array(buf)) + // We only need the first 16 bytes for the 32 characters + .slice(0, 16) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + ); +} - // Remove hyphens to get UUID without hyphens - const traceId = instanceId.replace(/-/g, ''); +async function propagationContextFromInstanceId(instanceId: string): Promise { + const traceId = UUID_REGEX.test(instanceId) + ? instanceId.replace(/-/g, '') + : await deterministicTraceIdFromInstanceId(instanceId); // Derive sampleRand from last 4 characters of the random UUID // @@ -60,7 +70,7 @@ async function workflowStepWithSentry( addCloudResourceContext(isolationScope); return withScope(async scope => { - const propagationContext = propagationContextFromInstanceId(instanceId); + const propagationContext = await propagationContextFromInstanceId(instanceId); scope.setPropagationContext(propagationContext); // eslint-disable-next-line no-return-await diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index b627c4051b41..2add5dde9343 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -8,6 +8,7 @@ describe('instrumentDurableObjectWithSentry', () => { afterEach(() => { vi.restoreAllMocks(); }); + it('Generic functionality', () => { const options = vi.fn(); const instrumented = instrumentDurableObjectWithSentry(options, vi.fn()); @@ -15,13 +16,35 @@ describe('instrumentDurableObjectWithSentry', () => { expect(() => Reflect.construct(instrumented, [])).not.toThrow(); expect(options).toHaveBeenCalledOnce(); }); - it('Instruments prototype methods and defines implementation in the object', () => { + + it('Instruments sync prototype methods and defines implementation in the object', () => { const testClass = class { - method() {} + method() { + return 'sync-result'; + } }; const obj = Reflect.construct(instrumentDurableObjectWithSentry(vi.fn(), testClass as any), []) as any; expect(obj.method).toBe(obj.method); + + const result = obj.method(); + expect(result).not.toBeInstanceOf(Promise); + expect(result).toEqual('sync-result'); }); + + it('Instruments async prototype methods and returns a promise', async () => { + const testClass = class { + async asyncMethod() { + return 'async-result'; + } + }; + const obj = Reflect.construct(instrumentDurableObjectWithSentry(vi.fn(), testClass as any), []) as any; + expect(obj.asyncMethod).toBe(obj.asyncMethod); + + const result = obj.asyncMethod(); + expect(result).toBeInstanceOf(Promise); + expect(await result).toBe('async-result'); + }); + it('Instruments prototype methods without "sticking" to the options', () => { const initCore = vi.spyOn(SentryCore, 'initAndBind'); vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); @@ -41,6 +64,7 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(1, expect.any(Function), expect.objectContaining({ orgId: 1 })); expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); + it('All available durable object methods are instrumented', () => { const testClass = class { propertyFunction = vi.fn(); @@ -72,6 +96,7 @@ describe('instrumentDurableObjectWithSentry', () => { expect(isInstrumented((obj as any)[method_name]), `Method ${method_name} is instrumented`).toBeTruthy(); } }); + it('flush performs after all waitUntil promises are finished', async () => { vi.useFakeTimers(); onTestFinished(() => { diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index 03eee5191eb2..c403023fb525 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/unbound-method */ import type { WorkflowEvent, WorkflowStep, WorkflowStepConfig } from 'cloudflare:workers'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { instrumentWorkflowWithSentry } from '../src/workflows'; +import { deterministicTraceIdFromInstanceId, instrumentWorkflowWithSentry } from '../src/workflows'; + +const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0]!); const mockStep: WorkflowStep = { do: vi @@ -63,11 +65,18 @@ const INSTANCE_ID = 'ae0ee067-61b3-4852-9219-5d62282270f0'; const SAMPLE_RAND = '0.44116884107728693'; const TRACE_ID = INSTANCE_ID.replace(/-/g, ''); -describe('workflows', () => { +describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => { beforeEach(() => { vi.clearAllMocks(); }); + test('hashStringToUuid hashes a string to a UUID for Sentry trace ID', async () => { + const UUID_WITHOUT_HYPHENS_REGEX = /^[0-9a-f]{32}$/i; + expect(await deterministicTraceIdFromInstanceId('s')).toMatch(UUID_WITHOUT_HYPHENS_REGEX); + expect(await deterministicTraceIdFromInstanceId('test-string')).toMatch(UUID_WITHOUT_HYPHENS_REGEX); + expect(await deterministicTraceIdFromInstanceId(INSTANCE_ID)).toMatch(UUID_WITHOUT_HYPHENS_REGEX); + }); + test('Calls expected functions', async () => { class BasicTestWorkflow { constructor(_ctx: ExecutionContext, _env: unknown) {} @@ -133,6 +142,71 @@ describe('workflows', () => { ]); }); + test('Calls expected functions with non-uuid instance id', async () => { + class BasicTestWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const files = await step.do('first step', async () => { + return { files: ['doc_7392_rev3.pdf', 'report_x29_final.pdf'] }; + }); + } + } + + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(getSentryOptions, BasicTestWorkflow as any); + const workflow = new TestWorkflowInstrumented(mockContext, {}) as BasicTestWorkflow; + const event = { payload: {}, timestamp: new Date(), instanceId: 'ae0ee067' }; + await workflow.run(event, mockStep); + + expect(mockStep.do).toHaveBeenCalledTimes(1); + expect(mockStep.do).toHaveBeenCalledWith('first step', expect.any(Function)); + expect(mockContext.waitUntil).toHaveBeenCalledTimes(1); + expect(mockContext.waitUntil).toHaveBeenCalledWith(expect.any(Promise)); + expect(mockTransport.send).toHaveBeenCalledTimes(1); + expect(mockTransport.send).toHaveBeenCalledWith([ + expect.objectContaining({ + trace: expect.objectContaining({ + transaction: 'first step', + trace_id: '0d2b6d1743ce6d53af4f5ee416ad5d1b', + sample_rand: '0.3636987869077592', + }), + }), + [ + [ + { + type: 'transaction', + }, + expect.objectContaining({ + event_id: expect.any(String), + contexts: { + trace: { + parent_span_id: undefined, + span_id: expect.any(String), + trace_id: '0d2b6d1743ce6d53af4f5ee416ad5d1b', + data: { + 'sentry.origin': 'auto.faas.cloudflare.workflow', + 'sentry.op': 'function.step.do', + 'sentry.source': 'task', + 'sentry.sample_rate': 1, + }, + op: 'function.step.do', + status: 'ok', + origin: 'auto.faas.cloudflare.workflow', + }, + cloud_resource: { 'cloud.provider': 'cloudflare' }, + runtime: { name: 'cloudflare' }, + }, + type: 'transaction', + transaction_info: { source: 'task' }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ], + ], + ]); + }); + class ErrorTestWorkflow { count = 0; constructor(_ctx: ExecutionContext, _env: unknown) {} diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts new file mode 100644 index 000000000000..fbb8afd3b9fe --- /dev/null +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -0,0 +1,428 @@ +/** + * Sentry-internal base interface for build-time options used in Sentry's meta-framework SDKs (e.g., Next.js, Nuxt, SvelteKit). + * + * SDKs should extend this interface to add framework-specific configurations. To include bundler-specific + * options, combine this type with one of the `Unstable[Bundler]PluginOptions` types, such as + * `UnstableVitePluginOptions` or `UnstableWebpackPluginOptions`. + * + * If an option from this base interface doesn't apply to an SDK, use the `Omit` utility type to exclude it. + * + * @example + * ```typescript + * import type { BuildTimeOptionsBase, UnstableVitePluginOptions } from '@sentry/core'; + * import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; + * + * // Example of how a framework SDK would define its build-time options + * type MyFrameworkBuildOptions = + * BuildTimeOptionsBase & + * UnstableVitePluginOptions & { + * // Framework-specific options can be added here + * myFrameworkSpecificOption?: boolean; + * }; + * ``` + * + * @internal Only meant for Sentry-internal SDK usage. + * @hidden + */ +export interface BuildTimeOptionsBase { + /** + * The slug of the Sentry organization associated with the app. + * + * This value can also be specified via the `SENTRY_ORG` environment variable. + */ + org?: string; + + /** + * The slug of the Sentry project associated with the app. + * + * This value can also be specified via the `SENTRY_PROJECT` environment variable. + */ + project?: string; + + /** + * The authentication token to use for all communication with Sentry. + * Can be obtained from https://sentry.io/orgredirect/organizations/:orgslug/settings/auth-tokens/. + * + * This value can also be specified via the `SENTRY_AUTH_TOKEN` environment variable. + * + * @see https://docs.sentry.io/product/accounts/auth-tokens/#organization-auth-tokens + */ + authToken?: string; + + /** + * The base URL of your Sentry instance. Use this if you are using a self-hosted + * or Sentry instance other than sentry.io. + * + * This value can also be set via the `SENTRY_URL` environment variable. + * + * @default "https://sentry.io" + */ + sentryUrl?: string; + + /** + * Additional headers to send with every outgoing request to Sentry. + */ + headers?: Record; + + /** + * If this flag is `true`, internal plugin errors and performance data will be sent to Sentry. + * It will not collect any sensitive or user-specific data. + * + * At Sentry, we like to use Sentry ourselves to deliver faster and more stable products. + * We're very careful of what we're sending. We won't collect anything other than error + * and high-level performance data. We will never collect your code or any details of the + * projects in which you're using this plugin. + * + * @default true + */ + telemetry?: boolean; + + /** + * Suppresses all Sentry SDK build logs. + * + * @default false + */ + silent?: boolean; + + /** + * Enable debug information logs about the SDK during build-time. + * Enabling this will give you, for example, logs about source maps. + * + * @default false + */ + debug?: boolean; + + /** + * Options related to source maps upload and processing. + */ + sourcemaps?: SourceMapsOptions; + + /** + * Options related to managing the Sentry releases for a build. + * + * More info: https://docs.sentry.io/product/releases/ + */ + release?: ReleaseOptions; + + /** + * Options for bundle size optimizations by excluding certain features of the Sentry SDK. + */ + bundleSizeOptimizations?: BundleSizeOptimizationsOptions; +} + +/** + * Utility type for adding Vite plugin options to build-time configuration. + * Use this type to extend your build-time options with Vite-specific plugin configurations. + * + * @template PluginOptionsType - The type of Vite plugin options to include + * + * @example + * ```typescript + * type SomeSDKsBuildOptions = BuildTimeOptionsBase & UnstableVitePluginOptions; + * ``` + * + * @internal Only meant for Sentry-internal SDK usage. + * @hidden + */ +export type UnstableVitePluginOptions = { + /** + * Options to be passed directly to the Sentry Vite Plugin (`@sentry/vite-plugin`) that ships with the Sentry SDK. + * You can use this option to override any options the SDK passes to the Vite plugin. + * + * Please note that this option is unstable and may change in a breaking way in any release. + */ + unstable_sentryVitePluginOptions?: PluginOptionsType; +}; + +/** + * Utility type for adding Webpack plugin options to build-time configuration. + * Use this type to extend your build-time options with Webpack-specific plugin configurations. + * + * @template PluginOptionsType - The type of Webpack plugin options to include + * + * @example + * ```typescript + * type SomeSDKsBuildOptions = BuildTimeOptionsBase & UnstableWebpackPluginOptions; + * ``` + * + * @internal Only meant for Sentry-internal SDK usage. + * @hidden + */ +export type UnstableWebpackPluginOptions = { + /** + * Options to be passed directly to the Sentry Webpack Plugin (`@sentry/webpack-plugin`) that ships with the Sentry SDK. + * You can use this option to override any options the SDK passes to the Webpack plugin. + * + * Please note that this option is unstable and may change in a breaking way in any release. + */ + unstable_sentryWebpackPluginOptions?: PluginOptionsType; +}; + +/** + * Utility type for adding Rollup plugin options to build-time configuration. + * Use this type to extend your build-time options with Rollup-specific plugin configurations. + * + * @template PluginOptionsType - The type of Rollup plugin options to include + * + * @example + * ```typescript + * type SomeSDKsBuildOptions = BuildTimeOptionsBase & UnstableRollupPluginOptions; + * ``` + * + * @internal Only meant for Sentry-internal SDK usage. + * @hidden + */ +export type UnstableRollupPluginOptions = { + /** + * Options to be passed directly to the Sentry Rollup Plugin (`@sentry/rollup-plugin`) that ships with the Sentry SDK. + * You can use this option to override any options the SDK passes to the Rollup plugin. + * + * Please note that this option is unstable and may change in a breaking way in any release. + */ + unstable_sentryRollupPluginOptions?: PluginOptionsType; +}; + +interface SourceMapsOptions { + /** + * If this flag is `true`, any functionality related to source maps will be disabled. + * + * @default false + */ + disable?: boolean; + + /** + * A glob or an array of globs that specify the build artifacts and source maps that will be uploaded to Sentry. + * + * The globbing patterns must follow the implementation of the `glob` package: https://www.npmjs.com/package/glob#glob-primer + * + * If this option is not specified, the plugin will try to upload all JavaScript files and source map files that are created during build. + * Use the `debug` option to print information about which files end up being uploaded. + * + */ + assets?: string | string[]; + + /** + * A glob or an array of globs that specifies which build artifacts should not be uploaded to Sentry. + * + * The globbing patterns must follow the implementation of the `glob` package: https://www.npmjs.com/package/glob#glob-primer + * + * Use the `debug` option to print information about which files end up being uploaded. + * + * @default [] + */ + ignore?: string | string[]; + + /** + * A glob or an array of globs that specifies the build artifacts that should be deleted after the artifact + * upload to Sentry has been completed. + * + * The globbing patterns must follow the implementation of the `glob` package: https://www.npmjs.com/package/glob#glob-primer + */ + filesToDeleteAfterUpload?: string | Array; +} + +interface ReleaseOptions { + /** + * Unique identifier for the release you want to create. + * + * This value can also be specified via the `SENTRY_RELEASE` environment variable. + * + * Defaults to automatically detecting a value for your environment. + * This includes values for Cordova, Heroku, AWS CodeBuild, CircleCI, Xcode, and Gradle, and otherwise uses the git `HEAD`'s commit SHA + * (the latter requires access to git CLI and for the root directory to be a valid repository). + * + * If no `name` is provided and the plugin can't automatically detect one, no release will be created. + */ + name?: string; + + /** + * Whether to create a new release. + * + * Note that a release may still appear in Sentry even if this value is `false`. Any Sentry event that has a release value attached + * will automatically create a release (for example, via the `inject` option). + * + * @default true + */ + create?: boolean; + + /** + * Whether to automatically finalize the release. The release is finalized by adding an end timestamp after the build ends. + * + * @default true + */ + finalize?: boolean; + + /** + * Unique distribution identifier for the release. Used to further segment the release. + * + * Usually your build number. + */ + dist?: string; + + /** + * Version control system (VCS) remote name. + * + * This value can also be specified via the `SENTRY_VSC_REMOTE` environment variable. + * + * @default "origin" + */ + vcsRemote?: string; + + /** + * Configuration for associating the release with its commits in Sentry. + */ + setCommits?: ( + | { + /** + * Automatically sets `commit` and `previousCommit`. Sets `commit` to `HEAD` + * and `previousCommit` as described in the option's documentation. + * + * If you set this to `true`, manually specified `commit` and `previousCommit` + * options will be overridden. It is best to not specify them at all if you + * set this option to `true`. + */ + auto: true; + repo?: undefined; + commit?: undefined; + } + | { + auto?: false | undefined; + /** + * The full repo name as defined in Sentry. + * + * Required if the `auto` option is not set to `true`. + */ + repo: string; + + /** + * The current (last) commit in the release. + * + * Required if the `auto` option is not set to `true`. + */ + commit: string; + } + ) & { + /** + * The commit before the beginning of this release (in other words, + * the last commit of the previous release). + * + * Defaults to the last commit of the previous release in Sentry. + * + * If there was no previous release, the last 10 commits will be used. + */ + previousCommit?: string; + + /** + * If the flag is to `true` and the previous release commit was not found + * in the repository, the plugin creates a release with the default commits + * count instead of failing the command. + * + * @default false + */ + ignoreMissing?: boolean; + + /** + * If this flag is set, the setCommits step will not fail and just exit + * silently if no new commits for a given release have been found. + * + * @default false + */ + ignoreEmpty?: boolean; + }; + + /** + * Configuration for adding deployment information to the release in Sentry. + */ + deploy?: { + /** + * Environment for this release. Values that make sense here would + * be `production` or `staging`. + */ + env: string; + + /** + * Deployment start time in Unix timestamp (in seconds) or ISO 8601 format. + */ + started?: number | string; + + /** + * Deployment finish time in Unix timestamp (in seconds) or ISO 8601 format. + */ + finished?: number | string; + + /** + * Deployment duration (in seconds). Can be used instead of started and finished. + */ + time?: number; + + /** + * Human-readable name for the deployment. + */ + name?: string; + + /** + * URL that points to the deployment. + */ + url?: string; + }; +} + +interface BundleSizeOptimizationsOptions { + /** + * Exclude debug statements from the bundle, thus disabling features like the SDK's `debug` option. + * + * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) any debugging code within itself during the build. + * Note that the success of this depends on tree shaking being enabled in your build tooling. + * + * @default false + */ + excludeDebugStatements?: boolean; + + /** + * Exclude tracing functionality from the bundle, thus disabling features like performance monitoring. + * + * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) code within itself that is related to tracing and performance monitoring. + * Note that the success of this depends on tree shaking being enabled in your build tooling. + * + * **Notice:** Do not enable this when you're using any performance monitoring-related SDK features (e.g. `Sentry.startTransaction()`). + + * @default false + */ + excludeTracing?: boolean; + + /** + * Exclude Replay Shadow DOM functionality from the bundle. + * + * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) code related to the SDK's Session Replay Shadow DOM recording functionality. + * Note that the success of this depends on tree shaking being enabled in your build tooling. + * + * This option is safe to be used when you do not want to capture any Shadow DOM activity via Sentry Session Replay. + * + * @default false + */ + excludeReplayShadowDom?: boolean; + + /** + * Exclude Replay iFrame functionality from the bundle. + * + * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) code related to the SDK's Session Replay `iframe` recording functionality. + * Note that the success of this depends on tree shaking being enabled in your build tooling. + * + * You can safely do this when you do not want to capture any `iframe` activity via Sentry Session Replay. + * + * @default false + */ + excludeReplayIframe?: boolean; + + /** + * Exclude Replay worker functionality from the bundle. + * + * If set to `true`, the Sentry SDK will attempt to tree-shake (remove) code related to the SDK's Session Replay's Compression Web Worker. + * Note that the success of this depends on tree shaking being enabled in your build tooling. + * + * **Notice:** You should only use this option if you manually host a compression worker and configure it in your Sentry Session Replay integration config via the `workerUrl` option. + * + * @default false + */ + excludeReplayWorker?: boolean; +} diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts index 9fde3f7b74f0..201e79cb4514 100644 --- a/packages/core/src/carrier.ts +++ b/packages/core/src/carrier.ts @@ -3,7 +3,6 @@ import type { AsyncContextStrategy } from './asyncContext/types'; import type { Client } from './client'; import type { Scope } from './scope'; import type { SerializedLog } from './types-hoist/log'; -import type { Logger } from './utils/debug-logger'; import { SDK_VERSION } from './utils/version'; import { GLOBAL_OBJ } from './utils/worldwide'; @@ -26,9 +25,6 @@ export interface SentryCarrier { globalScope?: Scope; defaultIsolationScope?: Scope; defaultCurrentScope?: Scope; - /** @deprecated Logger is no longer set. Instead, we keep enabled state in loggerSettings. */ - // eslint-disable-next-line deprecation/deprecation - logger?: Logger; loggerSettings?: { enabled: boolean }; /** * A map of Sentry clients to their log buffers. diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index ab450788459c..8b03392106d9 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1246,18 +1246,6 @@ export abstract class Client { ): PromiseLike; } -/** - * @deprecated Use `Client` instead. This alias may be removed in a future major version. - */ -// TODO(v10): Remove -export type BaseClient = Client; - -/** - * @deprecated Use `Client` instead. This alias may be removed in a future major version. - */ -// TODO(v10): Remove -export const BaseClient = Client; - /** * Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so. */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3e020fc6aa77..0747258113a9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,11 +49,7 @@ export { Scope } from './scope'; export type { CaptureContext, ScopeContext, ScopeData } from './scope'; export { notifyEventProcessors } from './eventProcessors'; export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint } from './api'; -export { - Client, - // eslint-disable-next-line deprecation/deprecation - BaseClient, -} from './client'; +export { Client } from './client'; export { ServerRuntimeClient } from './server-runtime-client'; export { initAndBind, setCurrentClient } from './sdk'; export { createTransport } from './transports/base'; @@ -63,8 +59,6 @@ export { getIntegrationsToSetup, addIntegration, defineIntegration } from './int export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent'; export { prepareEvent } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; -// eslint-disable-next-line deprecation/deprecation -export { hasTracingEnabled } from './utils/hasSpansEnabled'; export { hasSpansEnabled } from './utils/hasSpansEnabled'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; @@ -118,7 +112,7 @@ export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integra export { profiler } from './profiling'; export { instrumentFetchRequest } from './fetch'; export { trpcMiddleware } from './trpc'; -export { wrapMcpServerWithSentry } from './mcp-server'; +export { wrapMcpServerWithSentry } from './integrations/mcp-server'; export { captureFeedback } from './feedback'; export type { ReportDialogOptions } from './report-dialog'; export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/exports'; @@ -128,6 +122,7 @@ export { instrumentOpenAiClient } from './utils/openai'; export { OPENAI_INTEGRATION_NAME } from './utils/openai/constants'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; export type { FeatureFlag } from './utils/featureFlags'; + export { _INTERNAL_copyFlagsFromScopeToEvent, _INTERNAL_insertFlagToScope, @@ -167,10 +162,8 @@ export { isVueViewModel, } from './utils/is'; export { isBrowser } from './utils/isBrowser'; -// eslint-disable-next-line deprecation/deprecation -export { CONSOLE_LEVELS, consoleSandbox, debug, logger, originalConsoleMethods } from './utils/debug-logger'; -// eslint-disable-next-line deprecation/deprecation -export type { Logger, SentryDebugLogger } from './utils/debug-logger'; +export { CONSOLE_LEVELS, consoleSandbox, debug, originalConsoleMethods } from './utils/debug-logger'; +export type { SentryDebugLogger } from './utils/debug-logger'; export { addContextToFrame, addExceptionMechanism, @@ -227,6 +220,7 @@ export { extractTraceparentData, generateSentryTraceHeader, propagationContextFromHeaders, + shouldContinueTrace, } from './utils/tracing'; export { getSDKSource, isBrowserBundle } from './utils/env'; export type { SdkSource } from './utils/env'; @@ -275,6 +269,7 @@ export { callFrameToStackFrame, watchdogTimer } from './utils/anr'; export { LRUMap } from './utils/lru'; export { generateTraceId, generateSpanId } from './utils/propagationContext'; export { vercelWaitUntil } from './utils/vercelWaitUntil'; +export { flushIfServerless } from './utils/flushIfServerless'; export { SDK_VERSION } from './utils/version'; export { getDebugImagesForResources, getFilenameToDebugIdMap } from './utils/debug-ids'; export { escapeStringForRegex } from './vendor/escapeStringForRegex'; @@ -444,3 +439,9 @@ export type { ContinuousProfiler, ProfilingIntegration, Profiler } from './types export type { ViewHierarchyData, ViewHierarchyWindow } from './types-hoist/view-hierarchy'; export type { LegacyCSPReport } from './types-hoist/csp'; export type { SerializedLog, SerializedLogContainer } from './types-hoist/log'; +export type { + BuildTimeOptionsBase, + UnstableVitePluginOptions, + UnstableRollupPluginOptions, + UnstableWebpackPluginOptions, +} from './build-time-plugins/buildTimeOptionsBase'; diff --git a/packages/core/src/integrations/mcp-server/attributeExtraction.ts b/packages/core/src/integrations/mcp-server/attributeExtraction.ts new file mode 100644 index 000000000000..68eade987a08 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/attributeExtraction.ts @@ -0,0 +1,379 @@ +/** + * Attribute extraction and building functions for MCP server instrumentation + */ + +import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url'; +import { + CLIENT_ADDRESS_ATTRIBUTE, + CLIENT_PORT_ATTRIBUTE, + MCP_LOGGING_DATA_TYPE_ATTRIBUTE, + MCP_LOGGING_LEVEL_ATTRIBUTE, + MCP_LOGGING_LOGGER_ATTRIBUTE, + MCP_LOGGING_MESSAGE_ATTRIBUTE, + MCP_PROTOCOL_VERSION_ATTRIBUTE, + MCP_REQUEST_ID_ATTRIBUTE, + MCP_RESOURCE_URI_ATTRIBUTE, + MCP_SERVER_NAME_ATTRIBUTE, + MCP_SERVER_TITLE_ATTRIBUTE, + MCP_SERVER_VERSION_ATTRIBUTE, + MCP_SESSION_ID_ATTRIBUTE, + MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE, + MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE, + MCP_TRANSPORT_ATTRIBUTE, + NETWORK_PROTOCOL_VERSION_ATTRIBUTE, + NETWORK_TRANSPORT_ATTRIBUTE, +} from './attributes'; +import { extractTargetInfo, getRequestArguments } from './methodConfig'; +import { + getClientInfoForTransport, + getProtocolVersionForTransport, + getSessionDataForTransport, +} from './sessionManagement'; +import type { + ExtraHandlerData, + JsonRpcNotification, + JsonRpcRequest, + McpSpanType, + MCPTransport, + PartyInfo, + SessionData, +} from './types'; + +/** + * Extracts transport types based on transport constructor name + * @param transport - MCP transport instance + * @returns Transport type mapping for span attributes + */ +export function getTransportTypes(transport: MCPTransport): { mcpTransport: string; networkTransport: string } { + const transportName = transport.constructor?.name?.toLowerCase() || ''; + + if (transportName.includes('stdio')) { + return { mcpTransport: 'stdio', networkTransport: 'pipe' }; + } + + if (transportName.includes('streamablehttp') || transportName.includes('streamable')) { + return { mcpTransport: 'http', networkTransport: 'tcp' }; + } + + if (transportName.includes('sse')) { + return { mcpTransport: 'sse', networkTransport: 'tcp' }; + } + + return { mcpTransport: 'unknown', networkTransport: 'unknown' }; +} + +/** + * Extracts additional attributes for specific notification types + * @param method - Notification method name + * @param params - Notification parameters + * @returns Method-specific attributes for span instrumentation + */ +export function getNotificationAttributes( + method: string, + params: Record, +): Record { + const attributes: Record = {}; + + switch (method) { + case 'notifications/cancelled': + if (params?.requestId) { + attributes['mcp.cancelled.request_id'] = String(params.requestId); + } + if (params?.reason) { + attributes['mcp.cancelled.reason'] = String(params.reason); + } + break; + + case 'notifications/message': + if (params?.level) { + attributes[MCP_LOGGING_LEVEL_ATTRIBUTE] = String(params.level); + } + if (params?.logger) { + attributes[MCP_LOGGING_LOGGER_ATTRIBUTE] = String(params.logger); + } + if (params?.data !== undefined) { + attributes[MCP_LOGGING_DATA_TYPE_ATTRIBUTE] = typeof params.data; + if (typeof params.data === 'string') { + attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = params.data; + } else { + attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = JSON.stringify(params.data); + } + } + break; + + case 'notifications/progress': + if (params?.progressToken) { + attributes['mcp.progress.token'] = String(params.progressToken); + } + if (typeof params?.progress === 'number') { + attributes['mcp.progress.current'] = params.progress; + } + if (typeof params?.total === 'number') { + attributes['mcp.progress.total'] = params.total; + if (typeof params?.progress === 'number') { + attributes['mcp.progress.percentage'] = (params.progress / params.total) * 100; + } + } + if (params?.message) { + attributes['mcp.progress.message'] = String(params.message); + } + break; + + case 'notifications/resources/updated': + if (params?.uri) { + attributes[MCP_RESOURCE_URI_ATTRIBUTE] = String(params.uri); + const urlObject = parseStringToURLObject(String(params.uri)); + if (urlObject && !isURLObjectRelative(urlObject)) { + attributes['mcp.resource.protocol'] = urlObject.protocol.replace(':', ''); + } + } + break; + + case 'notifications/initialized': + attributes['mcp.lifecycle.phase'] = 'initialization_complete'; + attributes['mcp.protocol.ready'] = 1; + break; + } + + return attributes; +} + +/** + * Extracts and validates PartyInfo from an unknown object + * @param obj - Unknown object that might contain party info + * @returns Validated PartyInfo object with only string properties + */ +function extractPartyInfo(obj: unknown): PartyInfo { + const partyInfo: PartyInfo = {}; + + if (obj && typeof obj === 'object' && obj !== null) { + const source = obj as Record; + if (typeof source.name === 'string') partyInfo.name = source.name; + if (typeof source.title === 'string') partyInfo.title = source.title; + if (typeof source.version === 'string') partyInfo.version = source.version; + } + + return partyInfo; +} + +/** + * Extracts session data from "initialize" requests + * @param request - JSON-RPC "initialize" request containing client info and protocol version + * @returns Session data extracted from request parameters including protocol version and client info + */ +export function extractSessionDataFromInitializeRequest(request: JsonRpcRequest): SessionData { + const sessionData: SessionData = {}; + if (request.params && typeof request.params === 'object' && request.params !== null) { + const params = request.params as Record; + if (typeof params.protocolVersion === 'string') { + sessionData.protocolVersion = params.protocolVersion; + } + if (params.clientInfo) { + sessionData.clientInfo = extractPartyInfo(params.clientInfo); + } + } + return sessionData; +} + +/** + * Extracts session data from "initialize" response + * @param result - "initialize" response result containing server info and protocol version + * @returns Partial session data extracted from response including protocol version and server info + */ +export function extractSessionDataFromInitializeResponse(result: unknown): Partial { + const sessionData: Partial = {}; + if (result && typeof result === 'object') { + const resultObj = result as Record; + if (typeof resultObj.protocolVersion === 'string') sessionData.protocolVersion = resultObj.protocolVersion; + if (resultObj.serverInfo) { + sessionData.serverInfo = extractPartyInfo(resultObj.serverInfo); + } + } + return sessionData; +} + +/** + * Build client attributes from stored client info + * @param transport - MCP transport instance + * @returns Client attributes for span instrumentation + */ +export function getClientAttributes(transport: MCPTransport): Record { + const clientInfo = getClientInfoForTransport(transport); + const attributes: Record = {}; + + if (clientInfo?.name) { + attributes['mcp.client.name'] = clientInfo.name; + } + if (clientInfo?.title) { + attributes['mcp.client.title'] = clientInfo.title; + } + if (clientInfo?.version) { + attributes['mcp.client.version'] = clientInfo.version; + } + + return attributes; +} + +/** + * Build server attributes from stored server info + * @param transport - MCP transport instance + * @returns Server attributes for span instrumentation + */ +export function getServerAttributes(transport: MCPTransport): Record { + const serverInfo = getSessionDataForTransport(transport)?.serverInfo; + const attributes: Record = {}; + + if (serverInfo?.name) { + attributes[MCP_SERVER_NAME_ATTRIBUTE] = serverInfo.name; + } + if (serverInfo?.title) { + attributes[MCP_SERVER_TITLE_ATTRIBUTE] = serverInfo.title; + } + if (serverInfo?.version) { + attributes[MCP_SERVER_VERSION_ATTRIBUTE] = serverInfo.version; + } + + return attributes; +} + +/** + * Extracts client connection info from extra handler data + * @param extra - Extra handler data containing connection info + * @returns Client address and port information + */ +export function extractClientInfo(extra: ExtraHandlerData): { + address?: string; + port?: number; +} { + return { + address: + extra?.requestInfo?.remoteAddress || + extra?.clientAddress || + extra?.request?.ip || + extra?.request?.connection?.remoteAddress, + port: extra?.requestInfo?.remotePort || extra?.clientPort || extra?.request?.connection?.remotePort, + }; +} + +/** + * Build transport and network attributes + * @param transport - MCP transport instance + * @param extra - Optional extra handler data + * @returns Transport attributes for span instrumentation + */ +export function buildTransportAttributes( + transport: MCPTransport, + extra?: ExtraHandlerData, +): Record { + const sessionId = transport.sessionId; + const clientInfo = extra ? extractClientInfo(extra) : {}; + const { mcpTransport, networkTransport } = getTransportTypes(transport); + const clientAttributes = getClientAttributes(transport); + const serverAttributes = getServerAttributes(transport); + const protocolVersion = getProtocolVersionForTransport(transport); + + const attributes = { + ...(sessionId && { [MCP_SESSION_ID_ATTRIBUTE]: sessionId }), + ...(clientInfo.address && { [CLIENT_ADDRESS_ATTRIBUTE]: clientInfo.address }), + ...(clientInfo.port && { [CLIENT_PORT_ATTRIBUTE]: clientInfo.port }), + [MCP_TRANSPORT_ATTRIBUTE]: mcpTransport, + [NETWORK_TRANSPORT_ATTRIBUTE]: networkTransport, + [NETWORK_PROTOCOL_VERSION_ATTRIBUTE]: '2.0', + ...(protocolVersion && { [MCP_PROTOCOL_VERSION_ATTRIBUTE]: protocolVersion }), + ...clientAttributes, + ...serverAttributes, + }; + + return attributes; +} + +/** + * Build type-specific attributes based on message type + * @param type - Span type (request or notification) + * @param message - JSON-RPC message + * @param params - Optional parameters for attribute extraction + * @returns Type-specific attributes for span instrumentation + */ +export function buildTypeSpecificAttributes( + type: McpSpanType, + message: JsonRpcRequest | JsonRpcNotification, + params?: Record, +): Record { + if (type === 'request') { + const request = message as JsonRpcRequest; + const targetInfo = extractTargetInfo(request.method, params || {}); + + return { + ...(request.id !== undefined && { [MCP_REQUEST_ID_ATTRIBUTE]: String(request.id) }), + ...targetInfo.attributes, + ...getRequestArguments(request.method, params || {}), + }; + } + + return getNotificationAttributes(message.method, params || {}); +} + +/** + * Build attributes for tool result content items + * @param content - Array of content items from tool result + * @returns Attributes extracted from each content item including type, text, mime type, URI, and resource info + */ +function buildAllContentItemAttributes(content: unknown[]): Record { + const attributes: Record = { + [MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE]: content.length, + }; + + for (const [i, item] of content.entries()) { + if (typeof item !== 'object' || item === null) continue; + + const contentItem = item as Record; + const prefix = content.length === 1 ? 'mcp.tool.result' : `mcp.tool.result.${i}`; + + const safeSet = (key: string, value: unknown): void => { + if (typeof value === 'string') attributes[`${prefix}.${key}`] = value; + }; + + safeSet('content_type', contentItem.type); + safeSet('mime_type', contentItem.mimeType); + safeSet('uri', contentItem.uri); + safeSet('name', contentItem.name); + + if (typeof contentItem.text === 'string') { + const text = contentItem.text; + const maxLength = 500; + attributes[`${prefix}.content`] = text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text; + } + + if (typeof contentItem.data === 'string') { + attributes[`${prefix}.data_size`] = contentItem.data.length; + } + + const resource = contentItem.resource; + if (typeof resource === 'object' && resource !== null) { + const res = resource as Record; + safeSet('resource_uri', res.uri); + safeSet('resource_mime_type', res.mimeType); + } + } + + return attributes; +} + +/** + * Extract tool result attributes for span instrumentation + * @param result - Tool execution result + * @returns Attributes extracted from tool result content + */ +export function extractToolResultAttributes(result: unknown): Record { + let attributes: Record = {}; + if (typeof result !== 'object' || result === null) return attributes; + + const resultObj = result as Record; + if (typeof resultObj.isError === 'boolean') { + attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = resultObj.isError; + } + if (Array.isArray(resultObj.content)) { + attributes = { ...attributes, ...buildAllContentItemAttributes(resultObj.content) }; + } + return attributes; +} diff --git a/packages/core/src/integrations/mcp-server/attributes.ts b/packages/core/src/integrations/mcp-server/attributes.ts new file mode 100644 index 000000000000..074bd09b7bdf --- /dev/null +++ b/packages/core/src/integrations/mcp-server/attributes.ts @@ -0,0 +1,144 @@ +/** + * Essential MCP attribute constants for Sentry instrumentation + * + * Based on OpenTelemetry MCP semantic conventions + * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md + */ + +// ============================================================================= +// CORE MCP ATTRIBUTES +// ============================================================================= + +/** The name of the request or notification method */ +export const MCP_METHOD_NAME_ATTRIBUTE = 'mcp.method.name'; + +/** JSON-RPC request identifier for the request. Unique within the MCP session. */ +export const MCP_REQUEST_ID_ATTRIBUTE = 'mcp.request.id'; + +/** Identifies the MCP session */ +export const MCP_SESSION_ID_ATTRIBUTE = 'mcp.session.id'; + +/** Transport method used for MCP communication */ +export const MCP_TRANSPORT_ATTRIBUTE = 'mcp.transport'; + +// ============================================================================= +// CLIENT ATTRIBUTES +// ============================================================================= + +/** Name of the MCP client application */ +export const MCP_CLIENT_NAME_ATTRIBUTE = 'mcp.client.name'; + +/** Display title of the MCP client application */ +export const MCP_CLIENT_TITLE_ATTRIBUTE = 'mcp.client.title'; + +/** Version of the MCP client application */ +export const MCP_CLIENT_VERSION_ATTRIBUTE = 'mcp.client.version'; + +// ============================================================================= +// SERVER ATTRIBUTES +// ============================================================================= + +/** Name of the MCP server application */ +export const MCP_SERVER_NAME_ATTRIBUTE = 'mcp.server.name'; + +/** Display title of the MCP server application */ +export const MCP_SERVER_TITLE_ATTRIBUTE = 'mcp.server.title'; + +/** Version of the MCP server application */ +export const MCP_SERVER_VERSION_ATTRIBUTE = 'mcp.server.version'; + +/** MCP protocol version used in the session */ +export const MCP_PROTOCOL_VERSION_ATTRIBUTE = 'mcp.protocol.version'; + +// ============================================================================= +// METHOD-SPECIFIC ATTRIBUTES +// ============================================================================= + +/** Name of the tool being called */ +export const MCP_TOOL_NAME_ATTRIBUTE = 'mcp.tool.name'; + +/** The resource URI being accessed */ +export const MCP_RESOURCE_URI_ATTRIBUTE = 'mcp.resource.uri'; + +/** Name of the prompt template */ +export const MCP_PROMPT_NAME_ATTRIBUTE = 'mcp.prompt.name'; + +// ============================================================================= +// TOOL RESULT ATTRIBUTES +// ============================================================================= + +/** Whether a tool execution resulted in an error */ +export const MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE = 'mcp.tool.result.is_error'; + +/** Number of content items in the tool result */ +export const MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE = 'mcp.tool.result.content_count'; + +/** Serialized content of the tool result */ +export const MCP_TOOL_RESULT_CONTENT_ATTRIBUTE = 'mcp.tool.result.content'; + +// ============================================================================= +// REQUEST ARGUMENT ATTRIBUTES +// ============================================================================= + +/** Prefix for MCP request argument prefix for each argument */ +export const MCP_REQUEST_ARGUMENT = 'mcp.request.argument'; + +// ============================================================================= +// LOGGING ATTRIBUTES +// ============================================================================= + +/** Log level for MCP logging operations */ +export const MCP_LOGGING_LEVEL_ATTRIBUTE = 'mcp.logging.level'; + +/** Logger name for MCP logging operations */ +export const MCP_LOGGING_LOGGER_ATTRIBUTE = 'mcp.logging.logger'; + +/** Data type of the logged message */ +export const MCP_LOGGING_DATA_TYPE_ATTRIBUTE = 'mcp.logging.data_type'; + +/** Log message content */ +export const MCP_LOGGING_MESSAGE_ATTRIBUTE = 'mcp.logging.message'; + +// ============================================================================= +// NETWORK ATTRIBUTES (OpenTelemetry Standard) +// ============================================================================= + +/** OSI transport layer protocol */ +export const NETWORK_TRANSPORT_ATTRIBUTE = 'network.transport'; + +/** The version of JSON RPC protocol used */ +export const NETWORK_PROTOCOL_VERSION_ATTRIBUTE = 'network.protocol.version'; + +/** Client address - domain name if available without reverse DNS lookup; otherwise, IP address or Unix domain socket name */ +export const CLIENT_ADDRESS_ATTRIBUTE = 'client.address'; + +/** Client port number */ +export const CLIENT_PORT_ATTRIBUTE = 'client.port'; + +// ============================================================================= +// SENTRY-SPECIFIC MCP ATTRIBUTE VALUES +// ============================================================================= + +/** Sentry operation value for MCP server spans */ +export const MCP_SERVER_OP_VALUE = 'mcp.server'; + +/** + * Sentry operation value for client-to-server notifications + * Following OpenTelemetry MCP semantic conventions + */ +export const MCP_NOTIFICATION_CLIENT_TO_SERVER_OP_VALUE = 'mcp.notification.client_to_server'; + +/** + * Sentry operation value for server-to-client notifications + * Following OpenTelemetry MCP semantic conventions + */ +export const MCP_NOTIFICATION_SERVER_TO_CLIENT_OP_VALUE = 'mcp.notification.server_to_client'; + +/** Sentry origin value for MCP function spans */ +export const MCP_FUNCTION_ORIGIN_VALUE = 'auto.function.mcp_server'; + +/** Sentry origin value for MCP notification spans */ +export const MCP_NOTIFICATION_ORIGIN_VALUE = 'auto.mcp.notification'; + +/** Sentry source value for MCP route spans */ +export const MCP_ROUTE_SOURCE_VALUE = 'route'; diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts new file mode 100644 index 000000000000..7f00341bdd5a --- /dev/null +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -0,0 +1,100 @@ +/** + * Request-span correlation system for MCP server instrumentation + * + * Handles mapping requestId to span data for correlation with handler execution. + * Uses WeakMap to scope correlation maps per transport instance, preventing + * request ID collisions between different MCP sessions. + */ + +import { getClient } from '../../currentScopes'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; +import { extractToolResultAttributes } from './attributeExtraction'; +import { filterMcpPiiFromSpanData } from './piiFiltering'; +import type { MCPTransport, RequestId, RequestSpanMapValue } from './types'; + +/** + * Transport-scoped correlation system that prevents collisions between different MCP sessions + * @internal Each transport instance gets its own correlation map, eliminating request ID conflicts + */ +const transportToSpanMap = new WeakMap>(); + +/** + * Gets or creates the span map for a specific transport instance + * @internal + * @param transport - MCP transport instance + * @returns Span map for the transport + */ +function getOrCreateSpanMap(transport: MCPTransport): Map { + let spanMap = transportToSpanMap.get(transport); + if (!spanMap) { + spanMap = new Map(); + transportToSpanMap.set(transport, spanMap); + } + return spanMap; +} + +/** + * Stores span context for later correlation with handler execution + * @param transport - MCP transport instance + * @param requestId - Request identifier + * @param span - Active span to correlate + * @param method - MCP method name + */ +export function storeSpanForRequest(transport: MCPTransport, requestId: RequestId, span: Span, method: string): void { + const spanMap = getOrCreateSpanMap(transport); + spanMap.set(requestId, { + span, + method, + startTime: Date.now(), + }); +} + +/** + * Completes span with tool results and cleans up correlation + * @param transport - MCP transport instance + * @param requestId - Request identifier + * @param result - Tool execution result for attribute extraction + */ +export function completeSpanWithResults(transport: MCPTransport, requestId: RequestId, result: unknown): void { + const spanMap = getOrCreateSpanMap(transport); + const spanData = spanMap.get(requestId); + if (spanData) { + const { span, method } = spanData; + + if (method === 'tools/call') { + const rawToolAttributes = extractToolResultAttributes(result); + const client = getClient(); + const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); + const toolAttributes = filterMcpPiiFromSpanData(rawToolAttributes, sendDefaultPii); + + span.setAttributes(toolAttributes); + } + + span.end(); + spanMap.delete(requestId); + } +} + +/** + * Cleans up pending spans for a specific transport (when that transport closes) + * @param transport - MCP transport instance + * @returns Number of pending spans that were cleaned up + */ +export function cleanupPendingSpansForTransport(transport: MCPTransport): number { + const spanMap = transportToSpanMap.get(transport); + if (!spanMap) return 0; + + const pendingCount = spanMap.size; + + for (const [, spanData] of spanMap) { + spanData.span.setStatus({ + code: SPAN_STATUS_ERROR, + message: 'cancelled', + }); + spanData.span.end(); + } + + spanMap.clear(); + return pendingCount; +} diff --git a/packages/core/src/integrations/mcp-server/errorCapture.ts b/packages/core/src/integrations/mcp-server/errorCapture.ts new file mode 100644 index 000000000000..544d61cf71ad --- /dev/null +++ b/packages/core/src/integrations/mcp-server/errorCapture.ts @@ -0,0 +1,50 @@ +/** + * Safe error capture utilities for MCP server instrumentation + * + * Ensures error reporting never interferes with MCP server operation. + * All capture operations are wrapped in try-catch to prevent side effects. + */ + +import { getClient } from '../../currentScopes'; +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { getActiveSpan } from '../../utils/spanUtils'; +import type { McpErrorType } from './types'; + +/** + * Captures an error without affecting MCP server operation. + * + * The active span already contains all MCP context (method, tool, arguments, etc.) + * @param error - Error to capture + * @param errorType - Classification of error type for filtering + * @param extraData - Additional context data to include + */ +export function captureError(error: Error, errorType?: McpErrorType, extraData?: Record): void { + try { + const client = getClient(); + if (!client) { + return; + } + + const activeSpan = getActiveSpan(); + if (activeSpan?.isRecording()) { + activeSpan.setStatus({ + code: SPAN_STATUS_ERROR, + message: 'internal_error', + }); + } + + captureException(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: errorType || 'handler_execution', + ...extraData, + }, + }, + }); + } catch { + // noop + } +} diff --git a/packages/core/src/integrations/mcp-server/handlers.ts b/packages/core/src/integrations/mcp-server/handlers.ts new file mode 100644 index 000000000000..9816d607b7c1 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/handlers.ts @@ -0,0 +1,161 @@ +/** + * Handler method wrapping for MCP server instrumentation + * + * Provides automatic error capture and span correlation for tool, resource, + * and prompt handlers. + */ + +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/debug-logger'; +import { fill } from '../../utils/object'; +import { captureError } from './errorCapture'; +import type { MCPHandler, MCPServerInstance } from './types'; + +/** + * Generic function to wrap MCP server method handlers + * @internal + * @param serverInstance - MCP server instance + * @param methodName - Method name to wrap (tool, resource, prompt) + */ +function wrapMethodHandler(serverInstance: MCPServerInstance, methodName: keyof MCPServerInstance): void { + fill(serverInstance, methodName, originalMethod => { + return function (this: MCPServerInstance, name: string, ...args: unknown[]) { + const handler = args[args.length - 1]; + + if (typeof handler !== 'function') { + return (originalMethod as (...args: unknown[]) => unknown).call(this, name, ...args); + } + + const wrappedHandler = createWrappedHandler(handler as MCPHandler, methodName, name); + return (originalMethod as (...args: unknown[]) => unknown).call(this, name, ...args.slice(0, -1), wrappedHandler); + }; + }); +} + +/** + * Creates a wrapped handler with span correlation and error capture + * @internal + * @param originalHandler - Original handler function + * @param methodName - MCP method name + * @param handlerName - Handler identifier + * @returns Wrapped handler function + */ +function createWrappedHandler(originalHandler: MCPHandler, methodName: keyof MCPServerInstance, handlerName: string) { + return function (this: unknown, ...handlerArgs: unknown[]): unknown { + try { + return createErrorCapturingHandler.call(this, originalHandler, methodName, handlerName, handlerArgs); + } catch (error) { + DEBUG_BUILD && debug.warn('MCP handler wrapping failed:', error); + return originalHandler.apply(this, handlerArgs); + } + }; +} + +/** + * Creates an error-capturing wrapper for handler execution + * @internal + * @param originalHandler - Original handler function + * @param methodName - MCP method name + * @param handlerName - Handler identifier + * @param handlerArgs - Handler arguments + * @param extraHandlerData - Additional handler context + * @returns Handler execution result + */ +function createErrorCapturingHandler( + this: MCPServerInstance, + originalHandler: MCPHandler, + methodName: keyof MCPServerInstance, + handlerName: string, + handlerArgs: unknown[], +): unknown { + try { + const result = originalHandler.apply(this, handlerArgs); + + if (result && typeof result === 'object' && typeof (result as { then?: unknown }).then === 'function') { + return Promise.resolve(result).catch(error => { + captureHandlerError(error, methodName, handlerName); + throw error; + }); + } + + return result; + } catch (error) { + captureHandlerError(error as Error, methodName, handlerName); + throw error; + } +} + +/** + * Captures handler execution errors based on handler type + * @internal + * @param error - Error to capture + * @param methodName - MCP method name + * @param handlerName - Handler identifier + */ +function captureHandlerError(error: Error, methodName: keyof MCPServerInstance, handlerName: string): void { + try { + const extraData: Record = {}; + + if (methodName === 'tool') { + extraData.tool_name = handlerName; + + if ( + error.name === 'ProtocolValidationError' || + error.message.includes('validation') || + error.message.includes('protocol') + ) { + captureError(error, 'validation', extraData); + } else if ( + error.name === 'ServerTimeoutError' || + error.message.includes('timed out') || + error.message.includes('timeout') + ) { + captureError(error, 'timeout', extraData); + } else { + captureError(error, 'tool_execution', extraData); + } + } else if (methodName === 'resource') { + extraData.resource_uri = handlerName; + captureError(error, 'resource_execution', extraData); + } else if (methodName === 'prompt') { + extraData.prompt_name = handlerName; + captureError(error, 'prompt_execution', extraData); + } + } catch (captureErr) { + // noop + } +} + +/** + * Wraps tool handlers to associate them with request spans + * @param serverInstance - MCP server instance + */ +export function wrapToolHandlers(serverInstance: MCPServerInstance): void { + wrapMethodHandler(serverInstance, 'tool'); +} + +/** + * Wraps resource handlers to associate them with request spans + * @param serverInstance - MCP server instance + */ +export function wrapResourceHandlers(serverInstance: MCPServerInstance): void { + wrapMethodHandler(serverInstance, 'resource'); +} + +/** + * Wraps prompt handlers to associate them with request spans + * @param serverInstance - MCP server instance + */ +export function wrapPromptHandlers(serverInstance: MCPServerInstance): void { + wrapMethodHandler(serverInstance, 'prompt'); +} + +/** + * Wraps all MCP handler types (tool, resource, prompt) for span correlation + * @param serverInstance - MCP server instance + */ +export function wrapAllMCPHandlers(serverInstance: MCPServerInstance): void { + wrapToolHandlers(serverInstance); + wrapResourceHandlers(serverInstance); + wrapPromptHandlers(serverInstance); +} diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts new file mode 100644 index 000000000000..1e16eaf202f3 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -0,0 +1,68 @@ +import { fill } from '../../utils/object'; +import { wrapAllMCPHandlers } from './handlers'; +import { wrapTransportError, wrapTransportOnClose, wrapTransportOnMessage, wrapTransportSend } from './transport'; +import type { MCPServerInstance, MCPTransport } from './types'; +import { validateMcpServerInstance } from './validation'; + +/** + * Tracks wrapped MCP server instances to prevent double-wrapping + * @internal + */ +const wrappedMcpServerInstances = new WeakSet(); + +/** + * Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation. + * + * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package. + * Automatically instruments transport methods and handler functions for comprehensive monitoring. + * + * @example + * ```typescript + * import * as Sentry from '@sentry/core'; + * import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + * import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + * + * const server = Sentry.wrapMcpServerWithSentry( + * new McpServer({ name: "my-server", version: "1.0.0" }) + * ); + * + * const transport = new StreamableHTTPServerTransport(); + * await server.connect(transport); + * ``` + * + * @param mcpServerInstance - MCP server instance to instrument + * @returns Instrumented server instance (same reference) + */ +export function wrapMcpServerWithSentry(mcpServerInstance: S): S { + if (wrappedMcpServerInstances.has(mcpServerInstance)) { + return mcpServerInstance; + } + + if (!validateMcpServerInstance(mcpServerInstance)) { + return mcpServerInstance; + } + + const serverInstance = mcpServerInstance as MCPServerInstance; + + fill(serverInstance, 'connect', originalConnect => { + return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) { + const result = await (originalConnect as (...args: unknown[]) => Promise).call( + this, + transport, + ...restArgs, + ); + + wrapTransportOnMessage(transport); + wrapTransportSend(transport); + wrapTransportOnClose(transport); + wrapTransportError(transport); + + return result; + }; + }); + + wrapAllMCPHandlers(serverInstance); + + wrappedMcpServerInstances.add(mcpServerInstance); + return mcpServerInstance as S; +} diff --git a/packages/core/src/integrations/mcp-server/methodConfig.ts b/packages/core/src/integrations/mcp-server/methodConfig.ts new file mode 100644 index 000000000000..a652cd8cc41a --- /dev/null +++ b/packages/core/src/integrations/mcp-server/methodConfig.ts @@ -0,0 +1,107 @@ +/** + * Method configuration and request processing for MCP server instrumentation + */ + +import { + MCP_PROMPT_NAME_ATTRIBUTE, + MCP_REQUEST_ARGUMENT, + MCP_RESOURCE_URI_ATTRIBUTE, + MCP_TOOL_NAME_ATTRIBUTE, +} from './attributes'; +import type { MethodConfig } from './types'; + +/** + * Configuration for MCP methods to extract targets and arguments + * @internal Maps method names to their extraction configuration + */ +const METHOD_CONFIGS: Record = { + 'tools/call': { + targetField: 'name', + targetAttribute: MCP_TOOL_NAME_ATTRIBUTE, + captureArguments: true, + argumentsField: 'arguments', + }, + 'resources/read': { + targetField: 'uri', + targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + captureUri: true, + }, + 'resources/subscribe': { + targetField: 'uri', + targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + }, + 'resources/unsubscribe': { + targetField: 'uri', + targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + }, + 'prompts/get': { + targetField: 'name', + targetAttribute: MCP_PROMPT_NAME_ATTRIBUTE, + captureName: true, + captureArguments: true, + argumentsField: 'arguments', + }, +}; + +/** + * Extracts target info from method and params based on method type + * @param method - MCP method name + * @param params - Method parameters + * @returns Target name and attributes for span instrumentation + */ +export function extractTargetInfo( + method: string, + params: Record, +): { + target?: string; + attributes: Record; +} { + const config = METHOD_CONFIGS[method as keyof typeof METHOD_CONFIGS]; + if (!config) { + return { attributes: {} }; + } + + const target = + config.targetField && typeof params?.[config.targetField] === 'string' + ? (params[config.targetField] as string) + : undefined; + + return { + target, + attributes: target && config.targetAttribute ? { [config.targetAttribute]: target } : {}, + }; +} + +/** + * Extracts request arguments based on method type + * @param method - MCP method name + * @param params - Method parameters + * @returns Arguments as span attributes with mcp.request.argument prefix + */ +export function getRequestArguments(method: string, params: Record): Record { + const args: Record = {}; + const config = METHOD_CONFIGS[method as keyof typeof METHOD_CONFIGS]; + + if (!config) { + return args; + } + + if (config.captureArguments && config.argumentsField && params?.[config.argumentsField]) { + const argumentsObj = params[config.argumentsField]; + if (typeof argumentsObj === 'object' && argumentsObj !== null) { + for (const [key, value] of Object.entries(argumentsObj as Record)) { + args[`${MCP_REQUEST_ARGUMENT}.${key.toLowerCase()}`] = JSON.stringify(value); + } + } + } + + if (config.captureUri && params?.uri) { + args[`${MCP_REQUEST_ARGUMENT}.uri`] = JSON.stringify(params.uri); + } + + if (config.captureName && params?.name) { + args[`${MCP_REQUEST_ARGUMENT}.name`] = JSON.stringify(params.name); + } + + return args; +} diff --git a/packages/core/src/integrations/mcp-server/piiFiltering.ts b/packages/core/src/integrations/mcp-server/piiFiltering.ts new file mode 100644 index 000000000000..654427ca2d6d --- /dev/null +++ b/packages/core/src/integrations/mcp-server/piiFiltering.ts @@ -0,0 +1,60 @@ +/** + * PII filtering for MCP server spans + * + * Removes sensitive data when sendDefaultPii is false. + * Uses configurable attribute filtering to protect user privacy. + */ +import type { SpanAttributeValue } from '../../types-hoist/span'; +import { + CLIENT_ADDRESS_ATTRIBUTE, + CLIENT_PORT_ATTRIBUTE, + MCP_LOGGING_MESSAGE_ATTRIBUTE, + MCP_REQUEST_ARGUMENT, + MCP_RESOURCE_URI_ATTRIBUTE, + MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, +} from './attributes'; + +/** + * PII attributes that should be removed when sendDefaultPii is false + * @internal + */ +const PII_ATTRIBUTES = new Set([ + CLIENT_ADDRESS_ATTRIBUTE, + CLIENT_PORT_ATTRIBUTE, + MCP_LOGGING_MESSAGE_ATTRIBUTE, + MCP_RESOURCE_URI_ATTRIBUTE, + MCP_TOOL_RESULT_CONTENT_ATTRIBUTE, +]); + +/** + * Checks if an attribute key should be considered PII + * @internal + */ +function isPiiAttribute(key: string): boolean { + return PII_ATTRIBUTES.has(key) || key.startsWith(`${MCP_REQUEST_ARGUMENT}.`); +} + +/** + * Removes PII attributes from span data when sendDefaultPii is false + * @param spanData - Raw span attributes + * @param sendDefaultPii - Whether to include PII data + * @returns Filtered span attributes + */ +export function filterMcpPiiFromSpanData( + spanData: Record, + sendDefaultPii: boolean, +): Record { + if (sendDefaultPii) { + return spanData as Record; + } + + return Object.entries(spanData).reduce( + (acc, [key, value]) => { + if (!isPiiAttribute(key)) { + acc[key] = value as SpanAttributeValue; + } + return acc; + }, + {} as Record, + ); +} diff --git a/packages/core/src/integrations/mcp-server/sessionManagement.ts b/packages/core/src/integrations/mcp-server/sessionManagement.ts new file mode 100644 index 000000000000..99ba2e0d8806 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/sessionManagement.ts @@ -0,0 +1,67 @@ +/** + * Session data management for MCP server instrumentation + */ + +import type { MCPTransport, PartyInfo, SessionData } from './types'; + +/** + * Transport-scoped session data storage (only for transports with sessionId) + * @internal Maps transport instances to session-level data + */ +const transportToSessionData = new WeakMap(); + +/** + * Stores session data for a transport with sessionId + * @param transport - MCP transport instance + * @param sessionData - Session data to store + */ +export function storeSessionDataForTransport(transport: MCPTransport, sessionData: SessionData): void { + if (transport.sessionId) transportToSessionData.set(transport, sessionData); +} + +/** + * Updates session data for a transport with sessionId (merges with existing data) + * @param transport - MCP transport instance + * @param partialSessionData - Partial session data to merge with existing data + */ +export function updateSessionDataForTransport(transport: MCPTransport, partialSessionData: Partial): void { + if (transport.sessionId) { + const existingData = transportToSessionData.get(transport) || {}; + transportToSessionData.set(transport, { ...existingData, ...partialSessionData }); + } +} + +/** + * Retrieves client information for a transport + * @param transport - MCP transport instance + * @returns Client information if available + */ +export function getClientInfoForTransport(transport: MCPTransport): PartyInfo | undefined { + return transportToSessionData.get(transport)?.clientInfo; +} + +/** + * Retrieves protocol version for a transport + * @param transport - MCP transport instance + * @returns Protocol version if available + */ +export function getProtocolVersionForTransport(transport: MCPTransport): string | undefined { + return transportToSessionData.get(transport)?.protocolVersion; +} + +/** + * Retrieves full session data for a transport + * @param transport - MCP transport instance + * @returns Complete session data if available + */ +export function getSessionDataForTransport(transport: MCPTransport): SessionData | undefined { + return transportToSessionData.get(transport); +} + +/** + * Cleans up session data for a specific transport (when that transport closes) + * @param transport - MCP transport instance + */ +export function cleanupSessionDataForTransport(transport: MCPTransport): void { + transportToSessionData.delete(transport); +} diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts new file mode 100644 index 000000000000..9a527046b6f2 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -0,0 +1,197 @@ +/** + * Span creation and management functions for MCP server instrumentation + * + * Provides unified span creation following OpenTelemetry MCP semantic conventions and our opinitionated take on MCP. + * Handles both request and notification spans with attribute extraction. + */ + +import { getClient } from '../../currentScopes'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '../../semanticAttributes'; +import { startSpan } from '../../tracing'; +import { buildTransportAttributes, buildTypeSpecificAttributes } from './attributeExtraction'; +import { + MCP_FUNCTION_ORIGIN_VALUE, + MCP_METHOD_NAME_ATTRIBUTE, + MCP_NOTIFICATION_CLIENT_TO_SERVER_OP_VALUE, + MCP_NOTIFICATION_ORIGIN_VALUE, + MCP_NOTIFICATION_SERVER_TO_CLIENT_OP_VALUE, + MCP_ROUTE_SOURCE_VALUE, + MCP_SERVER_OP_VALUE, +} from './attributes'; +import { extractTargetInfo } from './methodConfig'; +import { filterMcpPiiFromSpanData } from './piiFiltering'; +import type { ExtraHandlerData, JsonRpcNotification, JsonRpcRequest, McpSpanConfig, MCPTransport } from './types'; + +/** + * Creates a span name based on the method and target + * @internal + * @param method - MCP method name + * @param target - Optional target identifier + * @returns Formatted span name + */ +function createSpanName(method: string, target?: string): string { + return target ? `${method} ${target}` : method; +} + +/** + * Build Sentry-specific attributes based on span type + * @internal + * @param type - Span type configuration + * @returns Sentry-specific attributes + */ +function buildSentryAttributes(type: McpSpanConfig['type']): Record { + let op: string; + let origin: string; + + switch (type) { + case 'request': + op = MCP_SERVER_OP_VALUE; + origin = MCP_FUNCTION_ORIGIN_VALUE; + break; + case 'notification-incoming': + op = MCP_NOTIFICATION_CLIENT_TO_SERVER_OP_VALUE; + origin = MCP_NOTIFICATION_ORIGIN_VALUE; + break; + case 'notification-outgoing': + op = MCP_NOTIFICATION_SERVER_TO_CLIENT_OP_VALUE; + origin = MCP_NOTIFICATION_ORIGIN_VALUE; + break; + } + + return { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: MCP_ROUTE_SOURCE_VALUE, + }; +} + +/** + * Unified builder for creating MCP spans + * @internal + * @param config - Span configuration + * @returns Created span + */ +function createMcpSpan(config: McpSpanConfig): unknown { + const { type, message, transport, extra, callback } = config; + const { method } = message; + const params = message.params as Record | undefined; + + // Determine span name based on type and OTEL conventions + let spanName: string; + if (type === 'request') { + const targetInfo = extractTargetInfo(method, params || {}); + spanName = createSpanName(method, targetInfo.target); + } else { + // For notifications, use method name directly per OpenTelemetry conventions + spanName = method; + } + + const rawAttributes: Record = { + ...buildTransportAttributes(transport, extra), + [MCP_METHOD_NAME_ATTRIBUTE]: method, + ...buildTypeSpecificAttributes(type, message, params), + ...buildSentryAttributes(type), + }; + + const client = getClient(); + const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); + const attributes = filterMcpPiiFromSpanData(rawAttributes, sendDefaultPii) as Record; + + return startSpan( + { + name: spanName, + forceTransaction: true, + attributes, + }, + callback, + ); +} + +/** + * Creates a span for incoming MCP notifications + * @param jsonRpcMessage - Notification message + * @param transport - MCP transport instance + * @param extra - Extra handler data + * @param callback - Span execution callback + * @returns Span execution result + */ +export function createMcpNotificationSpan( + jsonRpcMessage: JsonRpcNotification, + transport: MCPTransport, + extra: ExtraHandlerData, + callback: () => unknown, +): unknown { + return createMcpSpan({ + type: 'notification-incoming', + message: jsonRpcMessage, + transport, + extra, + callback, + }); +} + +/** + * Creates a span for outgoing MCP notifications + * @param jsonRpcMessage - Notification message + * @param transport - MCP transport instance + * @param callback - Span execution callback + * @returns Span execution result + */ +export function createMcpOutgoingNotificationSpan( + jsonRpcMessage: JsonRpcNotification, + transport: MCPTransport, + callback: () => unknown, +): unknown { + return createMcpSpan({ + type: 'notification-outgoing', + message: jsonRpcMessage, + transport, + callback, + }); +} + +/** + * Builds span configuration for MCP server requests + * @param jsonRpcMessage - Request message + * @param transport - MCP transport instance + * @param extra - Optional extra handler data + * @returns Span configuration object + */ +export function buildMcpServerSpanConfig( + jsonRpcMessage: JsonRpcRequest, + transport: MCPTransport, + extra?: ExtraHandlerData, +): { + name: string; + op: string; + forceTransaction: boolean; + attributes: Record; +} { + const { method } = jsonRpcMessage; + const params = jsonRpcMessage.params as Record | undefined; + + const targetInfo = extractTargetInfo(method, params || {}); + const spanName = createSpanName(method, targetInfo.target); + + const rawAttributes: Record = { + ...buildTransportAttributes(transport, extra), + [MCP_METHOD_NAME_ATTRIBUTE]: method, + ...buildTypeSpecificAttributes('request', jsonRpcMessage, params), + ...buildSentryAttributes('request'), + }; + + const client = getClient(); + const sendDefaultPii = Boolean(client?.getOptions().sendDefaultPii); + const attributes = filterMcpPiiFromSpanData(rawAttributes, sendDefaultPii) as Record; + + return { + name: spanName, + op: MCP_SERVER_OP_VALUE, + forceTransaction: true, + attributes, + }; +} diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts new file mode 100644 index 000000000000..3244ce73e49a --- /dev/null +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -0,0 +1,186 @@ +/** + * Transport layer instrumentation for MCP server + * + * Handles message interception and response correlation. + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/transports + */ + +import { getIsolationScope, withIsolationScope } from '../../currentScopes'; +import { startInactiveSpan, withActiveSpan } from '../../tracing'; +import { fill } from '../../utils/object'; +import { + extractSessionDataFromInitializeRequest, + extractSessionDataFromInitializeResponse, +} from './attributeExtraction'; +import { cleanupPendingSpansForTransport, completeSpanWithResults, storeSpanForRequest } from './correlation'; +import { captureError } from './errorCapture'; +import { + cleanupSessionDataForTransport, + storeSessionDataForTransport, + updateSessionDataForTransport, +} from './sessionManagement'; +import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; +import type { ExtraHandlerData, MCPTransport } from './types'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse } from './validation'; + +/** + * 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. + * @param transport - MCP transport instance to wrap + */ +export function wrapTransportOnMessage(transport: MCPTransport): void { + if (transport.onmessage) { + fill(transport, 'onmessage', originalOnMessage => { + return function (this: MCPTransport, message: unknown, extra?: unknown) { + if (isJsonRpcRequest(message)) { + if (message.method === 'initialize') { + try { + const sessionData = extractSessionDataFromInitializeRequest(message); + storeSessionDataForTransport(this, sessionData); + } catch { + // noop + } + } + + const isolationScope = getIsolationScope().clone(); + + return withIsolationScope(isolationScope, () => { + const spanConfig = buildMcpServerSpanConfig(message, this, extra as ExtraHandlerData); + const span = startInactiveSpan(spanConfig); + + storeSpanForRequest(this, message.id, span, message.method); + + return withActiveSpan(span, () => { + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); + }); + }); + } + + if (isJsonRpcNotification(message)) { + return createMcpNotificationSpan(message, this, extra as ExtraHandlerData, () => { + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); + }); + } + + return (originalOnMessage as (...args: unknown[]) => unknown).call(this, message, extra); + }; + }); + } +} + +/** + * 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. + * @param transport - MCP transport instance to wrap + */ +export function wrapTransportSend(transport: MCPTransport): void { + if (transport.send) { + fill(transport, 'send', originalSend => { + return async function (this: MCPTransport, ...args: unknown[]) { + const [message] = args; + + if (isJsonRpcNotification(message)) { + return createMcpOutgoingNotificationSpan(message, this, () => { + return (originalSend as (...args: unknown[]) => unknown).call(this, ...args); + }); + } + + if (isJsonRpcResponse(message)) { + if (message.id !== null && message.id !== undefined) { + if (message.error) { + captureJsonRpcErrorResponse(message.error); + } + + if (message.result && typeof message.result === 'object') { + const result = message.result as Record; + if (result.protocolVersion || result.serverInfo) { + try { + const serverData = extractSessionDataFromInitializeResponse(message.result); + updateSessionDataForTransport(this, serverData); + } catch { + // noop + } + } + } + + completeSpanWithResults(this, message.id, message.result); + } + } + + return (originalSend as (...args: unknown[]) => unknown).call(this, ...args); + }; + }); + } +} + +/** + * Wraps transport.onclose to clean up pending spans for this transport only + * @param transport - MCP transport instance to wrap + */ +export function wrapTransportOnClose(transport: MCPTransport): void { + if (transport.onclose) { + fill(transport, 'onclose', originalOnClose => { + return function (this: MCPTransport, ...args: unknown[]) { + cleanupPendingSpansForTransport(this); + cleanupSessionDataForTransport(this); + return (originalOnClose as (...args: unknown[]) => unknown).call(this, ...args); + }; + }); + } +} + +/** + * Wraps transport error handlers to capture connection errors + * @param transport - MCP transport instance to wrap + */ +export function wrapTransportError(transport: MCPTransport): void { + if (transport.onerror) { + fill(transport, 'onerror', (originalOnError: (error: Error) => void) => { + return function (this: MCPTransport, error: Error) { + captureTransportError(error); + return originalOnError.call(this, error); + }; + }); + } +} + +/** + * Captures JSON-RPC error responses for server-side errors. + * @see https://www.jsonrpc.org/specification#error_object + * @internal + * @param errorResponse - JSON-RPC error response + */ +function captureJsonRpcErrorResponse(errorResponse: unknown): void { + try { + if (errorResponse && typeof errorResponse === 'object' && 'code' in errorResponse && 'message' in errorResponse) { + const jsonRpcError = errorResponse as { code: number; message: string; data?: unknown }; + + const isServerError = + jsonRpcError.code === -32603 || (jsonRpcError.code >= -32099 && jsonRpcError.code <= -32000); + + if (isServerError) { + const error = new Error(jsonRpcError.message); + error.name = `JsonRpcError_${jsonRpcError.code}`; + + captureError(error, 'protocol'); + } + } + } catch { + // noop + } +} + +/** + * Captures transport connection errors + * @internal + * @param error - Transport error + */ +function captureTransportError(error: Error): void { + try { + captureError(error, 'transport'); + } catch { + // noop + } +} diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts new file mode 100644 index 000000000000..7c25d52167c7 --- /dev/null +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -0,0 +1,185 @@ +import type { Span } from '../../types-hoist/span'; + +/** Types for MCP server instrumentation */ + +/** + * Configuration for extracting attributes from MCP methods + * @internal + */ +export type MethodConfig = { + targetField: string; + targetAttribute: string; + captureArguments?: boolean; + argumentsField?: string; + captureUri?: boolean; + captureName?: boolean; +}; + +/** + * JSON-RPC 2.0 request object + * @see https://www.jsonrpc.org/specification#request_object + */ +export interface JsonRpcRequest { + jsonrpc: '2.0'; + method: string; + id: string | number; + params?: Record; +} + +/** + * JSON-RPC 2.0 response object + * @see https://www.jsonrpc.org/specification#response_object + */ +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: string | number | null; + result?: unknown; + error?: JsonRpcError; +} + +/** + * JSON-RPC 2.0 error object + * @see https://www.jsonrpc.org/specification#error_object + */ +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +/** + * JSON-RPC 2.0 notification object + * @note Notifications do NOT have an 'id' field - this is what distinguishes them from requests + * @see https://www.jsonrpc.org/specification#notification + */ +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: Record; +} + +/** + * MCP transport interface + * @description Abstraction for MCP communication transport layer + */ +export interface MCPTransport { + /** + * Message handler for incoming JSON-RPC messages + * The first argument is a JSON RPC message + */ + onmessage?: (...args: unknown[]) => void; + + /** Close handler for transport lifecycle */ + onclose?: (...args: unknown[]) => void; + + /** Error handler for transport errors */ + onerror?: (error: Error) => void; + + /** Send method for outgoing messages */ + send?: (message: JsonRpcMessage, options?: Record) => Promise; + + /** Optional session identifier */ + sessionId?: SessionId; +} + +/** Union type for all JSON-RPC message types */ +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; + +/** + * MCP server instance interface + * @description MCP server methods for registering handlers + */ +export interface MCPServerInstance { + /** Register a resource handler */ + resource: (name: string, ...args: unknown[]) => void; + + /** Register a tool handler */ + tool: (name: string, ...args: unknown[]) => void; + + /** Register a prompt handler */ + prompt: (name: string, ...args: unknown[]) => void; + + /** Connect the server to a transport */ + connect(transport: MCPTransport): Promise; +} + +/** Client connection information for handlers */ +export interface ExtraHandlerData { + requestInfo?: { remoteAddress?: string; remotePort?: number }; + clientAddress?: string; + clientPort?: number; + request?: { + ip?: string; + connection?: { remoteAddress?: string; remotePort?: number }; + }; +} + +/** Types of MCP spans */ +export type McpSpanType = 'request' | 'notification-incoming' | 'notification-outgoing'; + +/** + * Configuration for creating MCP spans + * @internal + */ +export interface McpSpanConfig { + type: McpSpanType; + message: JsonRpcRequest | JsonRpcNotification; + transport: MCPTransport; + extra?: ExtraHandlerData; + callback: () => unknown; +} + +export type SessionId = string; +export type RequestId = string | number; + +/** + * Request-to-span correlation data + * @internal + */ +export type RequestSpanMapValue = { + span: Span; + method: string; + startTime: number; +}; + +/** Generic MCP handler function */ +export type MCPHandler = (...args: unknown[]) => unknown; + +/** + * Extra data passed to MCP handlers + * @internal + */ +export interface HandlerExtraData { + sessionId?: SessionId; + requestId: RequestId; +} + +/** Error types for MCP operations */ +export type McpErrorType = + | 'tool_execution' + | 'resource_execution' + | 'prompt_execution' + | 'transport' + | 'protocol' + | 'validation' + | 'timeout'; + +/** + * Party (client/server) information extracted from MCP initialize requests + * @internal + */ +export type PartyInfo = { + name?: string; + title?: string; + version?: string; +}; + +/** + * Session-level data collected from various MCP messages + * @internal + */ +export type SessionData = { + clientInfo?: PartyInfo; + protocolVersion?: string; + serverInfo?: PartyInfo; +}; diff --git a/packages/core/src/integrations/mcp-server/validation.ts b/packages/core/src/integrations/mcp-server/validation.ts new file mode 100644 index 000000000000..21d257c01aeb --- /dev/null +++ b/packages/core/src/integrations/mcp-server/validation.ts @@ -0,0 +1,77 @@ +/** + * Message validation functions for MCP server instrumentation + * + * Provides JSON-RPC 2.0 message type validation and MCP server instance validation. + */ + +import { DEBUG_BUILD } from '../../debug-build'; +import { debug } from '../../utils/debug-logger'; +import type { JsonRpcNotification, JsonRpcRequest, JsonRpcResponse } from './types'; + +/** + * Validates if a message is a JSON-RPC request + * @param message - Message to validate + * @returns True if message is a JSON-RPC request + */ +export function isJsonRpcRequest(message: unknown): message is JsonRpcRequest { + return ( + typeof message === 'object' && + message !== null && + 'jsonrpc' in message && + (message as JsonRpcRequest).jsonrpc === '2.0' && + 'method' in message && + 'id' in message + ); +} + +/** + * Validates if a message is a JSON-RPC notification + * @param message - Message to validate + * @returns True if message is a JSON-RPC notification + */ +export function isJsonRpcNotification(message: unknown): message is JsonRpcNotification { + return ( + typeof message === 'object' && + message !== null && + 'jsonrpc' in message && + (message as JsonRpcNotification).jsonrpc === '2.0' && + 'method' in message && + !('id' in message) + ); +} + +/** + * Validates if a message is a JSON-RPC response + * @param message - Message to validate + * @returns True if message is a JSON-RPC response + */ +export function isJsonRpcResponse(message: unknown): message is JsonRpcResponse { + return ( + typeof message === 'object' && + message !== null && + 'jsonrpc' in message && + (message as { jsonrpc: string }).jsonrpc === '2.0' && + 'id' in message && + ('result' in message || 'error' in message) + ); +} + +/** + * Validates MCP server instance with type checking + * @param instance - Object to validate as MCP server instance + * @returns True if instance has required MCP server methods + */ +export function validateMcpServerInstance(instance: unknown): boolean { + if ( + typeof instance === 'object' && + instance !== null && + 'resource' in instance && + 'tool' in instance && + 'prompt' in instance && + 'connect' in instance + ) { + return true; + } + DEBUG_BUILD && debug.warn('Did not patch MCP server. Interface is incompatible.'); + return false; +} diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index d2cdc8fa1d48..a69d406a659c 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -33,9 +33,9 @@ const _consoleLoggingIntegration = ((options: Partial = { return { name: INTEGRATION_NAME, setup(client) { - const { _experiments, normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = client.getOptions(); - if (!_experiments?.enableLogs) { - DEBUG_BUILD && debug.warn('`_experiments.enableLogs` is not enabled, ConsoleLogs integration disabled'); + const { enableLogs, normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = client.getOptions(); + if (!enableLogs) { + DEBUG_BUILD && debug.warn('`enableLogs` is not enabled, ConsoleLogs integration disabled'); return; } @@ -69,7 +69,7 @@ const _consoleLoggingIntegration = ((options: Partial = { }) satisfies IntegrationFn; /** - * Captures calls to the `console` API as logs in Sentry. Requires `_experiments.enableLogs` to be enabled. + * Captures calls to the `console` API as logs in Sentry. Requires the `enableLogs` option to be enabled. * * @experimental This feature is experimental and may be changed or removed in future versions. * @@ -83,6 +83,7 @@ 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/exports.ts b/packages/core/src/logs/exports.ts index 23246a7e1251..79dfedf4ec87 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/exports.ts @@ -124,8 +124,7 @@ export function _INTERNAL_captureLog( return; } - const { _experiments, release, environment } = client.getOptions(); - const { enableLogs = false, beforeSendLog } = _experiments ?? {}; + const { release, environment, enableLogs = false, 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/mcp-server.ts b/packages/core/src/mcp-server.ts deleted file mode 100644 index ded30dd50928..000000000000 --- a/packages/core/src/mcp-server.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { DEBUG_BUILD } from './debug-build'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, -} from './semanticAttributes'; -import { startSpan, withActiveSpan } from './tracing'; -import type { Span } from './types-hoist/span'; -import { debug } from './utils/debug-logger'; -import { getActiveSpan } from './utils/spanUtils'; - -interface MCPTransport { - // The first argument is a JSON RPC message - onmessage?: (...args: unknown[]) => void; - onclose?: (...args: unknown[]) => void; - sessionId?: string; -} - -interface MCPServerInstance { - // The first arg is always a name, the last arg should always be a callback function (ie a handler). - // TODO: We could also make use of the resource uri argument somehow. - resource: (name: string, ...args: unknown[]) => void; - // The first arg is always a name, the last arg should always be a callback function (ie a handler). - tool: (name: string, ...args: unknown[]) => void; - // The first arg is always a name, the last arg should always be a callback function (ie a handler). - prompt: (name: string, ...args: unknown[]) => void; - connect(transport: MCPTransport): Promise; -} - -const wrappedMcpServerInstances = new WeakSet(); - -/** - * Wraps a MCP Server instance from the `@modelcontextprotocol/sdk` package with Sentry instrumentation. - * - * Compatible with versions `^1.9.0` of the `@modelcontextprotocol/sdk` package. - */ -// We are exposing this API for non-node runtimes that cannot rely on auto-instrumentation. -export function wrapMcpServerWithSentry(mcpServerInstance: S): S { - if (wrappedMcpServerInstances.has(mcpServerInstance)) { - return mcpServerInstance; - } - - if (!isMcpServerInstance(mcpServerInstance)) { - DEBUG_BUILD && debug.warn('Did not patch MCP server. Interface is incompatible.'); - return mcpServerInstance; - } - - // eslint-disable-next-line @typescript-eslint/unbound-method - mcpServerInstance.connect = new Proxy(mcpServerInstance.connect, { - apply(target, thisArg, argArray) { - const [transport, ...restArgs] = argArray as [MCPTransport, ...unknown[]]; - - if (!transport.onclose) { - transport.onclose = () => { - if (transport.sessionId) { - handleTransportOnClose(transport.sessionId); - } - }; - } - - if (!transport.onmessage) { - transport.onmessage = jsonRpcMessage => { - if (transport.sessionId && isJsonRPCMessageWithRequestId(jsonRpcMessage)) { - handleTransportOnMessage(transport.sessionId, jsonRpcMessage.id); - } - }; - } - - const patchedTransport = new Proxy(transport, { - set(target, key, value) { - if (key === 'onmessage') { - target[key] = new Proxy(value, { - apply(onMessageTarget, onMessageThisArg, onMessageArgArray) { - const [jsonRpcMessage] = onMessageArgArray; - if (transport.sessionId && isJsonRPCMessageWithRequestId(jsonRpcMessage)) { - handleTransportOnMessage(transport.sessionId, jsonRpcMessage.id); - } - return Reflect.apply(onMessageTarget, onMessageThisArg, onMessageArgArray); - }, - }); - } else if (key === 'onclose') { - target[key] = new Proxy(value, { - apply(onCloseTarget, onCloseThisArg, onCloseArgArray) { - if (transport.sessionId) { - handleTransportOnClose(transport.sessionId); - } - return Reflect.apply(onCloseTarget, onCloseThisArg, onCloseArgArray); - }, - }); - } else { - target[key as keyof MCPTransport] = value; - } - return true; - }, - }); - - return Reflect.apply(target, thisArg, [patchedTransport, ...restArgs]); - }, - }); - - mcpServerInstance.resource = new Proxy(mcpServerInstance.resource, { - apply(target, thisArg, argArray) { - const resourceName: unknown = argArray[0]; - const resourceHandler: unknown = argArray[argArray.length - 1]; - - if (typeof resourceName !== 'string' || typeof resourceHandler !== 'function') { - return target.apply(thisArg, argArray); - } - - const wrappedResourceHandler = new Proxy(resourceHandler, { - apply(resourceHandlerTarget, resourceHandlerThisArg, resourceHandlerArgArray) { - const extraHandlerDataWithRequestId = resourceHandlerArgArray.find(isExtraHandlerDataWithRequestId); - return associateContextWithRequestSpan(extraHandlerDataWithRequestId, () => { - return startSpan( - { - name: `mcp-server/resource:${resourceName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.resource': resourceName, - }, - }, - () => resourceHandlerTarget.apply(resourceHandlerThisArg, resourceHandlerArgArray), - ); - }); - }, - }); - - return Reflect.apply(target, thisArg, [...argArray.slice(0, -1), wrappedResourceHandler]); - }, - }); - - mcpServerInstance.tool = new Proxy(mcpServerInstance.tool, { - apply(target, thisArg, argArray) { - const toolName: unknown = argArray[0]; - const toolHandler: unknown = argArray[argArray.length - 1]; - - if (typeof toolName !== 'string' || typeof toolHandler !== 'function') { - return target.apply(thisArg, argArray); - } - - const wrappedToolHandler = new Proxy(toolHandler, { - apply(toolHandlerTarget, toolHandlerThisArg, toolHandlerArgArray) { - const extraHandlerDataWithRequestId = toolHandlerArgArray.find(isExtraHandlerDataWithRequestId); - return associateContextWithRequestSpan(extraHandlerDataWithRequestId, () => { - return startSpan( - { - name: `mcp-server/tool:${toolName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.tool': toolName, - }, - }, - () => toolHandlerTarget.apply(toolHandlerThisArg, toolHandlerArgArray), - ); - }); - }, - }); - - return Reflect.apply(target, thisArg, [...argArray.slice(0, -1), wrappedToolHandler]); - }, - }); - - mcpServerInstance.prompt = new Proxy(mcpServerInstance.prompt, { - apply(target, thisArg, argArray) { - const promptName: unknown = argArray[0]; - const promptHandler: unknown = argArray[argArray.length - 1]; - - if (typeof promptName !== 'string' || typeof promptHandler !== 'function') { - return target.apply(thisArg, argArray); - } - - const wrappedPromptHandler = new Proxy(promptHandler, { - apply(promptHandlerTarget, promptHandlerThisArg, promptHandlerArgArray) { - const extraHandlerDataWithRequestId = promptHandlerArgArray.find(isExtraHandlerDataWithRequestId); - return associateContextWithRequestSpan(extraHandlerDataWithRequestId, () => { - return startSpan( - { - name: `mcp-server/prompt:${promptName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.prompt': promptName, - }, - }, - () => promptHandlerTarget.apply(promptHandlerThisArg, promptHandlerArgArray), - ); - }); - }, - }); - - return Reflect.apply(target, thisArg, [...argArray.slice(0, -1), wrappedPromptHandler]); - }, - }); - - wrappedMcpServerInstances.add(mcpServerInstance); - - return mcpServerInstance as S; -} - -function isMcpServerInstance(mcpServerInstance: unknown): mcpServerInstance is MCPServerInstance { - return ( - typeof mcpServerInstance === 'object' && - mcpServerInstance !== null && - 'resource' in mcpServerInstance && - typeof mcpServerInstance.resource === 'function' && - 'tool' in mcpServerInstance && - typeof mcpServerInstance.tool === 'function' && - 'prompt' in mcpServerInstance && - typeof mcpServerInstance.prompt === 'function' && - 'connect' in mcpServerInstance && - typeof mcpServerInstance.connect === 'function' - ); -} - -function isJsonRPCMessageWithRequestId(target: unknown): target is { id: RequestId } { - return ( - typeof target === 'object' && - target !== null && - 'id' in target && - (typeof target.id === 'number' || typeof target.id === 'string') - ); -} - -interface ExtraHandlerDataWithRequestId { - sessionId: SessionId; - requestId: RequestId; -} - -// Note that not all versions of the MCP library have `requestId` as a field on the extra data. -function isExtraHandlerDataWithRequestId(target: unknown): target is ExtraHandlerDataWithRequestId { - return ( - typeof target === 'object' && - target !== null && - 'sessionId' in target && - typeof target.sessionId === 'string' && - 'requestId' in target && - (typeof target.requestId === 'number' || typeof target.requestId === 'string') - ); -} - -type SessionId = string; -type RequestId = string | number; - -const sessionAndRequestToRequestParentSpanMap = new Map>(); - -function handleTransportOnClose(sessionId: SessionId): void { - sessionAndRequestToRequestParentSpanMap.delete(sessionId); -} - -function handleTransportOnMessage(sessionId: SessionId, requestId: RequestId): void { - const activeSpan = getActiveSpan(); - if (activeSpan) { - const requestIdToSpanMap = sessionAndRequestToRequestParentSpanMap.get(sessionId) ?? new Map(); - requestIdToSpanMap.set(requestId, activeSpan); - sessionAndRequestToRequestParentSpanMap.set(sessionId, requestIdToSpanMap); - } -} - -function associateContextWithRequestSpan( - extraHandlerData: ExtraHandlerDataWithRequestId | undefined, - cb: () => T, -): T { - if (extraHandlerData) { - const { sessionId, requestId } = extraHandlerData; - const requestIdSpanMap = sessionAndRequestToRequestParentSpanMap.get(sessionId); - - if (!requestIdSpanMap) { - return cb(); - } - - const span = requestIdSpanMap.get(requestId); - if (!span) { - return cb(); - } - - // remove the span from the map so it can be garbage collected - requestIdSpanMap.delete(requestId); - return withActiveSpan(span, () => { - return cb(); - }); - } - - return cb(); -} diff --git a/packages/core/src/profiling.ts b/packages/core/src/profiling.ts index b49a30070683..407c4a07c53c 100644 --- a/packages/core/src/profiling.ts +++ b/packages/core/src/profiling.ts @@ -4,8 +4,8 @@ import type { Profiler, ProfilingIntegration } from './types-hoist/profiling'; import { debug } from './utils/debug-logger'; function isProfilingIntegrationWithProfiler( - integration: ProfilingIntegration | undefined, -): integration is ProfilingIntegration { + integration: ProfilingIntegration | undefined, +): integration is ProfilingIntegration { return ( !!integration && typeof integration['_profiler'] !== 'undefined' && @@ -25,7 +25,7 @@ function startProfiler(): void { return; } - const integration = client.getIntegrationByName>('ProfilingIntegration'); + const integration = client.getIntegrationByName('ProfilingIntegration'); if (!integration) { DEBUG_BUILD && debug.warn('ProfilingIntegration is not available'); @@ -51,7 +51,7 @@ function stopProfiler(): void { return; } - const integration = client.getIntegrationByName>('ProfilingIntegration'); + const integration = client.getIntegrationByName('ProfilingIntegration'); if (!integration) { DEBUG_BUILD && debug.warn('ProfilingIntegration is not available'); return; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 246e0a88bc51..d9c44ed7149d 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -49,7 +49,7 @@ export class ServerRuntimeClient< this._logWeight = 0; - if (this._options._experiments?.enableLogs) { + if (this._options.enableLogs) { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index adbcf0ae032a..47d5657a7d87 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -10,7 +10,7 @@ import { import type { DynamicSamplingContext } from '../types-hoist/envelope'; import type { Span } from '../types-hoist/span'; import { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader } from '../utils/baggage'; -import { extractOrgIdFromDsnHost } from '../utils/dsn'; +import { extractOrgIdFromClient } from '../utils/dsn'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; import { addNonEnumerableProperty } from '../utils/object'; import { getRootSpan, spanIsSampled, spanToJSON } from '../utils/spanUtils'; @@ -42,14 +42,7 @@ export function freezeDscOnSpan(span: Span, dsc: Partial export function getDynamicSamplingContextFromClient(trace_id: string, client: Client): DynamicSamplingContext { const options = client.getOptions(); - const { publicKey: public_key, host } = client.getDsn() || {}; - - let org_id: string | undefined; - if (options.orgId) { - org_id = String(options.orgId); - } else if (host) { - org_id = extractOrgIdFromDsnHost(host); - } + const { publicKey: public_key } = client.getDsn() || {}; // Instead of conditionally adding non-undefined values, we add them and then remove them if needed // otherwise, the order of baggage entries changes, which "breaks" a bunch of tests etc. @@ -58,7 +51,7 @@ export function getDynamicSamplingContextFromClient(trace_id: string, client: Cl release: options.release, public_key, trace_id, - org_id, + org_id: extractOrgIdFromClient(client), }; client.emit('createDsc', dsc); diff --git a/packages/core/src/tracing/sentryNonRecordingSpan.ts b/packages/core/src/tracing/sentryNonRecordingSpan.ts index 69d1aa2a85ba..2f65e0eb8c08 100644 --- a/packages/core/src/tracing/sentryNonRecordingSpan.ts +++ b/packages/core/src/tracing/sentryNonRecordingSpan.ts @@ -32,7 +32,6 @@ export class SentryNonRecordingSpan implements Span { } /** @inheritdoc */ - // eslint-disable-next-line @typescript-eslint/no-empty-function public end(_timestamp?: SpanTimeInput): void {} /** @inheritdoc */ diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index ba6df741508c..98c3a33a8a79 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -11,6 +11,7 @@ import type { DynamicSamplingContext } from '../types-hoist/envelope'; import type { ClientOptions } from '../types-hoist/options'; import type { SentrySpanArguments, Span, SpanTimeInput } from '../types-hoist/span'; import type { StartSpanOptions } from '../types-hoist/startSpanOptions'; +import { baggageHeaderToDynamicSamplingContext } from '../utils/baggage'; import { debug } from '../utils/debug-logger'; import { handleCallbackErrors } from '../utils/handleCallbackErrors'; import { hasSpansEnabled } from '../utils/hasSpansEnabled'; @@ -18,7 +19,7 @@ import { parseSampleRate } from '../utils/parseSampleRate'; import { generateTraceId } from '../utils/propagationContext'; import { _getSpanForScope, _setSpanForScope } from '../utils/spanOnScope'; import { addChildSpanToSpan, getRootSpan, spanIsSampled, spanTimeInputToSeconds, spanToJSON } from '../utils/spanUtils'; -import { propagationContextFromHeaders } from '../utils/tracing'; +import { propagationContextFromHeaders, shouldContinueTrace } from '../utils/tracing'; import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { logSpanStart } from './logSpans'; import { sampleSpan } from './sampling'; @@ -216,6 +217,12 @@ export const continueTrace = ( const { sentryTrace, baggage } = options; + const client = getClient(); + const incomingDsc = baggageHeaderToDynamicSamplingContext(baggage); + if (client && !shouldContinueTrace(client, incomingDsc?.org_id)) { + return startNewTrace(callback); + } + return withScope(scope => { const propagationContext = propagationContextFromHeaders(sentryTrace, baggage); scope.setPropagationContext(propagationContext); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index a4119ed42a6e..ce0851f940a5 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -246,25 +246,6 @@ export interface ClientOptions Log | null; }; /** @@ -321,13 +302,47 @@ export interface ClientOptions Log | null; + /** * Function to compute tracing sample rate dynamically and filter unwanted traces. * diff --git a/packages/core/src/types-hoist/profiling.ts b/packages/core/src/types-hoist/profiling.ts index 2c0c439450bb..2c369c0d5e57 100644 --- a/packages/core/src/types-hoist/profiling.ts +++ b/packages/core/src/types-hoist/profiling.ts @@ -9,7 +9,7 @@ export interface ContinuousProfiler { stop(): void; } -export interface ProfilingIntegration extends Integration { +export interface ProfilingIntegration extends Integration { _profiler: ContinuousProfiler; } diff --git a/packages/core/src/utils/debug-logger.ts b/packages/core/src/utils/debug-logger.ts index 36e3169b1d52..6fc9c7a8c865 100644 --- a/packages/core/src/utils/debug-logger.ts +++ b/packages/core/src/utils/debug-logger.ts @@ -3,24 +3,6 @@ import { DEBUG_BUILD } from '../debug-build'; import type { ConsoleLevel } from '../types-hoist/instrument'; import { GLOBAL_OBJ } from './worldwide'; -/** - * A Sentry Logger instance. - * - * @deprecated Use {@link debug} instead with the {@link SentryDebugLogger} type. - */ -export interface Logger { - disable(): void; - enable(): void; - isEnabled(): boolean; - log(...args: Parameters): void; - info(...args: Parameters): void; - warn(...args: Parameters): void; - error(...args: Parameters): void; - debug(...args: Parameters): void; - assert(...args: Parameters): void; - trace(...args: Parameters): void; -} - export interface SentryDebugLogger { disable(): void; enable(): void; @@ -115,18 +97,6 @@ function error(...args: Parameters): void { _maybeLog('error', ...args); } -function _debug(...args: Parameters): void { - _maybeLog('debug', ...args); -} - -function assert(...args: Parameters): void { - _maybeLog('assert', ...args); -} - -function trace(...args: Parameters): void { - _maybeLog('trace', ...args); -} - function _maybeLog(level: ConsoleLevel, ...args: Parameters<(typeof console)[typeof level]>): void { if (!DEBUG_BUILD) { return; @@ -147,36 +117,6 @@ function _getLoggerSettings(): { enabled: boolean } { return getGlobalSingleton('loggerSettings', () => ({ enabled: false })); } -/** - * This is a logger singleton which either logs things or no-ops if logging is not enabled. - * The logger is a singleton on the carrier, to ensure that a consistent logger is used throughout the SDK. - * - * @deprecated Use {@link debug} instead. - */ -export const logger = { - /** Enable logging. */ - enable, - /** Disable logging. */ - disable, - /** Check if logging is enabled. */ - isEnabled, - /** Log a message. */ - log, - /** Log level info */ - info, - /** Log a warning. */ - warn, - /** Log an error. */ - error, - /** Log a debug message. */ - debug: _debug, - /** Log an assertion. */ - assert, - /** Log a trace. */ - trace, - // eslint-disable-next-line deprecation/deprecation -} satisfies Logger; - /** * This is a logger singleton which either logs things or no-ops if logging is not enabled. */ diff --git a/packages/core/src/utils/dsn.ts b/packages/core/src/utils/dsn.ts index 63e6472c10c7..492f2398c390 100644 --- a/packages/core/src/utils/dsn.ts +++ b/packages/core/src/utils/dsn.ts @@ -1,3 +1,4 @@ +import type { Client } from '../client'; import { DEBUG_BUILD } from '../debug-build'; import type { DsnComponents, DsnLike, DsnProtocol } from '../types-hoist/dsn'; import { consoleSandbox, debug } from './debug-logger'; @@ -129,6 +130,27 @@ export function extractOrgIdFromDsnHost(host: string): string | undefined { return match?.[1]; } +/** + * Returns the organization ID of the client. + * + * The organization ID is extracted from the DSN. If the client options include a `orgId`, this will always take precedence. + */ +export function extractOrgIdFromClient(client: Client): string | undefined { + const options = client.getOptions(); + + const { host } = client.getDsn() || {}; + + let org_id: string | undefined; + + if (options.orgId) { + org_id = String(options.orgId); + } else if (host) { + org_id = extractOrgIdFromDsnHost(host); + } + + return org_id; +} + /** * Creates a valid Sentry Dsn object, identifying a Sentry instance and project. * @returns a valid DsnComponents object or `undefined` if @param from is an invalid DSN source diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index 6f8c7ca8e946..86872017707a 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -15,7 +15,7 @@ declare const __SENTRY_BROWSER_BUNDLE__: boolean | undefined; -export type SdkSource = 'npm' | 'cdn' | 'loader'; +export type SdkSource = 'npm' | 'cdn' | 'loader' | 'aws-lambda-layer'; /** * Figures out if we're building a browser bundle. diff --git a/packages/core/src/utils/flushIfServerless.ts b/packages/core/src/utils/flushIfServerless.ts new file mode 100644 index 000000000000..2f8d387990c9 --- /dev/null +++ b/packages/core/src/utils/flushIfServerless.ts @@ -0,0 +1,77 @@ +import { flush } from '../exports'; +import { debug } from './debug-logger'; +import { vercelWaitUntil } from './vercelWaitUntil'; +import { GLOBAL_OBJ } from './worldwide'; + +type MinimalCloudflareContext = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + waitUntil(promise: Promise): void; +}; + +async function flushWithTimeout(timeout: number): Promise { + try { + debug.log('Flushing events...'); + await flush(timeout); + debug.log('Done flushing events'); + } catch (e) { + debug.log('Error while flushing events:\n', e); + } +} + +/** + * Flushes the event queue with a timeout in serverless environments to ensure that events are sent to Sentry before the + * serverless function execution ends. + * + * The function is async, but in environments that support a `waitUntil` mechanism, it will run synchronously. + * + * This function is aware of the following serverless platforms: + * - Cloudflare: If a Cloudflare context is provided, it will use `ctx.waitUntil()` to flush events (keeps the `this` context of `ctx`). + * If a `cloudflareWaitUntil` function is provided, it will use that to flush events (looses the `this` context of `ctx`). + * - Vercel: It detects the Vercel environment and uses Vercel's `waitUntil` function. + * - Other Serverless (AWS Lambda, Google Cloud, etc.): It detects the environment via environment variables + * and uses a regular `await flush()`. + * + * @internal This function is supposed for internal Sentry SDK usage only. + * @hidden + */ +export async function flushIfServerless( + params: // eslint-disable-next-line @typescript-eslint/no-explicit-any + | { timeout?: number; cloudflareWaitUntil?: (task: Promise) => void } + | { timeout?: number; cloudflareCtx?: MinimalCloudflareContext } = {}, +): Promise { + const { timeout = 2000 } = params; + + if ('cloudflareWaitUntil' in params && typeof params?.cloudflareWaitUntil === 'function') { + params.cloudflareWaitUntil(flushWithTimeout(timeout)); + return; + } + + if ('cloudflareCtx' in params && typeof params.cloudflareCtx?.waitUntil === 'function') { + params.cloudflareCtx.waitUntil(flushWithTimeout(timeout)); + return; + } + + // @ts-expect-error This is not typed + if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { + // Vercel has a waitUntil equivalent that works without execution context + vercelWaitUntil(flushWithTimeout(timeout)); + return; + } + + if (typeof process === 'undefined') { + return; + } + + const isServerless = + !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions + !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda + !!process.env.K_SERVICE || // Google Cloud Run + !!process.env.CF_PAGES || // Cloudflare Pages + !!process.env.VERCEL || + !!process.env.NETLIFY; + + if (isServerless) { + // Use regular flush for environments without a generic waitUntil mechanism + await flushWithTimeout(timeout); + } +} diff --git a/packages/core/src/utils/gen-ai-attributes.ts b/packages/core/src/utils/gen-ai-attributes.ts index cf8a073a4313..d1b45532e8a5 100644 --- a/packages/core/src/utils/gen-ai-attributes.ts +++ b/packages/core/src/utils/gen-ai-attributes.ts @@ -91,22 +91,39 @@ export const GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE = 'gen_ai.usage.output_tokens' export const GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE = 'gen_ai.usage.total_tokens'; /** - * The operation name for OpenAI API calls + * The operation name */ export const GEN_AI_OPERATION_NAME_ATTRIBUTE = 'gen_ai.operation.name'; /** - * The prompt messages sent to OpenAI (stringified JSON) + * The prompt messages * Only recorded when recordInputs is enabled */ export const GEN_AI_REQUEST_MESSAGES_ATTRIBUTE = 'gen_ai.request.messages'; /** - * The response text from OpenAI (stringified JSON array) + * The response text * Only recorded when recordOutputs is enabled */ export const GEN_AI_RESPONSE_TEXT_ATTRIBUTE = 'gen_ai.response.text'; +/** + * The available tools from incoming request + * Only recorded when recordInputs is enabled + */ +export const GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE = 'gen_ai.request.available_tools'; + +/** + * Whether the response is a streaming response + */ +export const GEN_AI_RESPONSE_STREAMING_ATTRIBUTE = 'gen_ai.response.streaming'; + +/** + * The tool calls from the response + * Only recorded when recordOutputs is enabled + */ +export const GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE = 'gen_ai.response.tool_calls'; + // ============================================================================= // OPENAI-SPECIFIC ATTRIBUTES // ============================================================================= @@ -127,12 +144,12 @@ export const OPENAI_RESPONSE_MODEL_ATTRIBUTE = 'openai.response.model'; export const OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE = 'openai.response.timestamp'; /** - * The number of completion tokens used (OpenAI specific) + * The number of completion tokens used */ export const OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE = 'openai.usage.completion_tokens'; /** - * The number of prompt tokens used (OpenAI specific) + * The number of prompt tokens used */ export const OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE = 'openai.usage.prompt_tokens'; @@ -145,4 +162,5 @@ export const OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE = 'openai.usage.prompt_tokens' */ export const OPENAI_OPERATIONS = { CHAT: 'chat', + RESPONSES: 'responses', } as const; diff --git a/packages/core/src/utils/handleCallbackErrors.ts b/packages/core/src/utils/handleCallbackErrors.ts index cf4d29766445..5675638e18f2 100644 --- a/packages/core/src/utils/handleCallbackErrors.ts +++ b/packages/core/src/utils/handleCallbackErrors.ts @@ -14,12 +14,7 @@ import { isThenable } from '../utils/is'; export function handleCallbackErrors< // eslint-disable-next-line @typescript-eslint/no-explicit-any Fn extends () => any, ->( - fn: Fn, - onError: (error: unknown) => void, - // eslint-disable-next-line @typescript-eslint/no-empty-function - onFinally: () => void = () => {}, -): ReturnType { +>(fn: Fn, onError: (error: unknown) => void, onFinally: () => void = () => {}): ReturnType { let maybePromiseResult: ReturnType; try { maybePromiseResult = fn(); diff --git a/packages/core/src/utils/hasSpansEnabled.ts b/packages/core/src/utils/hasSpansEnabled.ts index dc59ec770271..26a71eb7ca0b 100644 --- a/packages/core/src/utils/hasSpansEnabled.ts +++ b/packages/core/src/utils/hasSpansEnabled.ts @@ -34,11 +34,3 @@ export function hasSpansEnabled( (options.tracesSampleRate != null || !!options.tracesSampler) ); } - -/** - * @see JSDoc of `hasSpansEnabled` - * @deprecated Use `hasSpansEnabled` instead, which is a more accurately named version of this function. - * This function will be removed in the next major version of the SDK. - */ -// TODO(v10): Remove this export -export const hasTracingEnabled = hasSpansEnabled; diff --git a/packages/core/src/utils/node-stack-trace.ts b/packages/core/src/utils/node-stack-trace.ts index 9184d3bac53d..0cecd3dbf1e9 100644 --- a/packages/core/src/utils/node-stack-trace.ts +++ b/packages/core/src/utils/node-stack-trace.ts @@ -53,9 +53,18 @@ export function filenameIsInApp(filename: string, isNative: boolean = false): bo export function node(getModule?: GetModuleFn): StackLineParserFn { const FILENAME_MATCH = /^\s*[-]{4,}$/; const FULL_MATCH = /at (?:async )?(?:(.+?)\s+\()?(?:(.+):(\d+):(\d+)?|([^)]+))\)?/; + const DATA_URI_MATCH = /at (?:async )?(.+?) \(data:(.*?),/; // eslint-disable-next-line complexity return (line: string) => { + const dataUriMatch = line.match(DATA_URI_MATCH); + if (dataUriMatch) { + return { + filename: ``, + function: dataUriMatch[1], + }; + } + const lineMatch = line.match(FULL_MATCH); if (lineMatch) { diff --git a/packages/core/src/utils/openai/constants.ts b/packages/core/src/utils/openai/constants.ts index e552616cc1db..c4952b123b0f 100644 --- a/packages/core/src/utils/openai/constants.ts +++ b/packages/core/src/utils/openai/constants.ts @@ -3,3 +3,19 @@ export const OPENAI_INTEGRATION_NAME = 'OpenAI'; // https://platform.openai.com/docs/quickstart?api-mode=responses // https://platform.openai.com/docs/quickstart?api-mode=chat export const INSTRUMENTED_METHODS = ['responses.create', 'chat.completions.create'] as const; +export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ + 'response.output_item.added', + 'response.function_call_arguments.delta', + 'response.function_call_arguments.done', + 'response.output_item.done', +] as const; +export const RESPONSE_EVENT_TYPES = [ + 'response.created', + 'response.in_progress', + 'response.failed', + 'response.completed', + 'response.incomplete', + 'response.queued', + 'response.output_text.delta', + ...RESPONSES_TOOL_CALL_EVENT_TYPES, +] as const; diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index 2b5fdbef9c11..8bd1c3625782 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -1,31 +1,27 @@ import { getCurrentScope } from '../../currentScopes'; import { captureException } from '../../exports'; -import { startSpan } from '../../tracing/trace'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, - GEN_AI_RESPONSE_ID_ATTRIBUTE, - GEN_AI_RESPONSE_MODEL_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, - GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, - GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, - GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, - OPENAI_RESPONSE_ID_ATTRIBUTE, - OPENAI_RESPONSE_MODEL_ATTRIBUTE, - OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE, - OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, - OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE, } from '../gen-ai-attributes'; import { OPENAI_INTEGRATION_NAME } from './constants'; +import { instrumentStream } from './streaming'; import type { + ChatCompletionChunk, InstrumentedMethod, OpenAiChatCompletionObject, OpenAiClient, @@ -33,6 +29,8 @@ import type { OpenAiOptions, OpenAiResponse, OpenAIResponseObject, + OpenAIStream, + ResponseStreamingEvent, } from './types'; import { buildMethodPath, @@ -40,6 +38,8 @@ import { getSpanOperation, isChatCompletionResponse, isResponsesApiResponse, + setCommonResponseAttributes, + setTokenUsageAttributes, shouldInstrument, } from './utils'; @@ -52,6 +52,24 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record 0 && typeof args[0] === 'object' && args[0] !== null) { + const params = args[0] as Record; + + const tools = Array.isArray(params.tools) ? params.tools : []; + const hasWebSearchOptions = params.web_search_options && typeof params.web_search_options === 'object'; + const webSearchOptions = hasWebSearchOptions + ? [{ type: 'web_search_options', ...(params.web_search_options as Record) }] + : []; + + const availableTools = [...tools, ...webSearchOptions]; + + if (availableTools.length > 0) { + attributes[GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE] = JSON.stringify(availableTools); + } + } + if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { const params = args[0] as Record; @@ -61,6 +79,7 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record choice.message?.tool_calls) + .filter(calls => Array.isArray(calls) && calls.length > 0) + .flat(); + + if (toolCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls), + }); + } + } } } /** * Add attributes for Responses API responses */ -function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject): void { +function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject, recordOutputs?: boolean): void { setCommonResponseAttributes(span, response.id, response.model, response.created_at); if (response.status) { span.setAttributes({ @@ -162,6 +144,24 @@ function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject): response.usage.total_tokens, ); } + + // Extract function calls from output (only if recordOutputs is true) + if (recordOutputs) { + const responseWithOutput = response as OpenAIResponseObject & { output?: unknown[] }; + if (Array.isArray(responseWithOutput.output) && responseWithOutput.output.length > 0) { + // Filter for function_call type objects in the output array + const functionCalls = responseWithOutput.output.filter( + (item): unknown => + typeof item === 'object' && item !== null && (item as Record).type === 'function_call', + ); + + if (functionCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls), + }); + } + } + } } /** @@ -174,13 +174,13 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool const response = result as OpenAiResponse; if (isChatCompletionResponse(response)) { - addChatCompletionAttributes(span, response); + addChatCompletionAttributes(span, response, recordOutputs); if (recordOutputs && response.choices?.length) { const responseTexts = response.choices.map(choice => choice.message?.content || ''); span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: JSON.stringify(responseTexts) }); } } else if (isResponsesApiResponse(response)) { - addResponsesApiAttributes(span, response); + addResponsesApiAttributes(span, response, recordOutputs); if (recordOutputs && response.output_text) { span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.output_text }); } @@ -226,28 +226,68 @@ function instrumentMethod( const model = (requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] as string) || 'unknown'; const operationName = getOperationName(methodPath); - return startSpan( - { - name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), - attributes: requestAttributes as Record, - }, - async (span: Span) => { - try { - if (finalOptions.recordInputs && args[0] && typeof args[0] === 'object') { - addRequestAttributes(span, args[0] as Record); + const params = args[0] as Record | undefined; + const isStreamRequested = params && typeof params === 'object' && params.stream === true; + + if (isStreamRequested) { + // For streaming responses, use manual span management to properly handle the async generator lifecycle + return startSpanManual( + { + name: `${operationName} ${model} stream-response`, + op: getSpanOperation(methodPath), + attributes: requestAttributes as Record, + }, + async (span: Span) => { + try { + if (finalOptions.recordInputs && args[0] && typeof args[0] === 'object') { + addRequestAttributes(span, args[0] as Record); + } + + const result = await originalMethod.apply(context, args); + + return instrumentStream( + result as OpenAIStream, + span, + finalOptions.recordOutputs ?? false, + ) as unknown as R; + } catch (error) { + // For streaming requests that fail before stream creation, we still want to record + // them as streaming requests but end the span gracefully + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + }, + }); + span.end(); + throw error; } + }, + ); + } else { + // Non-streaming responses + return startSpan( + { + name: `${operationName} ${model}`, + op: getSpanOperation(methodPath), + attributes: requestAttributes as Record, + }, + async (span: Span) => { + try { + if (finalOptions.recordInputs && args[0] && typeof args[0] === 'object') { + addRequestAttributes(span, args[0] as Record); + } - const result = await originalMethod.apply(context, args); - // TODO: Add streaming support - addResponseAttributes(span, result, finalOptions.recordOutputs); - return result; - } catch (error) { - captureException(error); - throw error; - } - }, - ); + const result = await originalMethod.apply(context, args); + addResponseAttributes(span, result, finalOptions.recordOutputs); + return result; + } catch (error) { + captureException(error); + throw error; + } + }, + ); + } }; } @@ -264,6 +304,12 @@ function createDeepProxy(target: object, currentPath = '', options?: OpenAiOptio return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, obj, options); } + if (typeof value === 'function') { + // Bind non-instrumented functions to preserve the original `this` context, + // which is required for accessing private class fields (e.g. #baseURL) in OpenAI SDK v5. + return value.bind(obj); + } + if (value && typeof value === 'object') { return createDeepProxy(value as object, methodPath, options); } diff --git a/packages/core/src/utils/openai/streaming.ts b/packages/core/src/utils/openai/streaming.ts new file mode 100644 index 000000000000..2791e715920e --- /dev/null +++ b/packages/core/src/utils/openai/streaming.ts @@ -0,0 +1,278 @@ +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; +import { + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, +} from '../gen-ai-attributes'; +import { RESPONSE_EVENT_TYPES } from './constants'; +import type { OpenAIResponseObject } from './types'; +import { + type ChatCompletionChunk, + type ChatCompletionToolCall, + type ResponseFunctionCall, + type ResponseStreamingEvent, +} from './types'; +import { + isChatCompletionChunk, + isResponsesApiStreamEvent, + setCommonResponseAttributes, + setTokenUsageAttributes, +} from './utils'; + +/** + * State object used to accumulate information from a stream of OpenAI events/chunks. + */ +interface StreamingState { + /** Types of events encountered in the stream. */ + eventTypes: string[]; + /** Collected response text fragments (for output recording). */ + responseTexts: string[]; + /** Reasons for finishing the response, as reported by the API. */ + finishReasons: string[]; + /** The response ID. */ + responseId: string; + /** The model name. */ + responseModel: string; + /** The timestamp of the response. */ + responseTimestamp: number; + /** Number of prompt/input tokens used. */ + promptTokens: number | undefined; + /** Number of completion/output tokens used. */ + completionTokens: number | undefined; + /** Total number of tokens used (prompt + completion). */ + totalTokens: number | undefined; + /** + * Accumulated tool calls from Chat Completion streaming, indexed by tool call index. + * @see https://platform.openai.com/docs/guides/function-calling?api-mode=chat#streaming + */ + chatCompletionToolCalls: Record; + /** + * Accumulated function calls from Responses API streaming. + * @see https://platform.openai.com/docs/guides/function-calling?api-mode=responses#streaming + */ + responsesApiToolCalls: Array; +} + +/** + * Processes tool calls from a chat completion chunk delta. + * Follows the pattern: accumulate by index, then convert to array at the end. + * + * @param toolCalls - Array of tool calls from the delta. + * @param state - The current streaming state to update. + * + * @see https://platform.openai.com/docs/guides/function-calling#streaming + */ +function processChatCompletionToolCalls(toolCalls: ChatCompletionToolCall[], state: StreamingState): void { + for (const toolCall of toolCalls) { + const index = toolCall.index; + if (index === undefined || !toolCall.function) continue; + + // Initialize tool call if this is the first chunk for this index + if (!(index in state.chatCompletionToolCalls)) { + state.chatCompletionToolCalls[index] = { + ...toolCall, + function: { + name: toolCall.function.name, + arguments: toolCall.function.arguments || '', + }, + }; + } else { + // Accumulate function arguments from subsequent chunks + const existingToolCall = state.chatCompletionToolCalls[index]; + if (toolCall.function.arguments && existingToolCall?.function) { + existingToolCall.function.arguments += toolCall.function.arguments; + } + } + } +} + +/** + * Processes a single OpenAI ChatCompletionChunk event, updating the streaming state. + * + * @param chunk - The ChatCompletionChunk event to process. + * @param state - The current streaming state to update. + * @param recordOutputs - Whether to record output text fragments. + */ +function processChatCompletionChunk(chunk: ChatCompletionChunk, state: StreamingState, recordOutputs: boolean): void { + state.responseId = chunk.id ?? state.responseId; + state.responseModel = chunk.model ?? state.responseModel; + state.responseTimestamp = chunk.created ?? state.responseTimestamp; + + if (chunk.usage) { + // For stream responses, the input tokens remain constant across all events in the stream. + // Output tokens, however, are only finalized in the last event. + // Since we can't guarantee that the last event will include usage data or even be a typed event, + // we update the output token values on every event that includes them. + // This ensures that output token usage is always set, even if the final event lacks it. + state.promptTokens = chunk.usage.prompt_tokens; + state.completionTokens = chunk.usage.completion_tokens; + state.totalTokens = chunk.usage.total_tokens; + } + + for (const choice of chunk.choices ?? []) { + if (recordOutputs) { + if (choice.delta?.content) { + state.responseTexts.push(choice.delta.content); + } + + // Handle tool calls from delta + if (choice.delta?.tool_calls) { + processChatCompletionToolCalls(choice.delta.tool_calls, state); + } + } + if (choice.finish_reason) { + state.finishReasons.push(choice.finish_reason); + } + } +} + +/** + * Processes a single OpenAI Responses API streaming event, updating the streaming state and span. + * + * @param streamEvent - The event to process (may be an error or unknown object). + * @param state - The current streaming state to update. + * @param recordOutputs - Whether to record output text fragments. + * @param span - The span to update with error status if needed. + */ +function processResponsesApiEvent( + streamEvent: ResponseStreamingEvent | unknown | Error, + state: StreamingState, + recordOutputs: boolean, + span: Span, +): void { + if (!(streamEvent && typeof streamEvent === 'object')) { + state.eventTypes.push('unknown:non-object'); + return; + } + if (streamEvent instanceof Error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(streamEvent, { + mechanism: { + handled: false, + }, + }); + return; + } + + if (!('type' in streamEvent)) return; + const event = streamEvent as ResponseStreamingEvent; + + if (!RESPONSE_EVENT_TYPES.includes(event.type)) { + state.eventTypes.push(event.type); + return; + } + + // Handle output text delta + if (recordOutputs) { + // Handle tool call events for Responses API + if (event.type === 'response.output_item.done' && 'item' in event) { + state.responsesApiToolCalls.push(event.item); + } + + if (event.type === 'response.output_text.delta' && 'delta' in event && event.delta) { + state.responseTexts.push(event.delta); + return; + } + } + + if ('response' in event) { + const { response } = event as { response: OpenAIResponseObject }; + state.responseId = response.id ?? state.responseId; + state.responseModel = response.model ?? state.responseModel; + state.responseTimestamp = response.created_at ?? state.responseTimestamp; + + if (response.usage) { + // For stream responses, the input tokens remain constant across all events in the stream. + // Output tokens, however, are only finalized in the last event. + // Since we can't guarantee that the last event will include usage data or even be a typed event, + // we update the output token values on every event that includes them. + // This ensures that output token usage is always set, even if the final event lacks it. + state.promptTokens = response.usage.input_tokens; + state.completionTokens = response.usage.output_tokens; + state.totalTokens = response.usage.total_tokens; + } + + if (response.status) { + state.finishReasons.push(response.status); + } + + if (recordOutputs && response.output_text) { + state.responseTexts.push(response.output_text); + } + } +} + +/** + * Instruments a stream of OpenAI events, updating the provided span with relevant attributes and + * optionally recording output text. This function yields each event from the input stream as it is processed. + * + * @template T - The type of events in the stream. + * @param stream - The async iterable stream of events to instrument. + * @param span - The span to add attributes to and to finish at the end of the stream. + * @param recordOutputs - Whether to record output text fragments in the span. + * @returns An async generator yielding each event from the input stream. + */ +export async function* instrumentStream( + stream: AsyncIterable, + span: Span, + recordOutputs: boolean, +): AsyncGenerator { + const state: StreamingState = { + eventTypes: [], + responseTexts: [], + finishReasons: [], + responseId: '', + responseModel: '', + responseTimestamp: 0, + promptTokens: undefined, + completionTokens: undefined, + totalTokens: undefined, + chatCompletionToolCalls: {}, + responsesApiToolCalls: [], + }; + + try { + for await (const event of stream) { + if (isChatCompletionChunk(event)) { + processChatCompletionChunk(event as ChatCompletionChunk, state, recordOutputs); + } else if (isResponsesApiStreamEvent(event)) { + processResponsesApiEvent(event as ResponseStreamingEvent, state, recordOutputs, span); + } + yield event; + } + } finally { + setCommonResponseAttributes(span, state.responseId, state.responseModel, state.responseTimestamp); + setTokenUsageAttributes(span, state.promptTokens, state.completionTokens, state.totalTokens); + + span.setAttributes({ + [GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true, + }); + + if (state.finishReasons.length) { + span.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(state.finishReasons), + }); + } + + if (recordOutputs && state.responseTexts.length) { + span.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: state.responseTexts.join(''), + }); + } + + // Set tool calls attribute if any were accumulated + const chatCompletionToolCallsArray = Object.values(state.chatCompletionToolCalls); + const allToolCalls = [...chatCompletionToolCallsArray, ...state.responsesApiToolCalls]; + + if (allToolCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(allToolCalls), + }); + } + + span.end(); + } +} diff --git a/packages/core/src/utils/openai/types.ts b/packages/core/src/utils/openai/types.ts index c9a3870a959e..7ac8fb8d7b91 100644 --- a/packages/core/src/utils/openai/types.ts +++ b/packages/core/src/utils/openai/types.ts @@ -50,6 +50,7 @@ export interface OpenAiChatCompletionObject { content: string | null; refusal?: string | null; annotations?: Array; // Depends on whether annotations are enabled + tool_calls?: Array; }; logprobs?: unknown | null; finish_reason: string | null; @@ -132,6 +133,188 @@ export interface OpenAIResponseObject { export type OpenAiResponse = OpenAiChatCompletionObject | OpenAIResponseObject; +/** + * Streaming event types for the Responses API + * @see https://platform.openai.com/docs/api-reference/responses-streaming + * @see https://platform.openai.com/docs/guides/streaming-responses#read-the-responses for common events + */ +export type ResponseStreamingEvent = + | ResponseCreatedEvent + | ResponseInProgressEvent + | ResponseFailedEvent + | ResponseCompletedEvent + | ResponseIncompleteEvent + | ResponseQueuedEvent + | ResponseOutputTextDeltaEvent + | ResponseOutputItemAddedEvent + | ResponseFunctionCallArgumentsDeltaEvent + | ResponseFunctionCallArgumentsDoneEvent + | ResponseOutputItemDoneEvent; + +interface ResponseCreatedEvent { + type: 'response.created'; + response: OpenAIResponseObject; + sequence_number: number; +} + +interface ResponseInProgressEvent { + type: 'response.in_progress'; + response: OpenAIResponseObject; + sequence_number: number; +} + +interface ResponseOutputTextDeltaEvent { + content_index: number; + delta: string; + item_id: string; + logprobs: object; + output_index: number; + sequence_number: number; + type: 'response.output_text.delta'; +} + +interface ResponseFailedEvent { + type: 'response.failed'; + response: OpenAIResponseObject; + sequence_number: number; +} + +interface ResponseIncompleteEvent { + type: 'response.incomplete'; + response: OpenAIResponseObject; + sequence_number: number; +} + +interface ResponseCompletedEvent { + type: 'response.completed'; + response: OpenAIResponseObject; + sequence_number: number; +} +interface ResponseQueuedEvent { + type: 'response.queued'; + response: OpenAIResponseObject; + sequence_number: number; +} + +/** + * @see https://platform.openai.com/docs/api-reference/realtime-server-events/response/output_item/added + */ +interface ResponseOutputItemAddedEvent { + type: 'response.output_item.added'; + output_index: number; + item: unknown; + event_id: string; + response_id: string; +} + +/** + * @see https://platform.openai.com/docs/api-reference/realtime-server-events/response/function_call_arguments/delta + */ +interface ResponseFunctionCallArgumentsDeltaEvent { + type: 'response.function_call_arguments.delta'; + item_id: string; + output_index: number; + delta: string; + call_id: string; + event_id: string; + response_id: string; +} + +/** + * @see https://platform.openai.com/docs/api-reference/realtime-server-events/response/function_call_arguments/done + */ +interface ResponseFunctionCallArgumentsDoneEvent { + type: 'response.function_call_arguments.done'; + response_id: string; + item_id: string; + output_index: number; + arguments: string; + call_id: string; + event_id: string; +} + +/** + * @see https://platform.openai.com/docs/api-reference/realtime-server-events/response/output_item/done + */ +interface ResponseOutputItemDoneEvent { + type: 'response.output_item.done'; + response_id: string; + output_index: number; + item: unknown; + event_id: string; +} + +/** + * Tool call object for Chat Completion streaming + */ +export interface ChatCompletionToolCall { + index?: number; // Present for streaming responses + id: string; + type?: string; // Could be missing for streaming responses + function?: { + name: string; + arguments?: string; + }; +} + +/** + * Function call object for Responses API + */ +export interface ResponseFunctionCall { + type: string; + id: string; + call_id: string; + name: string; + arguments: string; +} + +/** + * Chat Completion streaming chunk type + * @see https://platform.openai.com/docs/api-reference/chat-streaming/streaming + */ +export interface ChatCompletionChunk { + id: string; + object: 'chat.completion.chunk'; + created: number; + model: string; + system_fingerprint: string; + service_tier?: string; + choices: Array<{ + index: number; + delta: { + content: string | null; + role: string; + function_call?: object; + refusal?: string | null; + tool_calls?: Array; + }; + logprobs?: unknown | null; + finish_reason?: string | null; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + completion_tokens_details: { + accepted_prediction_tokens: number; + audio_tokens: number; + reasoning_tokens: number; + rejected_prediction_tokens: number; + }; + prompt_tokens_details: { + audio_tokens: number; + cached_tokens: number; + }; + }; +} + +/** + * Represents a stream of events from OpenAI APIs + */ +export interface OpenAIStream extends AsyncIterable { + [Symbol.asyncIterator](): AsyncIterator; +} + /** * OpenAI Integration interface for type safety */ diff --git a/packages/core/src/utils/openai/utils.ts b/packages/core/src/utils/openai/utils.ts index b7d5e12ecf62..f76d26de5d6a 100644 --- a/packages/core/src/utils/openai/utils.ts +++ b/packages/core/src/utils/openai/utils.ts @@ -1,6 +1,25 @@ -import { OPENAI_OPERATIONS } from '../gen-ai-attributes'; +import type { Span } from '../../types-hoist/span'; +import { + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, + OPENAI_OPERATIONS, + OPENAI_RESPONSE_ID_ATTRIBUTE, + OPENAI_RESPONSE_MODEL_ATTRIBUTE, + OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE, + OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, + OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE, +} from '../gen-ai-attributes'; import { INSTRUMENTED_METHODS } from './constants'; -import type { InstrumentedMethod, OpenAiChatCompletionObject, OpenAIResponseObject } from './types'; +import type { + ChatCompletionChunk, + InstrumentedMethod, + OpenAiChatCompletionObject, + OpenAIResponseObject, + ResponseStreamingEvent, +} from './types'; /** * Maps OpenAI method paths to Sentry operation names @@ -10,8 +29,7 @@ export function getOperationName(methodPath: string): string { return OPENAI_OPERATIONS.CHAT; } if (methodPath.includes('responses')) { - // The responses API is also a chat operation - return OPENAI_OPERATIONS.CHAT; + return OPENAI_OPERATIONS.RESPONSES; } return methodPath.split('.').pop() || 'unknown'; } @@ -61,3 +79,81 @@ export function isResponsesApiResponse(response: unknown): response is OpenAIRes (response as Record).object === 'response' ); } + +/** + * Check if streaming event is from the Responses API + */ +export function isResponsesApiStreamEvent(event: unknown): event is ResponseStreamingEvent { + return ( + event !== null && + typeof event === 'object' && + 'type' in event && + typeof (event as Record).type === 'string' && + ((event as Record).type as string).startsWith('response.') + ); +} + +/** + * Check if streaming event is a chat completion chunk + */ +export function isChatCompletionChunk(event: unknown): event is ChatCompletionChunk { + return ( + event !== null && + typeof event === 'object' && + 'object' in event && + (event as Record).object === 'chat.completion.chunk' + ); +} + +/** + * Set token usage attributes + * @param span - The span to add attributes to + * @param promptTokens - The number of prompt tokens + * @param completionTokens - The number of completion tokens + * @param totalTokens - The number of total tokens + */ +export function setTokenUsageAttributes( + span: Span, + promptTokens?: number, + completionTokens?: number, + totalTokens?: number, +): void { + if (promptTokens !== undefined) { + span.setAttributes({ + [OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE]: promptTokens, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: promptTokens, + }); + } + if (completionTokens !== undefined) { + span.setAttributes({ + [OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE]: completionTokens, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: completionTokens, + }); + } + if (totalTokens !== undefined) { + span.setAttributes({ + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: totalTokens, + }); + } +} + +/** + * Set common response attributes + * @param span - The span to add attributes to + * @param id - The response id + * @param model - The response model + * @param timestamp - The response timestamp + */ +export function setCommonResponseAttributes(span: Span, id: string, model: string, timestamp: number): void { + span.setAttributes({ + [OPENAI_RESPONSE_ID_ATTRIBUTE]: id, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: id, + }); + span.setAttributes({ + [OPENAI_RESPONSE_MODEL_ATTRIBUTE]: model, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: model, + }); + span.setAttributes({ + [OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(timestamp * 1000).toISOString(), + }); +} diff --git a/packages/core/src/utils/stacktrace.ts b/packages/core/src/utils/stacktrace.ts index 1aa1fa5ab92d..c7aab77bf3be 100644 --- a/packages/core/src/utils/stacktrace.ts +++ b/packages/core/src/utils/stacktrace.ts @@ -23,13 +23,13 @@ export function createStackParser(...parsers: StackLineParser[]): StackParser { const lines = stack.split('\n'); for (let i = skipFirstLines; i < lines.length; i++) { - const line = lines[i] as string; - // Ignore lines over 1kb as they are unlikely to be stack frames. - // Many of the regular expressions use backtracking which results in run time that increases exponentially with - // input size. Huge strings can result in hangs/Denial of Service: + let line = lines[i] as string; + // Truncate lines over 1kb because many of the regular expressions use + // backtracking which results in run time that increases exponentially + // with input size. Huge strings can result in hangs/Denial of Service: // https://github.com/getsentry/sentry-javascript/issues/2286 if (line.length > 1024) { - continue; + line = line.slice(0, 1024); } // https://github.com/getsentry/sentry-javascript/issues/5459 diff --git a/packages/core/src/utils/tracing.ts b/packages/core/src/utils/tracing.ts index fe299d2d6338..509fff5acde0 100644 --- a/packages/core/src/utils/tracing.ts +++ b/packages/core/src/utils/tracing.ts @@ -1,7 +1,10 @@ +import type { Client } from '../client'; import type { DynamicSamplingContext } from '../types-hoist/envelope'; import type { PropagationContext } from '../types-hoist/tracing'; import type { TraceparentData } from '../types-hoist/transaction'; +import { debug } from '../utils/debug-logger'; import { baggageHeaderToDynamicSamplingContext } from './baggage'; +import { extractOrgIdFromClient } from './dsn'; import { parseSampleRate } from './parseSampleRate'; import { generateSpanId, generateTraceId } from './propagationContext'; @@ -124,3 +127,38 @@ function getSampleRandFromTraceparentAndDsc( return Math.random(); } } + +/** + * Determines whether a new trace should be continued based on the provided baggage org ID and the client's `strictTraceContinuation` option. + * If the trace should not be continued, a new trace will be started. + * + * The result is dependent on the `strictTraceContinuation` option in the client. + * See https://develop.sentry.dev/sdk/telemetry/traces/#stricttracecontinuation + */ +export function shouldContinueTrace(client: Client, baggageOrgId?: string): boolean { + const clientOrgId = extractOrgIdFromClient(client); + + // Case: baggage orgID and Client orgID don't match - always start new trace + if (baggageOrgId && clientOrgId && baggageOrgId !== clientOrgId) { + debug.log( + `Won't continue trace because org IDs don't match (incoming baggage: ${baggageOrgId}, SDK options: ${clientOrgId})`, + ); + return false; + } + + const strictTraceContinuation = client.getOptions().strictTraceContinuation || false; // default for `strictTraceContinuation` is `false` + + if (strictTraceContinuation) { + // With strict continuation enabled, don't continue trace if: + // - Baggage has orgID, but Client doesn't have one + // - Client has orgID, but baggage doesn't have one + if ((baggageOrgId && !clientOrgId) || (!baggageOrgId && clientOrgId)) { + debug.log( + `Starting a new trace because strict trace continuation is enabled but one org ID is missing (incoming baggage: ${baggageOrgId}, Sentry client: ${clientOrgId})`, + ); + return false; + } + } + + return true; +} diff --git a/packages/core/src/utils/vercel-ai.ts b/packages/core/src/utils/vercel-ai.ts index c5491376d7c4..9c20d49ea157 100644 --- a/packages/core/src/utils/vercel-ai.ts +++ b/packages/core/src/utils/vercel-ai.ts @@ -10,6 +10,7 @@ import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE, AI_PROMPT_TOOLS_ATTRIBUTE, + AI_RESPONSE_OBJECT_ATTRIBUTE, AI_RESPONSE_PROVIDER_METADATA_ATTRIBUTE, AI_RESPONSE_TEXT_ATTRIBUTE, AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, @@ -93,6 +94,7 @@ function processEndedVercelAiSpan(span: SpanJSON): void { renameAttributeKey(attributes, AI_PROMPT_MESSAGES_ATTRIBUTE, 'gen_ai.request.messages'); renameAttributeKey(attributes, AI_RESPONSE_TEXT_ATTRIBUTE, 'gen_ai.response.text'); renameAttributeKey(attributes, AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, 'gen_ai.response.tool_calls'); + renameAttributeKey(attributes, AI_RESPONSE_OBJECT_ATTRIBUTE, 'gen_ai.response.object'); renameAttributeKey(attributes, AI_PROMPT_TOOLS_ATTRIBUTE, 'gen_ai.request.available_tools'); renameAttributeKey(attributes, AI_TOOL_CALL_ARGS_ATTRIBUTE, 'gen_ai.tool.input'); diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 662d9b6adf67..4b1c7a378114 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -11,7 +11,6 @@ import { SyncPromise, withMonitor, } from '../../src'; -import type { BaseClient, Client } from '../../src/client'; import * as integrationModule from '../../src/integration'; import type { Envelope } from '../../src/types-hoist/envelope'; import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event'; @@ -2107,30 +2106,22 @@ describe('Client', () => { describe('hooks', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); - // Make sure types work for both Client & BaseClient - const scenarios = [ - // eslint-disable-next-line deprecation/deprecation - ['BaseClient', new TestClient(options) as BaseClient], - ['Client', new TestClient(options) as Client], - ] as const; - - describe.each(scenarios)('with client %s', (_, client) => { - it('should call a beforeEnvelope hook', () => { - expect.assertions(1); - - const mockEnvelope = [ - { - event_id: '12345', - }, - {}, - ] as Envelope; + it('should call a beforeEnvelope hook', () => { + const client = new TestClient(options); + expect.assertions(1); - client.on('beforeEnvelope', envelope => { - expect(envelope).toEqual(mockEnvelope); - }); + const mockEnvelope = [ + { + event_id: '12345', + }, + {}, + ] as Envelope; - client.emit('beforeEnvelope', mockEnvelope); + client.on('beforeEnvelope', envelope => { + expect(envelope).toEqual(mockEnvelope); }); + + client.emit('beforeEnvelope', mockEnvelope); }); }); diff --git a/packages/core/test/lib/integrations/mcp-server/mcpServerErrorCapture.test.ts b/packages/core/test/lib/integrations/mcp-server/mcpServerErrorCapture.test.ts new file mode 100644 index 000000000000..bd5af090b0d9 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/mcpServerErrorCapture.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import * as exports from '../../../../src/exports'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { captureError } from '../../../../src/integrations/mcp-server/errorCapture'; +import { createMockMcpServer } from './testUtils'; + +describe('MCP Server Error Capture', () => { + const captureExceptionSpy = vi.spyOn(exports, 'captureException'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + } as ReturnType); + }); + + describe('captureError', () => { + it('should capture errors with error type', () => { + const error = new Error('Tool execution failed'); + + captureError(error, 'tool_execution'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'tool_execution', + }, + }, + }); + }); + + it('should capture transport errors', () => { + const error = new Error('Connection failed'); + + captureError(error, 'transport'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'transport', + }, + }, + }); + }); + + it('should capture protocol errors', () => { + const error = new Error('Invalid JSON-RPC request'); + + captureError(error, 'protocol'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'protocol', + }, + }, + }); + }); + + it('should capture validation errors', () => { + const error = new Error('Invalid parameters'); + + captureError(error, 'validation'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'validation', + }, + }, + }); + }); + + it('should capture timeout errors', () => { + const error = new Error('Operation timed out'); + + captureError(error, 'timeout'); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'timeout', + }, + }, + }); + }); + + it('should capture errors with MCP data for filtering', () => { + const error = new Error('Tool failed'); + + captureError(error, 'tool_execution', { tool_name: 'my-tool' }); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + type: 'mcp_server', + handled: false, + data: { + error_type: 'tool_execution', + tool_name: 'my-tool', + }, + }, + }); + }); + + it('should not capture when no client is available', () => { + getClientSpy.mockReturnValue(undefined); + + const error = new Error('Test error'); + + captureError(error, 'tool_execution'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('should handle Sentry capture errors gracefully', () => { + captureExceptionSpy.mockImplementation(() => { + throw new Error('Sentry error'); + }); + + const error = new Error('Test error'); + + // Should not throw + expect(() => captureError(error, 'tool_execution')).not.toThrow(); + }); + + it('should handle undefined client gracefully', () => { + getClientSpy.mockReturnValue(undefined); + + const error = new Error('Test error'); + + // Should not throw and not capture + expect(() => captureError(error, 'tool_execution')).not.toThrow(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Error Capture Integration', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + }); + + it('should capture tool execution errors and continue normal flow', async () => { + const toolError = new Error('Tool execution failed'); + const mockToolHandler = vi.fn().mockRejectedValue(toolError); + + wrappedMcpServer.tool('failing-tool', mockToolHandler); + + await expect(mockToolHandler({ input: 'test' }, { requestId: 'req-123', sessionId: 'sess-456' })).rejects.toThrow( + 'Tool execution failed', + ); + + // The capture should be set up correctly + expect(captureExceptionSpy).toHaveBeenCalledTimes(0); // No capture yet since we didn't call the wrapped handler + }); + + it('should handle Sentry capture errors gracefully', async () => { + captureExceptionSpy.mockImplementation(() => { + throw new Error('Sentry error'); + }); + + // Test that the capture function itself doesn't throw + const toolError = new Error('Tool execution failed'); + const mockToolHandler = vi.fn().mockRejectedValue(toolError); + + wrappedMcpServer.tool('failing-tool', mockToolHandler); + + // The error capture should be resilient to Sentry errors + expect(captureExceptionSpy).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts b/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts new file mode 100644 index 000000000000..c277162017aa --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/mcpServerWrapper.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import * as tracingModule from '../../../../src/tracing'; +import { createMockMcpServer } from './testUtils'; + +describe('wrapMcpServerWithSentry', () => { + const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + // Mock client to return sendDefaultPii: + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + }); + + it('should return the same instance (modified) if it is a valid MCP server instance', () => { + const mockMcpServer = createMockMcpServer(); + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + expect(wrappedMcpServer).toBe(mockMcpServer); + }); + + it('should return the input unchanged if it is not a valid MCP server instance', () => { + const invalidMcpServer = { + resource: () => {}, + tool: () => {}, + // Missing required methods + }; + + const result = wrapMcpServerWithSentry(invalidMcpServer); + expect(result).toBe(invalidMcpServer); + + // Methods should not be wrapped + expect(result.resource).toBe(invalidMcpServer.resource); + expect(result.tool).toBe(invalidMcpServer.tool); + + // No calls to startSpan or startInactiveSpan + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(startInactiveSpanSpy).not.toHaveBeenCalled(); + }); + + it('should not wrap the same instance twice', () => { + const mockMcpServer = createMockMcpServer(); + + const wrappedOnce = wrapMcpServerWithSentry(mockMcpServer); + const wrappedTwice = wrapMcpServerWithSentry(wrappedOnce); + + expect(wrappedTwice).toBe(wrappedOnce); + }); + + it('should wrap the connect method to intercept transport', () => { + const mockMcpServer = createMockMcpServer(); + const originalConnect = mockMcpServer.connect; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + expect(wrappedMcpServer.connect).not.toBe(originalConnect); + expect(typeof wrappedMcpServer.connect).toBe('function'); + }); + + it('should wrap handler methods (tool, resource, prompt)', () => { + const mockMcpServer = createMockMcpServer(); + const originalTool = mockMcpServer.tool; + const originalResource = mockMcpServer.resource; + const originalPrompt = mockMcpServer.prompt; + + const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + + expect(wrappedMcpServer.tool).not.toBe(originalTool); + expect(wrappedMcpServer.resource).not.toBe(originalResource); + expect(wrappedMcpServer.prompt).not.toBe(originalPrompt); + }); + + describe('Handler Wrapping', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + }); + + it('should register tool handlers without throwing errors', () => { + const toolHandler = vi.fn(); + + expect(() => { + wrappedMcpServer.tool('test-tool', toolHandler); + }).not.toThrow(); + }); + + it('should register resource handlers without throwing errors', () => { + const resourceHandler = vi.fn(); + + expect(() => { + wrappedMcpServer.resource('test-resource', resourceHandler); + }).not.toThrow(); + }); + + it('should register prompt handlers without throwing errors', () => { + const promptHandler = vi.fn(); + + expect(() => { + wrappedMcpServer.prompt('test-prompt', promptHandler); + }).not.toThrow(); + }); + + it('should handle multiple arguments when registering handlers', () => { + const nonFunctionArg = { config: 'value' }; + + expect(() => { + wrappedMcpServer.tool('test-tool', nonFunctionArg, 'other-arg'); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts b/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts new file mode 100644 index 000000000000..14f803b28ccc --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/piiFiltering.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { filterMcpPiiFromSpanData } from '../../../../src/integrations/mcp-server/piiFiltering'; +import * as tracingModule from '../../../../src/tracing'; +import { createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP Server PII Filtering', () => { + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Integration Tests', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockTransport: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockTransport = createMockTransport(); + mockTransport.sessionId = 'test-session-123'; + }); + + it('should include PII data when sendDefaultPii is true', async () => { + // Mock client with sendDefaultPii: true + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as unknown as ReturnType); + + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-pii-true', + params: { name: 'weather', arguments: { location: 'London', units: 'metric' } }, + }; + + const extraWithClientInfo = { + requestInfo: { + remoteAddress: '192.168.1.100', + remotePort: 54321, + }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, extraWithClientInfo); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'tools/call weather', + op: 'mcp.server', + forceTransaction: true, + attributes: expect.objectContaining({ + 'client.address': '192.168.1.100', + 'client.port': 54321, + 'mcp.request.argument.location': '"London"', + 'mcp.request.argument.units': '"metric"', + 'mcp.tool.name': 'weather', + }), + }); + }); + + it('should exclude PII data when sendDefaultPii is false', async () => { + // Mock client with sendDefaultPii: false + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: false }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as unknown as ReturnType); + + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-pii-false', + params: { name: 'weather', arguments: { location: 'London', units: 'metric' } }, + }; + + const extraWithClientInfo = { + requestInfo: { + remoteAddress: '192.168.1.100', + remotePort: 54321, + }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, extraWithClientInfo); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'client.address': expect.anything(), + 'client.port': expect.anything(), + 'mcp.request.argument.location': expect.anything(), + 'mcp.request.argument.units': expect.anything(), + }), + }), + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'mcp.tool.name': 'weather', + 'mcp.method.name': 'tools/call', + }), + }), + ); + }); + + it('should filter tool result content when sendDefaultPii is false', async () => { + // Mock client with sendDefaultPii: false + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: false }), + } as ReturnType); + + await wrappedMcpServer.connect(mockTransport); + + const mockSpan = { + setAttributes: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + } as any; + startInactiveSpanSpy.mockReturnValueOnce(mockSpan); + + const toolCallRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-tool-result-filtered', + params: { name: 'weather-lookup' }, + }; + + mockTransport.onmessage?.(toolCallRequest, {}); + + const toolResponse = { + jsonrpc: '2.0', + id: 'req-tool-result-filtered', + result: { + content: [{ type: 'text', text: 'Sensitive weather data for London' }], + isError: false, + }, + }; + + mockTransport.send?.(toolResponse); + + // Tool result content should be filtered out + const setAttributesCall = mockSpan.setAttributes.mock.calls[0]?.[0]; + expect(setAttributesCall).toBeDefined(); + expect(setAttributesCall).not.toHaveProperty('mcp.tool.result.content'); + expect(setAttributesCall).toHaveProperty('mcp.tool.result.is_error', false); + expect(setAttributesCall).toHaveProperty('mcp.tool.result.content_count', 1); + }); + }); + + describe('filterMcpPiiFromSpanData Function', () => { + it('should preserve all data when sendDefaultPii is true', () => { + const spanData = { + 'client.address': '192.168.1.100', + 'client.port': 54321, + 'mcp.request.argument.location': '"San Francisco"', + 'mcp.tool.result.content': 'Weather data: 18°C', + 'mcp.logging.message': 'User requested weather', + 'mcp.resource.uri': 'file:///private/docs/secret.txt', + 'mcp.method.name': 'tools/call', // Non-PII should remain + }; + + const result = filterMcpPiiFromSpanData(spanData, true); + + expect(result).toEqual(spanData); // All data preserved + }); + + it('should remove PII data when sendDefaultPii is false', () => { + const spanData = { + 'client.address': '192.168.1.100', + 'client.port': 54321, + 'mcp.request.argument.location': '"San Francisco"', + 'mcp.request.argument.units': '"celsius"', + 'mcp.tool.result.content': 'Weather data: 18°C', + 'mcp.logging.message': 'User requested weather', + 'mcp.resource.uri': 'file:///private/docs/secret.txt', + 'mcp.method.name': 'tools/call', // Non-PII should remain + 'mcp.session.id': 'test-session-123', // Non-PII should remain + }; + + const result = filterMcpPiiFromSpanData(spanData, false); + + expect(result).not.toHaveProperty('client.address'); + expect(result).not.toHaveProperty('client.port'); + expect(result).not.toHaveProperty('mcp.request.argument.location'); + expect(result).not.toHaveProperty('mcp.request.argument.units'); + expect(result).not.toHaveProperty('mcp.tool.result.content'); + expect(result).not.toHaveProperty('mcp.logging.message'); + expect(result).not.toHaveProperty('mcp.resource.uri'); + + expect(result).toHaveProperty('mcp.method.name', 'tools/call'); + expect(result).toHaveProperty('mcp.session.id', 'test-session-123'); + }); + + it('should handle empty span data', () => { + const result = filterMcpPiiFromSpanData({}, false); + expect(result).toEqual({}); + }); + + it('should handle span data with no PII attributes', () => { + const spanData = { + 'mcp.method.name': 'tools/list', + 'mcp.session.id': 'test-session', + }; + + const result = filterMcpPiiFromSpanData(spanData, false); + expect(result).toEqual(spanData); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts new file mode 100644 index 000000000000..7b110a0b2756 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts @@ -0,0 +1,438 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import * as tracingModule from '../../../../src/tracing'; +import { createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP Server Semantic Conventions', () => { + const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + // Mock client to return sendDefaultPii: true for instrumentation tests + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as unknown as ReturnType); + }); + + describe('Span Creation & Semantic Conventions', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockTransport: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockTransport = createMockTransport(); + mockTransport.sessionId = 'test-session-123'; + }); + + it('should create spans with correct MCP server semantic attributes for tool operations', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-1', + params: { name: 'get-weather', arguments: { location: 'Seattle, WA' } }, + }; + + const extraWithClientInfo = { + requestInfo: { + remoteAddress: '192.168.1.100', + remotePort: 54321, + }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, extraWithClientInfo); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'tools/call get-weather', + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'get-weather', + 'mcp.request.id': 'req-1', + 'mcp.session.id': 'test-session-123', + 'client.address': '192.168.1.100', + 'client.port': 54321, + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.request.argument.location': '"Seattle, WA"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }); + }); + + it('should create spans with correct attributes for resource operations', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'resources/read', + id: 'req-2', + params: { uri: 'file:///docs/api.md' }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'resources/read file:///docs/api.md', + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'resources/read', + 'mcp.resource.uri': 'file:///docs/api.md', + 'mcp.request.id': 'req-2', + 'mcp.session.id': 'test-session-123', + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.request.argument.uri': '"file:///docs/api.md"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }); + }); + + it('should create spans with correct attributes for prompt operations', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'prompts/get', + id: 'req-3', + params: { name: 'analyze-code' }, + }; + + mockTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'prompts/get analyze-code', + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'prompts/get', + 'mcp.prompt.name': 'analyze-code', + 'mcp.request.id': 'req-3', + 'mcp.session.id': 'test-session-123', + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.request.argument.name': '"analyze-code"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }); + }); + + it('should create spans with correct attributes for notifications (no request id)', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcNotification = { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + params: {}, + }; + + mockTransport.onmessage?.(jsonRpcNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'notifications/tools/list_changed', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'notifications/tools/list_changed', + 'mcp.session.id': 'test-session-123', + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }, + }, + expect.any(Function), + ); + + // Should not include mcp.request.id for notifications + const callArgs = startSpanSpy.mock.calls[0]; + expect(callArgs).toBeDefined(); + const attributes = callArgs?.[0]?.attributes; + expect(attributes).not.toHaveProperty('mcp.request.id'); + }); + + it('should create spans for list operations without target in name', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/list', + id: 'req-4', + params: {}, + }; + + mockTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'tools/list', + forceTransaction: true, + attributes: expect.objectContaining({ + 'mcp.method.name': 'tools/list', + 'mcp.request.id': 'req-4', + 'mcp.session.id': 'test-session-123', + // Transport attributes + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + // Sentry-specific + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }), + }), + ); + }); + + it('should create spans with logging attributes for notifications/message', async () => { + await wrappedMcpServer.connect(mockTransport); + + const loggingNotification = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { + level: 'info', + logger: 'math-service', + data: 'Addition completed: 2 + 5 = 7', + }, + }; + + mockTransport.onmessage?.(loggingNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'notifications/message', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'notifications/message', + 'mcp.session.id': 'test-session-123', + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.logging.level': 'info', + 'mcp.logging.logger': 'math-service', + 'mcp.logging.data_type': 'string', + 'mcp.logging.message': 'Addition completed: 2 + 5 = 7', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }, + }, + expect.any(Function), + ); + }); + + it('should create spans with attributes for other notification types', async () => { + await wrappedMcpServer.connect(mockTransport); + + // Test notifications/cancelled + const cancelledNotification = { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: 'req-123', + reason: 'user_requested', + }, + }; + + mockTransport.onmessage?.(cancelledNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/cancelled', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/cancelled', + 'mcp.cancelled.request_id': 'req-123', + 'mcp.cancelled.reason': 'user_requested', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }), + }), + expect.any(Function), + ); + + vi.clearAllMocks(); + + // Test notifications/progress + const progressNotification = { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 'token-456', + progress: 75, + total: 100, + message: 'Processing files...', + }, + }; + + mockTransport.onmessage?.(progressNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/progress', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/progress', + 'mcp.progress.token': 'token-456', + 'mcp.progress.current': 75, + 'mcp.progress.total': 100, + 'mcp.progress.percentage': 75, + 'mcp.progress.message': 'Processing files...', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }), + }), + expect.any(Function), + ); + + vi.clearAllMocks(); + + // Test notifications/resources/updated + const resourceUpdatedNotification = { + jsonrpc: '2.0', + method: 'notifications/resources/updated', + params: { + uri: 'file:///tmp/data.json', + }, + }; + + mockTransport.onmessage?.(resourceUpdatedNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/resources/updated', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/resources/updated', + 'mcp.resource.uri': 'file:///tmp/data.json', + 'mcp.resource.protocol': 'file', + 'sentry.op': 'mcp.notification.client_to_server', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }), + }), + expect.any(Function), + ); + }); + + it('should create spans with correct operation for outgoing notifications', async () => { + await wrappedMcpServer.connect(mockTransport); + + const outgoingNotification = { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + }; + + await mockTransport.send?.(outgoingNotification); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/tools/list_changed', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/tools/list_changed', + 'sentry.op': 'mcp.notification.server_to_client', + 'sentry.origin': 'auto.mcp.notification', + 'sentry.source': 'route', + }), + }), + expect.any(Function), + ); + }); + + it('should instrument tool call results and complete span with enriched attributes', async () => { + await wrappedMcpServer.connect(mockTransport); + + const setAttributesSpy = vi.fn(); + const setStatusSpy = vi.fn(); + const endSpy = vi.fn(); + const mockSpan = { + setAttributes: setAttributesSpy, + setStatus: setStatusSpy, + end: endSpy, + }; + startInactiveSpanSpy.mockReturnValueOnce( + mockSpan as unknown as ReturnType, + ); + + const toolCallRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-tool-result', + params: { + name: 'weather-lookup', + arguments: { location: 'San Francisco', units: 'celsius' }, + }, + }; + + // Simulate the incoming tool call request + mockTransport.onmessage?.(toolCallRequest, {}); + + // Verify span was created for the request + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'tools/call weather-lookup', + op: 'mcp.server', + forceTransaction: true, + attributes: expect.objectContaining({ + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'weather-lookup', + 'mcp.request.id': 'req-tool-result', + }), + }), + ); + + // Simulate tool execution response with results + const toolResponse = { + jsonrpc: '2.0', + id: 'req-tool-result', + result: { + content: [ + { + type: 'text', + text: 'The weather in San Francisco is 18°C with partly cloudy skies.', + }, + ], + isError: false, + }, + }; + + // Simulate the outgoing response (this should trigger span completion) + mockTransport.send?.(toolResponse); + + // Verify that the span was enriched with tool result attributes + expect(setAttributesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + 'mcp.tool.result.is_error': false, + 'mcp.tool.result.content_count': 1, + 'mcp.tool.result.content_type': 'text', + 'mcp.tool.result.content': 'The weather in San Francisco is 18°C with partly cloudy skies.', + }), + ); + + // Verify span was completed successfully (no error status set) + expect(setStatusSpy).not.toHaveBeenCalled(); + expect(endSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/testUtils.ts b/packages/core/test/lib/integrations/mcp-server/testUtils.ts new file mode 100644 index 000000000000..9593391ca856 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/testUtils.ts @@ -0,0 +1,63 @@ +import { vi } from 'vitest'; + +/** + * Create a mock MCP server instance for testing + */ +export function createMockMcpServer() { + return { + resource: vi.fn(), + tool: vi.fn(), + prompt: vi.fn(), + connect: vi.fn().mockResolvedValue(undefined), + server: { + setRequestHandler: vi.fn(), + }, + }; +} + +/** + * Create a mock HTTP transport (StreamableHTTPServerTransport) + * Uses exact naming pattern from the official SDK + */ +export function createMockTransport() { + class StreamableHTTPServerTransport { + onmessage = vi.fn(); + onclose = vi.fn(); + onerror = vi.fn(); + send = vi.fn().mockResolvedValue(undefined); + sessionId = 'test-session-123'; + protocolVersion = '2025-06-18'; + } + + return new StreamableHTTPServerTransport(); +} + +/** + * Create a mock stdio transport (StdioServerTransport) + * Uses exact naming pattern from the official SDK + */ +export function createMockStdioTransport() { + class StdioServerTransport { + onmessage = vi.fn(); + onclose = vi.fn(); + send = vi.fn().mockResolvedValue(undefined); + sessionId = 'stdio-session-456'; + } + + return new StdioServerTransport(); +} + +/** + * Create a mock SSE transport (SSEServerTransport) + * For backwards compatibility testing + */ +export function createMockSseTransport() { + class SSEServerTransport { + onmessage = vi.fn(); + onclose = vi.fn(); + send = vi.fn().mockResolvedValue(undefined); + sessionId = 'sse-session-789'; + } + + return new SSEServerTransport(); +} diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts new file mode 100644 index 000000000000..7f06eb886cdb --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -0,0 +1,503 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { + extractSessionDataFromInitializeRequest, + extractSessionDataFromInitializeResponse, +} from '../../../../src/integrations/mcp-server/attributeExtraction'; +import { + cleanupSessionDataForTransport, + getClientInfoForTransport, + getProtocolVersionForTransport, + getSessionDataForTransport, + storeSessionDataForTransport, + updateSessionDataForTransport, +} from '../../../../src/integrations/mcp-server/sessionManagement'; +import { buildMcpServerSpanConfig } from '../../../../src/integrations/mcp-server/spans'; +import { + wrapTransportError, + wrapTransportOnClose, + wrapTransportOnMessage, + wrapTransportSend, +} from '../../../../src/integrations/mcp-server/transport'; +import * as tracingModule from '../../../../src/tracing'; +import { + createMockMcpServer, + createMockSseTransport, + createMockStdioTransport, + createMockTransport, +} from './testUtils'; + +describe('MCP Server Transport Instrumentation', () => { + const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); + const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); + const getClientSpy = vi.spyOn(currentScopes, 'getClient'); + + beforeEach(() => { + vi.clearAllMocks(); + // Mock client to return sendDefaultPii: true for instrumentation tests + getClientSpy.mockReturnValue({ + getOptions: () => ({ sendDefaultPii: true }), + getDsn: () => ({ publicKey: 'test-key', host: 'test-host' }), + emit: vi.fn(), + } as any); + }); + + describe('Transport-level instrumentation', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockTransport: ReturnType; + let originalConnect: any; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + originalConnect = mockMcpServer.connect; + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockTransport = createMockTransport(); + }); + + it('should proxy the connect method', () => { + // We need to test this before connection, so create fresh instances + const freshMockMcpServer = createMockMcpServer(); + const originalConnect = freshMockMcpServer.connect; + + const freshWrappedMcpServer = wrapMcpServerWithSentry(freshMockMcpServer); + + expect(freshWrappedMcpServer.connect).not.toBe(originalConnect); + }); + + it('should intercept transport onmessage handler', async () => { + const originalOnMessage = mockTransport.onmessage; + + await wrappedMcpServer.connect(mockTransport); + + // onmessage should be wrapped after connection + expect(mockTransport.onmessage).not.toBe(originalOnMessage); + }); + + it('should intercept transport send handler', async () => { + const originalSend = mockTransport.send; + + await wrappedMcpServer.connect(mockTransport); + + // send should be wrapped after connection + expect(mockTransport.send).not.toBe(originalSend); + }); + + it('should intercept transport onclose handler', async () => { + const originalOnClose = mockTransport.onclose; + + await wrappedMcpServer.connect(mockTransport); + + // onclose should be wrapped after connection + expect(mockTransport.onclose).not.toBe(originalOnClose); + }); + + it('should call original connect and preserve functionality', async () => { + await wrappedMcpServer.connect(mockTransport); + + // Check the original spy was called + expect(originalConnect).toHaveBeenCalledWith(mockTransport); + }); + + it('should create spans for incoming JSON-RPC requests', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-1', + params: { name: 'get-weather' }, + }; + + // Simulate incoming message + mockTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'tools/call get-weather', + forceTransaction: true, + }), + ); + }); + + it('should create spans for incoming JSON-RPC notifications', async () => { + await wrappedMcpServer.connect(mockTransport); + + const jsonRpcNotification = { + jsonrpc: '2.0', + method: 'notifications/initialized', + // No 'id' field - this makes it a notification + }; + + // Simulate incoming notification + mockTransport.onmessage?.(jsonRpcNotification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/initialized', + forceTransaction: true, + }), + expect.any(Function), + ); + }); + + it('should create spans for outgoing notifications', async () => { + await wrappedMcpServer.connect(mockTransport); + + const outgoingNotification = { + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + // No 'id' field + }; + + // Simulate outgoing notification + await mockTransport.send?.(outgoingNotification); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/tools/list_changed', + forceTransaction: true, + }), + expect.any(Function), + ); + }); + + it('should not create spans for non-JSON-RPC messages', async () => { + await wrappedMcpServer.connect(mockTransport); + + // Simulate non-JSON-RPC message + mockTransport.onmessage?.({ some: 'data' }, {}); + + expect(startSpanSpy).not.toHaveBeenCalled(); + }); + + it('should handle transport onclose events', async () => { + await wrappedMcpServer.connect(mockTransport); + mockTransport.sessionId = 'test-session-123'; + + // Trigger onclose - should not throw + expect(() => mockTransport.onclose?.()).not.toThrow(); + }); + }); + + describe('Stdio Transport Tests', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockStdioTransport: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockStdioTransport = createMockStdioTransport(); + mockStdioTransport.sessionId = 'stdio-session-456'; + }); + + it('should detect stdio transport and set correct attributes', async () => { + await wrappedMcpServer.connect(mockStdioTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'req-stdio-1', + params: { name: 'process-file', arguments: { path: '/tmp/data.txt' } }, + }; + + mockStdioTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'tools/call process-file', + op: 'mcp.server', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'process-file', + 'mcp.request.id': 'req-stdio-1', + 'mcp.session.id': 'stdio-session-456', + 'mcp.transport': 'stdio', // Should be stdio, not http + 'network.transport': 'pipe', // Should be pipe, not tcp + 'network.protocol.version': '2.0', + 'mcp.request.argument.path': '"/tmp/data.txt"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }, + }); + }); + + it('should handle stdio transport notifications correctly', async () => { + await wrappedMcpServer.connect(mockStdioTransport); + + const notification = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { + level: 'debug', + data: 'Processing stdin input', + }, + }; + + mockStdioTransport.onmessage?.(notification, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'notifications/message', + attributes: expect.objectContaining({ + 'mcp.method.name': 'notifications/message', + 'mcp.session.id': 'stdio-session-456', + 'mcp.transport': 'stdio', + 'network.transport': 'pipe', + 'mcp.logging.level': 'debug', + 'mcp.logging.message': 'Processing stdin input', + }), + }), + expect.any(Function), + ); + }); + }); + + describe('SSE Transport Tests (Backwards Compatibility)', () => { + let mockMcpServer: ReturnType; + let wrappedMcpServer: ReturnType; + let mockSseTransport: ReturnType; + + beforeEach(() => { + mockMcpServer = createMockMcpServer(); + wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); + mockSseTransport = createMockSseTransport(); + mockSseTransport.sessionId = 'sse-session-789'; + }); + + it('should detect SSE transport for backwards compatibility', async () => { + await wrappedMcpServer.connect(mockSseTransport); + + const jsonRpcRequest = { + jsonrpc: '2.0', + method: 'resources/read', + id: 'req-sse-1', + params: { uri: 'https://api.example.com/data' }, + }; + + mockSseTransport.onmessage?.(jsonRpcRequest, {}); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'resources/read https://api.example.com/data', + attributes: expect.objectContaining({ + 'mcp.method.name': 'resources/read', + 'mcp.resource.uri': 'https://api.example.com/data', + 'mcp.transport': 'sse', // Deprecated but supported + 'network.transport': 'tcp', + 'mcp.session.id': 'sse-session-789', + }), + }), + ); + }); + }); + + describe('Direct Transport Function Tests', () => { + let mockTransport: ReturnType; + + beforeEach(() => { + mockTransport = createMockTransport(); + mockTransport.sessionId = 'test-session-direct'; + }); + + it('should test wrapTransportOnMessage directly', () => { + const originalOnMessage = mockTransport.onmessage; + + wrapTransportOnMessage(mockTransport); + + expect(mockTransport.onmessage).not.toBe(originalOnMessage); + }); + + it('should test wrapTransportSend directly', () => { + const originalSend = mockTransport.send; + + wrapTransportSend(mockTransport); + + expect(mockTransport.send).not.toBe(originalSend); + }); + + it('should test wrapTransportOnClose directly', () => { + const originalOnClose = mockTransport.onclose; + + wrapTransportOnClose(mockTransport); + + expect(mockTransport.onclose).not.toBe(originalOnClose); + }); + + it('should test wrapTransportError directly', () => { + const originalOnError = mockTransport.onerror; + + wrapTransportError(mockTransport); + + expect(mockTransport.onerror).not.toBe(originalOnError); + }); + + it('should test buildMcpServerSpanConfig directly', () => { + const jsonRpcRequest = { + jsonrpc: '2.0' as const, + method: 'tools/call', + id: 'req-direct-test', + params: { name: 'test-tool', arguments: { input: 'test' } }, + }; + + const config = buildMcpServerSpanConfig(jsonRpcRequest, mockTransport, { + requestInfo: { + remoteAddress: '127.0.0.1', + remotePort: 8080, + }, + }); + + expect(config).toEqual({ + name: 'tools/call test-tool', + op: 'mcp.server', + forceTransaction: true, + attributes: expect.objectContaining({ + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'test-tool', + 'mcp.request.id': 'req-direct-test', + 'mcp.session.id': 'test-session-direct', + 'client.address': '127.0.0.1', + 'client.port': 8080, + 'mcp.transport': 'http', + 'network.transport': 'tcp', + 'network.protocol.version': '2.0', + 'mcp.request.argument.input': '"test"', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.source': 'route', + }), + }); + }); + }); + + describe('Session Management', () => { + let mockTransport: ReturnType; + + beforeEach(() => { + mockTransport = createMockTransport(); + mockTransport.sessionId = 'test-session-123'; + }); + + it('should extract session data from initialize request', () => { + const initializeRequest = { + jsonrpc: '2.0' as const, + method: 'initialize', + id: 'init-1', + params: { + protocolVersion: '2025-06-18', + clientInfo: { + name: 'test-client', + title: 'Test Client', + version: '1.0.0', + }, + }, + }; + + const sessionData = extractSessionDataFromInitializeRequest(initializeRequest); + + expect(sessionData).toEqual({ + protocolVersion: '2025-06-18', + clientInfo: { + name: 'test-client', + title: 'Test Client', + version: '1.0.0', + }, + }); + }); + + it('should extract session data from initialize response', () => { + const initializeResponse = { + protocolVersion: '2025-06-18', + serverInfo: { + name: 'test-server', + title: 'Test Server', + version: '2.0.0', + }, + capabilities: {}, + }; + + const sessionData = extractSessionDataFromInitializeResponse(initializeResponse); + + expect(sessionData).toEqual({ + protocolVersion: '2025-06-18', + serverInfo: { + name: 'test-server', + title: 'Test Server', + version: '2.0.0', + }, + }); + }); + + it('should store and retrieve session data', () => { + const sessionData = { + protocolVersion: '2025-06-18', + clientInfo: { + name: 'test-client', + version: '1.0.0', + }, + }; + + storeSessionDataForTransport(mockTransport, sessionData); + + expect(getSessionDataForTransport(mockTransport)).toEqual(sessionData); + expect(getProtocolVersionForTransport(mockTransport)).toBe('2025-06-18'); + expect(getClientInfoForTransport(mockTransport)).toEqual({ + name: 'test-client', + version: '1.0.0', + }); + }); + + it('should update existing session data', () => { + const initialData = { + protocolVersion: '2025-06-18', + clientInfo: { name: 'test-client' }, + }; + + storeSessionDataForTransport(mockTransport, initialData); + + const serverData = { + serverInfo: { name: 'test-server', version: '2.0.0' }, + }; + + updateSessionDataForTransport(mockTransport, serverData); + + const updatedData = getSessionDataForTransport(mockTransport); + expect(updatedData).toEqual({ + protocolVersion: '2025-06-18', + clientInfo: { name: 'test-client' }, + serverInfo: { name: 'test-server', version: '2.0.0' }, + }); + }); + + it('should clean up session data', () => { + const sessionData = { + protocolVersion: '2025-06-18', + clientInfo: { name: 'test-client' }, + }; + + storeSessionDataForTransport(mockTransport, sessionData); + expect(getSessionDataForTransport(mockTransport)).toEqual(sessionData); + + cleanupSessionDataForTransport(mockTransport); + expect(getSessionDataForTransport(mockTransport)).toBeUndefined(); + }); + + it('should only store data for transports with sessionId', () => { + const transportWithoutSession = { + onmessage: vi.fn(), + onclose: vi.fn(), + onerror: vi.fn(), + send: vi.fn().mockResolvedValue(undefined), + protocolVersion: '2025-06-18', + }; + + const sessionData = { protocolVersion: '2025-06-18' }; + + storeSessionDataForTransport(transportWithoutSession, sessionData); + expect(getSessionDataForTransport(transportWithoutSession)).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/lib/logs/exports.test.ts b/packages/core/test/lib/logs/exports.test.ts index 536ae9a2adfd..4fd01a16a304 100644 --- a/packages/core/test/lib/logs/exports.test.ts +++ b/packages/core/test/lib/logs/exports.test.ts @@ -82,7 +82,7 @@ describe('logAttributeToSerializedLogAttribute', () => { describe('_INTERNAL_captureLog', () => { it('captures and sends logs', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); _INTERNAL_captureLog({ level: 'info', message: 'test log message' }, client, undefined); @@ -99,7 +99,7 @@ describe('_INTERNAL_captureLog', () => { ); }); - it('does not capture logs when enableLogs experiment is not enabled', () => { + it('does not capture logs when enableLogs is not enabled', () => { const logWarnSpy = vi.spyOn(loggerModule.debug, 'warn').mockImplementation(() => undefined); const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); @@ -113,7 +113,7 @@ describe('_INTERNAL_captureLog', () => { }); it('includes trace context when available', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); const scope = new Scope(); scope.setPropagationContext({ @@ -134,7 +134,7 @@ describe('_INTERNAL_captureLog', () => { it('includes release and environment in log attributes when available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, release: '1.0.0', environment: 'test', }); @@ -158,7 +158,7 @@ describe('_INTERNAL_captureLog', () => { it('includes SDK metadata in log attributes when available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, }); const client = new TestClient(options); // Mock getSdkMetadata to return SDK info @@ -187,7 +187,7 @@ describe('_INTERNAL_captureLog', () => { it('does not include SDK metadata in log attributes when not available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, }); const client = new TestClient(options); // Mock getSdkMetadata to return no SDK info @@ -205,7 +205,7 @@ describe('_INTERNAL_captureLog', () => { }); it('includes custom attributes in log', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); _INTERNAL_captureLog( @@ -232,7 +232,7 @@ describe('_INTERNAL_captureLog', () => { }); it('flushes logs buffer when it reaches max size', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); // Fill the buffer to max size (100 is the MAX_LOG_BUFFER_SIZE constant in client.ts) @@ -249,7 +249,7 @@ describe('_INTERNAL_captureLog', () => { }); it('does not flush logs buffer when it is empty', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); const mockSendEnvelope = vi.spyOn(client as any, 'sendEnvelope').mockImplementation(() => {}); _INTERNAL_flushLogsBuffer(client); @@ -257,7 +257,7 @@ describe('_INTERNAL_captureLog', () => { }); it('handles parameterized strings correctly', () => { - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); const parameterizedMessage = fmt`Hello ${'John'}, welcome to ${'Sentry'}`; @@ -290,7 +290,8 @@ describe('_INTERNAL_captureLog', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true, beforeSendLog }, + enableLogs: true, + beforeSendLog, }); const client = new TestClient(options); @@ -336,7 +337,8 @@ describe('_INTERNAL_captureLog', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true, beforeSendLog }, + enableLogs: true, + beforeSendLog, }); const client = new TestClient(options); @@ -360,7 +362,7 @@ describe('_INTERNAL_captureLog', () => { it('emits beforeCaptureLog and afterCaptureLog events', () => { const beforeCaptureLogSpy = vi.spyOn(TestClient.prototype, 'emit'); - const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); const client = new TestClient(options); const log: Log = { @@ -380,7 +382,7 @@ describe('_INTERNAL_captureLog', () => { it('includes user data in log attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -412,7 +414,7 @@ describe('_INTERNAL_captureLog', () => { it('includes partial user data when only some fields are available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -436,7 +438,7 @@ describe('_INTERNAL_captureLog', () => { it('includes user email and username without id', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -465,7 +467,7 @@ describe('_INTERNAL_captureLog', () => { it('does not include user data when user object is empty', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -481,7 +483,7 @@ describe('_INTERNAL_captureLog', () => { it('combines user data with other log attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, sendDefaultPii: true, release: '1.0.0', environment: 'test', @@ -535,7 +537,7 @@ describe('_INTERNAL_captureLog', () => { it('handles user data with non-string values', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -564,7 +566,7 @@ describe('_INTERNAL_captureLog', () => { it('preserves existing user attributes in log and does not override them', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -607,7 +609,7 @@ describe('_INTERNAL_captureLog', () => { it('only adds scope user data for attributes that do not already exist', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, sendDefaultPii: true, }); const client = new TestClient(options); @@ -656,7 +658,7 @@ describe('_INTERNAL_captureLog', () => { it('overrides user-provided system attributes with SDK values', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, release: 'sdk-release-1.0.0', environment: 'sdk-environment', }); diff --git a/packages/core/test/lib/mcp-server.test.ts b/packages/core/test/lib/mcp-server.test.ts deleted file mode 100644 index 12e85f9f370e..000000000000 --- a/packages/core/test/lib/mcp-server.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { wrapMcpServerWithSentry } from '../../src/mcp-server'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, -} from '../../src/semanticAttributes'; -import * as tracingModule from '../../src/tracing'; - -vi.mock('../../src/tracing'); - -describe('wrapMcpServerWithSentry', () => { - beforeEach(() => { - vi.clearAllMocks(); - // @ts-expect-error mocking span is annoying - vi.mocked(tracingModule.startSpan).mockImplementation((_, cb) => cb()); - }); - - it('should wrap valid MCP server instance methods with Sentry spans', () => { - // Create a mock MCP server instance - const mockResource = vi.fn(); - const mockTool = vi.fn(); - const mockPrompt = vi.fn(); - - const mockMcpServer = { - resource: mockResource, - tool: mockTool, - prompt: mockPrompt, - connect: vi.fn(), - }; - - // Wrap the MCP server - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - - // Verify it returns the same instance (modified) - expect(wrappedMcpServer).toBe(mockMcpServer); - - // Original methods should be wrapped - expect(wrappedMcpServer.resource).not.toBe(mockResource); - expect(wrappedMcpServer.tool).not.toBe(mockTool); - expect(wrappedMcpServer.prompt).not.toBe(mockPrompt); - }); - - it('should return the input unchanged if it is not a valid MCP server instance', () => { - const invalidMcpServer = { - // Missing required methods - resource: () => {}, - tool: () => {}, - // No prompt method - }; - - const result = wrapMcpServerWithSentry(invalidMcpServer); - expect(result).toBe(invalidMcpServer); - - // Methods should not be wrapped - expect(result.resource).toBe(invalidMcpServer.resource); - expect(result.tool).toBe(invalidMcpServer.tool); - - // No calls to startSpan - expect(tracingModule.startSpan).not.toHaveBeenCalled(); - }); - - it('should not wrap the same instance twice', () => { - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - }; - - // First wrap - const wrappedOnce = wrapMcpServerWithSentry(mockMcpServer); - - // Store references to wrapped methods - const wrappedResource = wrappedOnce.resource; - const wrappedTool = wrappedOnce.tool; - const wrappedPrompt = wrappedOnce.prompt; - - // Second wrap - const wrappedTwice = wrapMcpServerWithSentry(wrappedOnce); - - // Should be the same instance with the same wrapped methods - expect(wrappedTwice).toBe(wrappedOnce); - expect(wrappedTwice.resource).toBe(wrappedResource); - expect(wrappedTwice.tool).toBe(wrappedTool); - expect(wrappedTwice.prompt).toBe(wrappedPrompt); - }); - - describe('resource method wrapping', () => { - it('should create a span with proper attributes when resource is called', () => { - const mockResourceHandler = vi.fn(); - const resourceName = 'test-resource'; - - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - wrappedMcpServer.resource(resourceName, {}, mockResourceHandler); - - // The original registration should use a wrapped handler - expect(mockMcpServer.resource).toHaveBeenCalledWith(resourceName, {}, expect.any(Function)); - - // Invoke the wrapped handler to trigger Sentry span - const wrappedResourceHandler = (mockMcpServer.resource as any).mock.calls[0][2]; - wrappedResourceHandler('test-uri', { foo: 'bar' }); - - expect(tracingModule.startSpan).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).toHaveBeenCalledWith( - { - name: `mcp-server/resource:${resourceName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.resource': resourceName, - }, - }, - expect.any(Function), - ); - - // Verify the original handler was called within the span - expect(mockResourceHandler).toHaveBeenCalledWith('test-uri', { foo: 'bar' }); - }); - - it('should call the original resource method directly if name or handler is not valid', () => { - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - - // Call without string name - wrappedMcpServer.resource({} as any, 'handler'); - - // Call without function handler - wrappedMcpServer.resource('name', 'not-a-function'); - - // Original method should be called directly without creating spans - expect(mockMcpServer.resource).toHaveBeenCalledTimes(2); - expect(tracingModule.startSpan).not.toHaveBeenCalled(); - }); - }); - - describe('tool method wrapping', () => { - it('should create a span with proper attributes when tool is called', () => { - const mockToolHandler = vi.fn(); - const toolName = 'test-tool'; - - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - wrappedMcpServer.tool(toolName, {}, mockToolHandler); - - // The original registration should use a wrapped handler - expect(mockMcpServer.tool).toHaveBeenCalledWith(toolName, {}, expect.any(Function)); - - // Invoke the wrapped handler to trigger Sentry span - const wrappedToolHandler = (mockMcpServer.tool as any).mock.calls[0][2]; - wrappedToolHandler({ arg: 'value' }, { foo: 'baz' }); - - expect(tracingModule.startSpan).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).toHaveBeenCalledWith( - { - name: `mcp-server/tool:${toolName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.tool': toolName, - }, - }, - expect.any(Function), - ); - - // Verify the original handler was called within the span - expect(mockToolHandler).toHaveBeenCalledWith({ arg: 'value' }, { foo: 'baz' }); - }); - - it('should call the original tool method directly if name or handler is not valid', () => { - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - - // Call without string name - wrappedMcpServer.tool({} as any, 'handler'); - - // Original method should be called directly without creating spans - expect(mockMcpServer.tool).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).not.toHaveBeenCalled(); - }); - }); - - describe('prompt method wrapping', () => { - it('should create a span with proper attributes when prompt is called', () => { - const mockPromptHandler = vi.fn(); - const promptName = 'test-prompt'; - - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - wrappedMcpServer.prompt(promptName, {}, mockPromptHandler); - - // The original registration should use a wrapped handler - expect(mockMcpServer.prompt).toHaveBeenCalledWith(promptName, {}, expect.any(Function)); - - // Invoke the wrapped handler to trigger Sentry span - const wrappedPromptHandler = (mockMcpServer.prompt as any).mock.calls[0][2]; - wrappedPromptHandler({ msg: 'hello' }, { data: 123 }); - - expect(tracingModule.startSpan).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).toHaveBeenCalledWith( - { - name: `mcp-server/prompt:${promptName}`, - forceTransaction: true, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.mcp-server', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - 'mcp_server.prompt': promptName, - }, - }, - expect.any(Function), - ); - - // Verify the original handler was called within the span - expect(mockPromptHandler).toHaveBeenCalledWith({ msg: 'hello' }, { data: 123 }); - }); - - it('should call the original prompt method directly if name or handler is not valid', () => { - const mockMcpServer = { - resource: vi.fn(), - tool: vi.fn(), - prompt: vi.fn(), - connect: vi.fn(), - }; - - const wrappedMcpServer = wrapMcpServerWithSentry(mockMcpServer); - - // Call without function handler - wrappedMcpServer.prompt('name', 'not-a-function'); - - // Original method should be called directly without creating spans - expect(mockMcpServer.prompt).toHaveBeenCalledTimes(1); - expect(tracingModule.startSpan).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index d5b9dc33681d..708ac8716070 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -211,7 +211,7 @@ describe('ServerRuntimeClient', () => { it('flushes logs when weight exceeds 800KB', () => { const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, }); client = new ServerRuntimeClient(options); @@ -228,7 +228,7 @@ describe('ServerRuntimeClient', () => { it('accumulates log weight without flushing when under threshold', () => { const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, }); client = new ServerRuntimeClient(options); @@ -245,7 +245,7 @@ describe('ServerRuntimeClient', () => { it('flushes logs on flush event', () => { const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, }); client = new ServerRuntimeClient(options); @@ -265,7 +265,6 @@ describe('ServerRuntimeClient', () => { it('does not flush logs when logs are disabled', () => { const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: false }, }); client = new ServerRuntimeClient(options); @@ -282,7 +281,7 @@ describe('ServerRuntimeClient', () => { it('flushes logs when flush event is triggered', () => { const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, + enableLogs: true, }); client = new ServerRuntimeClient(options); diff --git a/packages/core/test/lib/tracing/trace.test.ts b/packages/core/test/lib/tracing/trace.test.ts index 31d04f0f971d..a1d786dfdd10 100644 --- a/packages/core/test/lib/tracing/trace.test.ts +++ b/packages/core/test/lib/tracing/trace.test.ts @@ -660,30 +660,57 @@ describe('startSpan', () => { }); }); - it('samples with a tracesSampler', () => { - const tracesSampler = vi.fn(() => { + describe('uses tracesSampler if defined', () => { + const tracesSampler = vi.fn<() => boolean | number>(() => { return true; }); - const options = getDefaultTestClientOptions({ tracesSampler }); - client = new TestClient(options); - setCurrentClient(client); - client.init(); + it.each([true, 1])('returns a positive sampling decision if tracesSampler returns %s', tracesSamplerResult => { + tracesSampler.mockReturnValueOnce(tracesSamplerResult); + + const options = getDefaultTestClientOptions({ tracesSampler }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + startSpan({ name: 'outer', attributes: { test1: 'aa', test2: 'aa', test3: 'bb' } }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(spanIsSampled(outerSpan)).toBe(true); + }); - startSpan({ name: 'outer', attributes: { test1: 'aa', test2: 'aa', test3: 'bb' } }, outerSpan => { - expect(outerSpan).toBeDefined(); + expect(tracesSampler).toBeCalledTimes(1); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: undefined, + name: 'outer', + attributes: { + test1: 'aa', + test2: 'aa', + test3: 'bb', + }, + inheritOrSampleWith: expect.any(Function), + }); }); - expect(tracesSampler).toBeCalledTimes(1); - expect(tracesSampler).toHaveBeenLastCalledWith({ - parentSampled: undefined, - name: 'outer', - attributes: { - test1: 'aa', - test2: 'aa', - test3: 'bb', - }, - inheritOrSampleWith: expect.any(Function), + it.each([false, 0])('returns a negative sampling decision if tracesSampler returns %s', tracesSamplerResult => { + tracesSampler.mockReturnValueOnce(tracesSamplerResult); + + const options = getDefaultTestClientOptions({ tracesSampler }); + client = new TestClient(options); + setCurrentClient(client); + client.init(); + + startSpan({ name: 'outer' }, outerSpan => { + expect(outerSpan).toBeDefined(); + expect(spanIsSampled(outerSpan)).toBe(false); + }); + + expect(tracesSampler).toBeCalledTimes(1); + expect(tracesSampler).toHaveBeenLastCalledWith({ + parentSampled: undefined, + attributes: {}, + name: 'outer', + inheritOrSampleWith: expect.any(Function), + }); }); }); @@ -1849,6 +1876,151 @@ describe('continueTrace', () => { expect(result).toEqual('aha'); }); + + describe('strictTraceContinuation', () => { + const creatOrgIdInDsn = (orgId: number) => { + vi.spyOn(client, 'getDsn').mockReturnValue({ + host: `o${orgId}.ingest.sentry.io`, + protocol: 'https', + projectId: 'projId', + }); + }; + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('continues trace when org IDs match', () => { + creatOrgIdInDsn(123); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + + it('starts new trace when both SDK and baggage org IDs are set and do not match', () => { + creatOrgIdInDsn(123); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=456', + }, + () => { + return getCurrentScope(); + }, + ); + + // Should start a new trace with a different trace ID + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + + describe('when strictTraceContinuation is true', () => { + it('starts new trace when baggage org ID is missing', () => { + client.getOptions().strictTraceContinuation = true; + + creatOrgIdInDsn(123); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-environment=production', + }, + () => { + return getCurrentScope(); + }, + ); + + // Should start a new trace with a different trace ID + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + + it('starts new trace when SDK org ID is missing', () => { + client.getOptions().strictTraceContinuation = true; + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + // Should start a new trace with a different trace ID + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + + it('continues trace when both org IDs are missing', () => { + client.getOptions().strictTraceContinuation = true; + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-environment=production', + }, + () => { + return getCurrentScope(); + }, + ); + + // Should continue the trace + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + }); + + describe('when strictTraceContinuation is false', () => { + it('continues trace when baggage org ID is missing', () => { + client.getOptions().strictTraceContinuation = false; + + creatOrgIdInDsn(123); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-environment=production', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + + it('SDK org ID is missing', () => { + client.getOptions().strictTraceContinuation = false; + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + }); + }); }); describe('getActiveSpan', () => { diff --git a/packages/core/test/lib/utils/dsn.test.ts b/packages/core/test/lib/utils/dsn.test.ts index 5d1cfbe2b538..0555ae583c02 100644 --- a/packages/core/test/lib/utils/dsn.test.ts +++ b/packages/core/test/lib/utils/dsn.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, test, vi } from 'vitest'; import { DEBUG_BUILD } from '../../../src/debug-build'; import { debug } from '../../../src/utils/debug-logger'; -import { dsnToString, extractOrgIdFromDsnHost, makeDsn } from '../../../src/utils/dsn'; +import { dsnToString, extractOrgIdFromClient, extractOrgIdFromDsnHost, makeDsn } from '../../../src/utils/dsn'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; function testIf(condition: boolean) { return condition ? test : test.skip; @@ -247,3 +248,53 @@ describe('extractOrgIdFromDsnHost', () => { expect(extractOrgIdFromDsnHost('')).toBeUndefined(); }); }); + +describe('extractOrgIdFromClient', () => { + let client: TestClient; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('returns orgId from client options when available', () => { + client = new TestClient( + getDefaultTestClientOptions({ + orgId: '00222111', + dsn: 'https://public@sentry.example.com/1', + }), + ); + + const result = extractOrgIdFromClient(client); + expect(result).toBe('00222111'); + }); + + test('converts non-string orgId to string', () => { + client = new TestClient( + getDefaultTestClientOptions({ + orgId: 12345, + dsn: 'https://public@sentry.example.com/1', + }), + ); + + const result = extractOrgIdFromClient(client); + expect(result).toBe('12345'); + }); + + test('extracts orgId from DSN host when options.orgId is not available', () => { + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@o012300.example.com/1', + }), + ); + + const result = extractOrgIdFromClient(client); + expect(result).toBe('012300'); + }); + + test('returns undefined when neither options.orgId nor DSN host are available', () => { + client = new TestClient(getDefaultTestClientOptions({})); + + const result = extractOrgIdFromClient(client); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/core/test/lib/utils/flushIfServerless.test.ts b/packages/core/test/lib/utils/flushIfServerless.test.ts new file mode 100644 index 000000000000..aa0314f183dc --- /dev/null +++ b/packages/core/test/lib/utils/flushIfServerless.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as flushModule from '../../../src/exports'; +import { flushIfServerless } from '../../../src/utils/flushIfServerless'; +import * as vercelWaitUntilModule from '../../../src/utils/vercelWaitUntil'; +import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; + +describe('flushIfServerless', () => { + let originalProcess: typeof process; + + beforeEach(() => { + vi.resetAllMocks(); + originalProcess = global.process; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('should bind context (preserve `this`) when calling waitUntil from the Cloudflare execution context', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + + // Mock Cloudflare context with `waitUntil` (which should be called if `this` is bound correctly) + const mockCloudflareCtx = { + contextData: 'test-data', + waitUntil: function (promise: Promise) { + // This will fail if 'this' is not bound correctly + expect(this.contextData).toBe('test-data'); + return promise; + }, + }; + + const waitUntilSpy = vi.spyOn(mockCloudflareCtx, 'waitUntil'); + + await flushIfServerless({ cloudflareCtx: mockCloudflareCtx }); + + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(flushMock).toHaveBeenCalledWith(2000); + }); + + test('should use cloudflare waitUntil when valid cloudflare context is provided', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + const mockCloudflareCtx = { + waitUntil: vi.fn(), + }; + + await flushIfServerless({ cloudflareCtx: mockCloudflareCtx, timeout: 5000 }); + + expect(mockCloudflareCtx.waitUntil).toHaveBeenCalledTimes(1); + expect(flushMock).toHaveBeenCalledWith(5000); + }); + + test('should use cloudflare waitUntil when Cloudflare `waitUntil` is provided', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + const mockCloudflareCtx = { + waitUntil: vi.fn(), + }; + + await flushIfServerless({ cloudflareWaitUntil: mockCloudflareCtx.waitUntil, timeout: 5000 }); + + expect(mockCloudflareCtx.waitUntil).toHaveBeenCalledTimes(1); + expect(flushMock).toHaveBeenCalledWith(5000); + }); + + test('should ignore cloudflare context when waitUntil is not a function (and use Vercel waitUntil instead)', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + const vercelWaitUntilSpy = vi.spyOn(vercelWaitUntilModule, 'vercelWaitUntil').mockImplementation(() => {}); + + // Mock Vercel environment + // @ts-expect-error This is not typed + GLOBAL_OBJ[Symbol.for('@vercel/request-context')] = { get: () => ({ waitUntil: vi.fn() }) }; + + const mockCloudflareCtx = { + waitUntil: 'not-a-function', // Invalid waitUntil + }; + + // @ts-expect-error Using the wrong type here on purpose + await flushIfServerless({ cloudflareCtx: mockCloudflareCtx }); + + expect(vercelWaitUntilSpy).toHaveBeenCalledTimes(1); + expect(flushMock).toHaveBeenCalledWith(2000); + }); + + test('should handle multiple serverless environment variables simultaneously', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + + global.process = { + ...originalProcess, + env: { + ...originalProcess.env, + LAMBDA_TASK_ROOT: '/var/task', + VERCEL: '1', + NETLIFY: 'true', + CF_PAGES: '1', + }, + }; + + await flushIfServerless({ timeout: 4000 }); + + expect(flushMock).toHaveBeenCalledWith(4000); + }); + + test('should use default timeout when not specified', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + const mockCloudflareCtx = { + waitUntil: vi.fn(), + }; + + await flushIfServerless({ cloudflareCtx: mockCloudflareCtx }); + + expect(flushMock).toHaveBeenCalledWith(2000); + }); + + test('should handle zero timeout value', async () => { + const flushMock = vi.spyOn(flushModule, 'flush').mockResolvedValue(true); + + global.process = { + ...originalProcess, + env: { + ...originalProcess.env, + LAMBDA_TASK_ROOT: '/var/task', + }, + }; + + await flushIfServerless({ timeout: 0 }); + + expect(flushMock).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/core/test/lib/utils/hasTracingEnabled.test.ts b/packages/core/test/lib/utils/hasSpansEnabled.test.ts similarity index 83% rename from packages/core/test/lib/utils/hasTracingEnabled.test.ts rename to packages/core/test/lib/utils/hasSpansEnabled.test.ts index f4c7a51307c2..ed2ae841fc92 100644 --- a/packages/core/test/lib/utils/hasTracingEnabled.test.ts +++ b/packages/core/test/lib/utils/hasSpansEnabled.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { hasSpansEnabled, hasTracingEnabled } from '../../../src'; +import { hasSpansEnabled } from '../../../src'; describe('hasSpansEnabled', () => { const tracesSampler = () => 1; @@ -15,7 +15,5 @@ describe('hasSpansEnabled', () => { ['With tracesSampler and tracesSampleRate', { tracesSampler, tracesSampleRate }, true], ])('%s', (_: string, input: Parameters[0], output: ReturnType) => { expect(hasSpansEnabled(input)).toBe(output); - // eslint-disable-next-line deprecation/deprecation - expect(hasTracingEnabled(input)).toBe(output); }); }); diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index bcff545627ed..0076a617e219 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -3,8 +3,10 @@ import { buildMethodPath, getOperationName, getSpanOperation, + isChatCompletionChunk, isChatCompletionResponse, isResponsesApiResponse, + isResponsesApiStreamEvent, shouldInstrument, } from '../../../src/utils/openai/utils'; @@ -15,9 +17,9 @@ describe('openai-utils', () => { expect(getOperationName('some.path.chat.completions.method')).toBe('chat'); }); - it('should return chat for responses methods', () => { - expect(getOperationName('responses.create')).toBe('chat'); - expect(getOperationName('some.path.responses.method')).toBe('chat'); + it('should return responses for responses methods', () => { + expect(getOperationName('responses.create')).toBe('responses'); + expect(getOperationName('some.path.responses.method')).toBe('responses'); }); it('should return the last part of path for unknown methods', () => { @@ -33,7 +35,7 @@ describe('openai-utils', () => { describe('getSpanOperation', () => { it('should prefix operation with gen_ai', () => { expect(getSpanOperation('chat.completions.create')).toBe('gen_ai.chat'); - expect(getSpanOperation('responses.create')).toBe('gen_ai.chat'); + expect(getSpanOperation('responses.create')).toBe('gen_ai.responses'); expect(getSpanOperation('some.custom.operation')).toBe('gen_ai.operation'); }); }); @@ -101,4 +103,47 @@ describe('openai-utils', () => { expect(isResponsesApiResponse({ object: null })).toBe(false); }); }); + + describe('isResponsesApiStreamEvent', () => { + it('should return true for valid responses API stream events', () => { + expect(isResponsesApiStreamEvent({ type: 'response.created' })).toBe(true); + expect(isResponsesApiStreamEvent({ type: 'response.in_progress' })).toBe(true); + expect(isResponsesApiStreamEvent({ type: 'response.completed' })).toBe(true); + expect(isResponsesApiStreamEvent({ type: 'response.failed' })).toBe(true); + expect(isResponsesApiStreamEvent({ type: 'response.output_text.delta' })).toBe(true); + }); + + it('should return false for non-response events', () => { + expect(isResponsesApiStreamEvent(null)).toBe(false); + expect(isResponsesApiStreamEvent(undefined)).toBe(false); + expect(isResponsesApiStreamEvent('string')).toBe(false); + expect(isResponsesApiStreamEvent(123)).toBe(false); + expect(isResponsesApiStreamEvent({})).toBe(false); + expect(isResponsesApiStreamEvent({ type: 'chat.completion' })).toBe(false); + expect(isResponsesApiStreamEvent({ type: null })).toBe(false); + expect(isResponsesApiStreamEvent({ type: 123 })).toBe(false); + }); + }); + + describe('isChatCompletionChunk', () => { + it('should return true for valid chat completion chunks', () => { + const validChunk = { + object: 'chat.completion.chunk', + id: 'chatcmpl-123', + model: 'gpt-4', + choices: [], + }; + expect(isChatCompletionChunk(validChunk)).toBe(true); + }); + + it('should return false for invalid chunks', () => { + expect(isChatCompletionChunk(null)).toBe(false); + expect(isChatCompletionChunk(undefined)).toBe(false); + expect(isChatCompletionChunk('string')).toBe(false); + expect(isChatCompletionChunk(123)).toBe(false); + expect(isChatCompletionChunk({})).toBe(false); + expect(isChatCompletionChunk({ object: 'chat.completion' })).toBe(false); + expect(isChatCompletionChunk({ object: null })).toBe(false); + }); + }); }); diff --git a/packages/core/test/lib/utils/stacktrace.test.ts b/packages/core/test/lib/utils/stacktrace.test.ts index b0d74e2e9f75..0551a74be6f0 100644 --- a/packages/core/test/lib/utils/stacktrace.test.ts +++ b/packages/core/test/lib/utils/stacktrace.test.ts @@ -380,4 +380,16 @@ describe('node', () => { expect(node(input)).toEqual(expectedOutput); }); + + it('parses function name when filename is a data uri ', () => { + const input = + "at dynamicFn (data:application/javascript,export function dynamicFn() { throw new Error('Error from data-uri module');};:1:38)"; + + const expectedOutput = { + function: 'dynamicFn', + filename: '', + }; + + expect(node(input)).toEqual(expectedOutput); + }); }); diff --git a/packages/core/test/lib/utils/tracing.test.ts b/packages/core/test/lib/utils/tracing.test.ts index ea99555e70e1..ea41190f3bb3 100644 --- a/packages/core/test/lib/utils/tracing.test.ts +++ b/packages/core/test/lib/utils/tracing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, test } from 'vitest'; -import { extractTraceparentData, propagationContextFromHeaders } from '../../../src/utils/tracing'; +import { extractTraceparentData, propagationContextFromHeaders, shouldContinueTrace } from '../../../src/utils/tracing'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; const EXAMPLE_SENTRY_TRACE = '12312012123120121231201212312012-1121201211212012-1'; const EXAMPLE_BAGGAGE = 'sentry-release=1.2.3,sentry-foo=bar,other=baz,sentry-sample_rand=0.42'; @@ -124,3 +125,55 @@ describe('extractTraceparentData', () => { expect(extractTraceparentData('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-x')).toBeUndefined(); }); }); + +describe('shouldContinueTrace', () => { + test('returns true when both baggage and SDK org IDs are undefined', () => { + const client = new TestClient(getDefaultTestClientOptions({})); + + const result = shouldContinueTrace(client, undefined); + expect(result).toBe(true); + }); + + test('returns true when org IDs match', () => { + const orgId = '123456'; + const client = new TestClient(getDefaultTestClientOptions({ orgId })); + + const result = shouldContinueTrace(client, orgId); + expect(result).toBe(true); + }); + + test('returns false when org IDs do not match', () => { + const client = new TestClient(getDefaultTestClientOptions({ orgId: '123456' })); + + const result = shouldContinueTrace(client, '654321'); + expect(result).toBe(false); + }); + + test('returns true when baggage org ID is undefined and strictTraceContinuation is false', () => { + const client = new TestClient(getDefaultTestClientOptions({ orgId: '123456', strictTraceContinuation: false })); + + const result = shouldContinueTrace(client, undefined); + expect(result).toBe(true); + }); + + test('returns true when SDK org ID is undefined and strictTraceContinuation is false', () => { + const client = new TestClient(getDefaultTestClientOptions({ strictTraceContinuation: false })); + + const result = shouldContinueTrace(client, '123456'); + expect(result).toBe(true); + }); + + test('returns false when baggage org ID is undefined and strictTraceContinuation is true', () => { + const client = new TestClient(getDefaultTestClientOptions({ orgId: '123456', strictTraceContinuation: true })); + + const result = shouldContinueTrace(client, undefined); + expect(result).toBe(false); + }); + + test('returns false when SDK org ID is undefined and strictTraceContinuation is true', () => { + const client = new TestClient(getDefaultTestClientOptions({ strictTraceContinuation: true })); + + const result = shouldContinueTrace(client, '123456'); + expect(result).toBe(false); + }); +}); diff --git a/packages/core/test/utils/openai-integration-functions.test.ts b/packages/core/test/utils/openai-integration-functions.test.ts new file mode 100644 index 000000000000..d032fb1e69ed --- /dev/null +++ b/packages/core/test/utils/openai-integration-functions.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { OpenAiClient } from '../../src'; +import { instrumentOpenAiClient } from '../../src/utils/openai'; + +interface FullOpenAIClient { + chat: { + completions: { + create: (params: ChatCompletionParams) => Promise; + parse: (params: ParseCompletionParams) => Promise; + }; + }; +} +interface ChatCompletionParams { + model: string; + messages: Array<{ role: string; content: string }>; +} + +interface ChatCompletionResponse { + id: string; + model: string; + choices: Array<{ message: { content: string } }>; +} + +interface ParseCompletionParams { + model: string; + messages: Array<{ role: string; content: string }>; + response_format: { + type: string; + json_schema: { + name: string; + schema: { + type: string; + properties: Record; + }; + }; + }; +} + +interface ParseCompletionResponse { + id: string; + model: string; + choices: Array<{ + message: { + content: string; + parsed: { name: string; age: number }; + }; + }>; + parsed: { name: string; age: number }; +} + +/** + * Mock OpenAI client that simulates the private field behavior + * that causes the "Cannot read private member" error + */ +class MockOpenAIClient implements FullOpenAIClient { + // Simulate private fields using WeakMap (similar to how TypeScript private fields work) + static #privateData = new WeakMap(); + + // Simulate instrumented methods + chat = { + completions: { + create: async (params: ChatCompletionParams): Promise => { + this.#buildURL('/chat/completions'); + return { id: 'test', model: params.model, choices: [{ message: { content: 'Hello!' } }] }; + }, + + // This is NOT instrumented + parse: async (params: ParseCompletionParams): Promise => { + this.#buildURL('/chat/completions'); + return { + id: 'test', + model: params.model, + choices: [ + { + message: { + content: 'Hello!', + parsed: { name: 'John', age: 30 }, + }, + }, + ], + parsed: { name: 'John', age: 30 }, + }; + }, + }, + }; + + constructor() { + MockOpenAIClient.#privateData.set(this, { + apiKey: 'test-key', + baseURL: 'https://api.openai.com', + }); + } + + // Simulate the buildURL method that accesses private fields + #buildURL(path: string): string { + const data = MockOpenAIClient.#privateData.get(this); + if (!data) { + throw new TypeError('Cannot read private member from an object whose class did not declare it'); + } + return `${data.baseURL}${path}`; + } +} + +describe('OpenAI Integration Private Field Fix', () => { + let mockClient: MockOpenAIClient; + let instrumentedClient: FullOpenAIClient & OpenAiClient; + + beforeEach(() => { + mockClient = new MockOpenAIClient(); + instrumentedClient = instrumentOpenAiClient(mockClient as unknown as OpenAiClient) as FullOpenAIClient & + OpenAiClient; + }); + + it('should work with instrumented methods (chat.completions.create)', async () => { + // This should work because it's instrumented and we handle it properly + const result = await instrumentedClient.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }); + + expect(result.model).toBe('gpt-4'); + }); + + it('should work with non-instrumented methods without breaking private fields', async () => { + // The parse method should work now with our fix - previously it would throw: + // "TypeError: Cannot read private member from an object whose class did not declare it" + + await expect( + instrumentedClient.chat.completions.parse({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Extract name and age from: John is 30 years old' }], + response_format: { + type: 'json_schema', + json_schema: { + name: 'person', + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }, + }, + }, + }), + ).resolves.toBeDefined(); + }); + + it('should preserve the original context for all method calls', async () => { + // Verify that 'this' context is preserved for instrumented methods + const createResult = await instrumentedClient.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'test' }], + }); + + expect(createResult.model).toBe('gpt-4'); + + // Verify that 'this' context is preserved for non-instrumented methods + const parseResult = await instrumentedClient.chat.completions.parse({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Extract name and age from: John is 30 years old' }], + response_format: { + type: 'json_schema', + json_schema: { + name: 'person', + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }, + }, + }, + }); + + expect(parseResult.parsed).toEqual({ name: 'John', age: 30 }); + }); + + it('should handle nested object access correctly', async () => { + expect(typeof instrumentedClient.chat.completions.create).toBe('function'); + expect(typeof instrumentedClient.chat.completions.parse).toBe('function'); + }); + + it('should work with non-instrumented methods', async () => { + const result = await instrumentedClient.chat.completions.parse({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Extract name and age from: John is 30 years old' }], + response_format: { + type: 'json_schema', + json_schema: { + name: 'person', + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }, + }, + }, + }); + + expect(result.model).toBe('gpt-4'); + expect(result.parsed).toEqual({ name: 'John', age: 30 }); + + // Verify we can access the parse method without issues + expect(typeof instrumentedClient.chat.completions.parse).toBe('function'); + }); +}); diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json index c2b522d04ee1..0db9ad3bf16c 100644 --- a/packages/core/tsconfig.test.json +++ b/packages/core/tsconfig.test.json @@ -5,6 +5,7 @@ "compilerOptions": { "lib": ["DOM", "ES2018"], + "module": "ESNext", // support dynamic import() // should include all types from `./tsconfig.json` plus types for all test frameworks used "types": ["node"] diff --git a/packages/eslint-config-sdk/src/base.js b/packages/eslint-config-sdk/src/base.js index fade0633fc98..52334507ac8b 100644 --- a/packages/eslint-config-sdk/src/base.js +++ b/packages/eslint-config-sdk/src/base.js @@ -107,6 +107,9 @@ module.exports = { // Be explicit about class member accessibility (public, private, protected). Turned off // on tests for ease of use. '@typescript-eslint/explicit-member-accessibility': ['error'], + + // We do not care about empty functions + '@typescript-eslint/no-empty-function': 'off', }, }, { @@ -178,7 +181,6 @@ module.exports = { '@typescript-eslint/explicit-member-accessibility': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-floating-promises': 'off', '@sentry-internal/sdk/no-focused-tests': 'error', '@sentry-internal/sdk/no-skipped-tests': 'error', diff --git a/packages/feedback/src/modal/integration.tsx b/packages/feedback/src/modal/integration.tsx index dd2c341760a7..5bdf4362509d 100644 --- a/packages/feedback/src/modal/integration.tsx +++ b/packages/feedback/src/modal/integration.tsx @@ -22,7 +22,6 @@ function getUser(): User | undefined { export const feedbackModalIntegration = ((): FeedbackModalIntegration => { return { name: 'FeedbackModal', - // eslint-disable-next-line @typescript-eslint/no-empty-function setupOnce() {}, createDialog: ({ options, screenshotIntegration, sendFeedback, shadow }) => { const shadowRoot = shadow as unknown as ShadowRoot; diff --git a/packages/feedback/src/screenshot/integration.ts b/packages/feedback/src/screenshot/integration.ts index 68c8c8535f8e..457a93935843 100644 --- a/packages/feedback/src/screenshot/integration.ts +++ b/packages/feedback/src/screenshot/integration.ts @@ -7,7 +7,6 @@ import { ScreenshotEditorFactory } from './components/ScreenshotEditor'; export const feedbackScreenshotIntegration = ((): FeedbackScreenshotIntegration => { return { name: 'FeedbackScreenshot', - // eslint-disable-next-line @typescript-eslint/no-empty-function setupOnce() {}, createInput: ({ h, hooks, dialog, options }) => { const outputBuffer = DOCUMENT.createElement('canvas'); diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 564e96747e0d..b45d3aa77496 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -47,7 +47,7 @@ "dependencies": { "@sentry/core": "9.40.0", "@sentry/react": "9.40.0", - "@sentry/webpack-plugin": "^3.5.0" + "@sentry/webpack-plugin": "^4.0.0" }, "peerDependencies": { "gatsby": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index ba6d9640a8b5..8339e95c77a3 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -90,6 +90,7 @@ export { connectIntegration, setupConnectErrorHandler, fastifyIntegration, + firebaseIntegration, genericPoolIntegration, graphqlIntegration, knexIntegration, diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 4a2d3f32c3fe..a2502a70cd0c 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -45,9 +45,9 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "0.57.2", - "@opentelemetry/instrumentation-nestjs-core": "0.44.1", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-nestjs-core": "0.49.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@sentry/core": "9.40.0", "@sentry/node": "9.40.0" diff --git a/packages/nestjs/src/decorators.ts b/packages/nestjs/src/decorators.ts index 9ac7315dabd8..7ac4941be877 100644 --- a/packages/nestjs/src/decorators.ts +++ b/packages/nestjs/src/decorators.ts @@ -110,10 +110,18 @@ function copyFunctionNameAndMetadata({ }); // copy metadata + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect if (typeof Reflect !== 'undefined' && typeof Reflect.getMetadataKeys === 'function') { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect const originalMetaData = Reflect.getMetadataKeys(originalMethod); for (const key of originalMetaData) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect const value = Reflect.getMetadata(key, originalMethod); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect Reflect.defineMetadata(key, value, descriptor.value); } } diff --git a/packages/nestjs/src/integrations/nest.ts b/packages/nestjs/src/integrations/nest.ts index 4cc68c720541..53086b7da302 100644 --- a/packages/nestjs/src/integrations/nest.ts +++ b/packages/nestjs/src/integrations/nest.ts @@ -1,13 +1,13 @@ +import { NestInstrumentation as NestInstrumentationCore } from '@opentelemetry/instrumentation-nestjs-core'; import { defineIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node'; -import { NestInstrumentation } from './sentry-nest-core-instrumentation'; import { SentryNestEventInstrumentation } from './sentry-nest-event-instrumentation'; import { SentryNestInstrumentation } from './sentry-nest-instrumentation'; const INTEGRATION_NAME = 'Nest'; const instrumentNestCore = generateInstrumentOnce('Nest-Core', () => { - return new NestInstrumentation(); + return new NestInstrumentationCore(); }); const instrumentNestCommon = generateInstrumentOnce('Nest-Common', () => { diff --git a/packages/nestjs/src/integrations/sentry-nest-core-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-core-instrumentation.ts deleted file mode 100644 index aec664633342..000000000000 --- a/packages/nestjs/src/integrations/sentry-nest-core-instrumentation.ts +++ /dev/null @@ -1,307 +0,0 @@ -/* - * This file is based on code from the OpenTelemetry Authors - * Source: https://github.com/open-telemetry/opentelemetry-js-contrib - * - * Modified for immediate requirements while maintaining compliance - * with the original Apache 2.0 license terms. - * - * Original License: - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Controller } from '@nestjs/common/interfaces'; -import type { NestFactory } from '@nestjs/core/nest-factory.js'; -import type { RouterExecutionContext } from '@nestjs/core/router/router-execution-context.js'; -import * as api from '@opentelemetry/api'; -import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; -import { - InstrumentationBase, - InstrumentationNodeModuleDefinition, - InstrumentationNodeModuleFile, - isWrapped, -} from '@opentelemetry/instrumentation'; -import { ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_ROUTE, SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions'; -import { SDK_VERSION } from '@sentry/core'; - -const supportedVersions = ['>=4.0.0 <12']; -const COMPONENT = '@nestjs/core'; - -enum AttributeNames { - VERSION = 'nestjs.version', - TYPE = 'nestjs.type', - MODULE = 'nestjs.module', - CONTROLLER = 'nestjs.controller', - CALLBACK = 'nestjs.callback', - PIPES = 'nestjs.pipes', - INTERCEPTORS = 'nestjs.interceptors', - GUARDS = 'nestjs.guards', -} - -export enum NestType { - APP_CREATION = 'app_creation', - REQUEST_CONTEXT = 'request_context', - REQUEST_HANDLER = 'handler', -} - -/** - * - */ -export class NestInstrumentation extends InstrumentationBase { - public constructor(config: InstrumentationConfig = {}) { - super('sentry-nestjs', SDK_VERSION, config); - } - - /** - * - */ - public init(): InstrumentationNodeModuleDefinition { - const module = new InstrumentationNodeModuleDefinition(COMPONENT, supportedVersions); - - module.files.push( - this._getNestFactoryFileInstrumentation(supportedVersions), - this._getRouterExecutionContextFileInstrumentation(supportedVersions), - ); - - return module; - } - - /** - * - */ - private _getNestFactoryFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { - return new InstrumentationNodeModuleFile( - '@nestjs/core/nest-factory.js', - versions, - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (NestFactoryStatic: any, moduleVersion?: string) => { - this._ensureWrapped( - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - NestFactoryStatic.NestFactoryStatic.prototype, - 'create', - createWrapNestFactoryCreate(this.tracer, moduleVersion), - ); - return NestFactoryStatic; - }, - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (NestFactoryStatic: any) => { - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this._unwrap(NestFactoryStatic.NestFactoryStatic.prototype, 'create'); - }, - ); - } - - /** - * - */ - private _getRouterExecutionContextFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { - return new InstrumentationNodeModuleFile( - '@nestjs/core/router/router-execution-context.js', - versions, - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (RouterExecutionContext: any, moduleVersion?: string) => { - this._ensureWrapped( - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - RouterExecutionContext.RouterExecutionContext.prototype, - 'create', - createWrapCreateHandler(this.tracer, moduleVersion), - ); - return RouterExecutionContext; - }, - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (RouterExecutionContext: any) => { - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this._unwrap(RouterExecutionContext.RouterExecutionContext.prototype, 'create'); - }, - ); - } - - /** - * - */ - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _ensureWrapped(obj: any, methodName: string, wrapper: (original: any) => any): void { - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (isWrapped(obj[methodName])) { - this._unwrap(obj, methodName); - } - this._wrap(obj, methodName, wrapper); - } -} - -function createWrapNestFactoryCreate(tracer: api.Tracer, moduleVersion?: string) { - return function wrapCreate(original: typeof NestFactory.create) { - return function createWithTrace( - this: typeof NestFactory, - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - nestModule: any, - /* serverOrOptions */ - ) { - const span = tracer.startSpan('Create Nest App', { - attributes: { - component: COMPONENT, - [AttributeNames.TYPE]: NestType.APP_CREATION, - [AttributeNames.VERSION]: moduleVersion, - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - [AttributeNames.MODULE]: nestModule.name, - }, - }); - const spanContext = api.trace.setSpan(api.context.active(), span); - - return api.context.with(spanContext, async () => { - try { - // todo - // eslint-disable-next-line prefer-rest-params, @typescript-eslint/no-explicit-any - return await original.apply(this, arguments as any); - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - throw addError(span, e); - } finally { - span.end(); - } - }); - }; - }; -} - -function createWrapCreateHandler(tracer: api.Tracer, moduleVersion?: string) { - return function wrapCreateHandler(original: RouterExecutionContext['create']) { - return function createHandlerWithTrace( - this: RouterExecutionContext, - instance: Controller, - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - callback: (...args: any[]) => unknown, - ) { - // todo - // eslint-disable-next-line prefer-rest-params - arguments[1] = createWrapHandler(tracer, moduleVersion, callback); - // todo - // eslint-disable-next-line prefer-rest-params, @typescript-eslint/no-explicit-any - const handler = original.apply(this, arguments as any); - const callbackName = callback.name; - const instanceName = - // todo - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - instance.constructor && instance.constructor.name ? instance.constructor.name : 'UnnamedInstance'; - const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName; - - // todo - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any - return function (this: any, req: any, res: any, next: (...args: any[]) => unknown) { - const span = tracer.startSpan(spanName, { - attributes: { - component: COMPONENT, - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.TYPE]: NestType.REQUEST_CONTEXT, - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - [ATTR_HTTP_REQUEST_METHOD]: req.method, - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, deprecation/deprecation - [SEMATTRS_HTTP_URL]: req.originalUrl || req.url, - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - [ATTR_HTTP_ROUTE]: req.route?.path || req.routeOptions?.url || req.routerPath, - [AttributeNames.CONTROLLER]: instanceName, - [AttributeNames.CALLBACK]: callbackName, - }, - }); - const spanContext = api.trace.setSpan(api.context.active(), span); - - return api.context.with(spanContext, async () => { - try { - // todo - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, prefer-rest-params - return await handler.apply(this, arguments as unknown); - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - throw addError(span, e); - } finally { - span.end(); - } - }); - }; - }; - }; -} - -function createWrapHandler( - tracer: api.Tracer, - moduleVersion: string | undefined, - // todo - // eslint-disable-next-line @typescript-eslint/ban-types - handler: Function, - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): (this: RouterExecutionContext) => Promise { - const spanName = handler.name || 'anonymous nest handler'; - const options = { - attributes: { - component: COMPONENT, - [AttributeNames.VERSION]: moduleVersion, - [AttributeNames.TYPE]: NestType.REQUEST_HANDLER, - [AttributeNames.CALLBACK]: handler.name, - }, - }; - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const wrappedHandler = function (this: RouterExecutionContext): Promise { - const span = tracer.startSpan(spanName, options); - const spanContext = api.trace.setSpan(api.context.active(), span); - - return api.context.with(spanContext, async () => { - try { - // todo - // eslint-disable-next-line prefer-rest-params - return await handler.apply(this, arguments); - // todo - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - throw addError(span, e); - } finally { - span.end(); - } - }); - }; - - if (handler.name) { - Object.defineProperty(wrappedHandler, 'name', { value: handler.name }); - } - - // Get the current metadata and set onto the wrapper to ensure other decorators ( ie: NestJS EventPattern / RolesGuard ) - // won't be affected by the use of this instrumentation - Reflect.getMetadataKeys(handler).forEach(metadataKey => { - Reflect.defineMetadata(metadataKey, Reflect.getMetadata(metadataKey, handler), wrappedHandler); - }); - return wrappedHandler; -} - -const addError = (span: api.Span, error: Error): Error => { - span.recordException(error); - span.setStatus({ code: api.SpanStatusCode.ERROR, message: error.message }); - return error; -}; diff --git a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts index 968c24a469e4..a572bb93a52f 100644 --- a/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-event-instrumentation.ts @@ -1,9 +1,9 @@ -import { isWrapped } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition, InstrumentationNodeModuleFile, + isWrapped, } from '@opentelemetry/instrumentation'; import { captureException, SDK_VERSION, startSpan } from '@sentry/core'; import { getEventSpanOptions } from './helpers'; @@ -83,7 +83,11 @@ export class SentryNestEventInstrumentation extends InstrumentationBase { descriptor.value = async function (...args: unknown[]) { // When multiple @OnEvent decorators are used on a single method, we need to get all event names // from the reflector metadata as there is no information during execution which event triggered it + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect if (Reflect.getMetadataKeys(descriptor.value).includes('EVENT_LISTENER_METADATA')) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - reflect-metadata of nestjs adds these methods to Reflect const eventData = Reflect.getMetadata('EVENT_LISTENER_METADATA', descriptor.value); if (Array.isArray(eventData)) { eventName = eventData diff --git a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts index fff9f92616f3..04b20f5d4d6a 100644 --- a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts +++ b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts @@ -1,9 +1,9 @@ -import { isWrapped } from '@opentelemetry/core'; import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition, InstrumentationNodeModuleFile, + isWrapped, } from '@opentelemetry/instrumentation'; import type { Span } from '@sentry/core'; import { diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index aae8ce697045..213cc8aeb694 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -85,7 +85,7 @@ "@sentry/opentelemetry": "9.40.0", "@sentry/react": "9.40.0", "@sentry/vercel-edge": "9.40.0", - "@sentry/webpack-plugin": "^3.5.0", + "@sentry/webpack-plugin": "^4.0.0", "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index 2a1781481536..ea9f0aeb63ea 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -45,7 +45,7 @@ export async function devErrorSymbolicationEventProcessor(event: Event, hint: Ev let resolvedFrames: ({ originalCodeFrame: string | null; - originalStackFrame: StackFrame | null; + originalStackFrame: (StackFrame & { line1?: number; column1?: number }) | null; } | null)[]; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -84,8 +84,9 @@ export async function devErrorSymbolicationEventProcessor(event: Event, hint: Ev post_context: postContextLines, function: resolvedFrame.originalStackFrame.methodName, filename: resolvedFrame.originalStackFrame.file || undefined, - lineno: resolvedFrame.originalStackFrame.lineNumber || undefined, - colno: resolvedFrame.originalStackFrame.column || undefined, + lineno: + resolvedFrame.originalStackFrame.lineNumber || resolvedFrame.originalStackFrame.line1 || undefined, + colno: resolvedFrame.originalStackFrame.column || resolvedFrame.originalStackFrame.column1 || undefined, }; }, ); @@ -175,6 +176,8 @@ async function resolveStackFrames( arguments: [], lineNumber: frame.lineNumber ?? 0, column: frame.column ?? 0, + line1: frame.lineNumber ?? 0, + column1: frame.column ?? 0, }; }), isServer: false, diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts index 529acab7f96e..23b960c857cf 100644 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ b/packages/nextjs/src/common/utils/wrapperUtils.ts @@ -6,6 +6,7 @@ import { getRootSpan, getTraceData, httpRequestToRequestData, + isThenable, } from '@sentry/core'; import type { IncomingMessage, ServerResponse } from 'http'; import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL } from '../span-attributes-with-logic-attached'; @@ -102,3 +103,31 @@ export async function callDataFetcherTraced Promis throw e; } } + +/** + * Extracts the params and searchParams from the props object. + * + * Depending on the next version, params and searchParams may be a promise which we do not want to resolve in this function. + */ +export function maybeExtractSynchronousParamsAndSearchParams(props: unknown): { + params: Record | undefined; + searchParams: Record | undefined; +} { + let params = + props && typeof props === 'object' && 'params' in props + ? (props.params as Record | Promise> | undefined) + : undefined; + if (isThenable(params)) { + params = undefined; + } + + let searchParams = + props && typeof props === 'object' && 'searchParams' in props + ? (props.searchParams as Record | Promise> | undefined) + : undefined; + if (isThenable(searchParams)) { + searchParams = undefined; + } + + return { params, searchParams }; +} diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index 9f8673a2fab8..e3cc2831d5e4 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -13,9 +13,9 @@ import { vercelWaitUntil, withIsolationScope, } from '@sentry/core'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { DEBUG_BUILD } from './debug-build'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; interface Options { formData?: FormData; diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index aad64e0f4ea4..2067ebccc245 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -24,6 +24,7 @@ import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavi import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; +import { maybeExtractSynchronousParamsAndSearchParams } from './utils/wrapperUtils'; /** * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. */ @@ -65,9 +66,7 @@ export function wrapGenerationFunctionWithSentry a let data: Record | undefined = undefined; if (getClient()?.getOptions().sendDefaultPii) { const props: unknown = args[0]; - const params = props && typeof props === 'object' && 'params' in props ? props.params : undefined; - const searchParams = - props && typeof props === 'object' && 'searchParams' in props ? props.searchParams : undefined; + const { params, searchParams } = maybeExtractSynchronousParamsAndSearchParams(props); data = { params, searchParams }; } diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 66e598b5c10f..3a9ca786d697 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -13,8 +13,8 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import type { EdgeRouteHandler } from '../edge/types'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; /** * Wraps Next.js middleware with Sentry error and performance instrumentation. diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index 60dfe0e8b421..e1a2238b05a1 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -12,12 +12,14 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCapturedScopesOnSpan, setHttpStatus, + vercelWaitUntil, winterCGHeadersToDict, withIsolationScope, withScope, } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import type { RouteHandlerContext } from './types'; +import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope } from './utils/tracingUtils'; /** @@ -92,6 +94,9 @@ export function wrapRouteHandlerWithSentry any>( }); } }, + () => { + vercelWaitUntil(flushSafelyWithTimeout()); + }, ); try { diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 16f6728deda1..9dd097cb75ae 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -22,10 +22,11 @@ import { } from '@sentry/core'; import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import type { ServerComponentContext } from '../common/types'; +import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; -import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; import { getSanitizedRequestUrl } from './utils/urls'; +import { maybeExtractSynchronousParamsAndSearchParams } from './utils/wrapperUtils'; /** * Wraps an `app` directory server component with Sentry error instrumentation. @@ -64,10 +65,8 @@ export function wrapServerComponentWithSentry any> if (getClient()?.getOptions().sendDefaultPii) { const props: unknown = args[0]; - params = - props && typeof props === 'object' && 'params' in props - ? (props.params as Record) - : undefined; + const { params: paramsFromProps } = maybeExtractSynchronousParamsAndSearchParams(props); + params = paramsFromProps; } isolationScope.setSDKProcessingMetadata({ diff --git a/packages/node-core/README.md b/packages/node-core/README.md index 570957a394ee..fa3bd7946ec0 100644 --- a/packages/node-core/README.md +++ b/packages/node-core/README.md @@ -10,9 +10,6 @@ [![npm dm](https://img.shields.io/npm/dm/@sentry/node-core.svg)](https://www.npmjs.com/package/@sentry/node-core) [![npm dt](https://img.shields.io/npm/dt/@sentry/node-core.svg)](https://www.npmjs.com/package/@sentry/node-core) -> [!CAUTION] -> This package is in alpha state and may be subject to breaking changes. - Unlike the `@sentry/node` SDK, this SDK comes with no OpenTelemetry auto-instrumentation out of the box. It requires the following OpenTelemetry dependencies and supports both v1 and v2 of OpenTelemetry: - `@opentelemetry/api` diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 1962ff71925a..752af8fc0042 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -72,11 +72,11 @@ }, "devDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "^0.57.2", - "@opentelemetry/resources": "^1.30.1", - "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@types/node": "^18.19.1" }, diff --git a/packages/node-core/src/integrations/onunhandledrejection.ts b/packages/node-core/src/integrations/onunhandledrejection.ts index 8b41da189a0f..a11d5c3cf7b0 100644 --- a/packages/node-core/src/integrations/onunhandledrejection.ts +++ b/packages/node-core/src/integrations/onunhandledrejection.ts @@ -1,5 +1,5 @@ -import type { Client, IntegrationFn, SeverityLevel } from '@sentry/core'; -import { captureException, consoleSandbox, defineIntegration, getClient } from '@sentry/core'; +import type { Client, IntegrationFn, SeverityLevel, Span } from '@sentry/core'; +import { captureException, consoleSandbox, defineIntegration, getClient, withActiveSpan } from '@sentry/core'; import { logAndExitProcess } from '../utils/errorhandling'; type UnhandledRejectionMode = 'none' | 'warn' | 'strict'; @@ -51,16 +51,27 @@ export function makeUnhandledPromiseHandler( const level: SeverityLevel = options.mode === 'strict' ? 'fatal' : 'error'; - captureException(reason, { - originalException: promise, - captureContext: { - extra: { unhandledPromiseRejection: true }, - level, - }, - mechanism: { - handled: false, - type: 'onunhandledrejection', - }, + // this can be set in places where we cannot reliably get access to the active span/error + // when the error bubbles up to this handler, we can use this to set the active span + const activeSpanForError = + reason && typeof reason === 'object' ? (reason as { _sentry_active_span?: Span })._sentry_active_span : undefined; + + const activeSpanWrapper = activeSpanForError + ? (fn: () => void) => withActiveSpan(activeSpanForError, fn) + : (fn: () => void) => fn(); + + activeSpanWrapper(() => { + captureException(reason, { + originalException: promise, + captureContext: { + extra: { unhandledPromiseRejection: true }, + level, + }, + mechanism: { + handled: false, + type: 'onunhandledrejection', + }, + }); }); handleRejection(reason, options.mode); diff --git a/packages/node-core/src/integrations/winston.ts b/packages/node-core/src/integrations/winston.ts index a485a6c56431..a58a3ea31ad0 100644 --- a/packages/node-core/src/integrations/winston.ts +++ b/packages/node-core/src/integrations/winston.ts @@ -28,7 +28,7 @@ interface WinstonTransportOptions { } /** - * Creates a new Sentry Winston transport that fowards logs to Sentry. Requires `_experiments.enableLogs` to be enabled. + * Creates a new Sentry Winston transport that fowards logs to Sentry. Requires the `enableLogs` option to be enabled. * * Supports Winston 3.x.x. * diff --git a/packages/node-core/src/logs/exports.ts b/packages/node-core/src/logs/exports.ts index c18b69f6770a..665d4d78d9ad 100644 --- a/packages/node-core/src/logs/exports.ts +++ b/packages/node-core/src/logs/exports.ts @@ -1,7 +1,7 @@ import { type CaptureLogArgs, captureLog } from './capture'; /** - * @summary Capture a log with the `trace` level. Requires `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `trace` level. Requires the `enableLogs` option to be enabled. * * 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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `debug` level. Requires the `enableLogs` option to be enabled. * * 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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `info` level. Requires the `enableLogs` option to be enabled. * * 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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `warn` level. Requires the `enableLogs` option to be enabled. * * 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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `error` level. Requires the `enableLogs` option to be enabled. * * 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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `fatal` level. Requires the `enableLogs` option to be enabled. * * You can either pass a message and attributes or a message template, params and attributes. * diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts index 50b0d4d92b6e..c17ee22d71b4 100644 --- a/packages/node-core/src/sdk/client.ts +++ b/packages/node-core/src/sdk/client.ts @@ -45,7 +45,7 @@ export class NodeClient extends ServerRuntimeClient { super(clientOptions); - if (this.getOptions()._experiments?.enableLogs) { + if (this.getOptions().enableLogs) { this._logOnExitFlushListener = () => { _INTERNAL_flushLogsBuffer(this); }; diff --git a/packages/node-core/test/helpers/mockSdkInit.ts b/packages/node-core/test/helpers/mockSdkInit.ts index ce82f92de3d8..0ea8a93cb064 100644 --- a/packages/node-core/test/helpers/mockSdkInit.ts +++ b/packages/node-core/test/helpers/mockSdkInit.ts @@ -1,6 +1,6 @@ import { context, propagation, ProxyTracerProvider, trace } from '@opentelemetry/api'; -import { Resource } from '@opentelemetry/resources'; -import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'; +import { type SpanProcessor, BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, @@ -64,12 +64,14 @@ export function setupOtel(client: NodeClient): BasicTracerProvider | undefined { // Create and configure TracerProvider with same config as Node SDK const provider = new BasicTracerProvider({ sampler: new SentrySampler(client), - resource: new Resource({ - [ATTR_SERVICE_NAME]: 'node', - // eslint-disable-next-line deprecation/deprecation - [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', - [ATTR_SERVICE_VERSION]: SDK_VERSION, - }), + resource: defaultResource().merge( + resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'node', + // eslint-disable-next-line deprecation/deprecation + [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', + [ATTR_SERVICE_VERSION]: SDK_VERSION, + }), + ), forceFlushTimeoutMillis: 500, spanProcessors: [ new SentrySpanProcessor({ @@ -128,6 +130,30 @@ export function cleanupOtel(_provider?: BasicTracerProvider): void { resetGlobals(); } +export function getSpanProcessor(): SentrySpanProcessor | undefined { + const client = getClient(); + if (!client?.traceProvider) { + return undefined; + } + + const provider = getProvider(client.traceProvider); + if (!provider) { + return undefined; + } + + // Access the span processors from the provider via _activeSpanProcessor + // Casted as any because _activeSpanProcessor is marked as readonly + const multiSpanProcessor = (provider as any)._activeSpanProcessor as + | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) + | undefined; + + const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( + (spanProcessor: SpanProcessor) => spanProcessor instanceof SentrySpanProcessor, + ) as SentrySpanProcessor | undefined; + + return spanProcessor; +} + export function getProvider(_provider?: BasicTracerProvider): BasicTracerProvider | undefined { let provider = _provider || getClient()?.traceProvider || trace.getTracerProvider(); diff --git a/packages/node-core/test/integration/transactions.test.ts b/packages/node-core/test/integration/transactions.test.ts index 0ce3f7c99984..7b13a400dedb 100644 --- a/packages/node-core/test/integration/transactions.test.ts +++ b/packages/node-core/test/integration/transactions.test.ts @@ -1,11 +1,9 @@ import { context, trace, TraceFlags } from '@opentelemetry/api'; -import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; import type { TransactionEvent } from '@sentry/core'; import { debug, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import { SentrySpanProcessor } from '@sentry/opentelemetry'; import { afterEach, describe, expect, it, vi } from 'vitest'; import * as Sentry from '../../src'; -import { cleanupOtel, getProvider, mockSdkInit } from '../helpers/mockSdkInit'; +import { cleanupOtel, getSpanProcessor, mockSdkInit } from '../helpers/mockSdkInit'; describe('Integration | Transactions', () => { afterEach(() => { @@ -562,13 +560,7 @@ describe('Integration | Transactions', () => { mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); - const provider = getProvider(); - const multiSpanProcessor = provider?.activeSpanProcessor as - | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) - | undefined; - const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( - spanProcessor => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + const spanProcessor = getSpanProcessor(); const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; diff --git a/packages/node-core/test/sdk/client.test.ts b/packages/node-core/test/sdk/client.test.ts index f053b1ba7e0e..7f57d4772212 100644 --- a/packages/node-core/test/sdk/client.test.ts +++ b/packages/node-core/test/sdk/client.test.ts @@ -296,7 +296,7 @@ describe('NodeClient', () => { describe('log capture', () => { it('adds server name to log attributes', () => { - const options = getDefaultNodeClientOptions({ _experiments: { enableLogs: true } }); + const options = getDefaultNodeClientOptions({ enableLogs: true }); const client = new NodeClient(options); const log: Log = { level: 'info', message: 'test message', attributes: {} }; @@ -309,7 +309,7 @@ describe('NodeClient', () => { it('preserves existing log attributes', () => { const serverName = 'test-server'; - const options = getDefaultNodeClientOptions({ serverName, _experiments: { enableLogs: true } }); + const options = getDefaultNodeClientOptions({ serverName, enableLogs: true }); const client = new NodeClient(options); const log: Log = { level: 'info', message: 'test message', attributes: { 'existing.attr': 'value' } }; diff --git a/packages/node-native/package.json b/packages/node-native/package.json index 3fbb587e80b0..cc288b41e707 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -63,7 +63,7 @@ "build:tarball": "npm pack" }, "dependencies": { - "@sentry-internal/node-native-stacktrace": "^0.2.0", + "@sentry-internal/node-native-stacktrace": "^0.2.2", "@sentry/core": "9.40.0", "@sentry/node": "9.40.0" }, diff --git a/packages/node/package.json b/packages/node/package.json index 9e26d5756c65..e5b3e440e1b1 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -66,35 +66,35 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "^0.57.2", - "@opentelemetry/instrumentation-amqplib": "^0.46.1", - "@opentelemetry/instrumentation-connect": "0.43.1", - "@opentelemetry/instrumentation-dataloader": "0.16.1", - "@opentelemetry/instrumentation-express": "0.47.1", - "@opentelemetry/instrumentation-fs": "0.19.1", - "@opentelemetry/instrumentation-generic-pool": "0.43.1", - "@opentelemetry/instrumentation-graphql": "0.47.1", - "@opentelemetry/instrumentation-hapi": "0.45.2", - "@opentelemetry/instrumentation-http": "0.57.2", - "@opentelemetry/instrumentation-ioredis": "0.47.1", - "@opentelemetry/instrumentation-kafkajs": "0.7.1", - "@opentelemetry/instrumentation-knex": "0.44.1", - "@opentelemetry/instrumentation-koa": "0.47.1", - "@opentelemetry/instrumentation-lru-memoizer": "0.44.1", - "@opentelemetry/instrumentation-mongodb": "0.52.0", - "@opentelemetry/instrumentation-mongoose": "0.46.1", - "@opentelemetry/instrumentation-mysql": "0.45.1", - "@opentelemetry/instrumentation-mysql2": "0.45.2", - "@opentelemetry/instrumentation-pg": "0.51.1", - "@opentelemetry/instrumentation-redis-4": "0.46.1", - "@opentelemetry/instrumentation-tedious": "0.18.1", - "@opentelemetry/instrumentation-undici": "0.10.1", - "@opentelemetry/resources": "^1.30.1", - "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/instrumentation-amqplib": "0.50.0", + "@opentelemetry/instrumentation-connect": "0.47.0", + "@opentelemetry/instrumentation-dataloader": "0.21.0", + "@opentelemetry/instrumentation-express": "0.52.0", + "@opentelemetry/instrumentation-fs": "0.23.0", + "@opentelemetry/instrumentation-generic-pool": "0.47.0", + "@opentelemetry/instrumentation-graphql": "0.51.0", + "@opentelemetry/instrumentation-hapi": "0.50.0", + "@opentelemetry/instrumentation-http": "0.203.0", + "@opentelemetry/instrumentation-ioredis": "0.51.0", + "@opentelemetry/instrumentation-kafkajs": "0.12.0", + "@opentelemetry/instrumentation-knex": "0.48.0", + "@opentelemetry/instrumentation-koa": "0.51.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.48.0", + "@opentelemetry/instrumentation-mongodb": "0.56.0", + "@opentelemetry/instrumentation-mongoose": "0.50.0", + "@opentelemetry/instrumentation-mysql": "0.49.0", + "@opentelemetry/instrumentation-mysql2": "0.49.0", + "@opentelemetry/instrumentation-pg": "0.55.0", + "@opentelemetry/instrumentation-redis": "0.51.0", + "@opentelemetry/instrumentation-tedious": "0.22.0", + "@opentelemetry/instrumentation-undici": "0.14.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0", - "@prisma/instrumentation": "6.11.1", + "@prisma/instrumentation": "6.12.0", "@sentry/core": "9.40.0", "@sentry/node-core": "9.40.0", "@sentry/opentelemetry": "9.40.0", diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 4e7a8482c474..bba0f98bc75e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -32,6 +32,7 @@ export { statsigIntegration, unleashIntegration, } from './integrations/featureFlagShims'; +export { firebaseIntegration } from './integrations/tracing/firebase'; export { init, diff --git a/packages/node/src/integrations/tracing/express-v5/enums/AttributeNames.ts b/packages/node/src/integrations/tracing/express-v5/enums/AttributeNames.ts deleted file mode 100644 index f6a83e31b073..000000000000 --- a/packages/node/src/integrations/tracing/express-v5/enums/AttributeNames.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export enum AttributeNames { - EXPRESS_TYPE = 'express.type', - EXPRESS_NAME = 'express.name', -} diff --git a/packages/node/src/integrations/tracing/express-v5/enums/ExpressLayerType.ts b/packages/node/src/integrations/tracing/express-v5/enums/ExpressLayerType.ts deleted file mode 100644 index 5cfc47c555d9..000000000000 --- a/packages/node/src/integrations/tracing/express-v5/enums/ExpressLayerType.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -export enum ExpressLayerType { - ROUTER = 'router', - MIDDLEWARE = 'middleware', - REQUEST_HANDLER = 'request_handler', -} diff --git a/packages/node/src/integrations/tracing/express-v5/instrumentation.ts b/packages/node/src/integrations/tracing/express-v5/instrumentation.ts deleted file mode 100644 index bc810341db35..000000000000 --- a/packages/node/src/integrations/tracing/express-v5/instrumentation.ts +++ /dev/null @@ -1,326 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/member-ordering */ -/* eslint-disable guard-for-in */ -/* eslint-disable @typescript-eslint/ban-types */ -/* eslint-disable prefer-rest-params */ -/* eslint-disable @typescript-eslint/no-this-alias */ -/* eslint-disable jsdoc/require-jsdoc */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable @typescript-eslint/explicit-member-accessibility */ - -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Attributes } from '@opentelemetry/api'; -import { context, diag, SpanStatusCode, trace } from '@opentelemetry/api'; -import { getRPCMetadata, RPCType } from '@opentelemetry/core'; -import { - InstrumentationBase, - InstrumentationNodeModuleDefinition, - isWrapped, - safeExecuteInTheMiddle, -} from '@opentelemetry/instrumentation'; -import { SEMATTRS_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import type * as express from 'express'; -import { AttributeNames } from './enums/AttributeNames'; -import { ExpressLayerType } from './enums/ExpressLayerType'; -import type { ExpressLayer, ExpressRouter, PatchedRequest } from './internal-types'; -import { _LAYERS_STORE_PROPERTY, kLayerPatched } from './internal-types'; -import type { ExpressInstrumentationConfig, ExpressRequestInfo } from './types'; -import { asErrorAndMessage, getLayerMetadata, getLayerPath, isLayerIgnored, storeLayerPath } from './utils'; - -export const PACKAGE_VERSION = '0.1.0'; -export const PACKAGE_NAME = '@sentry/instrumentation-express-v5'; - -/** Express instrumentation for OpenTelemetry */ -export class ExpressInstrumentationV5 extends InstrumentationBase { - constructor(config: ExpressInstrumentationConfig = {}) { - super(PACKAGE_NAME, PACKAGE_VERSION, config); - } - - init() { - return [ - new InstrumentationNodeModuleDefinition( - 'express', - ['>=5.0.0'], - moduleExports => this._setup(moduleExports), - moduleExports => this._tearDown(moduleExports), - ), - ]; - } - - private _setup(moduleExports: any) { - const routerProto = moduleExports.Router.prototype; - // patch express.Router.route - if (isWrapped(routerProto.route)) { - this._unwrap(routerProto, 'route'); - } - this._wrap(routerProto, 'route', this._getRoutePatch()); - // patch express.Router.use - if (isWrapped(routerProto.use)) { - this._unwrap(routerProto, 'use'); - } - this._wrap(routerProto, 'use', this._getRouterUsePatch() as any); - // patch express.Application.use - if (isWrapped(moduleExports.application.use)) { - this._unwrap(moduleExports.application, 'use'); - } - this._wrap(moduleExports.application, 'use', this._getAppUsePatch() as any); - return moduleExports; - } - - private _tearDown(moduleExports: any) { - if (moduleExports === undefined) return; - const routerProto = moduleExports.Router.prototype; - this._unwrap(routerProto, 'route'); - this._unwrap(routerProto, 'use'); - this._unwrap(moduleExports.application, 'use'); - } - - /** - * Get the patch for Router.route function - */ - private _getRoutePatch() { - const instrumentation = this; - return function (original: express.Router['route']) { - return function route_trace(this: ExpressRouter, ...args: Parameters) { - const route = original.apply(this, args); - const layer = this.stack[this.stack.length - 1] as ExpressLayer; - instrumentation._applyPatch(layer, getLayerPath(args)); - return route; - }; - }; - } - - /** - * Get the patch for Router.use function - */ - private _getRouterUsePatch() { - const instrumentation = this; - return function (original: express.Router['use']) { - return function use(this: express.Application, ...args: Parameters) { - const route = original.apply(this, args); - const layer = this.stack[this.stack.length - 1] as ExpressLayer; - instrumentation._applyPatch(layer, getLayerPath(args)); - return route; - }; - }; - } - - /** - * Get the patch for Application.use function - */ - private _getAppUsePatch() { - const instrumentation = this; - return function (original: express.Application['use']) { - return function use( - // In express 5.x the router is stored in `router` whereas in 4.x it's stored in `_router` - this: { _router?: ExpressRouter; router?: ExpressRouter }, - ...args: Parameters - ) { - // if we access app.router in express 4.x we trigger an assertion error - // This property existed in v3, was removed in v4 and then re-added in v5 - const router = this.router; - const route = original.apply(this, args); - if (router) { - const layer = router.stack[router.stack.length - 1] as ExpressLayer; - instrumentation._applyPatch(layer, getLayerPath(args)); - } - return route; - }; - }; - } - - /** Patch each express layer to create span and propagate context */ - private _applyPatch(this: ExpressInstrumentationV5, layer: ExpressLayer, layerPath?: string) { - const instrumentation = this; - // avoid patching multiple times the same layer - if (layer[kLayerPatched] === true) return; - layer[kLayerPatched] = true; - - this._wrap(layer, 'handle', original => { - // TODO: instrument error handlers - if (original.length === 4) return original; - - const patched = function (this: ExpressLayer, req: PatchedRequest, res: express.Response) { - storeLayerPath(req, layerPath); - const route = (req[_LAYERS_STORE_PROPERTY] as string[]) - .filter(path => path !== '/' && path !== '/*') - .join('') - // remove duplicate slashes to normalize route - .replace(/\/{2,}/g, '/'); - - const actualRoute = route.length > 0 ? route : undefined; - - const attributes: Attributes = { - // eslint-disable-next-line deprecation/deprecation - [SEMATTRS_HTTP_ROUTE]: actualRoute, - }; - const metadata = getLayerMetadata(route, layer, layerPath); - const type = metadata.attributes[AttributeNames.EXPRESS_TYPE] as ExpressLayerType; - - const rpcMetadata = getRPCMetadata(context.active()); - if (rpcMetadata?.type === RPCType.HTTP) { - rpcMetadata.route = actualRoute; - } - - // verify against the config if the layer should be ignored - if (isLayerIgnored(metadata.name, type, instrumentation.getConfig())) { - if (type === ExpressLayerType.MIDDLEWARE) { - (req[_LAYERS_STORE_PROPERTY] as string[]).pop(); - } - return original.apply(this, arguments); - } - - if (trace.getSpan(context.active()) === undefined) { - return original.apply(this, arguments); - } - - const spanName = instrumentation._getSpanName( - { - request: req, - layerType: type, - route, - }, - metadata.name, - ); - const span = instrumentation.tracer.startSpan(spanName, { - attributes: Object.assign(attributes, metadata.attributes), - }); - - const { requestHook } = instrumentation.getConfig(); - if (requestHook) { - safeExecuteInTheMiddle( - () => - requestHook(span, { - request: req, - layerType: type, - route, - }), - e => { - if (e) { - diag.error('express instrumentation: request hook failed', e); - } - }, - true, - ); - } - - let spanHasEnded = false; - if (metadata.attributes[AttributeNames.EXPRESS_TYPE] !== ExpressLayerType.MIDDLEWARE) { - span.end(); - spanHasEnded = true; - } - // listener for response.on('finish') - const onResponseFinish = () => { - if (spanHasEnded === false) { - spanHasEnded = true; - span.end(); - } - }; - - // verify we have a callback - const args = Array.from(arguments); - const callbackIdx = args.findIndex(arg => typeof arg === 'function'); - if (callbackIdx >= 0) { - arguments[callbackIdx] = function () { - // express considers anything but an empty value, "route" or "router" - // passed to its callback to be an error - const maybeError = arguments[0]; - const isError = ![undefined, null, 'route', 'router'].includes(maybeError); - if (!spanHasEnded && isError) { - const [error, message] = asErrorAndMessage(maybeError); - span.recordException(error); - span.setStatus({ - code: SpanStatusCode.ERROR, - message, - }); - } - - if (spanHasEnded === false) { - spanHasEnded = true; - req.res?.removeListener('finish', onResponseFinish); - span.end(); - } - if (!(req.route && isError)) { - (req[_LAYERS_STORE_PROPERTY] as string[]).pop(); - } - const callback = args[callbackIdx] as Function; - return callback.apply(this, arguments); - }; - } - - try { - return original.apply(this, arguments); - } catch (anyError) { - const [error, message] = asErrorAndMessage(anyError); - span.recordException(error); - span.setStatus({ - code: SpanStatusCode.ERROR, - message, - }); - throw anyError; - } finally { - /** - * At this point if the callback wasn't called, that means either the - * layer is asynchronous (so it will call the callback later on) or that - * the layer directly end the http response, so we'll hook into the "finish" - * event to handle the later case. - */ - if (!spanHasEnded) { - res.once('finish', onResponseFinish); - } - } - }; - - // `handle` isn't just a regular function in some cases. It also contains - // some properties holding metadata and state so we need to proxy them - // through through patched function - // ref: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/1950 - // Also some apps/libs do their own patching before OTEL and have these properties - // in the proptotype. So we use a `for...in` loop to get own properties and also - // any enumerable prop in the prototype chain - // ref: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2271 - for (const key in original) { - Object.defineProperty(patched, key, { - get() { - return original[key]; - }, - set(value) { - original[key] = value; - }, - }); - } - return patched; - }); - } - - _getSpanName(info: ExpressRequestInfo, defaultName: string) { - const { spanNameHook } = this.getConfig(); - - if (!(spanNameHook instanceof Function)) { - return defaultName; - } - - try { - return spanNameHook(info, defaultName) ?? defaultName; - } catch (err) { - diag.error('express instrumentation: error calling span name rewrite hook', err); - return defaultName; - } - } -} diff --git a/packages/node/src/integrations/tracing/express-v5/internal-types.ts b/packages/node/src/integrations/tracing/express-v5/internal-types.ts deleted file mode 100644 index 482dc0b6b4ea..000000000000 --- a/packages/node/src/integrations/tracing/express-v5/internal-types.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ - -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Request } from 'express'; - -/** - * This symbol is used to mark express layer as being already instrumented - * since its possible to use a given layer multiple times (ex: middlewares) - */ -export const kLayerPatched: unique symbol = Symbol('express-layer-patched'); - -/** - * This const define where on the `request` object the Instrumentation will mount the - * current stack of express layer. - * - * It is necessary because express doesn't store the different layers - * (ie: middleware, router etc) that it called to get to the current layer. - * Given that, the only way to know the route of a given layer is to - * store the path of where each previous layer has been mounted. - * - * ex: bodyParser > auth middleware > /users router > get /:id - * in this case the stack would be: ["/users", "/:id"] - * - * ex2: bodyParser > /api router > /v1 router > /users router > get /:id - * stack: ["/api", "/v1", "/users", ":id"] - * - */ -export const _LAYERS_STORE_PROPERTY = '__ot_middlewares'; - -export type PatchedRequest = { - [_LAYERS_STORE_PROPERTY]?: string[]; -} & Request; -export type PathParams = string | RegExp | Array; - -// https://github.com/expressjs/express/blob/main/lib/router/index.js#L53 -export type ExpressRouter = { - stack: ExpressLayer[]; -}; - -// https://github.com/expressjs/express/blob/main/lib/router/layer.js#L33 -export type ExpressLayer = { - handle: Function & Record; - [kLayerPatched]?: boolean; - name: string; - path: string; - route?: ExpressLayer; -}; diff --git a/packages/node/src/integrations/tracing/express-v5/types.ts b/packages/node/src/integrations/tracing/express-v5/types.ts deleted file mode 100644 index 0623cac1cbc5..000000000000 --- a/packages/node/src/integrations/tracing/express-v5/types.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Span } from '@opentelemetry/api'; -import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; -import type { ExpressLayerType } from './enums/ExpressLayerType'; - -export type LayerPathSegment = string | RegExp | number; - -export type IgnoreMatcher = string | RegExp | ((name: string) => boolean); - -export type ExpressRequestInfo = { - /** An express request object */ - request: T; - route: string; - layerType: ExpressLayerType; -}; - -export type SpanNameHook = ( - info: ExpressRequestInfo, - /** - * If no decision is taken based on RequestInfo, the default name - * supplied by the instrumentation can be used instead. - */ - defaultName: string, -) => string; - -/** - * Function that can be used to add custom attributes to the current span or the root span on - * a Express request - * @param span - The Express middleware layer span. - * @param info - An instance of ExpressRequestInfo that contains info about the request such as the route, and the layer type. - */ -export interface ExpressRequestCustomAttributeFunction { - (span: Span, info: ExpressRequestInfo): void; -} - -/** - * Options available for the Express Instrumentation (see [documentation](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/plugins/node/opentelemetry-instrumentation-express#express-instrumentation-options)) - */ -export interface ExpressInstrumentationConfig extends InstrumentationConfig { - /** Ignore specific based on their name */ - ignoreLayers?: IgnoreMatcher[]; - /** Ignore specific layers based on their type */ - ignoreLayersType?: ExpressLayerType[]; - spanNameHook?: SpanNameHook; - - /** Function for adding custom attributes on Express request */ - requestHook?: ExpressRequestCustomAttributeFunction; -} diff --git a/packages/node/src/integrations/tracing/express-v5/utils.ts b/packages/node/src/integrations/tracing/express-v5/utils.ts deleted file mode 100644 index 85bf42958bdd..000000000000 --- a/packages/node/src/integrations/tracing/express-v5/utils.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-assertion */ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ - -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Attributes } from '@opentelemetry/api'; -import { AttributeNames } from './enums/AttributeNames'; -import { ExpressLayerType } from './enums/ExpressLayerType'; -import type { ExpressLayer, PatchedRequest } from './internal-types'; -import { _LAYERS_STORE_PROPERTY } from './internal-types'; -import type { ExpressInstrumentationConfig, IgnoreMatcher, LayerPathSegment } from './types'; - -/** - * Store layers path in the request to be able to construct route later - * @param request The request where - * @param [value] the value to push into the array - */ -export const storeLayerPath = (request: PatchedRequest, value?: string): void => { - if (Array.isArray(request[_LAYERS_STORE_PROPERTY]) === false) { - Object.defineProperty(request, _LAYERS_STORE_PROPERTY, { - enumerable: false, - value: [], - }); - } - if (value === undefined) return; - (request[_LAYERS_STORE_PROPERTY] as string[]).push(value); -}; - -/** - * Recursively search the router path from layer stack - * @param path The path to reconstruct - * @param layer The layer to reconstruct from - * @returns The reconstructed path - */ -export const getRouterPath = (path: string, layer: ExpressLayer): string => { - const stackLayer = layer.handle?.stack?.[0]; - - if (stackLayer?.route?.path) { - return `${path}${stackLayer.route.path}`; - } - - if (stackLayer?.handle?.stack) { - return getRouterPath(path, stackLayer); - } - - return path; -}; - -/** - * Parse express layer context to retrieve a name and attributes. - * @param route The route of the layer - * @param layer Express layer - * @param [layerPath] if present, the path on which the layer has been mounted - */ -export const getLayerMetadata = ( - route: string, - layer: ExpressLayer, - layerPath?: string, -): { - attributes: Attributes; - name: string; -} => { - if (layer.name === 'router') { - const maybeRouterPath = getRouterPath('', layer); - const extractedRouterPath = maybeRouterPath ? maybeRouterPath : layerPath || route || '/'; - - return { - attributes: { - [AttributeNames.EXPRESS_NAME]: extractedRouterPath, - [AttributeNames.EXPRESS_TYPE]: ExpressLayerType.ROUTER, - }, - name: `router - ${extractedRouterPath}`, - }; - } else if (layer.name === 'bound dispatch' || layer.name === 'handle') { - return { - attributes: { - [AttributeNames.EXPRESS_NAME]: (route || layerPath) ?? 'request handler', - [AttributeNames.EXPRESS_TYPE]: ExpressLayerType.REQUEST_HANDLER, - }, - name: `request handler${layer.path ? ` - ${route || layerPath}` : ''}`, - }; - } else { - return { - attributes: { - [AttributeNames.EXPRESS_NAME]: layer.name, - [AttributeNames.EXPRESS_TYPE]: ExpressLayerType.MIDDLEWARE, - }, - name: `middleware - ${layer.name}`, - }; - } -}; - -/** - * Check whether the given obj match pattern - * @param constant e.g URL of request - * @param obj obj to inspect - * @param pattern Match pattern - */ -const satisfiesPattern = (constant: string, pattern: IgnoreMatcher): boolean => { - if (typeof pattern === 'string') { - return pattern === constant; - } else if (pattern instanceof RegExp) { - return pattern.test(constant); - } else if (typeof pattern === 'function') { - return pattern(constant); - } else { - throw new TypeError('Pattern is in unsupported datatype'); - } -}; - -/** - * Check whether the given request is ignored by configuration - * It will not re-throw exceptions from `list` provided by the client - * @param constant e.g URL of request - * @param [list] List of ignore patterns - * @param [onException] callback for doing something when an exception has - * occurred - */ -export const isLayerIgnored = ( - name: string, - type: ExpressLayerType, - config?: ExpressInstrumentationConfig, -): boolean => { - if (Array.isArray(config?.ignoreLayersType) && config?.ignoreLayersType?.includes(type)) { - return true; - } - if (Array.isArray(config?.ignoreLayers) === false) return false; - try { - for (const pattern of config!.ignoreLayers!) { - if (satisfiesPattern(name, pattern)) { - return true; - } - } - } catch { - /* catch block */ - } - - return false; -}; - -/** - * Converts a user-provided error value into an error and error message pair - * - * @param error - User-provided error value - * @returns Both an Error or string representation of the value and an error message - */ -export const asErrorAndMessage = (error: unknown): [error: string | Error, message: string] => - error instanceof Error ? [error, error.message] : [String(error), String(error)]; - -/** - * Extracts the layer path from the route arguments - * - * @param args - Arguments of the route - * @returns The layer path - */ -export const getLayerPath = (args: [LayerPathSegment | LayerPathSegment[], ...unknown[]]): string | undefined => { - const firstArg = args[0]; - - if (Array.isArray(firstArg)) { - return firstArg.map(arg => extractLayerPathSegment(arg) || '').join(','); - } - - return extractLayerPathSegment(firstArg); -}; - -const extractLayerPathSegment = (arg: LayerPathSegment) => { - if (typeof arg === 'string') { - return arg; - } - - if (arg instanceof RegExp || typeof arg === 'number') { - return arg.toString(); - } - - return; -}; diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index 24155e2f5452..fed0a65cfd0b 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -15,10 +15,8 @@ import { } from '@sentry/core'; import { addOriginToSpan, ensureIsWrapped, generateInstrumentOnce } from '@sentry/node-core'; import { DEBUG_BUILD } from '../../debug-build'; -import { ExpressInstrumentationV5 } from './express-v5/instrumentation'; const INTEGRATION_NAME = 'Express'; -const INTEGRATION_NAME_V5 = 'Express-V5'; function requestHook(span: Span): void { addOriginToSpan(span, 'auto.http.otel.express'); @@ -61,21 +59,11 @@ export const instrumentExpress = generateInstrumentOnce( }), ); -export const instrumentExpressV5 = generateInstrumentOnce( - INTEGRATION_NAME_V5, - () => - new ExpressInstrumentationV5({ - requestHook: span => requestHook(span), - spanNameHook: (info, defaultName) => spanNameHook(info, defaultName), - }), -); - const _expressIntegration = (() => { return { name: INTEGRATION_NAME, setupOnce() { instrumentExpress(); - instrumentExpressV5(); }, }; }) satisfies IntegrationFn; diff --git a/packages/node/src/integrations/tracing/fastify/index.ts b/packages/node/src/integrations/tracing/fastify/index.ts index 0aaf7814e9e3..fd8894e29a96 100644 --- a/packages/node/src/integrations/tracing/fastify/index.ts +++ b/packages/node/src/integrations/tracing/fastify/index.ts @@ -17,6 +17,41 @@ import { FastifyOtelInstrumentation } from './fastify-otel/index'; import type { FastifyInstance, FastifyReply, FastifyRequest } from './types'; import { FastifyInstrumentationV3 } from './v3/instrumentation'; +/** + * 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 + * + * ```javascript + * Sentry.init({ + * integrations: [ + * Sentry.fastifyIntegration({ + * shouldHandleError(_error, _request, reply) { + * return reply.statusCode >= 500; + * }, + * }); + * }, + * }); + * ``` + * + */ +interface FastifyIntegrationOptions { + /** + * 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. + * + * @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) + */ + shouldHandleError: (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean; +} + interface FastifyHandlerOptions { /** * Callback method deciding whether error should be captured and sent to Sentry @@ -27,6 +62,7 @@ interface FastifyHandlerOptions { * * @example * + * * ```javascript * setupFastifyErrorHandler(app, { * shouldHandleError(_error, _request, reply) { @@ -35,6 +71,7 @@ interface FastifyHandlerOptions { * }); * ``` * + * * If using TypeScript, you can cast the request and reply to get full type safety. * * ```typescript @@ -53,10 +90,20 @@ interface FastifyHandlerOptions { } const INTEGRATION_NAME = 'Fastify'; +const INTEGRATION_NAME_V5 = 'Fastify-V5'; const INTEGRATION_NAME_V3 = 'Fastify-V3'; export const instrumentFastifyV3 = generateInstrumentOnce(INTEGRATION_NAME_V3, () => new FastifyInstrumentationV3()); +function getFastifyIntegration(): ReturnType | undefined { + const client = getClient(); + if (!client) { + return undefined; + } else { + return client.getIntegrationByName(INTEGRATION_NAME) as ReturnType | undefined; + } +} + function handleFastifyError( this: { diagnosticsChannelExists?: boolean; @@ -64,9 +111,9 @@ function handleFastifyError( error: Error, request: FastifyRequest & { opentelemetry?: () => { span?: Span } }, reply: FastifyReply, - shouldHandleError: (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean, handlerOrigin: 'diagnostics-channel' | 'onError-hook', ): void { + const shouldHandleError = getFastifyIntegration()?.getShouldHandleError() || defaultShouldHandleError; // Diagnostics channel runs before the onError hook, so we can use it to check if the handler was already registered if (handlerOrigin === 'diagnostics-channel') { this.diagnosticsChannelExists = true; @@ -76,7 +123,7 @@ function handleFastifyError( DEBUG_BUILD && debug.warn( 'Fastify error handler was already registered via diagnostics channel.', - 'You can safely remove `setupFastifyErrorHandler` call.', + 'You can safely remove `setupFastifyErrorHandler` call and set `shouldHandleError` on the integration options.', ); // If the diagnostics channel already exists, we don't need to handle the error again @@ -84,15 +131,13 @@ function handleFastifyError( } if (shouldHandleError(error, request, reply)) { - captureException(error); + captureException(error, { mechanism: { handled: false, type: 'fastify' } }); } } -export const instrumentFastify = generateInstrumentOnce(INTEGRATION_NAME, () => { +export const instrumentFastify = generateInstrumentOnce(INTEGRATION_NAME_V5, () => { const fastifyOtelInstrumentationInstance = new FastifyOtelInstrumentation(); const plugin = fastifyOtelInstrumentationInstance.plugin(); - const options = fastifyOtelInstrumentationInstance.getConfig(); - const shouldHandleError = (options as FastifyHandlerOptions)?.shouldHandleError || defaultShouldHandleError; // This message handler works for Fastify versions 3, 4 and 5 diagnosticsChannel.subscribe('fastify.initialization', message => { @@ -120,20 +165,30 @@ export const instrumentFastify = generateInstrumentOnce(INTEGRATION_NAME, () => reply: FastifyReply; }; - handleFastifyError.call(handleFastifyError, error, request, reply, shouldHandleError, 'diagnostics-channel'); + handleFastifyError.call(handleFastifyError, error, request, reply, 'diagnostics-channel'); }); // Returning this as unknown not to deal with the internal types of the FastifyOtelInstrumentation - return fastifyOtelInstrumentationInstance as Instrumentation; + return fastifyOtelInstrumentationInstance as Instrumentation; }); -const _fastifyIntegration = (() => { +const _fastifyIntegration = (({ shouldHandleError }: Partial) => { + let _shouldHandleError: (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean; + return { name: INTEGRATION_NAME, setupOnce() { + _shouldHandleError = shouldHandleError || defaultShouldHandleError; + instrumentFastifyV3(); instrumentFastify(); }, + getShouldHandleError() { + return _shouldHandleError; + }, + setShouldHandleError(fn: (error: Error, request: FastifyRequest, reply: FastifyReply) => boolean): void { + _shouldHandleError = fn; + }, }; }) satisfies IntegrationFn; @@ -153,7 +208,9 @@ const _fastifyIntegration = (() => { * }) * ``` */ -export const fastifyIntegration = defineIntegration(_fastifyIntegration); +export const fastifyIntegration = defineIntegration((options: Partial = {}) => + _fastifyIntegration(options), +); /** * Default function to determine if an error should be sent to Sentry @@ -187,11 +244,14 @@ function defaultShouldHandleError(_error: Error, _request: FastifyRequest, reply * ``` */ export function setupFastifyErrorHandler(fastify: FastifyInstance, options?: Partial): void { - const shouldHandleError = options?.shouldHandleError || defaultShouldHandleError; + if (options?.shouldHandleError) { + getFastifyIntegration()?.setShouldHandleError(options.shouldHandleError); + } + const plugin = Object.assign( function (fastify: FastifyInstance, _options: unknown, done: () => void): void { fastify.addHook('onError', async (request, reply, error) => { - handleFastifyError.call(handleFastifyError, error, request, reply, shouldHandleError, 'onError-hook'); + handleFastifyError.call(handleFastifyError, error, request, reply, 'onError-hook'); }); done(); }, diff --git a/packages/node/src/integrations/tracing/firebase/firebase.ts b/packages/node/src/integrations/tracing/firebase/firebase.ts new file mode 100644 index 000000000000..9f2abbfe31fd --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/firebase.ts @@ -0,0 +1,28 @@ +import type { Span } from '@opentelemetry/api'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; +import { type FirebaseInstrumentationConfig, FirebaseInstrumentation } from './otel'; + +const INTEGRATION_NAME = 'Firebase'; + +const config: FirebaseInstrumentationConfig = { + firestoreSpanCreationHook: span => { + addOriginToSpan(span as Span, 'auto.firebase.otel.firestore'); + + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'db.query'); + }, +}; + +export const instrumentFirebase = generateInstrumentOnce(INTEGRATION_NAME, () => new FirebaseInstrumentation(config)); + +const _firebaseIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentFirebase(); + }, + }; +}) satisfies IntegrationFn; + +export const firebaseIntegration = defineIntegration(_firebaseIntegration); diff --git a/packages/node/src/integrations/tracing/firebase/index.ts b/packages/node/src/integrations/tracing/firebase/index.ts new file mode 100644 index 000000000000..5588511bf303 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/index.ts @@ -0,0 +1 @@ +export * from './firebase'; diff --git a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts new file mode 100644 index 000000000000..ad67ea701079 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts @@ -0,0 +1,37 @@ +import { type InstrumentationNodeModuleDefinition, InstrumentationBase } from '@opentelemetry/instrumentation'; +import { SDK_VERSION } from '@sentry/core'; +import { patchFirestore } from './patches/firestore'; +import type { FirebaseInstrumentationConfig } from './types'; + +const DefaultFirebaseInstrumentationConfig: FirebaseInstrumentationConfig = {}; +const firestoreSupportedVersions = ['>=3.0.0 <5']; // firebase 9+ + +/** + * Instrumentation for Firebase services, specifically Firestore. + */ +export class FirebaseInstrumentation extends InstrumentationBase { + public constructor(config: FirebaseInstrumentationConfig = DefaultFirebaseInstrumentationConfig) { + super('@sentry/instrumentation-firebase', SDK_VERSION, config); + } + + /** + * sets config + * @param config + */ + public override setConfig(config: FirebaseInstrumentationConfig = {}): void { + super.setConfig({ ...DefaultFirebaseInstrumentationConfig, ...config }); + } + + /** + * + * @protected + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + protected init(): InstrumentationNodeModuleDefinition | InstrumentationNodeModuleDefinition[] | void { + const modules: InstrumentationNodeModuleDefinition[] = []; + + modules.push(patchFirestore(this.tracer, firestoreSupportedVersions, this._wrap, this._unwrap, this.getConfig())); + + return modules; + } +} diff --git a/packages/node/src/integrations/tracing/firebase/otel/index.ts b/packages/node/src/integrations/tracing/firebase/otel/index.ts new file mode 100644 index 000000000000..88520ba3efee --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/otel/index.ts @@ -0,0 +1,3 @@ +// The structure inside OTEL is to be kept as close as possible to an opentelemetry plugin. +export * from './firebaseInstrumentation'; +export * from './types'; diff --git a/packages/node/src/integrations/tracing/firebase/otel/patches/firestore.ts b/packages/node/src/integrations/tracing/firebase/otel/patches/firestore.ts new file mode 100644 index 000000000000..b450be959b69 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/otel/patches/firestore.ts @@ -0,0 +1,336 @@ +import * as net from 'node:net'; +import type { Span, Tracer } from '@opentelemetry/api'; +import { context, diag, SpanKind, trace } from '@opentelemetry/api'; +import { + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + isWrapped, + safeExecuteInTheMiddle, +} from '@opentelemetry/instrumentation'; +import { + ATTR_DB_COLLECTION_NAME, + ATTR_DB_NAMESPACE, + ATTR_DB_OPERATION_NAME, + ATTR_DB_SYSTEM_NAME, + ATTR_SERVER_ADDRESS, + ATTR_SERVER_PORT, +} from '@opentelemetry/semantic-conventions'; +import type { SpanAttributes } from '@sentry/core'; +import type { unwrap as shimmerUnwrap, wrap as shimmerWrap } from 'shimmer'; +import type { FirebaseInstrumentation } from '../firebaseInstrumentation'; +import type { + AddDocType, + CollectionReference, + DeleteDocType, + DocumentData, + DocumentReference, + FirebaseApp, + FirebaseInstrumentationConfig, + FirebaseOptions, + FirestoreSettings, + FirestoreSpanCreationHook, + GetDocsType, + PartialWithFieldValue, + QuerySnapshot, + SetDocType, + SetOptions, + WithFieldValue, +} from '../types'; + +/** + * + * @param tracer - Opentelemetry Tracer + * @param firestoreSupportedVersions - supported version of firebase/firestore + * @param wrap - reference to native instrumentation wrap function + * @param unwrap - reference to native instrumentation wrap function + */ +export function patchFirestore( + tracer: Tracer, + firestoreSupportedVersions: string[], + wrap: typeof shimmerWrap, + unwrap: typeof shimmerUnwrap, + config: FirebaseInstrumentationConfig, +): InstrumentationNodeModuleDefinition { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const defaultFirestoreSpanCreationHook: FirestoreSpanCreationHook = () => {}; + + let firestoreSpanCreationHook: FirestoreSpanCreationHook = defaultFirestoreSpanCreationHook; + const configFirestoreSpanCreationHook = config.firestoreSpanCreationHook; + + if (typeof configFirestoreSpanCreationHook === 'function') { + firestoreSpanCreationHook = (span: Span) => { + safeExecuteInTheMiddle( + () => configFirestoreSpanCreationHook(span), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + + const moduleFirestoreCJS = new InstrumentationNodeModuleDefinition( + '@firebase/firestore', + firestoreSupportedVersions, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (moduleExports: any) => wrapMethods(moduleExports, wrap, unwrap, tracer, firestoreSpanCreationHook), + ); + const files: string[] = [ + '@firebase/firestore/dist/lite/index.node.cjs.js', + '@firebase/firestore/dist/lite/index.node.mjs.js', + '@firebase/firestore/dist/lite/index.rn.esm2017.js', + '@firebase/firestore/dist/lite/index.cjs.js', + ]; + + for (const file of files) { + moduleFirestoreCJS.files.push( + new InstrumentationNodeModuleFile( + file, + firestoreSupportedVersions, + moduleExports => wrapMethods(moduleExports, wrap, unwrap, tracer, firestoreSpanCreationHook), + moduleExports => unwrapMethods(moduleExports, unwrap), + ), + ); + } + + return moduleFirestoreCJS; +} + +function wrapMethods( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + moduleExports: any, + wrap: typeof shimmerWrap, + unwrap: typeof shimmerUnwrap, + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any { + unwrapMethods(moduleExports, unwrap); + + wrap(moduleExports, 'addDoc', patchAddDoc(tracer, firestoreSpanCreationHook)); + wrap(moduleExports, 'getDocs', patchGetDocs(tracer, firestoreSpanCreationHook)); + wrap(moduleExports, 'setDoc', patchSetDoc(tracer, firestoreSpanCreationHook)); + wrap(moduleExports, 'deleteDoc', patchDeleteDoc(tracer, firestoreSpanCreationHook)); + + return moduleExports; +} + +function unwrapMethods( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + moduleExports: any, + unwrap: typeof shimmerUnwrap, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any { + for (const method of ['addDoc', 'getDocs', 'setDoc', 'deleteDoc']) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (isWrapped(moduleExports[method])) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + unwrap(moduleExports, method); + } + } + return moduleExports; +} + +function patchAddDoc( + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, +): ( + original: AddDocType, +) => ( + this: FirebaseInstrumentation, + reference: CollectionReference, + data: WithFieldValue, +) => Promise> { + return function addDoc(original: AddDocType) { + return function ( + reference: CollectionReference, + data: WithFieldValue, + ): Promise> { + const span = startDBSpan(tracer, 'addDoc', reference); + firestoreSpanCreationHook(span); + return executeContextWithSpan>>(span, () => { + return original(reference, data); + }); + }; + }; +} + +function patchDeleteDoc( + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, +): ( + original: DeleteDocType, +) => (this: FirebaseInstrumentation, reference: DocumentReference) => Promise { + return function deleteDoc(original: DeleteDocType) { + return function (reference: DocumentReference): Promise { + const span = startDBSpan(tracer, 'deleteDoc', reference.parent || reference); + firestoreSpanCreationHook(span); + return executeContextWithSpan>(span, () => { + return original(reference); + }); + }; + }; +} + +function patchGetDocs( + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, +): ( + original: GetDocsType, +) => ( + this: FirebaseInstrumentation, + reference: CollectionReference, +) => Promise> { + return function getDocs(original: GetDocsType) { + return function ( + reference: CollectionReference, + ): Promise> { + const span = startDBSpan(tracer, 'getDocs', reference); + firestoreSpanCreationHook(span); + return executeContextWithSpan>>(span, () => { + return original(reference); + }); + }; + }; +} + +function patchSetDoc( + tracer: Tracer, + firestoreSpanCreationHook: FirestoreSpanCreationHook, +): ( + original: SetDocType, +) => ( + this: FirebaseInstrumentation, + reference: DocumentReference, + data: WithFieldValue & PartialWithFieldValue, + options?: SetOptions, +) => Promise { + return function setDoc(original: SetDocType) { + return function ( + reference: DocumentReference, + data: WithFieldValue & PartialWithFieldValue, + options?: SetOptions, + ): Promise { + const span = startDBSpan(tracer, 'setDoc', reference.parent || reference); + firestoreSpanCreationHook(span); + + return executeContextWithSpan>(span, () => { + return typeof options !== 'undefined' ? original(reference, data, options) : original(reference, data); + }); + }; + }; +} + +function executeContextWithSpan(span: Span, callback: () => T): T { + return context.with(trace.setSpan(context.active(), span), () => { + return safeExecuteInTheMiddle( + (): T => { + return callback(); + }, + err => { + if (err) { + span.recordException(err); + } + span.end(); + }, + true, + ); + }); +} + +function startDBSpan( + tracer: Tracer, + spanName: string, + reference: CollectionReference | DocumentReference, +): Span { + const span = tracer.startSpan(`${spanName} ${reference.path}`, { kind: SpanKind.CLIENT }); + addAttributes(span, reference); + span.setAttribute(ATTR_DB_OPERATION_NAME, spanName); + return span; +} + +/** + * Gets the server address and port attributes from the Firestore settings. + * It's best effort to extract the address and port from the settings, especially for IPv6. + * @param span - The span to set attributes on. + * @param settings - The Firestore settings containing host information. + */ +export function getPortAndAddress(settings: FirestoreSettings): { + address?: string; + port?: number; +} { + let address: string | undefined; + let port: string | undefined; + + if (typeof settings.host === 'string') { + if (settings.host.startsWith('[')) { + // IPv6 addresses can be enclosed in square brackets, e.g., [2001:db8::1]:8080 + if (settings.host.endsWith(']')) { + // IPv6 with square brackets without port + address = settings.host.replace(/^\[|\]$/g, ''); + } else if (settings.host.includes(']:')) { + // IPv6 with square brackets with port + const lastColonIndex = settings.host.lastIndexOf(':'); + if (lastColonIndex !== -1) { + address = settings.host.slice(1, lastColonIndex).replace(/^\[|\]$/g, ''); + port = settings.host.slice(lastColonIndex + 1); + } + } + } else { + // IPv4 or IPv6 without square brackets + // If it's an IPv6 address without square brackets, we assume it does not have a port. + if (net.isIPv6(settings.host)) { + address = settings.host; + } + // If it's an IPv4 address, we can extract the port if it exists. + else { + const lastColonIndex = settings.host.lastIndexOf(':'); + if (lastColonIndex !== -1) { + address = settings.host.slice(0, lastColonIndex); + port = settings.host.slice(lastColonIndex + 1); + } else { + address = settings.host; + } + } + } + } + return { + address: address, + port: port ? parseInt(port, 10) : undefined, + }; +} + +function addAttributes( + span: Span, + reference: CollectionReference | DocumentReference, +): void { + const firestoreApp: FirebaseApp = reference.firestore.app; + const firestoreOptions: FirebaseOptions = firestoreApp.options; + const json: { settings?: FirestoreSettings } = reference.firestore.toJSON() || {}; + const settings: FirestoreSettings = json.settings || {}; + + const attributes: SpanAttributes = { + [ATTR_DB_COLLECTION_NAME]: reference.path, + [ATTR_DB_NAMESPACE]: firestoreApp.name, + [ATTR_DB_SYSTEM_NAME]: 'firebase.firestore', + 'firebase.firestore.type': reference.type, + 'firebase.firestore.options.projectId': firestoreOptions.projectId, + 'firebase.firestore.options.appId': firestoreOptions.appId, + 'firebase.firestore.options.messagingSenderId': firestoreOptions.messagingSenderId, + 'firebase.firestore.options.storageBucket': firestoreOptions.storageBucket, + }; + + const { address, port } = getPortAndAddress(settings); + + if (address) { + attributes[ATTR_SERVER_ADDRESS] = address; + } + if (port) { + attributes[ATTR_SERVER_PORT] = port; + } + + span.setAttributes(attributes); +} diff --git a/packages/node/src/integrations/tracing/firebase/otel/types.ts b/packages/node/src/integrations/tracing/firebase/otel/types.ts new file mode 100644 index 000000000000..ecc48bc09498 --- /dev/null +++ b/packages/node/src/integrations/tracing/firebase/otel/types.ts @@ -0,0 +1,119 @@ +import type { Span } from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; + +// Inlined types from 'firebase/app' +export interface FirebaseOptions { + [key: string]: any; + apiKey?: string; + authDomain?: string; + databaseURL?: string; + projectId?: string; + storageBucket?: string; + messagingSenderId?: string; + appId?: string; + measurementId?: string; +} + +export interface FirebaseApp { + name: string; + options: FirebaseOptions; + automaticDataCollectionEnabled: boolean; + delete(): Promise; +} + +// Inlined types from 'firebase/firestore' +export interface DocumentData { + [field: string]: any; +} + +export type WithFieldValue = T; + +export type PartialWithFieldValue = Partial; + +export interface SetOptions { + merge?: boolean; + mergeFields?: (string | number | symbol)[]; +} + +export interface DocumentReference { + id: string; + firestore: { + app: FirebaseApp; + settings: FirestoreSettings; + useEmulator: (host: string, port: number) => void; + toJSON: () => { + app: FirebaseApp; + settings: FirestoreSettings; + }; + }; + type: 'collection' | 'document' | string; + path: string; + parent: CollectionReference; +} + +export interface CollectionReference { + id: string; + firestore: { + app: FirebaseApp; + settings: FirestoreSettings; + useEmulator: (host: string, port: number) => void; + toJSON: () => { + app: FirebaseApp; + settings: FirestoreSettings; + }; + }; + type: string; // 'collection' or 'document' + path: string; + parent: DocumentReference | null; +} + +export interface QuerySnapshot { + docs: Array>; + size: number; + empty: boolean; +} + +export interface FirestoreSettings { + host?: string; + ssl?: boolean; + ignoreUndefinedProperties?: boolean; + cacheSizeBytes?: number; + experimentalForceLongPolling?: boolean; + experimentalAutoDetectLongPolling?: boolean; + useFetchStreams?: boolean; +} + +/** + * Firebase Auto Instrumentation + */ +export interface FirebaseInstrumentationConfig extends InstrumentationConfig { + firestoreSpanCreationHook?: FirestoreSpanCreationHook; +} + +export interface FirestoreSpanCreationHook { + (span: Span): void; +} + +// Function types (addDoc, getDocs, setDoc, deleteDoc) are defined below as types +export type GetDocsType = ( + query: CollectionReference, +) => Promise>; + +export type SetDocType = (( + reference: DocumentReference, + data: WithFieldValue, +) => Promise) & + (( + reference: DocumentReference, + data: PartialWithFieldValue, + options: SetOptions, + ) => Promise); + +export type AddDocType = ( + reference: CollectionReference, + data: WithFieldValue, +) => Promise>; + +export type DeleteDocType = ( + reference: DocumentReference, +) => Promise; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 54fb4c72be2d..6035cf3669f8 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -2,8 +2,9 @@ import type { Integration } from '@sentry/core'; import { instrumentOtelHttp } from '../http'; import { amqplibIntegration, instrumentAmqplib } from './amqplib'; import { connectIntegration, instrumentConnect } from './connect'; -import { expressIntegration, instrumentExpress, instrumentExpressV5 } from './express'; +import { expressIntegration, instrumentExpress } from './express'; import { fastifyIntegration, instrumentFastify, instrumentFastifyV3 } from './fastify'; +import { firebaseIntegration, instrumentFirebase } from './firebase'; import { genericPoolIntegration, instrumentGenericPool } from './genericPool'; import { graphqlIntegration, instrumentGraphql } from './graphql'; import { hapiIntegration, instrumentHapi } from './hapi'; @@ -48,6 +49,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { vercelAIIntegration(), openAIIntegration(), postgresJsIntegration(), + firebaseIntegration(), ]; } @@ -59,7 +61,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => return [ instrumentOtelHttp, instrumentExpress, - instrumentExpressV5, instrumentConnect, instrumentFastify, instrumentFastifyV3, @@ -81,5 +82,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentVercelAi, instrumentOpenAi, instrumentPostgresJs, + instrumentFirebase, ]; } diff --git a/packages/node/src/integrations/tracing/openai/instrumentation.ts b/packages/node/src/integrations/tracing/openai/instrumentation.ts index 2cce987db182..714c0f872261 100644 --- a/packages/node/src/integrations/tracing/openai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/openai/instrumentation.ts @@ -83,12 +83,19 @@ export class SentryOpenAiInstrumentation extends InstrumentationBase { }); }); -const instrumentRedis4 = generateInstrumentOnce('Redis-4', () => { +const instrumentRedisModule = generateInstrumentOnce('Redis', () => { return new RedisInstrumentation({ responseHook: cacheResponseHook, }); @@ -91,7 +91,7 @@ const instrumentRedis4 = generateInstrumentOnce('Redis-4', () => { export const instrumentRedis = Object.assign( (): void => { instrumentIORedis(); - instrumentRedis4(); + instrumentRedisModule(); // todo: implement them gradually // new LegacyRedisInstrumentation({}), diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 4b823670793a..22ec18a682f0 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,6 +1,12 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; -import { getCurrentScope, SDK_VERSION } from '@sentry/core'; +import { + addNonEnumerableProperty, + getActiveSpan, + getCurrentScope, + handleCallbackErrors, + SDK_VERSION, +} from '@sentry/core'; import { INTEGRATION_NAME } from './constants'; import type { TelemetrySettings, VercelAiIntegration } from './types'; @@ -132,8 +138,21 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { recordOutputs, }; - // @ts-expect-error we know that the method exists - return originalMethod.apply(this, args); + return handleCallbackErrors( + () => { + // @ts-expect-error we know that the method exists + return originalMethod.apply(this, args); + }, + error => { + // This error bubbles up to unhandledrejection handler (if not handled before), + // where we do not know the active span anymore + // So to circumvent this, we set the active span on the error object + // which is picked up by the unhandledrejection handler + if (error && typeof error === 'object') { + addNonEnumerableProperty(error, '_sentry_active_span', getActiveSpan()); + } + }, + ); }; } diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 4e58414f347a..fc6b02c3830d 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -1,5 +1,5 @@ import { context, propagation, trace } from '@opentelemetry/api'; -import { Resource } from '@opentelemetry/resources'; +import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'; import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { @@ -109,12 +109,14 @@ export function setupOtel(client: NodeClient, options: AdditionalOpenTelemetryOp // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new SentrySampler(client), - resource: new Resource({ - [ATTR_SERVICE_NAME]: 'node', - // eslint-disable-next-line deprecation/deprecation - [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', - [ATTR_SERVICE_VERSION]: SDK_VERSION, - }), + resource: defaultResource().merge( + resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'node', + // eslint-disable-next-line deprecation/deprecation + [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', + [ATTR_SERVICE_VERSION]: SDK_VERSION, + }), + ), forceFlushTimeoutMillis: 500, spanProcessors: [ new SentrySpanProcessor({ diff --git a/packages/node/test/helpers/mockSdkInit.ts b/packages/node/test/helpers/mockSdkInit.ts index 29e19f50e0f8..dc4c3586d978 100644 --- a/packages/node/test/helpers/mockSdkInit.ts +++ b/packages/node/test/helpers/mockSdkInit.ts @@ -1,6 +1,7 @@ import { context, propagation, ProxyTracerProvider, trace } from '@opentelemetry/api'; -import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; +import { type SpanProcessor, BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { SentrySpanProcessor } from '@sentry/opentelemetry'; import type { NodeClient } from '../../src'; import { init } from '../../src/sdk'; import type { NodeClientOptions } from '../../src/types'; @@ -42,6 +43,29 @@ export function cleanupOtel(_provider?: BasicTracerProvider): void { propagation.disable(); } +export function getSpanProcessor(): SentrySpanProcessor | undefined { + const client = getClient(); + if (!client?.traceProvider) { + return undefined; + } + + const provider = getProvider(client.traceProvider); + if (!provider) { + return undefined; + } + + // Access the span processors from the provider via _activeSpanProcessor + const multiSpanProcessor = provider?.['_activeSpanProcessor'] as + | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) + | undefined; + + const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( + (spanProcessor: SpanProcessor) => spanProcessor instanceof SentrySpanProcessor, + ) as SentrySpanProcessor | undefined; + + return spanProcessor; +} + export function getProvider(_provider?: BasicTracerProvider): BasicTracerProvider | undefined { let provider = _provider || getClient()?.traceProvider || trace.getTracerProvider(); diff --git a/packages/node/test/integration/transactions.test.ts b/packages/node/test/integration/transactions.test.ts index 0ce3f7c99984..7b13a400dedb 100644 --- a/packages/node/test/integration/transactions.test.ts +++ b/packages/node/test/integration/transactions.test.ts @@ -1,11 +1,9 @@ import { context, trace, TraceFlags } from '@opentelemetry/api'; -import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; import type { TransactionEvent } from '@sentry/core'; import { debug, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import { SentrySpanProcessor } from '@sentry/opentelemetry'; import { afterEach, describe, expect, it, vi } from 'vitest'; import * as Sentry from '../../src'; -import { cleanupOtel, getProvider, mockSdkInit } from '../helpers/mockSdkInit'; +import { cleanupOtel, getSpanProcessor, mockSdkInit } from '../helpers/mockSdkInit'; describe('Integration | Transactions', () => { afterEach(() => { @@ -562,13 +560,7 @@ describe('Integration | Transactions', () => { mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); - const provider = getProvider(); - const multiSpanProcessor = provider?.activeSpanProcessor as - | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) - | undefined; - const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( - spanProcessor => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + const spanProcessor = getSpanProcessor(); const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; diff --git a/packages/node/test/integrations/tracing/firebase.test.ts b/packages/node/test/integrations/tracing/firebase.test.ts new file mode 100644 index 000000000000..0fe0309f4449 --- /dev/null +++ b/packages/node/test/integrations/tracing/firebase.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getPortAndAddress } from '../../../src/integrations/tracing/firebase/otel/patches/firestore'; + +describe('setPortAndAddress', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('IPv6 addresses', () => { + it('should correctly parse IPv6 address without port', () => { + const { address, port } = getPortAndAddress({ host: '[2001:db8::1]' }); + + expect(address).toBe('2001:db8::1'); + expect(port).toBeUndefined(); + }); + + it('should correctly parse IPv6 address with port', () => { + const { address, port } = getPortAndAddress({ host: '[2001:db8::1]:8080' }); + expect(address).toBe('2001:db8::1'); + expect(port).toBe(8080); + }); + + it('should handle IPv6 localhost without port', () => { + const { address, port } = getPortAndAddress({ host: '[::1]' }); + + expect(address).toBe('::1'); + expect(port).toBeUndefined(); + }); + + it('should handle IPv6 localhost with port', () => { + const { address, port } = getPortAndAddress({ host: '[::1]:3000' }); + + expect(address).toBe('::1'); + expect(port).toBe(3000); + }); + }); + + describe('IPv4 and hostname addresses', () => { + it('should correctly parse IPv4 address with port', () => { + const { address, port } = getPortAndAddress({ host: '192.168.1.1:8080' }); + + expect(address).toBe('192.168.1.1'); + expect(port).toBe(8080); + }); + + it('should correctly parse hostname with port', () => { + const { address, port } = getPortAndAddress({ host: 'localhost:3000' }); + + expect(address).toBe('localhost'); + expect(port).toBe(3000); + }); + + it('should correctly parse hostname without port', () => { + const { address, port } = getPortAndAddress({ host: 'example.com' }); + + expect(address).toBe('example.com'); + expect(port).toBeUndefined(); + }); + + it('should correctly parse hostname with port', () => { + const { address, port } = getPortAndAddress({ host: 'example.com:4000' }); + + expect(address).toBe('example.com'); + expect(port).toBe(4000); + }); + + it('should handle empty string', () => { + const { address, port } = getPortAndAddress({ host: '' }); + + expect(address).toBe(''); + expect(port).toBeUndefined(); + }); + }); +}); diff --git a/packages/node/test/sdk/client.test.ts b/packages/node/test/sdk/client.test.ts index f053b1ba7e0e..7f57d4772212 100644 --- a/packages/node/test/sdk/client.test.ts +++ b/packages/node/test/sdk/client.test.ts @@ -296,7 +296,7 @@ describe('NodeClient', () => { describe('log capture', () => { it('adds server name to log attributes', () => { - const options = getDefaultNodeClientOptions({ _experiments: { enableLogs: true } }); + const options = getDefaultNodeClientOptions({ enableLogs: true }); const client = new NodeClient(options); const log: Log = { level: 'info', message: 'test message', attributes: {} }; @@ -309,7 +309,7 @@ describe('NodeClient', () => { it('preserves existing log attributes', () => { const serverName = 'test-server'; - const options = getDefaultNodeClientOptions({ serverName, _experiments: { enableLogs: true } }); + const options = getDefaultNodeClientOptions({ serverName, enableLogs: true }); 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 8374bde60ac8..11d1fba00878 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -51,8 +51,8 @@ "@sentry/cloudflare": "9.40.0", "@sentry/core": "9.40.0", "@sentry/node": "9.40.0", - "@sentry/rollup-plugin": "^3.5.0", - "@sentry/vite-plugin": "^3.5.0", + "@sentry/rollup-plugin": "^4.0.0", + "@sentry/vite-plugin": "^4.0.0", "@sentry/vue": "9.40.0" }, "devDependencies": { diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index 8f38bd11061c..7a27b7e6e4c6 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -1,8 +1,8 @@ -import { captureException, getClient, getCurrentScope } from '@sentry/core'; +import { captureException, flushIfServerless, getClient, getCurrentScope } from '@sentry/core'; // eslint-disable-next-line import/no-extraneous-dependencies import { H3Error } from 'h3'; import type { CapturedErrorContext } from 'nitropack/types'; -import { extractErrorContext, flushIfServerless } from '../utils'; +import { extractErrorContext } from '../utils'; /** * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index 543a8a78ebe1..c76f7ffce5bf 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,4 +1,10 @@ -import { debug, getDefaultIsolationScope, getIsolationScope, withIsolationScope } from '@sentry/core'; +import { + debug, + flushIfServerless, + getDefaultIsolationScope, + getIsolationScope, + withIsolationScope, +} from '@sentry/core'; // eslint-disable-next-line import/no-extraneous-dependencies import { type EventHandler } from 'h3'; // eslint-disable-next-line import/no-extraneous-dependencies @@ -6,7 +12,7 @@ import { defineNitroPlugin } from 'nitropack/runtime'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; -import { addSentryTracingMetaTags, flushIfServerless } from '../utils'; +import { addSentryTracingMetaTags } from '../utils'; export default defineNitroPlugin(nitroApp => { nitroApp.h3App.handler = patchEventHandler(nitroApp.h3App.handler); diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 61a6726ec0d0..29abbe23ec62 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,5 +1,5 @@ import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; -import { captureException, debug, flush, getClient, getTraceMetaTags, GLOBAL_OBJ, vercelWaitUntil } from '@sentry/core'; +import { captureException, debug, getClient, getTraceMetaTags } from '@sentry/core'; import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack/types'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; @@ -85,32 +85,3 @@ export function reportNuxtError(options: { }); }); } - -async function flushWithTimeout(): Promise { - try { - debug.log('Flushing events...'); - await flush(2000); - debug.log('Done flushing events'); - } catch (e) { - debug.log('Error while flushing events:\n', e); - } -} - -/** - * Flushes if in a serverless environment - */ -export async function flushIfServerless(): Promise { - const isServerless = - !!process.env.FUNCTIONS_WORKER_RUNTIME || // Azure Functions - !!process.env.LAMBDA_TASK_ROOT || // AWS Lambda - !!process.env.CF_PAGES || // Cloudflare - !!process.env.VERCEL || - !!process.env.NETLIFY; - - // @ts-expect-error This is not typed - if (GLOBAL_OBJ[Symbol.for('@vercel/request-context')]) { - vercelWaitUntil(flushWithTimeout()); - } else if (isServerless) { - await flushWithTimeout(); - } -} diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index 0a1ede6b83a1..ed04267a2536 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -94,7 +94,7 @@ function getNuxtDefaultIntegrations(options: NodeOptions): Integration[] { /** * Flushes pending Sentry events with a 2-second timeout and in a way that cannot create unhandled promise rejections. */ -export async function flushSafelyWithTimeout(): Promise { +async function flushSafelyWithTimeout(): Promise { try { DEBUG_BUILD && debug.log('Flushing events...'); await flush(2000); diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 6ff4e1de048e..6734b3183b22 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -50,9 +50,9 @@ }, "devDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/sdk-trace-base": "^1.30.1", + "@opentelemetry/context-async-hooks": "^2.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" }, "scripts": { diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index 40afc10120df..7dc005521aa7 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -4,6 +4,7 @@ import { isTracingSuppressed, W3CBaggagePropagator } from '@opentelemetry/core'; import { ATTR_URL_FULL, SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions'; import type { Client, continueTrace, DynamicSamplingContext, Options, Scope } from '@sentry/core'; import { + baggageHeaderToDynamicSamplingContext, debug, generateSentryTraceHeader, getClient, @@ -15,6 +16,7 @@ import { parseBaggageHeader, propagationContextFromHeaders, SENTRY_BAGGAGE_KEY_PREFIX, + shouldContinueTrace, spanToJSON, stringMatchesSomePattern, } from '@sentry/core'; @@ -212,9 +214,12 @@ function getContextWithRemoteActiveSpan( const { traceId, parentSpanId, sampled, dsc } = propagationContext; + const client = getClient(); + const incomingDsc = baggageHeaderToDynamicSamplingContext(baggage); + // We only want to set the virtual span if we are continuing a concrete trace // Otherwise, we ignore the incoming trace here, e.g. if we have no trace headers - if (!parentSpanId) { + if (!parentSpanId || (client && !shouldContinueTrace(client, incomingDsc?.org_id))) { return ctx; } diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index e604d83bd4d8..c30377fe7763 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -259,7 +259,7 @@ export function continueTrace(options: Parameters[0 /** * Get the trace context for a given scope. - * We have a custom implemention here because we need an OTEL-specific way to get the span from a scope. + * We have a custom implementation here because we need an OTEL-specific way to get the span from a scope. */ export function getTraceContextForScope( client: Client, diff --git a/packages/opentelemetry/test/asyncContextStrategy.test.ts b/packages/opentelemetry/test/asyncContextStrategy.test.ts index 6541e8b9220c..89a3ab856075 100644 --- a/packages/opentelemetry/test/asyncContextStrategy.test.ts +++ b/packages/opentelemetry/test/asyncContextStrategy.test.ts @@ -23,7 +23,7 @@ describe('asyncContextStrategy', () => { const options = getDefaultTestClientOptions(); const client = new TestClient(options); - provider = setupOtel(client); + [provider] = setupOtel(client); setOpenTelemetryContextAsyncContextStrategy(); }); diff --git a/packages/opentelemetry/test/helpers/initOtel.ts b/packages/opentelemetry/test/helpers/initOtel.ts index fd7a33884b5c..bf281f716657 100644 --- a/packages/opentelemetry/test/helpers/initOtel.ts +++ b/packages/opentelemetry/test/helpers/initOtel.ts @@ -1,6 +1,6 @@ import { context, diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { Resource } from '@opentelemetry/resources'; +import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { ATTR_SERVICE_NAME, @@ -49,23 +49,27 @@ export function initOtel(): void { setupEventContextTrace(client); enhanceDscWithOpenTelemetryRootSpanName(client); - const provider = setupOtel(client); + const [provider, spanProcessor] = setupOtel(client); client.traceProvider = provider; + client.spanProcessor = spanProcessor; } /** Just exported for tests. */ -export function setupOtel(client: TestClientInterface): BasicTracerProvider { +export function setupOtel(client: TestClientInterface): [BasicTracerProvider, SentrySpanProcessor] { + const spanProcessor = new SentrySpanProcessor(); // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new SentrySampler(client), - resource: new Resource({ - [ATTR_SERVICE_NAME]: 'opentelemetry-test', - // eslint-disable-next-line deprecation/deprecation - [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', - [ATTR_SERVICE_VERSION]: SDK_VERSION, - }), + resource: defaultResource().merge( + resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'opentelemetry-test', + // eslint-disable-next-line deprecation/deprecation + [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', + [ATTR_SERVICE_VERSION]: SDK_VERSION, + }), + ), forceFlushTimeoutMillis: 500, - spanProcessors: [new SentrySpanProcessor()], + spanProcessors: [spanProcessor], }); // We use a custom context manager to keep context in sync with sentry scope @@ -75,5 +79,5 @@ export function setupOtel(client: TestClientInterface): BasicTracerProvider { propagation.setGlobalPropagator(new SentryPropagator()); context.setGlobalContextManager(new SentryContextManager()); - return provider; + return [provider, spanProcessor]; } diff --git a/dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts b/packages/opentelemetry/test/helpers/isSpan.ts similarity index 100% rename from dev-packages/opentelemetry-v2-tests/test/helpers/isSpan.ts rename to packages/opentelemetry/test/helpers/isSpan.ts diff --git a/packages/opentelemetry/test/helpers/mockSdkInit.ts b/packages/opentelemetry/test/helpers/mockSdkInit.ts index 486397e32cef..91b1369eb928 100644 --- a/packages/opentelemetry/test/helpers/mockSdkInit.ts +++ b/packages/opentelemetry/test/helpers/mockSdkInit.ts @@ -3,6 +3,7 @@ import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import type { ClientOptions, Options } from '@sentry/core'; import { flush, getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; import { setOpenTelemetryContextAsyncContextStrategy } from '../../src/asyncContextStrategy'; +import { SentrySpanProcessor } from '../../src/spanProcessor'; import type { OpenTelemetryClient } from '../../src/types'; import { clearOpenTelemetrySetupCheck } from '../../src/utils/setupCheck'; import { initOtel } from './initOtel'; @@ -51,6 +52,20 @@ export async function cleanupOtel(_provider?: BasicTracerProvider): Promise(); + if (!client) { + return undefined; + } + + const spanProcessor = client.spanProcessor; + if (spanProcessor instanceof SentrySpanProcessor) { + return spanProcessor; + } + + return undefined; +} + export function getProvider(_provider?: BasicTracerProvider): BasicTracerProvider | undefined { let provider = _provider || getClient()?.traceProvider || trace.getTracerProvider(); diff --git a/packages/opentelemetry/test/integration/transactions.test.ts b/packages/opentelemetry/test/integration/transactions.test.ts index b476d7536e5e..570df4a86aa8 100644 --- a/packages/opentelemetry/test/integration/transactions.test.ts +++ b/packages/opentelemetry/test/integration/transactions.test.ts @@ -1,7 +1,6 @@ import type { SpanContext } from '@opentelemetry/api'; import { context, ROOT_CONTEXT, trace, TraceFlags } from '@opentelemetry/api'; import { TraceState } from '@opentelemetry/core'; -import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; import type { Event, TransactionEvent } from '@sentry/core'; import { addBreadcrumb, @@ -15,10 +14,9 @@ import { } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SENTRY_TRACE_STATE_DSC } from '../../src/constants'; -import { SentrySpanProcessor } from '../../src/spanProcessor'; import { startInactiveSpan, startSpan } from '../../src/trace'; import { makeTraceState } from '../../src/utils/makeTraceState'; -import { cleanupOtel, getProvider, mockSdkInit } from '../helpers/mockSdkInit'; +import { cleanupOtel, getSpanProcessor, mockSdkInit } from '../helpers/mockSdkInit'; import type { TestClientInterface } from '../helpers/TestClient'; describe('Integration | Transactions', () => { @@ -444,13 +442,7 @@ describe('Integration | Transactions', () => { mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); - const provider = getProvider(); - const multiSpanProcessor = provider?.activeSpanProcessor as - | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) - | undefined; - const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( - spanProcessor => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + const spanProcessor = getSpanProcessor(); const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; @@ -522,13 +514,7 @@ describe('Integration | Transactions', () => { }, }); - const provider = getProvider(); - const multiSpanProcessor = provider?.activeSpanProcessor as - | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) - | undefined; - const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( - spanProcessor => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + const spanProcessor = getSpanProcessor(); const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; @@ -580,13 +566,7 @@ describe('Integration | Transactions', () => { }, }); - const provider = getProvider(); - const multiSpanProcessor = provider?.activeSpanProcessor as - | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) - | undefined; - const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( - spanProcessor => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + const spanProcessor = getSpanProcessor(); const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; @@ -651,13 +631,7 @@ describe('Integration | Transactions', () => { }, }); - const provider = getProvider(); - const multiSpanProcessor = provider?.activeSpanProcessor as - | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) - | undefined; - const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( - spanProcessor => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + const spanProcessor = getSpanProcessor(); const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; @@ -713,13 +687,7 @@ describe('Integration | Transactions', () => { }, }); - const provider = getProvider(); - const multiSpanProcessor = provider?.activeSpanProcessor as - | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) - | undefined; - const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( - spanProcessor => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + const spanProcessor = getSpanProcessor(); const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; diff --git a/packages/opentelemetry/test/sampler.test.ts b/packages/opentelemetry/test/sampler.test.ts index 6ae5c3d48308..b7ffd9522837 100644 --- a/packages/opentelemetry/test/sampler.test.ts +++ b/packages/opentelemetry/test/sampler.test.ts @@ -81,11 +81,13 @@ describe('SentrySampler', () => { const links = undefined; const actual = sampler.shouldSample(ctx, traceId, spanName, spanKind, spanAttributes, links); - expect(actual).toEqual({ - decision: SamplingDecision.RECORD_AND_SAMPLED, - attributes: { 'sentry.sample_rate': 1 }, - traceState: expect.any(TraceState), - }); + expect(actual).toEqual( + expect.objectContaining({ + decision: SamplingDecision.RECORD_AND_SAMPLED, + attributes: { 'sentry.sample_rate': 1 }, + }), + ); + expect(actual.traceState?.constructor.name).toBe('TraceState'); expect(spyOnDroppedEvent).toHaveBeenCalledTimes(0); spyOnDroppedEvent.mockReset(); diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index d8432172a601..173bd6359a5f 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -2,7 +2,6 @@ import type { Span, TimeInput } from '@opentelemetry/api'; import { context, ROOT_CONTEXT, SpanKind, trace, TraceFlags } from '@opentelemetry/api'; import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { Span as SpanClass } from '@opentelemetry/sdk-trace-base'; import { SEMATTRS_HTTP_METHOD } from '@opentelemetry/semantic-conventions'; import type { Event, Scope } from '@sentry/core'; import { @@ -21,6 +20,7 @@ import { withScope, } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId'; import { continueTrace, startInactiveSpan, startSpan, startSpanManual } from '../src/trace'; import type { AbstractSpan } from '../src/types'; import { getActiveSpan } from '../src/utils/getActiveSpan'; @@ -28,6 +28,7 @@ import { getSamplingDecision } from '../src/utils/getSamplingDecision'; import { getSpanKind } from '../src/utils/getSpanKind'; import { makeTraceState } from '../src/utils/makeTraceState'; import { spanHasAttributes, spanHasName } from '../src/utils/spanTypes'; +import { isSpan } from './helpers/isSpan'; import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; describe('trace', () => { @@ -534,7 +535,7 @@ describe('trace', () => { return span; }); - expect(span).not.toBeInstanceOf(SpanClass); + expect(isSpan(span)).toBe(false); }); it('creates a span if there is a parent', () => { @@ -546,7 +547,7 @@ describe('trace', () => { return span; }); - expect(span).toBeInstanceOf(SpanClass); + expect(isSpan(span)).toBe(true); }); }); }); @@ -826,7 +827,7 @@ describe('trace', () => { it('does not create a span if there is no parent', () => { const span = startInactiveSpan({ name: 'test span', onlyIfParent: true }); - expect(span).not.toBeInstanceOf(SpanClass); + expect(isSpan(span)).toBe(false); }); it('creates a span if there is a parent', () => { @@ -836,7 +837,7 @@ describe('trace', () => { return span; }); - expect(span).toBeInstanceOf(SpanClass); + expect(isSpan(span)).toBe(true); }); }); @@ -1196,7 +1197,7 @@ describe('trace', () => { return span; }); - expect(span).not.toBeInstanceOf(SpanClass); + expect(isSpan(span)).toBe(false); }); it('creates a span if there is a parent', () => { @@ -1208,7 +1209,7 @@ describe('trace', () => { return span; }); - expect(span).toBeInstanceOf(SpanClass); + expect(isSpan(span)).toBe(true); }); }); }); @@ -1972,5 +1973,5 @@ function getSpanAttributes(span: AbstractSpan): Record | undefi } function getSpanParentSpanId(span: AbstractSpan): string | undefined { - return (span as ReadableSpan).parentSpanId; + return getParentSpanId(span as ReadableSpan); } diff --git a/packages/opentelemetry/test/utils/getActiveSpan.test.ts b/packages/opentelemetry/test/utils/getActiveSpan.test.ts index 383f91d5d6af..7a3eefaa6b3d 100644 --- a/packages/opentelemetry/test/utils/getActiveSpan.test.ts +++ b/packages/opentelemetry/test/utils/getActiveSpan.test.ts @@ -12,7 +12,7 @@ describe('getActiveSpan', () => { beforeEach(() => { const client = new TestClient(getDefaultTestClientOptions()); - provider = setupOtel(client); + [provider] = setupOtel(client); }); afterEach(() => { @@ -97,7 +97,7 @@ describe('getRootSpan', () => { beforeEach(() => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - provider = setupOtel(client); + [provider] = setupOtel(client); }); afterEach(async () => { diff --git a/packages/opentelemetry/test/utils/getRequestSpanData.test.ts b/packages/opentelemetry/test/utils/getRequestSpanData.test.ts index 2f5484d916b9..ad40ec83d480 100644 --- a/packages/opentelemetry/test/utils/getRequestSpanData.test.ts +++ b/packages/opentelemetry/test/utils/getRequestSpanData.test.ts @@ -14,7 +14,7 @@ describe('getRequestSpanData', () => { beforeEach(() => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - provider = setupOtel(client); + [provider] = setupOtel(client); }); afterEach(() => { diff --git a/packages/opentelemetry/test/utils/groupSpansWithParents.test.ts b/packages/opentelemetry/test/utils/groupSpansWithParents.test.ts index d8ccec93f3e2..c71569c322d5 100644 --- a/packages/opentelemetry/test/utils/groupSpansWithParents.test.ts +++ b/packages/opentelemetry/test/utils/groupSpansWithParents.test.ts @@ -13,7 +13,7 @@ describe('groupSpansWithParents', () => { beforeEach(() => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - provider = setupOtel(client); + [provider] = setupOtel(client); }); afterEach(() => { diff --git a/packages/opentelemetry/test/utils/mapStatus.test.ts b/packages/opentelemetry/test/utils/mapStatus.test.ts index 4147eeca2251..1831ec01fc95 100644 --- a/packages/opentelemetry/test/utils/mapStatus.test.ts +++ b/packages/opentelemetry/test/utils/mapStatus.test.ts @@ -16,7 +16,7 @@ describe('mapStatus', () => { beforeEach(() => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - provider = setupOtel(client); + [provider] = setupOtel(client); }); afterEach(() => { diff --git a/packages/opentelemetry/test/utils/setupCheck.test.ts b/packages/opentelemetry/test/utils/setupCheck.test.ts index a2b204d063b6..526945108ba7 100644 --- a/packages/opentelemetry/test/utils/setupCheck.test.ts +++ b/packages/opentelemetry/test/utils/setupCheck.test.ts @@ -25,20 +25,18 @@ describe('openTelemetrySetupCheck', () => { it('returns all setup parts', () => { const client = new TestClient(getDefaultTestClientOptions()); - provider = setupOtel(client); + [provider] = setupOtel(client); const setup = openTelemetrySetupCheck(); - expect(setup).toEqual(['SentrySampler', 'SentrySpanProcessor', 'SentryPropagator', 'SentryContextManager']); + expect(setup).toEqual(['SentrySpanProcessor', 'SentrySampler', 'SentryPropagator', 'SentryContextManager']); }); it('returns partial setup parts', () => { const client = new TestClient(getDefaultTestClientOptions()); provider = new BasicTracerProvider({ sampler: new SentrySampler(client), + spanProcessors: [new SentrySpanProcessor()], }); - // We want to test this deprecated case also works - // eslint-disable-next-line deprecation/deprecation - provider.addSpanProcessor(new SentrySpanProcessor()); const setup = openTelemetrySetupCheck(); expect(setup).toEqual(['SentrySampler', 'SentrySpanProcessor']); diff --git a/packages/opentelemetry/test/utils/setupEventContextTrace.test.ts b/packages/opentelemetry/test/utils/setupEventContextTrace.test.ts index a705b546e610..19c8e178c160 100644 --- a/packages/opentelemetry/test/utils/setupEventContextTrace.test.ts +++ b/packages/opentelemetry/test/utils/setupEventContextTrace.test.ts @@ -29,7 +29,7 @@ describe('setupEventContextTrace', () => { client.init(); setupEventContextTrace(client); - provider = setupOtel(client); + [provider] = setupOtel(client); }); afterEach(() => { diff --git a/packages/opentelemetry/test/utils/spanToJSON.test.ts b/packages/opentelemetry/test/utils/spanToJSON.test.ts index 19115a18e0f7..c1f9fe2a18c7 100644 --- a/packages/opentelemetry/test/utils/spanToJSON.test.ts +++ b/packages/opentelemetry/test/utils/spanToJSON.test.ts @@ -18,7 +18,7 @@ describe('spanToJSON', () => { beforeEach(() => { const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 })); - provider = setupOtel(client); + [provider] = setupOtel(client); }); afterEach(() => { diff --git a/packages/pino-transport/README.md b/packages/pino-transport/README.md index 0bb6aeed81ed..fdc077aa2b01 100644 --- a/packages/pino-transport/README.md +++ b/packages/pino-transport/README.md @@ -30,7 +30,7 @@ pnpm add @sentry/pino-transport pino - Node.js 18+ - Pino v8 or v9 -- `@sentry/node` SDK with `_experiments.enableLogs: true` +- `@sentry/node` SDK with `enableLogs: true` ## Setup @@ -41,9 +41,7 @@ import * as Sentry from '@sentry/node'; Sentry.init({ dsn: 'YOUR_DSN', - _experiments: { - enableLogs: true, - }, + enableLogs: true, }); ``` @@ -252,7 +250,7 @@ const logger = pino({ ### Logs not appearing in Sentry -1. Ensure `_experiments.enableLogs: true` is set in your Sentry configuration. +1. Ensure `enableLogs: true` is set in your Sentry configuration. 2. Check that your DSN is correct and the SDK is properly initialized. 3. Verify the log level is included in the `levels` configuration. 4. Check your Sentry organization stats page to see if logs are being received by Sentry. diff --git a/packages/pino-transport/src/index.ts b/packages/pino-transport/src/index.ts index 7bff4dc35327..986c7e892fc2 100644 --- a/packages/pino-transport/src/index.ts +++ b/packages/pino-transport/src/index.ts @@ -84,7 +84,7 @@ interface PinoSourceConfig { } /** - * Creates a new Sentry Pino transport that forwards logs to Sentry. Requires `_experiments.enableLogs` to be enabled. + * Creates a new Sentry Pino transport that forwards logs to Sentry. Requires the `enableLogs` option to be enabled. * * Supports Pino v8 and v9. * diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 965392e80c10..a55ee2172e55 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -35,15 +35,15 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "0.57.2", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@sentry/browser": "9.40.0", "@sentry/cli": "^2.46.0", "@sentry/core": "9.40.0", "@sentry/node": "9.40.0", "@sentry/react": "9.40.0", - "@sentry/vite-plugin": "^3.5.0", + "@sentry/vite-plugin": "^4.0.0", "glob": "11.0.1" }, "devDependencies": { diff --git a/packages/react-router/src/server/createSentryHandleError.ts b/packages/react-router/src/server/createSentryHandleError.ts new file mode 100644 index 000000000000..65114d035655 --- /dev/null +++ b/packages/react-router/src/server/createSentryHandleError.ts @@ -0,0 +1,39 @@ +import { captureException, flushIfServerless } from '@sentry/core'; +import type { ActionFunctionArgs, HandleErrorFunction, LoaderFunctionArgs } from 'react-router'; + +export type SentryHandleErrorOptions = { + logErrors?: boolean; +}; + +/** + * A complete Sentry-instrumented handleError implementation that handles error reporting + * + * @returns A Sentry-instrumented handleError function + */ +export function createSentryHandleError({ logErrors = false }: SentryHandleErrorOptions): HandleErrorFunction { + const handleError = async function handleError( + error: unknown, + args: LoaderFunctionArgs | ActionFunctionArgs, + ): Promise { + // React Router may abort some interrupted requests, don't report those + if (!args.request.signal.aborted) { + captureException(error, { + mechanism: { + type: 'react-router', + handled: false, + }, + }); + if (logErrors) { + // eslint-disable-next-line no-console + console.error(error); + } + try { + await flushIfServerless(); + } catch { + // Ignore flush errors to ensure error handling completes gracefully + } + } + }; + + return handleError; +} diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index b42b769a78e6..91520059d3fb 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -6,3 +6,4 @@ export { wrapSentryHandleRequest, sentryHandleRequest, getMetaTagTransformer } f export { createSentryHandleRequest, type SentryHandleRequestOptions } from './createSentryHandleRequest'; export { wrapServerAction } from './wrapServerAction'; export { wrapServerLoader } from './wrapServerLoader'; +export { createSentryHandleError, type SentryHandleErrorOptions } from './createSentryHandleError'; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index df7d65109338..e5e10f2c05b2 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -2,6 +2,7 @@ import { context } from '@opentelemetry/api'; import { getRPCMetadata, RPCType } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { + flushIfServerless, getActiveSpan, getRootSpan, getTraceMetaTags, @@ -58,10 +59,15 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): }); } - return originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); + try { + return await originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); + } finally { + await flushIfServerless(); + } }; } +// todo(v11): remove this /** @deprecated Use `wrapSentryHandleRequest` instead. */ export const sentryHandleRequest = wrapSentryHandleRequest; diff --git a/packages/react-router/src/server/wrapServerAction.ts b/packages/react-router/src/server/wrapServerAction.ts index 7dc8851e2171..9cb0a7ddd067 100644 --- a/packages/react-router/src/server/wrapServerAction.ts +++ b/packages/react-router/src/server/wrapServerAction.ts @@ -1,6 +1,7 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes } from '@sentry/core'; import { + flushIfServerless, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -59,17 +60,21 @@ export function wrapServerAction(options: SpanOptions = {}, actionFn: (args: } } - return startSpan( - { - name, - ...options, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.action', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', - ...options.attributes, + try { + return await startSpan( + { + name, + ...options, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.action', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', + ...options.attributes, + }, }, - }, - () => actionFn(args), - ); + () => actionFn(args), + ); + } finally { + await flushIfServerless(); + } }; } diff --git a/packages/react-router/src/server/wrapServerLoader.ts b/packages/react-router/src/server/wrapServerLoader.ts index 3d32f0c9d159..981b2085d4b7 100644 --- a/packages/react-router/src/server/wrapServerLoader.ts +++ b/packages/react-router/src/server/wrapServerLoader.ts @@ -1,6 +1,7 @@ import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes } from '@sentry/core'; import { + flushIfServerless, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -59,17 +60,21 @@ export function wrapServerLoader(options: SpanOptions = {}, loaderFn: (args: } } } - return startSpan( - { - name, - ...options, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.loader', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', - ...options.attributes, + try { + return await startSpan( + { + name, + ...options, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.loader', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', + ...options.attributes, + }, }, - }, - () => loaderFn(args), - ); + () => loaderFn(args), + ); + } finally { + await flushIfServerless(); + } }; } diff --git a/packages/react-router/test/client/react-exports.test.ts b/packages/react-router/test/client/react-exports.test.ts index 02e5b970a21e..cae5ac56208a 100644 --- a/packages/react-router/test/client/react-exports.test.ts +++ b/packages/react-router/test/client/react-exports.test.ts @@ -76,7 +76,7 @@ describe('Re-exports from React SDK', () => { }); expect(WrappedComponent).toBeDefined(); - expect(typeof WrappedComponent).toBe('function'); + expect(typeof WrappedComponent).toBe('object'); expect(WrappedComponent.displayName).toBe('errorBoundary(TestComponent)'); const { getByText } = render(React.createElement(WrappedComponent)); diff --git a/packages/react-router/test/server/createSentryHandleError.test.ts b/packages/react-router/test/server/createSentryHandleError.test.ts new file mode 100644 index 000000000000..0cad5d2a04ba --- /dev/null +++ b/packages/react-router/test/server/createSentryHandleError.test.ts @@ -0,0 +1,198 @@ +import * as core from '@sentry/core'; +import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSentryHandleError } from '../../src/server/createSentryHandleError'; + +vi.mock('@sentry/core', () => ({ + captureException: vi.fn(), + flushIfServerless: vi.fn().mockResolvedValue(undefined), +})); + +const mechanism = { + handled: false, + type: 'react-router', +}; + +describe('createSentryHandleError', () => { + const mockCaptureException = vi.mocked(core.captureException); + const mockFlushIfServerless = vi.mocked(core.flushIfServerless); + const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const mockError = new Error('Test error'); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + mockConsoleError.mockClear(); + }); + + const createMockArgs = (aborted: boolean): LoaderFunctionArgs => { + const controller = new AbortController(); + if (aborted) { + controller.abort(); + } + + const request = { + signal: controller.signal, + } as Request; + + return { request } as LoaderFunctionArgs; + }; + + describe('with default options', () => { + it('should create a handle error function with logErrors disabled by default', async () => { + const handleError = createSentryHandleError({}); + + expect(typeof handleError).toBe('function'); + }); + + it('should capture exception and flush when request is not aborted', async () => { + const handleError = createSentryHandleError({}); + const mockArgs = createMockArgs(false); + + await handleError(mockError, mockArgs); + + expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism }); + expect(mockFlushIfServerless).toHaveBeenCalled(); + expect(mockConsoleError).not.toHaveBeenCalled(); + }); + + it('should not capture exception when request is aborted', async () => { + const handleError = createSentryHandleError({}); + const mockArgs = createMockArgs(true); + + await handleError(mockError, mockArgs); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockFlushIfServerless).not.toHaveBeenCalled(); + expect(mockConsoleError).not.toHaveBeenCalled(); + }); + }); + + describe('with logErrors enabled', () => { + it('should log errors to console when logErrors is true', async () => { + const handleError = createSentryHandleError({ logErrors: true }); + const mockArgs = createMockArgs(false); + + await handleError(mockError, mockArgs); + + expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism }); + expect(mockFlushIfServerless).toHaveBeenCalled(); + expect(mockConsoleError).toHaveBeenCalledWith(mockError); + }); + + it('should not log errors to console when request is aborted even with logErrors enabled', async () => { + const handleError = createSentryHandleError({ logErrors: true }); + const mockArgs = createMockArgs(true); + + await handleError(mockError, mockArgs); + + expect(mockCaptureException).not.toHaveBeenCalled(); + expect(mockFlushIfServerless).not.toHaveBeenCalled(); + expect(mockConsoleError).not.toHaveBeenCalled(); + }); + }); + + describe('with logErrors disabled explicitly', () => { + it('should not log errors to console when logErrors is false', async () => { + const handleError = createSentryHandleError({ logErrors: false }); + const mockArgs = createMockArgs(false); + + await handleError(mockError, mockArgs); + + expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism }); + expect(mockFlushIfServerless).toHaveBeenCalled(); + expect(mockConsoleError).not.toHaveBeenCalled(); + }); + }); + + describe('with different error types', () => { + it('should handle string errors', async () => { + const handleError = createSentryHandleError({}); + const stringError = 'String error message'; + const mockArgs = createMockArgs(false); + + await handleError(stringError, mockArgs); + + expect(mockCaptureException).toHaveBeenCalledWith(stringError, { mechanism }); + expect(mockFlushIfServerless).toHaveBeenCalled(); + }); + + it('should handle null/undefined errors', async () => { + const handleError = createSentryHandleError({}); + const mockArgs = createMockArgs(false); + + await handleError(null, mockArgs); + + expect(mockCaptureException).toHaveBeenCalledWith(null, { mechanism }); + expect(mockFlushIfServerless).toHaveBeenCalled(); + }); + + it('should handle custom error objects', async () => { + const handleError = createSentryHandleError({}); + const customError = { message: 'Custom error', code: 500 }; + const mockArgs = createMockArgs(false); + + await handleError(customError, mockArgs); + + expect(mockCaptureException).toHaveBeenCalledWith(customError, { mechanism }); + expect(mockFlushIfServerless).toHaveBeenCalled(); + }); + }); + + describe('with ActionFunctionArgs', () => { + it('should work with ActionFunctionArgs instead of LoaderFunctionArgs', async () => { + const handleError = createSentryHandleError({ logErrors: true }); + const mockArgs = createMockArgs(false) as ActionFunctionArgs; + + await handleError(mockError, mockArgs); + + expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism }); + expect(mockFlushIfServerless).toHaveBeenCalled(); + expect(mockConsoleError).toHaveBeenCalledWith(mockError); + }); + }); + + describe('flushIfServerless behavior', () => { + it('should wait for flushIfServerless to complete', async () => { + const handleError = createSentryHandleError({}); + + let resolveFlush: () => void; + const flushPromise = new Promise(resolve => { + resolveFlush = resolve; + }); + + mockFlushIfServerless.mockReturnValueOnce(flushPromise); + + const mockArgs = createMockArgs(false); + + const startTime = Date.now(); + + const handleErrorPromise = handleError(mockError, mockArgs); + + setTimeout(() => resolveFlush(), 10); + + await handleErrorPromise; + const endTime = Date.now(); + + expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism }); + expect(mockFlushIfServerless).toHaveBeenCalled(); + expect(endTime - startTime).toBeGreaterThanOrEqual(10); + }); + + it('should handle flushIfServerless rejection gracefully', async () => { + const handleError = createSentryHandleError({}); + + mockFlushIfServerless.mockRejectedValueOnce(new Error('Flush failed')); + + const mockArgs = createMockArgs(false); + + await expect(handleError(mockError, mockArgs)).resolves.toBeUndefined(); + + expect(mockCaptureException).toHaveBeenCalledWith(mockError, { mechanism }); + expect(mockFlushIfServerless).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index 40dce7c83702..f66a4822555e 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -1,6 +1,7 @@ import { RPCType } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { + flushIfServerless, getActiveSpan, getRootSpan, getTraceMetaTags, @@ -15,13 +16,13 @@ vi.mock('@opentelemetry/core', () => ({ RPCType: { HTTP: 'http' }, getRPCMetadata: vi.fn(), })); - vi.mock('@sentry/core', () => ({ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', getActiveSpan: vi.fn(), getRootSpan: vi.fn(), getTraceMetaTags: vi.fn(), + flushIfServerless: vi.fn(), })); describe('wrapSentryHandleRequest', () => { @@ -62,7 +63,8 @@ describe('wrapSentryHandleRequest', () => { (getActiveSpan as unknown as ReturnType).mockReturnValue(mockActiveSpan); (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); - vi.mocked(vi.importActual('@opentelemetry/core')).getRPCMetadata = getRPCMetadata; + (vi.importActual('@opentelemetry/core') as unknown as { getRPCMetadata: typeof getRPCMetadata }).getRPCMetadata = + getRPCMetadata; const routerContext = { staticHandlerContext: { @@ -110,7 +112,8 @@ describe('wrapSentryHandleRequest', () => { (getActiveSpan as unknown as ReturnType).mockReturnValue(null); const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); - vi.mocked(vi.importActual('@opentelemetry/core')).getRPCMetadata = getRPCMetadata; + (vi.importActual('@opentelemetry/core') as unknown as { getRPCMetadata: typeof getRPCMetadata }).getRPCMetadata = + getRPCMetadata; const routerContext = { staticHandlerContext: { @@ -122,6 +125,55 @@ describe('wrapSentryHandleRequest', () => { expect(getRPCMetadata).not.toHaveBeenCalled(); }); + + test('should call flushIfServerless on successful execution', async () => { + const originalHandler = vi.fn().mockResolvedValue('success response'); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const request = new Request('https://example.com'); + const responseStatusCode = 200; + const responseHeaders = new Headers(); + const routerContext = { staticHandlerContext: { matches: [] } } as any; + const loadContext = {} as any; + + await wrappedHandler(request, responseStatusCode, responseHeaders, routerContext, loadContext); + + expect(flushIfServerless).toHaveBeenCalled(); + }); + + test('should call flushIfServerless even when original handler throws an error', async () => { + const mockError = new Error('Handler failed'); + const originalHandler = vi.fn().mockRejectedValue(mockError); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const request = new Request('https://example.com'); + const responseStatusCode = 200; + const responseHeaders = new Headers(); + const routerContext = { staticHandlerContext: { matches: [] } } as any; + const loadContext = {} as any; + + await expect( + wrappedHandler(request, responseStatusCode, responseHeaders, routerContext, loadContext), + ).rejects.toThrow('Handler failed'); + + expect(flushIfServerless).toHaveBeenCalled(); + }); + + test('should propagate errors from original handler', async () => { + const mockError = new Error('Test error'); + const originalHandler = vi.fn().mockRejectedValue(mockError); + const wrappedHandler = wrapSentryHandleRequest(originalHandler); + + const request = new Request('https://example.com'); + const responseStatusCode = 500; + const responseHeaders = new Headers(); + const routerContext = { staticHandlerContext: { matches: [] } } as any; + const loadContext = {} as any; + + await expect(wrappedHandler(request, responseStatusCode, responseHeaders, routerContext, loadContext)).rejects.toBe( + mockError, + ); + }); }); describe('getMetaTagTransformer', () => { @@ -132,68 +184,64 @@ describe('getMetaTagTransformer', () => { ); }); - test('should inject meta tags before closing head tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); + test('should inject meta tags before closing head tag', () => { + return new Promise(resolve => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); - outputStream.on('end', () => { - expect(outputData).toContain(''); - expect(outputData).not.toContain(''); - done(); - }); + bodyStream.on('end', () => { + expect(outputData).toContain(''); + expect(outputData).not.toContain(''); + resolve(); + }); - transformer.pipe(outputStream); - - bodyStream.write('Test'); - bodyStream.end(); + transformer.write('Test'); + transformer.end(); + }); }); - test('should not modify chunks without head closing tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); + test('should not modify chunks without head closing tag', () => { + return new Promise(resolve => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toBe('Test'); - expect(getTraceMetaTags).toHaveBeenCalled(); - done(); - }); + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); - transformer.pipe(outputStream); + bodyStream.on('end', () => { + expect(outputData).toBe('Test'); + resolve(); + }); - bodyStream.write('Test'); - bodyStream.end(); + transformer.write('Test'); + transformer.end(); + }); }); - test('should handle buffer input', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); + test('should handle buffer input', () => { + return new Promise(resolve => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); - outputStream.on('end', () => { - expect(outputData).toContain(''); - done(); - }); + bodyStream.on('end', () => { + expect(outputData).toContain(''); + resolve(); + }); - transformer.pipe(outputStream); - - bodyStream.write(Buffer.from('Test')); - bodyStream.end(); + transformer.write(Buffer.from('Test')); + transformer.end(); + }); }); }); diff --git a/packages/react-router/test/server/wrapServerAction.test.ts b/packages/react-router/test/server/wrapServerAction.test.ts index 14933fe87e4f..5b707eb33547 100644 --- a/packages/react-router/test/server/wrapServerAction.test.ts +++ b/packages/react-router/test/server/wrapServerAction.test.ts @@ -3,6 +3,15 @@ import type { ActionFunctionArgs } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapServerAction } from '../../src/server/wrapServerAction'; +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + flushIfServerless: vi.fn(), + }; +}); + describe('wrapServerAction', () => { beforeEach(() => { vi.clearAllMocks(); @@ -12,11 +21,12 @@ describe('wrapServerAction', () => { const mockActionFn = vi.fn().mockResolvedValue('result'); const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; - const spy = vi.spyOn(core, 'startSpan'); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + const wrappedAction = wrapServerAction({}, mockActionFn); await wrappedAction(mockArgs); - expect(spy).toHaveBeenCalledWith( + expect(core.startSpan).toHaveBeenCalledWith( { name: 'Executing Server Action', attributes: { @@ -27,6 +37,7 @@ describe('wrapServerAction', () => { expect.any(Function), ); expect(mockActionFn).toHaveBeenCalledWith(mockArgs); + expect(core.flushIfServerless).toHaveBeenCalled(); }); it('should wrap an action function with custom options', async () => { @@ -40,11 +51,12 @@ describe('wrapServerAction', () => { const mockActionFn = vi.fn().mockResolvedValue('result'); const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; - const spy = vi.spyOn(core, 'startSpan'); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + const wrappedAction = wrapServerAction(customOptions, mockActionFn); await wrappedAction(mockArgs); - expect(spy).toHaveBeenCalledWith( + expect(core.startSpan).toHaveBeenCalledWith( { name: 'Custom Action', attributes: { @@ -56,5 +68,43 @@ describe('wrapServerAction', () => { expect.any(Function), ); expect(mockActionFn).toHaveBeenCalledWith(mockArgs); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should call flushIfServerless on successful execution', async () => { + const mockActionFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedAction = wrapServerAction({}, mockActionFn); + await wrappedAction(mockArgs); + + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should call flushIfServerless even when action throws an error', async () => { + const mockError = new Error('Action failed'); + const mockActionFn = vi.fn().mockRejectedValue(mockError); + const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedAction = wrapServerAction({}, mockActionFn); + + await expect(wrappedAction(mockArgs)).rejects.toThrow('Action failed'); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should propagate errors from action function', async () => { + const mockError = new Error('Test error'); + const mockActionFn = vi.fn().mockRejectedValue(mockError); + const mockArgs = { request: new Request('http://test.com') } as ActionFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedAction = wrapServerAction({}, mockActionFn); + + await expect(wrappedAction(mockArgs)).rejects.toBe(mockError); }); }); diff --git a/packages/react-router/test/server/wrapServerLoader.test.ts b/packages/react-router/test/server/wrapServerLoader.test.ts index 67b7d512bcbe..0838643ff7de 100644 --- a/packages/react-router/test/server/wrapServerLoader.test.ts +++ b/packages/react-router/test/server/wrapServerLoader.test.ts @@ -3,6 +3,15 @@ import type { LoaderFunctionArgs } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapServerLoader } from '../../src/server/wrapServerLoader'; +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn(), + flushIfServerless: vi.fn(), + }; +}); + describe('wrapServerLoader', () => { beforeEach(() => { vi.clearAllMocks(); @@ -12,11 +21,12 @@ describe('wrapServerLoader', () => { const mockLoaderFn = vi.fn().mockResolvedValue('result'); const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; - const spy = vi.spyOn(core, 'startSpan'); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + const wrappedLoader = wrapServerLoader({}, mockLoaderFn); await wrappedLoader(mockArgs); - expect(spy).toHaveBeenCalledWith( + expect(core.startSpan).toHaveBeenCalledWith( { name: 'Executing Server Loader', attributes: { @@ -27,6 +37,7 @@ describe('wrapServerLoader', () => { expect.any(Function), ); expect(mockLoaderFn).toHaveBeenCalledWith(mockArgs); + expect(core.flushIfServerless).toHaveBeenCalled(); }); it('should wrap a loader function with custom options', async () => { @@ -40,11 +51,12 @@ describe('wrapServerLoader', () => { const mockLoaderFn = vi.fn().mockResolvedValue('result'); const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; - const spy = vi.spyOn(core, 'startSpan'); + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + const wrappedLoader = wrapServerLoader(customOptions, mockLoaderFn); await wrappedLoader(mockArgs); - expect(spy).toHaveBeenCalledWith( + expect(core.startSpan).toHaveBeenCalledWith( { name: 'Custom Loader', attributes: { @@ -56,5 +68,43 @@ describe('wrapServerLoader', () => { expect.any(Function), ); expect(mockLoaderFn).toHaveBeenCalledWith(mockArgs); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should call flushIfServerless on successful execution', async () => { + const mockLoaderFn = vi.fn().mockResolvedValue('result'); + const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedLoader = wrapServerLoader({}, mockLoaderFn); + await wrappedLoader(mockArgs); + + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should call flushIfServerless even when loader throws an error', async () => { + const mockError = new Error('Loader failed'); + const mockLoaderFn = vi.fn().mockRejectedValue(mockError); + const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedLoader = wrapServerLoader({}, mockLoaderFn); + + await expect(wrappedLoader(mockArgs)).rejects.toThrow('Loader failed'); + expect(core.flushIfServerless).toHaveBeenCalled(); + }); + + it('should propagate errors from loader function', async () => { + const mockError = new Error('Test error'); + const mockLoaderFn = vi.fn().mockRejectedValue(mockError); + const mockArgs = { request: new Request('http://test.com') } as LoaderFunctionArgs; + + (core.startSpan as any).mockImplementation((_: any, fn: any) => fn()); + + const wrappedLoader = wrapServerLoader({}, mockLoaderFn); + + await expect(wrappedLoader(mockArgs)).rejects.toBe(mockError); }); }); diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx index e3f94b441ee6..f37afe961042 100644 --- a/packages/react/src/errorboundary.tsx +++ b/packages/react/src/errorboundary.tsx @@ -222,11 +222,11 @@ function withErrorBoundary

>( ): React.FC

{ const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT; - const Wrapped: React.FC

= (props: P) => ( + const Wrapped = React.memo((props: P) => ( - ); + )) as unknown as React.FC

; Wrapped.displayName = `errorBoundary(${componentDisplayName})`; diff --git a/packages/react/test/errorboundary.test.tsx b/packages/react/test/errorboundary.test.tsx index 275faa4e2079..5e731cc86b49 100644 --- a/packages/react/test/errorboundary.test.tsx +++ b/packages/react/test/errorboundary.test.tsx @@ -96,6 +96,122 @@ describe('withErrorBoundary', () => { const Component = withErrorBoundary(() =>

Hello World

, { fallback:

fallback

}); expect(Component.displayName).toBe(`errorBoundary(${UNKNOWN_COMPONENT})`); }); + + it('does not rerender when props are identical', () => { + let renderCount = 0; + const TestComponent = ({ title }: { title: string }) => { + renderCount++; + return

{title}

; + }; + + const WrappedComponent = withErrorBoundary(TestComponent, { fallback:

fallback

}); + const { rerender } = render(); + + expect(renderCount).toBe(1); + + // Rerender with identical props - should not cause TestComponent to rerender + rerender(); + expect(renderCount).toBe(1); + + // Rerender with different props - should cause TestComponent to rerender + rerender(); + expect(renderCount).toBe(2); + }); + + it('does not rerender when complex props are identical', () => { + let renderCount = 0; + const TestComponent = ({ data }: { data: { id: number; name: string } }) => { + renderCount++; + return

{data.name}

; + }; + + const WrappedComponent = withErrorBoundary(TestComponent, { fallback:

fallback

}); + const props = { data: { id: 1, name: 'test' } }; + const { rerender } = render(); + + expect(renderCount).toBe(1); + + // Rerender with same object reference - should not cause TestComponent to rerender + rerender(); + expect(renderCount).toBe(1); + + // Rerender with different object but same values - should cause rerender + rerender(); + expect(renderCount).toBe(2); + + // Rerender with different values - should cause rerender + rerender(); + expect(renderCount).toBe(3); + }); + + it('does not rerender when errorBoundaryOptions are the same', () => { + let renderCount = 0; + const TestComponent = ({ title }: { title: string }) => { + renderCount++; + return

{title}

; + }; + + const errorBoundaryOptions = { fallback:

fallback

}; + const WrappedComponent = withErrorBoundary(TestComponent, errorBoundaryOptions); + const { rerender } = render(); + + expect(renderCount).toBe(1); + + // Rerender with identical props - should not cause TestComponent to rerender + rerender(); + expect(renderCount).toBe(1); + }); + + it('preserves function component behavior with React.memo', () => { + const TestComponent = ({ title }: { title: string }) =>

{title}

; + const WrappedComponent = withErrorBoundary(TestComponent, { fallback:

fallback

}); + + expect(WrappedComponent).toBeDefined(); + expect(typeof WrappedComponent).toBe('object'); + expect(WrappedComponent.displayName).toBe('errorBoundary(TestComponent)'); + + const { container } = render(); + expect(container.innerHTML).toContain('test'); + }); + + it('does not rerender parent component unnecessarily', () => { + let parentRenderCount = 0; + let childRenderCount = 0; + + const ChildComponent = ({ value }: { value: number }) => { + childRenderCount++; + return
Child: {value}
; + }; + + const WrappedChild = withErrorBoundary(ChildComponent, { fallback:
Error
}); + + const ParentComponent = ({ childValue, otherProp }: { childValue: number; otherProp: string }) => { + parentRenderCount++; + return ( +
+
Parent: {otherProp}
+ +
+ ); + }; + + const { rerender } = render(); + + expect(parentRenderCount).toBe(1); + expect(childRenderCount).toBe(1); + + // Change otherProp but keep childValue the same + rerender(); + + expect(parentRenderCount).toBe(2); // Parent should rerender + expect(childRenderCount).toBe(1); // Child should NOT rerender due to memo + + // Change childValue + rerender(); + + expect(parentRenderCount).toBe(3); // Parent should rerender + expect(childRenderCount).toBe(2); // Child should rerender due to changed props + }); }); describe('ErrorBoundary', () => { diff --git a/packages/remix/package.json b/packages/remix/package.json index ba0dccdb3786..f8dde175cf86 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -65,10 +65,10 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@remix-run/router": "1.x", - "@sentry/cli": "^2.46.0", + "@sentry/cli": "^2.50.0", "@sentry/core": "9.40.0", "@sentry/node": "9.40.0", "@sentry/react": "9.40.0", diff --git a/packages/remix/scripts/createRelease.js b/packages/remix/scripts/createRelease.js index ab969c48be18..d00005d89520 100644 --- a/packages/remix/scripts/createRelease.js +++ b/packages/remix/scripts/createRelease.js @@ -27,13 +27,22 @@ async function createRelease(argv, URL_PREFIX, BUILD_PATH) { await sentry.releases.new(release); - await sentry.releases.uploadSourceMaps(release, { - urlPrefix: URL_PREFIX, - include: [BUILD_PATH], - useArtifactBundle: !argv.disableDebugIds, - }); + try { + await sentry.releases.uploadSourceMaps(release, { + urlPrefix: URL_PREFIX, + include: [BUILD_PATH], + useArtifactBundle: !argv.disableDebugIds, + live: 'rejectOnError', + }); + } catch { + console.warn('[sentry] Failed to upload sourcemaps.'); + } - await sentry.releases.finalize(release); + try { + await sentry.releases.finalize(release); + } catch { + console.warn('[sentry] Failed to finalize release.'); + } if (argv.deleteAfterUpload) { try { diff --git a/packages/remix/test/integration/app/root.tsx b/packages/remix/test/integration/app/root.tsx index 1b8e5e39e8f5..c1d0bf218baa 100644 --- a/packages/remix/test/integration/app/root.tsx +++ b/packages/remix/test/integration/app/root.tsx @@ -48,9 +48,9 @@ export const loader: LoaderFunction = async ({ request }) => { case 'returnRedirect': return redirect('/?type=plain'); case 'throwRedirectToExternal': - throw redirect('https://example.com'); + throw redirect(`https://docs.sentry.io`); case 'returnRedirectToExternal': - return redirect('https://example.com'); + return redirect('https://docs.sentry.io'); default: { return {}; } diff --git a/packages/remix/test/integration/test/client/root-loader.test.ts b/packages/remix/test/integration/test/client/root-loader.test.ts index e9273fbd6caa..fc557509e941 100644 --- a/packages/remix/test/integration/test/client/root-loader.test.ts +++ b/packages/remix/test/integration/test/client/root-loader.test.ts @@ -1,4 +1,4 @@ -import { type Page, expect, test } from '@playwright/test'; +import { type Page, expect, test, chromium } from '@playwright/test'; async function getRouteData(page: Page): Promise { return page.evaluate('window.__remixContext.state.loaderData').catch(err => { @@ -22,7 +22,6 @@ async function extractTraceAndBaggageFromMeta( test('should inject `sentry-trace` and `baggage` into root loader returning an empty object.', async ({ page }) => { await page.goto('/?type=empty'); - const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); expect(sentryTrace).toMatch(/.+/); @@ -38,7 +37,6 @@ test('should inject `sentry-trace` and `baggage` into root loader returning an e test('should inject `sentry-trace` and `baggage` into root loader returning a plain object.', async ({ page }) => { await page.goto('/?type=plain'); - const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); expect(sentryTrace).toMatch(/.+/); @@ -56,7 +54,6 @@ test('should inject `sentry-trace` and `baggage` into root loader returning a pl test('should inject `sentry-trace` and `baggage` into root loader returning a `JSON response`.', async ({ page }) => { await page.goto('/?type=json'); - const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); expect(sentryTrace).toMatch(/.+/); @@ -74,7 +71,6 @@ test('should inject `sentry-trace` and `baggage` into root loader returning a `J test('should inject `sentry-trace` and `baggage` into root loader returning a deferred response', async ({ page }) => { await page.goto('/?type=defer'); - const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); expect(sentryTrace).toMatch(/.+/); @@ -90,7 +86,6 @@ test('should inject `sentry-trace` and `baggage` into root loader returning a de test('should inject `sentry-trace` and `baggage` into root loader returning `null`.', async ({ page }) => { await page.goto('/?type=null'); - const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); expect(sentryTrace).toMatch(/.+/); @@ -106,7 +101,6 @@ test('should inject `sentry-trace` and `baggage` into root loader returning `nul test('should inject `sentry-trace` and `baggage` into root loader returning `undefined`.', async ({ page }) => { await page.goto('/?type=undefined'); - const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); expect(sentryTrace).toMatch(/.+/); @@ -124,12 +118,11 @@ test('should inject `sentry-trace` and `baggage` into root loader throwing a red page, }) => { await page.goto('/?type=throwRedirect'); + const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); // We should be successfully redirected to the path. expect(page.url()).toEqual(expect.stringContaining('/?type=plain')); - const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); - expect(sentryTrace).toMatch(/.+/); expect(sentryBaggage).toMatch(/.+/); @@ -143,14 +136,14 @@ test('should inject `sentry-trace` and `baggage` into root loader throwing a red test('should inject `sentry-trace` and `baggage` into root loader returning a redirection to valid path.', async ({ page, + baseURL, }) => { - await page.goto('/?type=returnRedirect'); + await page.goto(`${baseURL}/?type=returnRedirect`); + const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); // We should be successfully redirected to the path. expect(page.url()).toEqual(expect.stringContaining('/?type=plain')); - const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); - expect(sentryTrace).toMatch(/.+/); expect(sentryBaggage).toMatch(/.+/); @@ -162,11 +155,10 @@ test('should inject `sentry-trace` and `baggage` into root loader returning a re }); }); -test('should return redirect to an external path with no baggage and trace injected.', async ({ page }) => { - await page.goto('/?type=returnRedirectToExternal'); +test('should return redirect to an external path with no baggage and trace injected.', async ({ page, baseURL }) => { + await page.goto(`${baseURL}/?type=returnRedirectToExternal`); - // We should be successfully redirected to the external path. - expect(page.url()).toEqual(expect.stringContaining('https://example.com')); + expect(page.url()).toEqual(expect.stringContaining('docs.sentry.io')); const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); @@ -174,11 +166,11 @@ test('should return redirect to an external path with no baggage and trace injec expect(sentryBaggage).toBeUndefined(); }); -test('should throw redirect to an external path with no baggage and trace injected.', async ({ page }) => { - await page.goto('/?type=throwRedirectToExternal'); +test('should throw redirect to an external path with no baggage and trace injected.', async ({ page, baseURL }) => { + await page.goto(`${baseURL}/?type=throwRedirectToExternal`); // We should be successfully redirected to the external path. - expect(page.url()).toEqual(expect.stringContaining('https://example.com')); + expect(page.url()).toEqual(expect.stringContaining('docs.sentry.io')); const { sentryTrace, sentryBaggage } = await extractTraceAndBaggageFromMeta(page); diff --git a/packages/remix/test/scripts/upload-sourcemaps.test.ts b/packages/remix/test/scripts/upload-sourcemaps.test.ts index 0634930bf00e..677c602a011f 100644 --- a/packages/remix/test/scripts/upload-sourcemaps.test.ts +++ b/packages/remix/test/scripts/upload-sourcemaps.test.ts @@ -5,6 +5,8 @@ const uploadSourceMapsMock = vi.fn(); const finalizeMock = vi.fn(); const proposeVersionMock = vi.fn(() => '0.1.2.3.4'); +const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // The createRelease script requires the Sentry CLI, which we need to mock so we // hook require to do this async function mock(mockedUri: string, stub: any) { @@ -56,6 +58,7 @@ describe('createRelease', () => { urlPrefix: '~/build/', include: ['public/build'], useArtifactBundle: true, + live: 'rejectOnError', }); expect(finalizeMock).toHaveBeenCalledWith('0.1.2.3'); }); @@ -69,6 +72,7 @@ describe('createRelease', () => { urlPrefix: '~/build/', include: ['public/build'], useArtifactBundle: true, + live: 'rejectOnError', }); expect(finalizeMock).toHaveBeenCalledWith('0.1.2.3.4'); }); @@ -89,9 +93,35 @@ describe('createRelease', () => { urlPrefix: '~/build/', include: ['public/build'], useArtifactBundle: true, + live: 'rejectOnError', }); expect(finalizeMock).toHaveBeenCalledWith('0.1.2.3.4'); }); + + it('logs an error when uploadSourceMaps fails', async () => { + uploadSourceMapsMock.mockRejectedValue(new Error('Failed to upload sourcemaps')); + + await createRelease({}, '~/build/', 'public/build'); + + expect(uploadSourceMapsMock).toHaveBeenCalledWith('0.1.2.3.4', { + urlPrefix: '~/build/', + include: ['public/build'], + useArtifactBundle: true, + live: 'rejectOnError', + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith('[sentry] Failed to upload sourcemaps.'); + + expect(finalizeMock).toHaveBeenCalledWith('0.1.2.3.4'); + }); + + it('logs an error when finalize fails', async () => { + finalizeMock.mockRejectedValue(new Error('Failed to finalize release')); + + await createRelease({}, '~/build/', 'public/build'); + + expect(consoleWarnSpy).toHaveBeenCalledWith('[sentry] Failed to finalize release.'); + }); }); // To avoid `--isolatedModules` flag as we're not importing diff --git a/packages/replay-internal/src/coreHandlers/performanceObserver.ts b/packages/replay-internal/src/coreHandlers/performanceObserver.ts index 638ef53b05fb..cd3a2aafed4b 100644 --- a/packages/replay-internal/src/coreHandlers/performanceObserver.ts +++ b/packages/replay-internal/src/coreHandlers/performanceObserver.ts @@ -1,6 +1,5 @@ import { addClsInstrumentationHandler, - addFidInstrumentationHandler, addInpInstrumentationHandler, addLcpInstrumentationHandler, addPerformanceInstrumentationHandler, @@ -8,7 +7,6 @@ import { import type { ReplayContainer } from '../types'; import { getCumulativeLayoutShift, - getFirstInputDelay, getInteractionToNextPaint, getLargestContentfulPaint, webVitalHandler, @@ -39,7 +37,6 @@ export function setupPerformanceObserver(replay: ReplayContainer): () => void { clearCallbacks.push( addLcpInstrumentationHandler(webVitalHandler(getLargestContentfulPaint, replay)), addClsInstrumentationHandler(webVitalHandler(getCumulativeLayoutShift, replay)), - addFidInstrumentationHandler(webVitalHandler(getFirstInputDelay, replay)), addInpInstrumentationHandler(webVitalHandler(getInteractionToNextPaint, replay)), ); diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index e2a49bd0a83b..ae3aa9589cab 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -392,6 +392,7 @@ export class ReplayContainer implements ReplayContainerInterface { ); this.session = session; + this.recordingMode = 'session'; this._initializeRecording(); } @@ -503,6 +504,10 @@ export class ReplayContainer implements ReplayContainerInterface { // enter into an infinite loop when `stop()` is called while flushing. this._isEnabled = false; + // Make sure to reset `recordingMode` to `buffer` to avoid any additional + // breadcrumbs to trigger a flush (e.g. in `addUpdate()`) + this.recordingMode = 'buffer'; + try { DEBUG_BUILD && debug.log(`Stopping Replay${reason ? ` triggered by ${reason}` : ''}`); @@ -623,7 +628,7 @@ export class ReplayContainer implements ReplayContainerInterface { // If this option is turned on then we will only want to call `flush` // explicitly - if (this.recordingMode === 'buffer') { + if (this.recordingMode === 'buffer' || !this._isEnabled) { return; } @@ -934,7 +939,7 @@ export class ReplayContainer implements ReplayContainerInterface { // There is no way to remove these listeners, so ensure they are only added once if (!this._hasInitializedCoreListeners) { - addGlobalListeners(this, { autoFlushOnFeedback: this._options._experiments.autoFlushOnFeedback }); + addGlobalListeners(this); this._hasInitializedCoreListeners = true; } diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 68ee0d749067..1e7891a84e76 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -235,9 +235,8 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { * https://github.com/rrweb-io/rrweb/blob/master/docs/recipes/cross-origin-iframes.md#considerations */ recordCrossOriginIframes: boolean; - autoFlushOnFeedback: boolean; /** - * Completetly ignore mutations matching the given selectors. + * Completely ignore mutations matching the given selectors. * This can be used if a specific type of mutation is causing (e.g. performance) problems. * NOTE: This can be dangerous to use, as mutations are applied as incremental patches. * Make sure to verify that the captured replays still work when using this option. diff --git a/packages/replay-internal/src/types/replayFrame.ts b/packages/replay-internal/src/types/replayFrame.ts index 6eb1855b8f8a..d060204256bf 100644 --- a/packages/replay-internal/src/types/replayFrame.ts +++ b/packages/replay-internal/src/types/replayFrame.ts @@ -172,7 +172,7 @@ interface ReplayHistoryFrame extends ReplayBaseSpanFrame { interface ReplayWebVitalFrame extends ReplayBaseSpanFrame { data: WebVitalData; - op: 'largest-contentful-paint' | 'cumulative-layout-shift' | 'first-input-delay' | 'interaction-to-next-paint'; + op: 'largest-contentful-paint' | 'cumulative-layout-shift' | 'interaction-to-next-paint'; } interface ReplayMemoryFrame extends ReplayBaseSpanFrame { diff --git a/packages/replay-internal/src/util/addGlobalListeners.ts b/packages/replay-internal/src/util/addGlobalListeners.ts index 530749c6e3f7..cd5c141d0160 100644 --- a/packages/replay-internal/src/util/addGlobalListeners.ts +++ b/packages/replay-internal/src/util/addGlobalListeners.ts @@ -16,10 +16,7 @@ import type { ReplayContainer } from '../types'; /** * Add global listeners that cannot be removed. */ -export function addGlobalListeners( - replay: ReplayContainer, - { autoFlushOnFeedback }: { autoFlushOnFeedback?: boolean }, -): void { +export function addGlobalListeners(replay: ReplayContainer): void { // Listeners from core SDK // const client = getClient(); @@ -64,17 +61,15 @@ export function addGlobalListeners( const replayId = replay.getSessionId(); if (options?.includeReplay && replay.isEnabled() && replayId && feedbackEvent.contexts?.feedback) { // In case the feedback is sent via API and not through our widget, we want to flush replay - if (feedbackEvent.contexts.feedback.source === 'api' && autoFlushOnFeedback) { + if (feedbackEvent.contexts.feedback.source === 'api') { await replay.flush(); } feedbackEvent.contexts.feedback.replay_id = replayId; } }); - if (autoFlushOnFeedback) { - client.on('openFeedbackWidget', async () => { - await replay.flush(); - }); - } + client.on('openFeedbackWidget', async () => { + await replay.flush(); + }); } } diff --git a/packages/replay-internal/src/util/createPerformanceEntries.ts b/packages/replay-internal/src/util/createPerformanceEntries.ts index 6df2343327fe..b8a39f233074 100644 --- a/packages/replay-internal/src/util/createPerformanceEntries.ts +++ b/packages/replay-internal/src/util/createPerformanceEntries.ts @@ -221,15 +221,6 @@ export function getCumulativeLayoutShift(metric: Metric): ReplayPerformanceEntry return getWebVital(metric, 'cumulative-layout-shift', nodes, layoutShifts); } -/** - * Add a FID event to the replay based on a FID metric. - */ -export function getFirstInputDelay(metric: Metric): ReplayPerformanceEntry { - const lastEntry = metric.entries[metric.entries.length - 1] as (PerformanceEntry & { target?: Node }) | undefined; - const node = lastEntry?.target ? [lastEntry.target] : undefined; - return getWebVital(metric, 'first-input-delay', node); -} - /** * Add an INP event to the replay based on an INP metric. */ diff --git a/packages/replay-internal/test/integration/session.test.ts b/packages/replay-internal/test/integration/session.test.ts index 5cf259755e47..f867c43efbe8 100644 --- a/packages/replay-internal/test/integration/session.test.ts +++ b/packages/replay-internal/test/integration/session.test.ts @@ -207,14 +207,39 @@ describe('Integration | session', () => { await vi.advanceTimersByTimeAsync(DEFAULT_FLUSH_MIN_DELAY); await new Promise(process.nextTick); - const newTimestamp = BASE_TIMESTAMP + ELAPSED; + // The click actually does not trigger a flush because it never gets added to event buffer because + // the session is expired. We stop recording and re-sample the session expires. + expect(replay).not.toHaveLastSentReplay(); + + // This click will trigger a flush now that the session is active + // (sessionSampleRate=1 when resampling) + domHandler({ + name: 'click', + event: new Event('click'), + }); + await vi.advanceTimersByTimeAsync(DEFAULT_FLUSH_MIN_DELAY); + await new Promise(process.nextTick); + const newTimestamp = BASE_TIMESTAMP + ELAPSED + DEFAULT_FLUSH_MIN_DELAY; expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, + { data: { isCheckout: true }, timestamp: newTimestamp - DEFAULT_FLUSH_MIN_DELAY, type: 2 }, optionsEvent, - // the click is lost, but that's OK + { + type: 5, + timestamp: newTimestamp, + data: { + tag: 'breadcrumb', + payload: { + timestamp: newTimestamp / 1000, + type: 'default', + category: 'ui.click', + message: '', + data: {}, + }, + }, + }, ]), }); @@ -224,7 +249,7 @@ describe('Integration | session', () => { // `_context` should be reset when a new session is created expect(replay.getContext()).toEqual({ initialUrl: 'http://dummy/', - initialTimestamp: newTimestamp, + initialTimestamp: newTimestamp - DEFAULT_FLUSH_MIN_DELAY, urls: [], errorIds: new Set(), traceIds: new Set(), diff --git a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts index a17c421c518b..c87d18bff325 100644 --- a/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts +++ b/packages/replay-internal/test/unit/util/createPerformanceEntry.test.ts @@ -4,7 +4,6 @@ import { WINDOW } from '../../../src/constants'; import { createPerformanceEntries, getCumulativeLayoutShift, - getFirstInputDelay, getInteractionToNextPaint, getLargestContentfulPaint, } from '../../../src/util/createPerformanceEntries'; @@ -108,26 +107,6 @@ describe('Unit | util | createPerformanceEntries', () => { }); }); - describe('getFirstInputDelay', () => { - it('works with an FID metric', async () => { - const metric = { - value: 5108.299, - rating: 'good' as const, - entries: [], - }; - - const event = getFirstInputDelay(metric); - - expect(event).toEqual({ - type: 'web-vital', - name: 'first-input-delay', - start: 1672531205.108299, - end: 1672531205.108299, - data: { value: 5108.299, size: 5108.299, rating: 'good', nodeIds: undefined, attributions: undefined }, - }); - }); - }); - describe('getInteractionToNextPaint', () => { it('works with an INP metric', async () => { const metric = { diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index a33c9de5cb92..8d09131e5baa 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -69,7 +69,7 @@ "@sentry/core": "9.40.0", "@sentry/node": "9.40.0", "@sentry/solid": "9.40.0", - "@sentry/vite-plugin": "2.22.6" + "@sentry/vite-plugin": "^4.0.0" }, "devDependencies": { "@solidjs/router": "^0.13.4", diff --git a/packages/solidstart/src/server/utils.ts b/packages/solidstart/src/server/utils.ts index e4c70fef633b..1560b254bd22 100644 --- a/packages/solidstart/src/server/utils.ts +++ b/packages/solidstart/src/server/utils.ts @@ -1,22 +1,6 @@ import type { EventProcessor, Options } from '@sentry/core'; import { debug } from '@sentry/core'; -import { flush, getGlobalScope } from '@sentry/node'; -import { DEBUG_BUILD } from '../common/debug-build'; - -/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */ -export async function flushIfServerless(): Promise { - const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL; - - if (isServerless) { - try { - DEBUG_BUILD && debug.log('Flushing events...'); - await flush(2000); - DEBUG_BUILD && debug.log('Done flushing events'); - } catch (e) { - DEBUG_BUILD && debug.log('Error while flushing events:\n', e); - } - } -} +import { getGlobalScope } from '@sentry/node'; /** * Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route. diff --git a/packages/solidstart/src/server/withServerActionInstrumentation.ts b/packages/solidstart/src/server/withServerActionInstrumentation.ts index a894837c3947..c5c726614279 100644 --- a/packages/solidstart/src/server/withServerActionInstrumentation.ts +++ b/packages/solidstart/src/server/withServerActionInstrumentation.ts @@ -1,6 +1,11 @@ -import { handleCallbackErrors, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR } from '@sentry/core'; +import { + flushIfServerless, + handleCallbackErrors, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, +} from '@sentry/core'; import { captureException, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, startSpan } from '@sentry/node'; -import { flushIfServerless, isRedirect } from './utils'; +import { isRedirect } from './utils'; /** * Wraps a server action (functions that use the 'use server' directive) diff --git a/packages/solidstart/test/config/withSentry.test.ts b/packages/solidstart/test/config/withSentry.test.ts index 8f6f02245553..3f695ca36c46 100644 --- a/packages/solidstart/test/config/withSentry.test.ts +++ b/packages/solidstart/test/config/withSentry.test.ts @@ -81,7 +81,7 @@ describe('withSentry()', () => { 'sentry-solidstart-build-instrumentation-file', 'sentry-telemetry-plugin', 'sentry-vite-release-injection-plugin', - 'sentry-debug-id-upload-plugin', + 'sentry-release-management-plugin', 'sentry-vite-debug-id-injection-plugin', 'sentry-vite-debug-id-upload-plugin', 'sentry-file-deletion-plugin', @@ -109,7 +109,7 @@ describe('withSentry()', () => { 'sentry-solidstart-build-instrumentation-file', 'sentry-telemetry-plugin', 'sentry-vite-release-injection-plugin', - 'sentry-debug-id-upload-plugin', + 'sentry-release-management-plugin', 'sentry-vite-debug-id-injection-plugin', 'sentry-vite-debug-id-upload-plugin', 'sentry-file-deletion-plugin', @@ -141,7 +141,7 @@ describe('withSentry()', () => { 'sentry-solidstart-build-instrumentation-file', 'sentry-telemetry-plugin', 'sentry-vite-release-injection-plugin', - 'sentry-debug-id-upload-plugin', + 'sentry-release-management-plugin', 'sentry-vite-debug-id-injection-plugin', 'sentry-vite-debug-id-upload-plugin', 'sentry-file-deletion-plugin', diff --git a/packages/solidstart/test/server/withServerActionInstrumentation.test.ts b/packages/solidstart/test/server/withServerActionInstrumentation.test.ts index 76acc1e46b12..d2bc90259942 100644 --- a/packages/solidstart/test/server/withServerActionInstrumentation.test.ts +++ b/packages/solidstart/test/server/withServerActionInstrumentation.test.ts @@ -1,4 +1,4 @@ -import { SentrySpan } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; import * as SentryNode from '@sentry/node'; import { createTransport, @@ -16,7 +16,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { withServerActionInstrumentation } from '../../src/server'; const mockCaptureException = vi.spyOn(SentryNode, 'captureException').mockImplementation(() => ''); -const mockFlush = vi.spyOn(SentryNode, 'flush').mockImplementation(async () => true); +const mockFlush = vi.spyOn(SentryCore, 'flushIfServerless').mockImplementation(async () => {}); const mockGetActiveSpan = vi.spyOn(SentryNode, 'getActiveSpan'); const mockGetRequestEvent = vi.fn(); @@ -126,7 +126,7 @@ describe('withServerActionInstrumentation', () => { }); it('sets a server action name on the active span', async () => { - const span = new SentrySpan(); + const span = new SentryCore.SentrySpan(); span.setAttribute('http.target', '/_server'); mockGetActiveSpan.mockReturnValue(span); const mockSpanSetAttribute = vi.spyOn(span, 'setAttribute'); @@ -145,7 +145,7 @@ describe('withServerActionInstrumentation', () => { }); it('does not set a server action name if the active span had a non `/_server` target', async () => { - const span = new SentrySpan(); + const span = new SentryCore.SentrySpan(); span.setAttribute('http.target', '/users/5'); mockGetActiveSpan.mockReturnValue(span); const mockSpanSetAttribute = vi.spyOn(span, 'setAttribute'); diff --git a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts index 5e2d0f56232b..c40a4f7c8dbc 100644 --- a/packages/solidstart/test/vite/sentrySolidStartVite.test.ts +++ b/packages/solidstart/test/vite/sentrySolidStartVite.test.ts @@ -29,7 +29,7 @@ describe('sentrySolidStartVite()', () => { 'sentry-solidstart-build-instrumentation-file', 'sentry-telemetry-plugin', 'sentry-vite-release-injection-plugin', - 'sentry-debug-id-upload-plugin', + 'sentry-release-management-plugin', 'sentry-vite-debug-id-injection-plugin', 'sentry-vite-debug-id-upload-plugin', 'sentry-file-deletion-plugin', diff --git a/packages/svelte/src/preprocessors.ts b/packages/svelte/src/preprocessors.ts index b13b20ec59f6..c7df0e258c0f 100644 --- a/packages/svelte/src/preprocessors.ts +++ b/packages/svelte/src/preprocessors.ts @@ -97,7 +97,7 @@ function shouldInjectFunction( // because the code inside is not executed when the component is instantiated but // when the module is first imported. // see: https://svelte.dev/docs#component-format-script-context-module - if (attributes.context === 'module') { + if (attributes.module || attributes.context === 'module') { return false; } diff --git a/packages/svelte/test/preprocessors.test.ts b/packages/svelte/test/preprocessors.test.ts index 547f58b366ab..207e2d95cce5 100644 --- a/packages/svelte/test/preprocessors.test.ts +++ b/packages/svelte/test/preprocessors.test.ts @@ -170,6 +170,26 @@ describe('componentTrackingPreprocessor', () => { expect(processedComponent.newCode).toEqual(processedComponent.originalCode); }); + + it('doesnt inject the function call to a module context script block with Svelte 5 module attribute', () => { + const preProc = componentTrackingPreprocessor(); + const component = { + originalCode: 'console.log(cmp2)', + filename: 'lib/Cmp2.svelte', + name: 'Cmp2', + }; + + const res: any = preProc.script?.({ + content: component.originalCode, + filename: component.filename, + attributes: { module: true }, + markup: '', + }); + + const processedComponent = { ...component, newCode: res.code, map: res.map }; + + expect(processedComponent.newCode).toEqual(processedComponent.originalCode); + }); }); describe('markup hook', () => { diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index eff93b7cd494..aacb0ab5766e 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -52,7 +52,7 @@ "@sentry/core": "9.40.0", "@sentry/node": "9.40.0", "@sentry/svelte": "9.40.0", - "@sentry/vite-plugin": "^3.5.0", + "@sentry/vite-plugin": "^4.0.0", "magic-string": "0.30.7", "recast": "0.23.11", "sorcery": "1.0.0" diff --git a/packages/sveltekit/src/client/handleError.ts b/packages/sveltekit/src/client/handleError.ts index 447acce45e60..14a0a5b4b9cd 100644 --- a/packages/sveltekit/src/client/handleError.ts +++ b/packages/sveltekit/src/client/handleError.ts @@ -28,20 +28,35 @@ type SafeHandleServerErrorInput = Omit => { - // SvelteKit 2.0 offers a reliable way to check for a 404 error: - if (input.status !== 404) { - captureException(input.error, { - mechanism: { - type: 'sveltekit', - handled: false, - }, - }); +export function handleErrorWithSentry(handleError?: HandleClientError): HandleClientError { + const errorHandler = handleError ?? defaultErrorHandler; + + return (input: HandleClientErrorInput): ReturnType => { + if (is4xxError(input)) { + return errorHandler(input); } - // We're extra cautious with SafeHandleServerErrorInput - this type is not compatible with HandleServerErrorInput - // @ts-expect-error - we're still passing the same object, just with a different (backwards-compatible) type - return handleError(input); + captureException(input.error, { + mechanism: { + type: 'sveltekit', + handled: !!handleError, + }, + }); + + return errorHandler(input); }; } + +// 4xx are expected errors and thus we don't want to capture them +function is4xxError(input: SafeHandleServerErrorInput): boolean { + const { status } = input; + + // Pre-SvelteKit 2.x, the status is not available, + // so we don't know if this is a 4xx error + if (!status) { + return false; + } + + // SvelteKit 2.0 offers a reliable way to check for a Not Found error: + return status >= 400 && status < 500; +} diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index aa2649a28a3a..696c3d765c5b 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -2,6 +2,7 @@ import type { Span } from '@sentry/core'; import { continueTrace, debug, + flushIfServerless, getCurrentScope, getDefaultIsolationScope, getIsolationScope, @@ -15,7 +16,7 @@ import { } from '@sentry/core'; import type { Handle, ResolveOptions } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; -import { flushIfServerless, getTracePropagationData, sendErrorToSentry } from './utils'; +import { getTracePropagationData, sendErrorToSentry } from './utils'; export type SentryHandleOptions = { /** diff --git a/packages/sveltekit/src/server-common/handleError.ts b/packages/sveltekit/src/server-common/handleError.ts index 1586a9bc201e..0ca6597ea864 100644 --- a/packages/sveltekit/src/server-common/handleError.ts +++ b/packages/sveltekit/src/server-common/handleError.ts @@ -1,6 +1,5 @@ -import { captureException, consoleSandbox, flush } from '@sentry/core'; +import { captureException, consoleSandbox, flushIfServerless } from '@sentry/core'; import type { HandleServerError } from '@sveltejs/kit'; -import { flushIfServerless } from '../server-common/utils'; // The SvelteKit default error handler just logs the error's stack trace to the console // see: https://github.com/sveltejs/kit/blob/369e7d6851f543a40c947e033bfc4a9506fdc0a8/packages/kit/src/runtime/server/index.js#L43 @@ -27,18 +26,18 @@ type SafeHandleServerErrorInput = Omit => { - if (isNotFoundError(input)) { - // We're extra cautious with SafeHandleServerErrorInput - this type is not compatible with HandleServerErrorInput - // @ts-expect-error - we're still passing the same object, just with a different (backwards-compatible) type - return handleError(input); +export function handleErrorWithSentry(handleError?: HandleServerError): HandleServerError { + const errorHandler = handleError ?? defaultErrorHandler; + + return async (input: HandleServerErrorInput): Promise => { + if (is4xxError(input)) { + return errorHandler(input); } captureException(input.error, { mechanism: { type: 'sveltekit', - handled: false, + handled: !!handleError, }, }); @@ -48,32 +47,28 @@ export function handleErrorWithSentry(handleError: HandleServerError = defaultEr }; }; - // Cloudflare workers have a `waitUntil` method that we can use to flush the event queue + // Cloudflare workers have a `waitUntil` method on `ctx` that we can use to flush the event queue // We already call this in `wrapRequestHandler` from `sentryHandleInitCloudflare` // However, `handleError` can be invoked when wrapRequestHandler already finished // (e.g. when responses are streamed / returning promises from load functions) - const cloudflareWaitUntil = platform?.context?.waitUntil; - if (typeof cloudflareWaitUntil === 'function') { - const waitUntil = cloudflareWaitUntil.bind(platform.context); - waitUntil(flush(2000)); + if (typeof platform?.context?.waitUntil === 'function') { + await flushIfServerless({ cloudflareCtx: platform.context as { waitUntil(promise: Promise): void } }); } else { await flushIfServerless(); } - // We're extra cautious with SafeHandleServerErrorInput - this type is not compatible with HandleServerErrorInput - // @ts-expect-error - we're still passing the same object, just with a different (backwards-compatible) type - return handleError(input); + return errorHandler(input); }; } /** * When a page request fails because the page is not found, SvelteKit throws a "Not found" error. */ -function isNotFoundError(input: SafeHandleServerErrorInput): boolean { +function is4xxError(input: SafeHandleServerErrorInput): boolean { const { error, event, status } = input; // SvelteKit 2.0 offers a reliable way to check for a Not Found error: - if (status === 404) { + if (!!status && status >= 400 && status < 500) { return true; } diff --git a/packages/sveltekit/src/server-common/load.ts b/packages/sveltekit/src/server-common/load.ts index ede0991d29c4..8b9cfca7de9b 100644 --- a/packages/sveltekit/src/server-common/load.ts +++ b/packages/sveltekit/src/server-common/load.ts @@ -1,12 +1,13 @@ import { addNonEnumerableProperty, + flushIfServerless, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan, } from '@sentry/core'; import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit'; import type { SentryWrappedFlag } from '../common/utils'; -import { flushIfServerless, sendErrorToSentry } from './utils'; +import { sendErrorToSentry } from './utils'; type PatchedLoadEvent = LoadEvent & SentryWrappedFlag; type PatchedServerLoadEvent = ServerLoadEvent & SentryWrappedFlag; diff --git a/packages/sveltekit/src/server-common/serverRoute.ts b/packages/sveltekit/src/server-common/serverRoute.ts index 72607318ecb3..d09233cb3633 100644 --- a/packages/sveltekit/src/server-common/serverRoute.ts +++ b/packages/sveltekit/src/server-common/serverRoute.ts @@ -1,11 +1,12 @@ import { addNonEnumerableProperty, + flushIfServerless, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan, } from '@sentry/core'; import type { RequestEvent } from '@sveltejs/kit'; -import { flushIfServerless, sendErrorToSentry } from './utils'; +import { sendErrorToSentry } from './utils'; type PatchedServerRouteEvent = RequestEvent & { __sentry_wrapped__?: boolean }; diff --git a/packages/sveltekit/src/server-common/utils.ts b/packages/sveltekit/src/server-common/utils.ts index 34e1575a70ea..b861bf758697 100644 --- a/packages/sveltekit/src/server-common/utils.ts +++ b/packages/sveltekit/src/server-common/utils.ts @@ -1,6 +1,5 @@ -import { captureException, debug, flush, objectify } from '@sentry/core'; +import { captureException, objectify } from '@sentry/core'; import type { RequestEvent } from '@sveltejs/kit'; -import { DEBUG_BUILD } from '../common/debug-build'; import { isHttpError, isRedirect } from '../common/utils'; /** @@ -16,25 +15,6 @@ export function getTracePropagationData(event: RequestEvent): { sentryTrace: str return { sentryTrace, baggage }; } -/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */ -export async function flushIfServerless(): Promise { - if (typeof process === 'undefined') { - return; - } - - const platformSupportsStreaming = !process.env.LAMBDA_TASK_ROOT && !process.env.VERCEL; - - if (!platformSupportsStreaming) { - try { - DEBUG_BUILD && debug.log('Flushing events...'); - await flush(2000); - DEBUG_BUILD && debug.log('Done flushing events'); - } catch (e) { - DEBUG_BUILD && debug.log('Error while flushing events:\n', e); - } - } -} - /** * Extracts a server-side sveltekit error, filters a couple of known errors we don't want to capture * and captures the error via `captureException`. diff --git a/packages/sveltekit/src/vite/svelteConfig.ts b/packages/sveltekit/src/vite/svelteConfig.ts index ed0c9ec6f801..34874bfd2f97 100644 --- a/packages/sveltekit/src/vite/svelteConfig.ts +++ b/packages/sveltekit/src/vite/svelteConfig.ts @@ -86,13 +86,10 @@ async function getNodeAdapterOutputDir(svelteConfig: Config): Promise { }, // @ts-expect-error - No need to implement the other methods log: { - // eslint-disable-next-line @typescript-eslint/no-empty-function -- this should be a noop minor() {}, }, getBuildDirectory: () => '', - // eslint-disable-next-line @typescript-eslint/no-empty-function -- this should be a noop rimraf: () => {}, - // eslint-disable-next-line @typescript-eslint/no-empty-function -- this should be a noop mkdirp: () => {}, config: { diff --git a/packages/sveltekit/test/client/handleError.test.ts b/packages/sveltekit/test/client/handleError.test.ts index 520f98fec56d..810acd865aa2 100644 --- a/packages/sveltekit/test/client/handleError.test.ts +++ b/packages/sveltekit/test/client/handleError.test.ts @@ -27,7 +27,7 @@ const captureExceptionEventHint = { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(_ => {}); -describe('handleError', () => { +describe('handleError (client)', () => { beforeEach(() => { mockCaptureException.mockClear(); consoleErrorSpy.mockClear(); @@ -55,19 +55,22 @@ describe('handleError', () => { expect(returnVal.message).toEqual('Whoops!'); expect(mockCaptureException).toHaveBeenCalledTimes(1); - expect(mockCaptureException).toHaveBeenCalledWith(mockError, captureExceptionEventHint); + expect(mockCaptureException).toHaveBeenCalledWith(mockError, { + mechanism: { handled: true, type: 'sveltekit' }, + }); + // Check that the default handler wasn't invoked expect(consoleErrorSpy).toHaveBeenCalledTimes(0); }); }); - it("doesn't capture 404 errors", async () => { + it.each([400, 401, 402, 403, 404, 429, 499])("doesn't capture %s errors", async statusCode => { const wrappedHandleError = handleErrorWithSentry(handleError); const returnVal = (await wrappedHandleError({ - error: new Error('404 Not Found'), + error: new Error(`Error with status ${statusCode}`), event: navigationEvent, - status: 404, - message: 'Not Found', + status: statusCode, + message: `Error with status ${statusCode}`, })) as any; expect(returnVal.message).toEqual('Whoops!'); diff --git a/packages/sveltekit/test/server-common/handleError.test.ts b/packages/sveltekit/test/server-common/handleError.test.ts index 287e8f2c88e8..6b4b3af992d1 100644 --- a/packages/sveltekit/test/server-common/handleError.test.ts +++ b/packages/sveltekit/test/server-common/handleError.test.ts @@ -19,7 +19,7 @@ const requestEvent = {} as RequestEvent; const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(_ => {}); -describe('handleError', () => { +describe('handleError (server)', () => { beforeEach(() => { mockCaptureException.mockClear(); consoleErrorSpy.mockClear(); @@ -42,20 +42,23 @@ describe('handleError', () => { expect(consoleErrorSpy).toHaveBeenCalledTimes(1); }); - it('doesn\'t capture "Not found" errors for incorrect navigations [Kit 2.x]', async () => { - const wrappedHandleError = handleErrorWithSentry(); + it.each([400, 401, 402, 403, 404, 429, 499])( + "doesn't capture %s errors for incorrect navigations [Kit 2.x]", + async statusCode => { + const wrappedHandleError = handleErrorWithSentry(); - const returnVal = await wrappedHandleError({ - error: new Error('404 /asdf/123'), - event: requestEvent, - status: 404, - message: 'Not Found', - }); + const returnVal = await wrappedHandleError({ + error: new Error(`Error with status ${statusCode}`), + event: requestEvent, + status: statusCode, + message: `Error with status ${statusCode}`, + }); - expect(returnVal).not.toBeDefined(); - expect(mockCaptureException).toHaveBeenCalledTimes(0); - expect(consoleErrorSpy).toHaveBeenCalledTimes(1); - }); + expect(returnVal).not.toBeDefined(); + expect(mockCaptureException).toHaveBeenCalledTimes(0); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }, + ); describe('calls captureException', () => { it('invokes the default handler if no handleError func is provided', async () => { @@ -87,7 +90,9 @@ describe('handleError', () => { expect(returnVal.message).toEqual('Whoops!'); expect(mockCaptureException).toHaveBeenCalledTimes(1); - expect(mockCaptureException).toHaveBeenCalledWith(mockError, captureExceptionEventHint); + expect(mockCaptureException).toHaveBeenCalledWith(mockError, { + mechanism: { handled: true, type: 'sveltekit' }, + }); // Check that the default handler wasn't invoked expect(consoleErrorSpy).toHaveBeenCalledTimes(0); }); diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 63209f104add..a5926948be3a 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -40,15 +40,15 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/resources": "^1.30.1", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@sentry/core": "9.40.0", - "@sentry/opentelemetry": "9.40.0" + "@opentelemetry/resources": "^2.0.0", + "@sentry/core": "9.40.0" }, "devDependencies": { "@edge-runtime/types": "3.0.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/sdk-trace-base": "^1.30.1" + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/sdk-trace-base": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@sentry/opentelemetry": "9.40.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/vercel-edge/rollup.npm.config.mjs b/packages/vercel-edge/rollup.npm.config.mjs index 3cfd779d57f6..ae01f43703d0 100644 --- a/packages/vercel-edge/rollup.npm.config.mjs +++ b/packages/vercel-edge/rollup.npm.config.mjs @@ -4,7 +4,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants, plugins } from '@sentry-inter export default makeNPMConfigVariants( makeBaseNPMConfig({ entrypoints: ['src/index.ts'], - bundledBuiltins: ['perf_hooks'], + bundledBuiltins: ['perf_hooks', 'util'], packageSpecificConfig: { context: 'globalThis', output: { @@ -21,9 +21,10 @@ export default makeNPMConfigVariants( }), { // This plugin is needed because otel imports `performance` from `perf_hooks` and also uses it via the `performance` global. + // It also imports `inspect` and `promisify` from node's `util` which are not available in the edge runtime so we need to define a polyfill. // Both of these APIs are not available in the edge runtime so we need to define a polyfill. // Vercel does something similar in the `@vercel/otel` package: https://github.com/vercel/otel/blob/087601ae585cb116bb2b46c211d014520de76c71/packages/otel/build.ts#L62 - name: 'perf-hooks-performance-polyfill', + name: 'edge-runtime-polyfills', banner: ` { if (globalThis.performance === undefined) { @@ -37,6 +38,8 @@ export default makeNPMConfigVariants( resolveId: source => { if (source === 'perf_hooks') { return '\0perf_hooks_sentry_shim'; + } else if (source === 'util') { + return '\0util_sentry_shim'; } else { return null; } @@ -49,6 +52,22 @@ export default makeNPMConfigVariants( now: () => Date.now() } `; + } else if (id === '\0util_sentry_shim') { + return ` + export const inspect = (object) => + JSON.stringify(object, null, 2); + + export const promisify = (fn) => { + return (...args) => { + return new Promise((resolve, reject) => { + fn(...args, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); + }; + }; + `; } else { return null; } diff --git a/packages/vercel-edge/src/client.ts b/packages/vercel-edge/src/client.ts index bdf8512be933..a34d1b36f09c 100644 --- a/packages/vercel-edge/src/client.ts +++ b/packages/vercel-edge/src/client.ts @@ -39,11 +39,8 @@ export class VercelEdgeClient extends ServerRuntimeClient { const provider = this.traceProvider; - const spanProcessor = provider?.activeSpanProcessor; - if (spanProcessor) { - await spanProcessor.forceFlush(); - } + await provider?.forceFlush(); if (this.getOptions().sendClientReports) { this._flushOutcomes(); diff --git a/packages/vercel-edge/src/logs/exports.ts b/packages/vercel-edge/src/logs/exports.ts index ef2614b81f55..c21477e378b3 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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `trace` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `debug` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `info` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `warn` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `error` level. Requires the `enableLogs` option to be enabled. * * @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 `_experiments.enableLogs` to be enabled. + * @summary Capture a log with the `fatal` level. Requires the `enableLogs` option to be enabled. * * @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/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index 12485a6c6579..ba83d7752ed3 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -1,5 +1,5 @@ import { context, diag, DiagLogLevel, propagation, trace } from '@opentelemetry/api'; -import { Resource } from '@opentelemetry/resources'; +import { defaultResource, resourceFromAttributes } from '@opentelemetry/resources'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { ATTR_SERVICE_NAME, @@ -158,12 +158,14 @@ export function setupOtel(client: VercelEdgeClient): void { // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new SentrySampler(client), - resource: new Resource({ - [ATTR_SERVICE_NAME]: 'edge', - // eslint-disable-next-line deprecation/deprecation - [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', - [ATTR_SERVICE_VERSION]: SDK_VERSION, - }), + resource: defaultResource().merge( + resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'edge', + // eslint-disable-next-line deprecation/deprecation + [SEMRESATTRS_SERVICE_NAMESPACE]: 'sentry', + [ATTR_SERVICE_VERSION]: SDK_VERSION, + }), + ), forceFlushTimeoutMillis: 500, spanProcessors: [ new SentrySpanProcessor({ diff --git a/yarn.lock b/yarn.lock index 16bf8fe6d899..22d7a8c0d4b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -76,9 +76,9 @@ integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q== "@adobe/css-tools@^4.0.1", "@adobe/css-tools@^4.4.0": - version "4.4.2" - resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.2.tgz#c836b1bd81e6d62cd6cdf3ee4948bcdce8ea79c8" - integrity sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A== + version "4.4.3" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.3.tgz#beebbefb0264fdeb32d3052acae0e0d94315a9a2" + integrity sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA== "@ai-sdk/provider-utils@2.2.8": version "2.2.8" @@ -2694,6 +2694,11 @@ resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-4.20250620.0.tgz#a22e635a631212963b84e315191614b20c4ad317" integrity sha512-EVvRB/DJEm6jhdKg+A4Qm4y/ry1cIvylSgSO3/f/Bv161vldDRxaXM2YoQQWFhLOJOw0qtrHsKOD51KYxV1XCw== +"@cloudflare/workers-types@^4.20250708.0": + version "4.20250726.0" + resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-4.20250726.0.tgz#2bcd78bc5e26aa222d4a8f8cf9edb8f5f3427bb3" + integrity sha512-NtM1yVBKJFX4LgSoZkVU0EDhWWvSb1vt6REO+uMYZRgx1HAfQz9GDN6bBB0B+fm2ZIxzt6FzlDbmrXpGJ2M/4Q== + "@cnakazawa/watch@^1.0.3": version "1.0.4" resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a" @@ -5843,249 +5848,252 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@opentelemetry/context-async-hooks@^1.30.1": - version "1.30.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz#4f76280691a742597fd0bf682982126857622948" - integrity sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA== - "@opentelemetry/context-async-hooks@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.0.tgz#c98a727238ca199cda943780acf6124af8d8cd80" integrity sha512-IEkJGzK1A9v3/EHjXh3s2IiFc6L4jfK+lNgKVgUjeUJQRRhnVFMIO3TAvKwonm9O1HebCuoOt98v8bZW7oVQHA== -"@opentelemetry/core@1.30.1", "@opentelemetry/core@^1.1.0", "@opentelemetry/core@^1.26.0", "@opentelemetry/core@^1.30.1", "@opentelemetry/core@^1.8.0": - version "1.30.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-1.30.1.tgz#a0b468bb396358df801881709ea38299fc30ab27" - integrity sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ== - dependencies: - "@opentelemetry/semantic-conventions" "1.28.0" - -"@opentelemetry/core@2.0.0", "@opentelemetry/core@^2.0.0": +"@opentelemetry/core@2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.0.0.tgz#37e9f0e9ddec4479b267aca6f32d88757c941b3a" integrity sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ== dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/instrumentation-amqplib@^0.46.1": - version "0.46.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz#7101678488d0e942162ca85c9ac6e93e1f3e0008" - integrity sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ== +"@opentelemetry/core@2.0.1", "@opentelemetry/core@^2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.0.1.tgz#44e1149d5666a4743cde943ef89841db3ce0f8bc" + integrity sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.57.1" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/instrumentation-aws-lambda@0.50.3": - version "0.50.3" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.50.3.tgz#bf76bd137780004aecfbb5c8335482afe5739878" - integrity sha512-kotm/mRvSWUauudxcylc5YCDei+G/r+jnOH6q5S99aPLQ/Ms8D2yonMIxEJUILIPlthEmwLYxkw3ualWzMjm/A== +"@opentelemetry/instrumentation-amqplib@0.50.0": + version "0.50.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.50.0.tgz#91899a7e2821db956daeaa803d3bd8f5af8b8050" + integrity sha512-kwNs/itehHG/qaQBcVrLNcvXVPW0I4FCOVtw3LHMLdYIqD7GJ6Yv2nX+a4YHjzbzIeRYj8iyMp0Bl7tlkidq5w== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" - "@types/aws-lambda" "8.10.147" -"@opentelemetry/instrumentation-aws-sdk@0.49.1": - version "0.49.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.49.1.tgz#e5de7235af82a5b77eca2132da62d41d64dbbba9" - integrity sha512-Vbj4BYeV/1K4Pbbfk+gQ8gwYL0w+tBeUwG88cOxnF7CLPO1XnskGV8Q3Gzut2Ah/6Dg17dBtlzEqL3UiFP2Z6A== +"@opentelemetry/instrumentation-aws-lambda@0.54.0": + version "0.54.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.54.0.tgz#835263593aa988ec460e840d3d47110392aaf92e" + integrity sha512-uiYI+kcMUJ/H9cxAwB8c9CaG8behLRgcYSOEA8M/tMQ54Y1ZmzAuEE3QKOi21/s30x5Q+by9g7BwiVfDtqzeMA== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.57.1" - "@opentelemetry/propagation-utils" "^0.30.16" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" + "@types/aws-lambda" "8.10.150" -"@opentelemetry/instrumentation-connect@0.43.1": - version "0.43.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.43.1.tgz#8ce88b94ce211c7bbdc9bd984b7a37876061bde3" - integrity sha512-ht7YGWQuV5BopMcw5Q2hXn3I8eG8TH0J/kc/GMcW4CuNTgiP6wCu44BOnucJWL3CmFWaRHI//vWyAhaC8BwePw== +"@opentelemetry/instrumentation-aws-sdk@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.56.0.tgz#a65cd88351b7bd8566413798764679295166754a" + integrity sha512-Jl2B/FYEb6tBCk9G31CMomKPikGU2g+CEhrGddDI0o1YeNpg3kAO9dExF+w489/IJUGZX6/wudyNvV7z4k9NjQ== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" + "@opentelemetry/propagation-utils" "^0.31.3" + "@opentelemetry/semantic-conventions" "^1.34.0" + +"@opentelemetry/instrumentation-connect@0.47.0": + version "0.47.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.47.0.tgz#47271b8454fa88d97aa78e175c3d0cb7e10bd9e2" + integrity sha512-pjenvjR6+PMRb6/4X85L4OtkQCootgb/Jzh/l/Utu3SJHBid1F+gk9sTGU2FWuhhEfV6P7MZ7BmCdHXQjgJ42g== + dependencies: + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" "@types/connect" "3.4.38" -"@opentelemetry/instrumentation-dataloader@0.16.1": - version "0.16.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.16.1.tgz#5d1d2c79f067c3102df7101f1753060ed93a1566" - integrity sha512-K/qU4CjnzOpNkkKO4DfCLSQshejRNAJtd4esgigo/50nxCB6XCyi1dhAblUHM9jG5dRm8eu0FB+t87nIo99LYQ== +"@opentelemetry/instrumentation-dataloader@0.21.0": + version "0.21.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.21.0.tgz#19202a85000cae9612f74bc689005ed3164e30a4" + integrity sha512-Xu4CZ1bfhdkV3G6iVHFgKTgHx8GbKSqrTU01kcIJRGHpowVnyOPEv1CW5ow+9GU2X4Eki8zoNuVUenFc3RluxQ== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/instrumentation" "^0.203.0" -"@opentelemetry/instrumentation-express@0.47.1": - version "0.47.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.1.tgz#7cf74f35e43cc3c8186edd1249fdb225849c48b2" - integrity sha512-QNXPTWteDclR2B4pDFpz0TNghgB33UMjUt14B+BZPmtH1MwUFAfLHBaP5If0Z5NZC+jaH8oF2glgYjrmhZWmSw== +"@opentelemetry/instrumentation-express@0.52.0": + version "0.52.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.52.0.tgz#d87d2130fe779dd757db28edb78262af83510d5b" + integrity sha512-W7pizN0Wh1/cbNhhTf7C62NpyYw7VfCFTYg0DYieSTrtPBT1vmoSZei19wfKLnrMsz3sHayCg0HxCVL2c+cz5w== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-fs@0.19.1": - version "0.19.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.19.1.tgz#ebfe40781949574a66a82b8511d9bcd414dbfe98" - integrity sha512-6g0FhB3B9UobAR60BGTcXg4IHZ6aaYJzp0Ki5FhnxyAPt8Ns+9SSvgcrnsN2eGmk3RWG5vYycUGOEApycQL24A== +"@opentelemetry/instrumentation-fs@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.23.0.tgz#e3cd3a53fa975c69de33e207b35561f3f90106f0" + integrity sha512-Puan+QopWHA/KNYvDfOZN6M/JtF6buXEyD934vrb8WhsX1/FuM7OtoMlQyIqAadnE8FqqDL4KDPiEfCQH6pQcQ== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" -"@opentelemetry/instrumentation-generic-pool@0.43.1": - version "0.43.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.43.1.tgz#6d1e181b32debc9510bdbbd63fe4ce5bc310d577" - integrity sha512-M6qGYsp1cURtvVLGDrPPZemMFEbuMmCXgQYTReC/IbimV5sGrLBjB+/hANUpRZjX67nGLdKSVLZuQQAiNz+sww== +"@opentelemetry/instrumentation-generic-pool@0.47.0": + version "0.47.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.47.0.tgz#f5fa9d42236eb7d57fa544954f316faee937b0b4" + integrity sha512-UfHqf3zYK+CwDwEtTjaD12uUqGGTswZ7ofLBEdQ4sEJp9GHSSJMQ2hT3pgBxyKADzUdoxQAv/7NqvL42ZI+Qbw== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/instrumentation" "^0.203.0" -"@opentelemetry/instrumentation-graphql@0.47.1": - version "0.47.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.47.1.tgz#1037bb546c82060d6d5d6f5dbd8765e31ccf6c26" - integrity sha512-EGQRWMGqwiuVma8ZLAZnExQ7sBvbOx0N/AE/nlafISPs8S+QtXX+Viy6dcQwVWwYHQPAcuY3bFt3xgoAwb4ZNQ== +"@opentelemetry/instrumentation-graphql@0.51.0": + version "0.51.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.51.0.tgz#1b29aa6330d196d523460e593167dca7dbcd42bb" + integrity sha512-LchkOu9X5DrXAnPI1+Z06h/EH/zC7D6sA86hhPrk3evLlsJTz0grPrkL/yUJM9Ty0CL/y2HSvmWQCjbJEz/ADg== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/instrumentation" "^0.203.0" -"@opentelemetry/instrumentation-hapi@0.45.2": - version "0.45.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.45.2.tgz#14d670e0bbbdf864187a9f80265a9219ed2d01cf" - integrity sha512-7Ehow/7Wp3aoyCrZwQpU7a2CnoMq0XhIcioFuKjBb0PLYfBfmTsFTUyatlHu0fRxhwcRsSQRTvEhmZu8CppBpQ== +"@opentelemetry/instrumentation-hapi@0.50.0": + version "0.50.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.50.0.tgz#c755e9c21bfeb82046221bfd51303f816ae649e8" + integrity sha512-5xGusXOFQXKacrZmDbpHQzqYD1gIkrMWuwvlrEPkYOsjUqGUjl1HbxCsn5Y9bUXOCgP1Lj6A4PcKt1UiJ2MujA== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-http@0.57.2": - version "0.57.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.2.tgz#f425eda67b6241c3abe08e4ea972169b85ef3064" - integrity sha512-1Uz5iJ9ZAlFOiPuwYg29Bf7bJJc/GeoeJIFKJYQf67nTVKFe8RHbEtxgkOmK4UGZNHKXcpW4P8cWBYzBn1USpg== +"@opentelemetry/instrumentation-http@0.203.0": + version "0.203.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.203.0.tgz#21f198547b5c72fc64e83ed25cdc991aef7b8fee" + integrity sha512-y3uQAcCOAwnO6vEuNVocmpVzG3PER6/YZqbPbbffDdJ9te5NkHEkfSMNzlC3+v7KlE+WinPGc3N7MR30G1HY2g== dependencies: - "@opentelemetry/core" "1.30.1" - "@opentelemetry/instrumentation" "0.57.2" - "@opentelemetry/semantic-conventions" "1.28.0" + "@opentelemetry/core" "2.0.1" + "@opentelemetry/instrumentation" "0.203.0" + "@opentelemetry/semantic-conventions" "^1.29.0" forwarded-parse "2.1.2" - semver "^7.5.2" -"@opentelemetry/instrumentation-ioredis@0.47.1": - version "0.47.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.47.1.tgz#5cedd0ebe8cfd3569513a9b44945827bf844b331" - integrity sha512-OtFGSN+kgk/aoKgdkKQnBsQFDiG8WdCxu+UrHr0bXScdAmtSzLSraLo7wFIb25RVHfRWvzI5kZomqJYEg/l1iA== +"@opentelemetry/instrumentation-ioredis@0.51.0": + version "0.51.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.51.0.tgz#47360999ad2b035aa2ac604c410272da671142d3" + integrity sha512-9IUws0XWCb80NovS+17eONXsw1ZJbHwYYMXiwsfR9TSurkLV5UNbRSKb9URHO+K+pIJILy9wCxvyiOneMr91Ig== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" - "@opentelemetry/redis-common" "^0.36.2" + "@opentelemetry/instrumentation" "^0.203.0" + "@opentelemetry/redis-common" "^0.38.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-kafkajs@0.7.1": - version "0.7.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.7.1.tgz#cc7a31a5fe2c14171611da8e46827f762f332625" - integrity sha512-OtjaKs8H7oysfErajdYr1yuWSjMAectT7Dwr+axIoZqT9lmEOkD/H/3rgAs8h/NIuEi2imSXD+vL4MZtOuJfqQ== +"@opentelemetry/instrumentation-kafkajs@0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.12.0.tgz#231e6cc8a2a70d06162ed7e4ebe2ab5baa3a6670" + integrity sha512-bIe4aSAAxytp88nzBstgr6M7ZiEpW6/D1/SuKXdxxuprf18taVvFL2H5BDNGZ7A14K27haHqzYqtCTqFXHZOYg== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.203.0" + "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-knex@0.44.1": - version "0.44.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.44.1.tgz#72f4efd798695c077ab218045d4c682231fbb36a" - integrity sha512-U4dQxkNhvPexffjEmGwCq68FuftFK15JgUF05y/HlK3M6W/G2iEaACIfXdSnwVNe9Qh0sPfw8LbOPxrWzGWGMQ== +"@opentelemetry/instrumentation-knex@0.48.0": + version "0.48.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.48.0.tgz#ed24a81dfe6099cfe56136a3fed90565e1259f58" + integrity sha512-V5wuaBPv/lwGxuHjC6Na2JFRjtPgstw19jTFl1B1b6zvaX8zVDYUDaR5hL7glnQtUSCMktPttQsgK4dhXpddcA== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.203.0" + "@opentelemetry/semantic-conventions" "^1.33.1" -"@opentelemetry/instrumentation-koa@0.47.1": - version "0.47.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.1.tgz#ba57eccd44a75ec59e3129757fda4e8c8dd7ce2c" - integrity sha512-l/c+Z9F86cOiPJUllUCt09v+kICKvT+Vg1vOAJHtHPsJIzurGayucfCMq2acd/A/yxeNWunl9d9eqZ0G+XiI6A== +"@opentelemetry/instrumentation-koa@0.51.0": + version "0.51.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.51.0.tgz#1ff57866b7882033639477d3d2d9bada19a2129f" + integrity sha512-XNLWeMTMG1/EkQBbgPYzCeBD0cwOrfnn8ao4hWgLv0fNCFQu1kCsJYygz2cvKuCs340RlnG4i321hX7R8gj3Rg== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-lru-memoizer@0.44.1": - version "0.44.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.44.1.tgz#1f0ec28130f8c379d310dc531a8b25780be8e445" - integrity sha512-5MPkYCvG2yw7WONEjYj5lr5JFehTobW7wX+ZUFy81oF2lr9IPfZk9qO+FTaM0bGEiymwfLwKe6jE15nHn1nmHg== +"@opentelemetry/instrumentation-lru-memoizer@0.48.0": + version "0.48.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.48.0.tgz#b9fbbd45b7a742a6795bf7166f65684251f184b1" + integrity sha512-KUW29wfMlTPX1wFz+NNrmE7IzN7NWZDrmFWHM/VJcmFEuQGnnBuTIdsP55CnBDxKgQ/qqYFp4udQFNtjeFosPw== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/instrumentation" "^0.203.0" -"@opentelemetry/instrumentation-mongodb@0.52.0": - version "0.52.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.52.0.tgz#a5ed123f3fac5d7d08347353cd37d9cf00893746" - integrity sha512-1xmAqOtRUQGR7QfJFfGV/M2kC7wmI2WgZdpru8hJl3S0r4hW0n3OQpEHlSGXJAaNFyvT+ilnwkT+g5L4ljHR6g== +"@opentelemetry/instrumentation-mongodb@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.56.0.tgz#81281d2d151c3bfb26864c50b938a82ba2831b2d" + integrity sha512-YG5IXUUmxX3Md2buVMvxm9NWlKADrnavI36hbJsihqqvBGsWnIfguf0rUP5Srr0pfPqhQjUP+agLMsvu0GmUpA== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-mongoose@0.46.1": - version "0.46.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.46.1.tgz#23f22b7d4d5a548ac8add2a52ec2fec4e61c7de1" - integrity sha512-3kINtW1LUTPkiXFRSSBmva1SXzS/72we/jL22N+BnF3DFcoewkdkHPYOIdAAk9gSicJ4d5Ojtt1/HeibEc5OQg== +"@opentelemetry/instrumentation-mongoose@0.50.0": + version "0.50.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.50.0.tgz#1fae5d2769ca7e67d15291fb91b61403839ad91d" + integrity sha512-Am8pk1Ct951r4qCiqkBcGmPIgGhoDiFcRtqPSLbJrUZqEPUsigjtMjoWDRLG1Ki1NHgOF7D0H7d+suWz1AAizw== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-mysql2@0.45.2": - version "0.45.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.2.tgz#590ed22f274a6999e57c3283433a119274cb572b" - integrity sha512-h6Ad60FjCYdJZ5DTz1Lk2VmQsShiViKe0G7sYikb0GHI0NVvApp2XQNRHNjEMz87roFttGPLHOYVPlfy+yVIhQ== +"@opentelemetry/instrumentation-mysql2@0.49.0": + version "0.49.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.49.0.tgz#ad518f9420cf8d2035bd4f80519406b66b66bb1a" + integrity sha512-dCub9wc02mkJWNyHdVEZ7dvRzy295SmNJa+LrAJY2a/+tIiVBQqEAajFzKwp9zegVVnel9L+WORu34rGLQDzxA== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" - "@opentelemetry/sql-common" "^0.40.1" + "@opentelemetry/sql-common" "^0.41.0" -"@opentelemetry/instrumentation-mysql@0.45.1": - version "0.45.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.45.1.tgz#6fb3fdf7b5afa62bfa4ce73fae213539bb660841" - integrity sha512-TKp4hQ8iKQsY7vnp/j0yJJ4ZsP109Ht6l4RHTj0lNEG1TfgTrIH5vJMbgmoYXWzNHAqBH2e7fncN12p3BP8LFg== +"@opentelemetry/instrumentation-mysql@0.49.0": + version "0.49.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.49.0.tgz#24fa7473134867236ed4068ee645e51922bcb654" + integrity sha512-QU9IUNqNsrlfE3dJkZnFHqLjlndiU39ll/YAAEvWE40sGOCi9AtOF6rmEGzJ1IswoZ3oyePV7q2MP8SrhJfVAA== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" - "@types/mysql" "2.15.26" + "@types/mysql" "2.15.27" -"@opentelemetry/instrumentation-nestjs-core@0.44.1": - version "0.44.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.44.1.tgz#54ee5877080055732093c59f8a9bc2aba4fae5f0" - integrity sha512-4TXaqJK27QXoMqrt4+hcQ6rKFd8B6V4JfrTJKnqBmWR1cbaqd/uwyl9yxhNH1JEkyo8GaBfdpBC4ZE4FuUhPmg== +"@opentelemetry/instrumentation-nestjs-core@0.49.0": + version "0.49.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.49.0.tgz#a79428e72c14250c44913a3a57f39c7297aab013" + integrity sha512-1R/JFwdmZIk3T/cPOCkVvFQeKYzbbUvDxVH3ShXamUwBlGkdEu5QJitlRMyVNZaHkKZKWgYrBarGQsqcboYgaw== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.203.0" + "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-pg@0.51.1": - version "0.51.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.51.1.tgz#a999a13fa56dc67da49a1ccf8f5e56a9ed409477" - integrity sha512-QxgjSrxyWZc7Vk+qGSfsejPVFL1AgAJdSBMYZdDUbwg730D09ub3PXScB9d04vIqPriZ+0dqzjmQx0yWKiCi2Q== +"@opentelemetry/instrumentation-pg@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.55.0.tgz#f411d1e48c50b1c1f0f185d9fe94cfbb8812d8f6" + integrity sha512-yfJ5bYE7CnkW/uNsnrwouG/FR7nmg09zdk2MSs7k0ZOMkDDAE3WBGpVFFApGgNu2U+gtzLgEzOQG4I/X+60hXw== dependencies: - "@opentelemetry/core" "^1.26.0" - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" - "@opentelemetry/sql-common" "^0.40.1" - "@types/pg" "8.6.1" + "@opentelemetry/sql-common" "^0.41.0" + "@types/pg" "8.15.4" "@types/pg-pool" "2.0.6" -"@opentelemetry/instrumentation-redis-4@0.46.1": - version "0.46.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.46.1.tgz#325697dfccda3e70662769c6db230a37812697c6" - integrity sha512-UMqleEoabYMsWoTkqyt9WAzXwZ4BlFZHO40wr3d5ZvtjKCHlD4YXLm+6OLCeIi/HkX7EXvQaz8gtAwkwwSEvcQ== +"@opentelemetry/instrumentation-redis@0.51.0": + version "0.51.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.51.0.tgz#70504ba6c3856fcb25e436b4915e85efaa7d38a6" + integrity sha512-uL/GtBA0u72YPPehwOvthAe+Wf8k3T+XQPBssJmTYl6fzuZjNq8zTfxVFhl9nRFjFVEe+CtiYNT0Q3AyqW1Z0A== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" - "@opentelemetry/redis-common" "^0.36.2" + "@opentelemetry/instrumentation" "^0.203.0" + "@opentelemetry/redis-common" "^0.38.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-tedious@0.18.1": - version "0.18.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.18.1.tgz#d87dba9d0ddfc77f9fcbcceabcc31cb5a5f7bb11" - integrity sha512-5Cuy/nj0HBaH+ZJ4leuD7RjgvA844aY2WW+B5uLcWtxGjRZl3MNLuxnNg5DYWZNPO+NafSSnra0q49KWAHsKBg== +"@opentelemetry/instrumentation-tedious@0.22.0": + version "0.22.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.22.0.tgz#f71374c52cb9c57a6b879bea3256a1465c02efbb" + integrity sha512-XrrNSUCyEjH1ax9t+Uo6lv0S2FCCykcF7hSxBMxKf7Xn0bPRxD3KyFUZy25aQXzbbbUHhtdxj3r2h88SfEM3aA== dependencies: - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" "@types/tedious" "^4.0.14" -"@opentelemetry/instrumentation-undici@0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.10.1.tgz#228b7fc267e55533708be16c43e70bbb51a691de" - integrity sha512-rkOGikPEyRpMCmNu9AQuV5dtRlDmJp2dK5sw8roVshAGoB6hH/3QjDtRhdwd75SsJwgynWUNRUYe0wAkTo16tQ== +"@opentelemetry/instrumentation-undici@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.14.0.tgz#7a9cd276f7664773b5daf5ae53365b3593e6e7a9" + integrity sha512-2HN+7ztxAReXuxzrtA3WboAKlfP5OsPA57KQn2AdYZbJ3zeRPcLXyW4uO/jpLE6PLm0QRtmeGCmfYpqRlwgSwg== dependencies: - "@opentelemetry/core" "^1.8.0" - "@opentelemetry/instrumentation" "^0.57.1" + "@opentelemetry/core" "^2.0.0" + "@opentelemetry/instrumentation" "^0.203.0" + +"@opentelemetry/instrumentation@0.203.0", "@opentelemetry/instrumentation@^0.203.0": + version "0.203.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.203.0.tgz#5c74a41cd6868f7ba47b346ff5a58ea7b18cf381" + integrity sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ== + dependencies: + "@opentelemetry/api-logs" "0.203.0" + import-in-the-middle "^1.8.1" + require-in-the-middle "^7.1.1" -"@opentelemetry/instrumentation@0.57.2", "@opentelemetry/instrumentation@^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0", "@opentelemetry/instrumentation@^0.57.1", "@opentelemetry/instrumentation@^0.57.2": +"@opentelemetry/instrumentation@^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0": version "0.57.2" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz#8924549d7941ba1b5c6f04d5529cf48330456d1d" integrity sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg== @@ -6097,32 +6105,15 @@ semver "^7.5.2" shimmer "^1.2.1" -"@opentelemetry/instrumentation@^0.203.0": - version "0.203.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.203.0.tgz#5c74a41cd6868f7ba47b346ff5a58ea7b18cf381" - integrity sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ== - dependencies: - "@opentelemetry/api-logs" "0.203.0" - import-in-the-middle "^1.8.1" - require-in-the-middle "^7.1.1" - -"@opentelemetry/propagation-utils@^0.30.16": - version "0.30.16" - resolved "https://registry.yarnpkg.com/@opentelemetry/propagation-utils/-/propagation-utils-0.30.16.tgz#6715d0225b618ea66cf34cc3800fa3452a8475fa" - integrity sha512-ZVQ3Z/PQ+2GQlrBfbMMMT0U7MzvYZLCPP800+ooyaBqm4hMvuQHfP028gB9/db0mwkmyEAMad9houukUVxhwcw== +"@opentelemetry/propagation-utils@^0.31.3": + version "0.31.3" + resolved "https://registry.yarnpkg.com/@opentelemetry/propagation-utils/-/propagation-utils-0.31.3.tgz#42aab61a1a3cec64ce221cbcec5f3f6fc84e9701" + integrity sha512-ZI6LKjyo+QYYZY5SO8vfoCQ9A69r1/g+pyjvtu5RSK38npINN1evEmwqbqhbg2CdcIK3a4PN6pDAJz/yC5/gAA== -"@opentelemetry/redis-common@^0.36.2": - version "0.36.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz#906ac8e4d804d4109f3ebd5c224ac988276fdc47" - integrity sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g== - -"@opentelemetry/resources@1.30.1", "@opentelemetry/resources@^1.30.1": - version "1.30.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.30.1.tgz#a4eae17ebd96947fdc7a64f931ca4b71e18ce964" - integrity sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA== - dependencies: - "@opentelemetry/core" "1.30.1" - "@opentelemetry/semantic-conventions" "1.28.0" +"@opentelemetry/redis-common@^0.38.0": + version "0.38.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.0.tgz#87d2a792dcbcf466a41bb7dfb8a7cd094d643d0b" + integrity sha512-4Wc0AWURII2cfXVVoZ6vDqK+s5n4K5IssdrlVrvGsx6OEOKdghKtJZqXAHWFiZv4nTDLH2/2fldjIHY8clMOjQ== "@opentelemetry/resources@2.0.0": version "2.0.0" @@ -6132,14 +6123,13 @@ "@opentelemetry/core" "2.0.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-base@^1.30.1": - version "1.30.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz#41a42234096dc98e8f454d24551fc80b816feb34" - integrity sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg== +"@opentelemetry/resources@^2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.0.1.tgz#0365d134291c0ed18d96444a1e21d0e6a481c840" + integrity sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw== dependencies: - "@opentelemetry/core" "1.30.1" - "@opentelemetry/resources" "1.30.1" - "@opentelemetry/semantic-conventions" "1.28.0" + "@opentelemetry/core" "2.0.1" + "@opentelemetry/semantic-conventions" "^1.29.0" "@opentelemetry/sdk-trace-base@^2.0.0": version "2.0.0" @@ -6150,22 +6140,17 @@ "@opentelemetry/resources" "2.0.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/semantic-conventions@1.28.0": - version "1.28.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz#337fb2bca0453d0726696e745f50064411f646d6" - integrity sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA== - -"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.34.0": +"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0": version "1.34.0" resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz#8b6a46681b38a4d5947214033ac48128328c1738" integrity sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA== -"@opentelemetry/sql-common@^0.40.1": - version "0.40.1" - resolved "https://registry.yarnpkg.com/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz#93fbc48d8017449f5b3c3274f2268a08af2b83b6" - integrity sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg== +"@opentelemetry/sql-common@^0.41.0": + version "0.41.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sql-common/-/sql-common-0.41.0.tgz#7ddef1ea7fb6338dcca8a9d2485c7dfd53c076b4" + integrity sha512-pmzXctVbEERbqSfiAgdes9Y63xjoOyXcD7B6IXBkVb+vbM7M9U98mn33nGXxPf4dfYR0M+vhcKRZmbSJ7HfqFA== dependencies: - "@opentelemetry/core" "^1.1.0" + "@opentelemetry/core" "^2.0.0" "@parcel/watcher-android-arm64@2.5.1": version "2.5.1" @@ -6320,10 +6305,10 @@ resolved "https://registry.yarnpkg.com/@poppinss/exception/-/exception-1.2.2.tgz#8d30d42e126c54fe84e997433e4dcac610090743" integrity sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg== -"@prisma/instrumentation@6.11.1": - version "6.11.1" - resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.11.1.tgz#db3c40dbf325cf7a816504b8bc009ca3d4734c2f" - integrity sha512-mrZOev24EDhnefmnZX7WVVT7v+r9LttPRqf54ONvj6re4XMF7wFTpK2tLJi4XHB7fFp/6xhYbgRel8YV7gQiyA== +"@prisma/instrumentation@6.12.0": + version "6.12.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.12.0.tgz#fd47ed75bfb8f1180a0d77695084f2c0c46bb4d7" + integrity sha512-UfwLME9uRDKGOu06Yrj5ERT5XVx4xvdyPsjRtQl2gY2ZgSK6c2ZNsKfEPVQHwrNl4hu2m9Rw1KCcy0sdEnefKw== dependencies: "@opentelemetry/instrumentation" "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" @@ -6862,10 +6847,10 @@ detect-libc "^2.0.3" node-abi "^3.73.0" -"@sentry-internal/node-native-stacktrace@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.2.0.tgz#d759d9ba62101aea46829c436aec490d4a63f9f7" - integrity sha512-MPkjcXFUaBVxbpx8whvqQu7UncriCt3nUN7uA+ojgauHF2acvSp5nJCqKM2a4KInFWNiI1AxJ6tLE7EuBJ4WBQ== +"@sentry-internal/node-native-stacktrace@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.2.2.tgz#b32dde884642f100dd691b12b643361040825eeb" + integrity sha512-ZRS+a1Ik+w6awjp9na5vHBqLNkIxysfGDswLVAkjtVdBUxtfsEVI8OA6r8PijJC5Gm1oAJJap2e9H7TSiCUQIQ== dependencies: detect-libc "^2.0.4" node-abi "^3.73.0" @@ -6938,142 +6923,74 @@ fflate "^0.4.4" mitt "^3.0.0" -"@sentry/babel-plugin-component-annotate@2.22.6": - version "2.22.6" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.22.6.tgz#829d6caf2c95c1c46108336de4e1049e6521435e" - integrity sha512-V2g1Y1I5eSe7dtUVMBvAJr8BaLRr4CLrgNgtPaZyMT4Rnps82SrZ5zqmEkLXPumlXhLUWR6qzoMNN2u+RXVXfQ== +"@sentry-internal/test-utils@link:dev-packages/test-utils": + version "9.40.0" + dependencies: + express "^4.21.1" -"@sentry/babel-plugin-component-annotate@3.5.0": - version "3.5.0" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.5.0.tgz#1b0d01f903b725da876117d551610085c3dd21c7" - integrity sha512-s2go8w03CDHbF9luFGtBHKJp4cSpsQzNVqgIa9Pfa4wnjipvrK6CxVT4icpLA3YO6kg5u622Yoa5GF3cJdippw== +"@sentry/babel-plugin-component-annotate@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.0.0.tgz#2f4dfeabba28a76b5a1b32a1058386e52f32634f" + integrity sha512-1sozj4esnQBhJ2QO4imiLMl1858StkLjUxFF1KxgX/X1uEL/QlW2MYL8CKzbLeACy1SkR9h4V8GXSZvCnci5Dw== -"@sentry/bundler-plugin-core@2.22.6": - version "2.22.6" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-2.22.6.tgz#a1ea1fd43700a3ece9e7db016997e79a2782b87d" - integrity sha512-1esQdgSUCww9XAntO4pr7uAM5cfGhLsgTK9MEwAKNfvpMYJi9NUTYa3A7AZmdA8V6107Lo4OD7peIPrDRbaDCg== +"@sentry/bundler-plugin-core@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.0.0.tgz#564463cf53f869496ab5d4986e97f86618a67677" + integrity sha512-dTdbcctT5MJUwdbttZm2zomO+ui1F062ZIkogHeHqlA938Fwd1+9JIJ328+XL4XdcUG2yiFAZBWUPW3bYwoN9A== dependencies: "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "2.22.6" - "@sentry/cli" "^2.36.1" + "@sentry/babel-plugin-component-annotate" "4.0.0" + "@sentry/cli" "^2.49.0" dotenv "^16.3.1" find-up "^5.0.0" glob "^9.3.2" magic-string "0.30.8" unplugin "1.0.1" -"@sentry/bundler-plugin-core@3.5.0": - version "3.5.0" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-3.5.0.tgz#b62af5be1b1a862e7062181655829c556c7d7c0b" - integrity sha512-zDzPrhJqAAy2VzV4g540qAZH4qxzisstK2+NIJPZUUKztWRWUV2cMHsyUtdctYgloGkLyGpZJBE3RE6dmP/xqQ== - dependencies: - "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "3.5.0" - "@sentry/cli" "2.42.2" - dotenv "^16.3.1" - find-up "^5.0.0" - glob "^9.3.2" - magic-string "0.30.8" - unplugin "1.0.1" +"@sentry/cli-darwin@2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.50.0.tgz#0fec0ece84afe37b33464ccd514367fc95d507f3" + integrity sha512-Aj+cLBZ0dCw+pdUxvJ1U71PnKh2YjvpzLN9h1ZTe8UI3FqmkKkSH/J8mN/5qmR7qUHjDcm2l+wfgVUaaP8CWbA== -"@sentry/cli-darwin@2.42.2": - version "2.42.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.42.2.tgz#a32a4f226e717122b37d9969e8d4d0e14779f720" - integrity sha512-GtJSuxER7Vrp1IpxdUyRZzcckzMnb4N5KTW7sbTwUiwqARRo+wxS+gczYrS8tdgtmXs5XYhzhs+t4d52ITHMIg== - -"@sentry/cli-darwin@2.46.0": - version "2.46.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.46.0.tgz#e07ff66f03e8cb6e1988b7673ae5dbd6ff957b1d" - integrity sha512-5Ll+e5KAdIk9OYiZO8aifMBRNWmNyPjSqdjaHlBC1Qfh7pE3b1zyzoHlsUazG0bv0sNrSGea8e7kF5wIO1hvyg== - -"@sentry/cli-linux-arm64@2.42.2": - version "2.42.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.42.2.tgz#1c06c83ff21f51ec23acf5be3b1f8c7553bf86b1" - integrity sha512-BOxzI7sgEU5Dhq3o4SblFXdE9zScpz6EXc5Zwr1UDZvzgXZGosUtKVc7d1LmkrHP8Q2o18HcDWtF3WvJRb5Zpw== - -"@sentry/cli-linux-arm64@2.46.0": - version "2.46.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.46.0.tgz#d5b27e5813e7b3add65c9e3dbdd75a8bea4ef324" - integrity sha512-OEJN8yAjI9y5B4telyqzu27Hi3+S4T8VxZCqJz1+z2Mp0Q/MZ622AahVPpcrVq/5bxrnlZR16+lKh8L1QwNFPg== - -"@sentry/cli-linux-arm@2.42.2": - version "2.42.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.42.2.tgz#00cadc359ae3c051efb3e63873c033c61dbd1ca1" - integrity sha512-7udCw+YL9lwq+9eL3WLspvnuG+k5Icg92YE7zsteTzWLwgPVzaxeZD2f8hwhsu+wmL+jNqbpCRmktPteh3i2mg== - -"@sentry/cli-linux-arm@2.46.0": - version "2.46.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.46.0.tgz#d2a0f21cd208ef8e844bc5e565b337640d125441" - integrity sha512-WRrLNq/TEX/TNJkGqq6Ad0tGyapd5dwlxtsPbVBrIdryuL1mA7VCBoaHBr3kcwJLsgBHFH0lmkMee2ubNZZdkg== - -"@sentry/cli-linux-i686@2.42.2": - version "2.42.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.42.2.tgz#3b817b715dd806c20dfbffd539725ad8089c310a" - integrity sha512-Sw/dQp5ZPvKnq3/y7wIJyxTUJYPGoTX/YeMbDs8BzDlu9to2LWV3K3r7hE7W1Lpbaw4tSquUHiQjP5QHCOS7aQ== - -"@sentry/cli-linux-i686@2.46.0": - version "2.46.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.46.0.tgz#73368ebe30236c8647caec420f717a7f45410f29" - integrity sha512-xko3/BVa4LX8EmRxVOCipV+PwfcK5Xs8lP6lgF+7NeuAHMNL4DqF6iV9rrN8gkGUHCUI9RXSve37uuZnFy55+Q== - -"@sentry/cli-linux-x64@2.42.2": - version "2.42.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.42.2.tgz#ddf906bc3071cc79ce6e633eddcb76bb9068e688" - integrity sha512-mU4zUspAal6TIwlNLBV5oq6yYqiENnCWSxtSQVzWs0Jyq97wtqGNG9U+QrnwjJZ+ta/hvye9fvL2X25D/RxHQw== - -"@sentry/cli-linux-x64@2.46.0": - version "2.46.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.46.0.tgz#49da3dfd873e0e72abef968e1c213b9397e5d70e" - integrity sha512-hJ1g5UEboYcOuRia96LxjJ0jhnmk8EWLDvlGnXLnYHkwy3ree/L7sNgdp/QsY8Z4j2PGO5f22Va+UDhSjhzlfQ== - -"@sentry/cli-win32-arm64@2.46.0": - version "2.46.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.46.0.tgz#4e26b254d5283eb114ac916ac504283a30b2ecdb" - integrity sha512-mN7cpPoCv2VExFRGHt+IoK11yx4pM4ADZQGEso5BAUZ5duViXB2WrAXCLd8DrwMnP0OE978a7N8OtzsFqjkbNA== - -"@sentry/cli-win32-i686@2.42.2": - version "2.42.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.42.2.tgz#9036085c7c6ce455ad45fda411c55ff39c06eb95" - integrity sha512-iHvFHPGqgJMNqXJoQpqttfsv2GI3cGodeTq4aoVLU/BT3+hXzbV0x1VpvvEhncJkDgDicJpFLM8sEPHb3b8abw== - -"@sentry/cli-win32-i686@2.46.0": - version "2.46.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.46.0.tgz#72f7c0a611f17b7e5b34e2b47309d165195a8276" - integrity sha512-6F73AUE3lm71BISUO19OmlnkFD5WVe4/wA1YivtLZTc1RU3eUYJLYxhDfaH3P77+ycDppQ2yCgemLRaA4A8mNQ== - -"@sentry/cli-win32-x64@2.42.2": - version "2.42.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.42.2.tgz#7d6464b63f32c9f97fff428f246b1f039b402233" - integrity sha512-vPPGHjYoaGmfrU7xhfFxG7qlTBacroz5NdT+0FmDn6692D8IvpNXl1K+eV3Kag44ipJBBeR8g1HRJyx/F/9ACw== - -"@sentry/cli-win32-x64@2.46.0": - version "2.46.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.46.0.tgz#8cfd438ec365b0ee925d9724a24b533b4cb75587" - integrity sha512-yuGVcfepnNL84LGA0GjHzdMIcOzMe0bjPhq/rwPsPN+zu11N+nPR2wV2Bum4U0eQdqYH3iAlMdL5/BEQfuLJww== - -"@sentry/cli@2.42.2": - version "2.42.2" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.42.2.tgz#8173df4d057d600a9ef0cf1e9b42b0c6607b46e4" - integrity sha512-spb7S/RUumCGyiSTg8DlrCX4bivCNmU/A1hcfkwuciTFGu8l5CDc2I6jJWWZw8/0enDGxuj5XujgXvU5tr4bxg== - dependencies: - https-proxy-agent "^5.0.0" - node-fetch "^2.6.7" - progress "^2.0.3" - proxy-from-env "^1.1.0" - which "^2.0.2" - optionalDependencies: - "@sentry/cli-darwin" "2.42.2" - "@sentry/cli-linux-arm" "2.42.2" - "@sentry/cli-linux-arm64" "2.42.2" - "@sentry/cli-linux-i686" "2.42.2" - "@sentry/cli-linux-x64" "2.42.2" - "@sentry/cli-win32-i686" "2.42.2" - "@sentry/cli-win32-x64" "2.42.2" - -"@sentry/cli@^2.36.1", "@sentry/cli@^2.46.0": - version "2.46.0" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.46.0.tgz#790864874ea04f804053aa85dc94501b2cc321bb" - integrity sha512-nqoPl7UCr446QFkylrsRrUXF51x8Z9dGquyf4jaQU+OzbOJMqclnYEvU6iwbwvaw3tu/2DnoZE/Og+Nq1h63sA== +"@sentry/cli-linux-arm64@2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.50.0.tgz#bbafacf82766d45ff05434cd7cabbda7005d1efd" + integrity sha512-p6hIh4Bb87qBfEz9w5dxEPAohIKcw68qoy5VUTx+cCanO8uXNWWsT78xtUNFRscW9zc6MxQMSITTWaCEIKvxRA== + +"@sentry/cli-linux-arm@2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.50.0.tgz#e1fed09b94c508db9de5353d8305828b0a3551e9" + integrity sha512-SGPAFwOY2of2C+RUBJcxMN2JXikVFEk8ypYOsQTEvV/48cLejcO/O2mHIj/YKgIkrfn3t7LlqdK6g75lkz+F8Q== + +"@sentry/cli-linux-i686@2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.50.0.tgz#95f0eb65bdde4c33e492830ae4ac207b60494f8e" + integrity sha512-umhGmbiCUG7MvjTm8lXFmFxQjyTVtYakilBwPTVzRELmNKxxhfKRxwSSA+hUKetAUzNd8fJx8K7yqdw+qRA7Pg== + +"@sentry/cli-linux-x64@2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.50.0.tgz#5266b6b8660e6b72688331b7c702e9d1ca6413ed" + integrity sha512-ugIIx9+wUmguxOUe9ZVacvdCffZwqtFSKwpJ06Nqes0XfL4ZER4Qlq3/miCZ8m150C4xK5ym/QCwB41ffBqI4g== + +"@sentry/cli-win32-arm64@2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.50.0.tgz#663d75fea42b853940c6faacf7ee76a16b449654" + integrity sha512-fMyBSKLrVHY9944t8oTpul+6osyQeuN8GGGP3diDxGQpynYL+vhcHZIpXFRH398+3kedG/IFoY7EwGgIEqWzmw== + +"@sentry/cli-win32-i686@2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.50.0.tgz#96813ca970f35a839d7f817534ac556bc1df1567" + integrity sha512-VbC+l2Y2kB7Lsun2c8t7ZGwmljmXnyncZLW9PjdEyJSTAJ9GnEnSvyFSPXNLV/eHJnfQffzU7QTjU8vkQ7XMYg== + +"@sentry/cli-win32-x64@2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.50.0.tgz#9f644efed8cb75943078a0ca4e414fa21dda6280" + integrity sha512-nMktyF93NtQUOViAAKHpHSWACOGjOkKjiewi4pD6W3sWllFiPPyt15XoyApqWwnICDRQu2DI5vnil4ck6/k7mw== + +"@sentry/cli@^2.46.0", "@sentry/cli@^2.49.0", "@sentry/cli@^2.50.0": + version "2.50.0" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.50.0.tgz#7e2298bea9a2bb50126bfb24116ae98199bc1f6f" + integrity sha512-OHRRQPUNjBpzOT6arNhxXQ71DKs5jSziCfDzmEGwAs+K8J/I1QxnvJkto88HbXE54oiWhSEJwL0pvcowFXyVbA== dependencies: https-proxy-agent "^5.0.0" node-fetch "^2.6.7" @@ -7081,45 +6998,37 @@ proxy-from-env "^1.1.0" which "^2.0.2" optionalDependencies: - "@sentry/cli-darwin" "2.46.0" - "@sentry/cli-linux-arm" "2.46.0" - "@sentry/cli-linux-arm64" "2.46.0" - "@sentry/cli-linux-i686" "2.46.0" - "@sentry/cli-linux-x64" "2.46.0" - "@sentry/cli-win32-arm64" "2.46.0" - "@sentry/cli-win32-i686" "2.46.0" - "@sentry/cli-win32-x64" "2.46.0" - -"@sentry/rollup-plugin@^3.5.0": - version "3.5.0" - resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-3.5.0.tgz#9015c48e00257f8440597167498499804371329b" - integrity sha512-aMPCvdNMkv//LZYjYCJsEcNiNiaQFinBO75+9NJVEe1OrKNdGqDi3hky2ll7zuY+xozEtZCZcUKJJz/aAYAS8A== - dependencies: - "@sentry/bundler-plugin-core" "3.5.0" - unplugin "1.0.1" - -"@sentry/vite-plugin@2.22.6", "@sentry/vite-plugin@^2.22.6": - version "2.22.6" - resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-2.22.6.tgz#d08a1ede05f137636d5b3c61845d24c0114f0d76" - integrity sha512-zIieP1VLWQb3wUjFJlwOAoaaJygJhXeUoGd0e/Ha2RLb2eW2S+4gjf6y6NqyY71tZ74LYVZKg/4prB6FAZSMXQ== + "@sentry/cli-darwin" "2.50.0" + "@sentry/cli-linux-arm" "2.50.0" + "@sentry/cli-linux-arm64" "2.50.0" + "@sentry/cli-linux-i686" "2.50.0" + "@sentry/cli-linux-x64" "2.50.0" + "@sentry/cli-win32-arm64" "2.50.0" + "@sentry/cli-win32-i686" "2.50.0" + "@sentry/cli-win32-x64" "2.50.0" + +"@sentry/rollup-plugin@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.0.0.tgz#746112b23333d39b3cd1f0e285ca242dd753de67" + integrity sha512-+UVAcx/HD+emrZAYNkIt/iKPz6gVCHVBX8D4s1qt3/gDUzfqX9E2Yq8wkN3sFlf0C4Ks31OCe4vMHJ4m6YLRRQ== dependencies: - "@sentry/bundler-plugin-core" "2.22.6" + "@sentry/bundler-plugin-core" "4.0.0" unplugin "1.0.1" -"@sentry/vite-plugin@^3.5.0": - version "3.5.0" - resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-3.5.0.tgz#138fc535c97e69eb8032d57c02aba9c161c7654a" - integrity sha512-jUnpTdpicG8wefamw7eNo2uO+Q3KCbOAiF76xH4gfNHSW6TN2hBfOtmLu7J+ive4c0Al3+NEHz19bIPR0lkwWg== +"@sentry/vite-plugin@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-4.0.0.tgz#ac2780e1d2d88371b0a4a6dc834eadc8bc92d8ea" + integrity sha512-JX5irzvyoOSKto0U0eXDqigsTXdXnPRQaAms/kcU6A6Bf+WaPfCTE5NrJWg6ZeLvi7GiPWch11OO+TB6ZN8RKA== dependencies: - "@sentry/bundler-plugin-core" "3.5.0" + "@sentry/bundler-plugin-core" "4.0.0" unplugin "1.0.1" -"@sentry/webpack-plugin@^3.5.0": - version "3.5.0" - resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-3.5.0.tgz#cde95534f1e945a4002d47465aeda01d382cd279" - integrity sha512-xvclj0QY2HyU7uJLzOlHSrZQBDwfnGKJxp8mmlU4L7CwmK+8xMCqlO7tYZoqE4K/wU3c2xpXql70x8qmvNMxzQ== +"@sentry/webpack-plugin@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.0.0.tgz#0c32a40b63098e6db505ad40a93840c13b81faed" + integrity sha512-Uhfjqnuxv4eYIt0GbPAdlFPum3BtasNhQrO3OJuVQRYRq21En7ARKXISoOhZHMo4tRRiiv+3npKYmpzHTALbQg== dependencies: - "@sentry/bundler-plugin-core" "3.5.0" + "@sentry/bundler-plugin-core" "4.0.0" unplugin "1.0.1" uuid "^9.0.0" @@ -8003,10 +7912,10 @@ resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== -"@types/aws-lambda@8.10.147", "@types/aws-lambda@^8.10.62": - version "8.10.147" - resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.147.tgz#dc5c89aa32f47a9b35e52c32630545c83afa6f2f" - integrity sha512-nD0Z9fNIZcxYX5Mai2CTmFD7wX7UldCkW2ezCF8D1T5hdiLsnTWDGRpfRYntU6VjTdLQjOvyszru7I1c1oCQew== +"@types/aws-lambda@8.10.150", "@types/aws-lambda@^8.10.62": + version "8.10.150" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.150.tgz#4998b238750ec389a326a7cdb625808834036bd3" + integrity sha512-AX+AbjH/rH5ezX1fbK8onC/a+HyQHo7QGmvoxAE42n22OsciAxvZoZNEr22tbXs8WfP1nIsBjKDpgPm3HjOZbA== "@types/babel__core@^7.20.1", "@types/babel__core@^7.20.4": version "7.20.5" @@ -8584,10 +8493,10 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.32.tgz#f6cd08939ae3ad886fcc92ef7f0109dacddf61ab" integrity sha512-xPSg0jm4mqgEkNhowKgZFBNtwoEwF6gJ4Dhww+GFpm3IgtNseHQZ5IqdNwnquZEoANxyDAKDRAdVo4Z72VvD/g== -"@types/mysql@2.15.26", "@types/mysql@^2.15.21": - version "2.15.26" - resolved "https://registry.yarnpkg.com/@types/mysql/-/mysql-2.15.26.tgz#f0de1484b9e2354d587e7d2bd17a873cc8300836" - integrity sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ== +"@types/mysql@2.15.27", "@types/mysql@^2.15.21": + version "2.15.27" + resolved "https://registry.yarnpkg.com/@types/mysql/-/mysql-2.15.27.tgz#fb13b0e8614d39d42f40f381217ec3215915f1e9" + integrity sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA== dependencies: "@types/node" "*" @@ -8671,19 +8580,10 @@ dependencies: "@types/pg" "*" -"@types/pg@*", "@types/pg@^8.6.5": - version "8.10.2" - resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.10.2.tgz#7814d1ca02c8071f4d0864c1b17c589b061dba43" - integrity sha512-MKFs9P6nJ+LAeHLU3V0cODEOgyThJ3OAnmOlsZsxux6sfQs3HRXR5bBn7xG5DjckEFhTAxsXi7k7cd0pCMxpJw== - dependencies: - "@types/node" "*" - pg-protocol "*" - pg-types "^4.0.1" - -"@types/pg@8.6.1": - version "8.6.1" - resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.6.1.tgz#099450b8dc977e8197a44f5229cedef95c8747f9" - integrity sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w== +"@types/pg@*", "@types/pg@8.15.4", "@types/pg@^8.6.5": + version "8.15.4" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.15.4.tgz#419f791c6fac8e0bed66dd8f514b60f8ba8db46d" + integrity sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg== dependencies: "@types/node" "*" pg-protocol "*" @@ -15388,6 +15288,16 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: dependencies: es-errors "^1.3.0" +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + es-shim-unscopables@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" @@ -17166,12 +17076,14 @@ foreground-child@^3.1.0: signal-exit "^4.0.1" form-data@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + version "4.0.4" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" + integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" mime-types "^2.1.12" formdata-polyfill@^4.0.10: @@ -17470,7 +17382,7 @@ get-caller-file@^2.0.5: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: +get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.2, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -23687,7 +23599,7 @@ object.values@^1.1.1, object.values@^1.1.6: define-properties "^1.1.4" es-abstract "^1.20.4" -obuf@^1.0.0, obuf@^1.1.2, obuf@~1.1.2: +obuf@^1.0.0, obuf@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== @@ -24533,11 +24445,6 @@ pg-int8@1.0.1: resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== -pg-numeric@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pg-numeric/-/pg-numeric-1.0.2.tgz#816d9a44026086ae8ae74839acd6a09b0636aa3a" - integrity sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw== - pg-pool@^3.10.0: version "3.10.0" resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.10.0.tgz#134b0213755c5e7135152976488aa7cd7ee1268d" @@ -24559,19 +24466,6 @@ pg-types@2.2.0, pg-types@^2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg-types@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/pg-types/-/pg-types-4.0.1.tgz#31857e89d00a6c66b06a14e907c3deec03889542" - integrity sha512-hRCSDuLII9/LE3smys1hRHcu5QGcLs9ggT7I/TCs0IE+2Eesxi9+9RWAAwZ0yaGjxoWICF/YHLOEjydGujoJ+g== - dependencies: - pg-int8 "1.0.1" - pg-numeric "1.0.2" - postgres-array "~3.0.1" - postgres-bytea "~3.0.0" - postgres-date "~2.0.1" - postgres-interval "^3.0.0" - postgres-range "^1.1.1" - pg@8.16.0: version "8.16.0" resolved "https://registry.yarnpkg.com/pg/-/pg-8.16.0.tgz#40b08eedb5eb1834252cf3e3629503e32e6c6c04" @@ -25378,33 +25272,16 @@ postgres-array@~2.0.0: resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== -postgres-array@~3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/postgres-array/-/postgres-array-3.0.2.tgz#68d6182cb0f7f152a7e60dc6a6889ed74b0a5f98" - integrity sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog== - postgres-bytea@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" integrity sha1-AntTPAqokOJtFy1Hz5zOzFIazTU= -postgres-bytea@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postgres-bytea/-/postgres-bytea-3.0.0.tgz#9048dc461ac7ba70a6a42d109221619ecd1cb089" - integrity sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw== - dependencies: - obuf "~1.1.2" - postgres-date@~1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== -postgres-date@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/postgres-date/-/postgres-date-2.0.1.tgz#638b62e5c33764c292d37b08f5257ecb09231457" - integrity sha512-YtMKdsDt5Ojv1wQRvUhnyDJNSr2dGIC96mQVKz7xufp07nfuFONzdaowrMHjlAzY6GDLd4f+LUHHAAM1h4MdUw== - postgres-interval@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" @@ -25412,16 +25289,6 @@ postgres-interval@^1.1.0: dependencies: xtend "^4.0.0" -postgres-interval@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postgres-interval/-/postgres-interval-3.0.0.tgz#baf7a8b3ebab19b7f38f07566c7aab0962f0c86a" - integrity sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw== - -postgres-range@^1.1.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.3.tgz#9ccd7b01ca2789eb3c2e0888b3184225fa859f76" - integrity sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g== - postgres@^3.4.7: version "3.4.7" resolved "https://registry.yarnpkg.com/postgres/-/postgres-3.4.7.tgz#122f460a808fe300cae53f592108b9906e625345"