diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 908dfc211966..13bfa91520fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -903,11 +903,15 @@ jobs: run: yarn test job_bun_integration_tests: - name: Bun Integration Tests + name: Bun Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_bun_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -928,14 +932,18 @@ jobs: - name: Run integration tests working-directory: dev-packages/bun-integration-tests - run: yarn test + run: yarn test --shard=${{ matrix.shard }}/2 job_deno_integration_tests: - name: Deno Integration Tests + name: Deno Integration Tests (${{ matrix.shard }}/2) needs: [job_get_metadata, job_build] if: needs.job_build.outputs.changed_deno_integration == 'true' || github.event_name != 'pull_request' runs-on: ubuntu-24.04 timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }}) uses: actions/checkout@v7 @@ -956,9 +964,16 @@ jobs: - name: Build @sentry/deno working-directory: packages/deno run: yarn build - - name: Run integration tests + - name: Run native Deno integration tests + if: matrix.shard == 1 working-directory: dev-packages/deno-integration-tests - run: yarn test + run: | + yarn install:deno + yarn deno-types + yarn test:unit + - name: Run shared Node integration tests + working-directory: dev-packages/deno-integration-tests + run: yarn test:node-suites --shard=${{ matrix.shard }}/2 job_build_tarballs: name: Build tarballs @@ -1040,8 +1055,8 @@ jobs: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun if: - contains(fromJSON('["node-exports-test-app","nextjs-16-bun", "elysia-bun", "elysia-bun-static", "hono-4", - "bun-bytecode", "bun-mysql"]'), matrix.test-application) + matrix.test-application == 'node-exports-test-app' || contains(matrix.test-application, 'bun') || + contains(matrix.label, 'bun') uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.14' @@ -1052,7 +1067,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: matrix.test-application == 'deno' || matrix.test-application == 'hono-4' + if: contains(matrix.test-application, 'deno') || contains(matrix.label, 'deno') uses: denoland/setup-deno@v2.0.5 with: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml index ebb003de3c26..f87a387d281b 100644 --- a/.github/workflows/cleanup-e2e-workers.yml +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -16,7 +16,7 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} strategy: matrix: - # Name prefix of every E2E app that deploys a real worker, see the app's global-setup.mjs + # Name prefix of every E2E app that deploys a real worker, see the app's global-setup.ts worker-prefix: - e2e-send-to-sentry steps: diff --git a/.github/workflows/label-external-prs.yml b/.github/workflows/label-external-prs.yml new file mode 100644 index 000000000000..e6a73aaae5a7 --- /dev/null +++ b/.github/workflows/label-external-prs.yml @@ -0,0 +1,22 @@ +name: 'Automation: Label external PRs' +on: + pull_request_target: + types: + - opened + +jobs: + label_external_pr: + name: Add external label + permissions: + pull-requests: write + runs-on: ubuntu-24.04 + if: | + github.event.pull_request.author_association != 'COLLABORATOR' + && github.event.pull_request.author_association != 'MEMBER' + && github.event.pull_request.author_association != 'OWNER' + && endsWith(github.event.pull_request.user.login, '[bot]') == false + steps: + - name: Add external label + uses: actions-ecosystem/action-add-labels@18f1af5e3544586314bbe15c0273249c770b2daf # v1.1.3 + with: + labels: external diff --git a/.size-limit.js b/.size-limit.js index 3991fe66e743..80ad8c9f5f4f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '142 KB', + limit: '145 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -452,7 +452,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '121 KB', + limit: '123 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b52a3163c26..ec252e25122e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,93 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 11.2.0 + +### Important Changes + +- **feat(hono): add orchestrion-based auto-instrumentation ([#24497](https://github.com/getsentry/sentry-javascript/pull/24497))** + + Hono is now instrumented automatically. Request spans are named after the matched Hono route, each middleware gets its own span, and errors thrown in handlers are captured. + +- **feat(node/bun): Enable dedupeIntegration by default ([#24794](https://github.com/getsentry/sentry-javascript/pull/24794))** + + `@sentry/node` and `@sentry/bun` now include `dedupeIntegration` in their default integrations, like the browser, Deno, Vercel Edge and Cloudflare SDKs. When the same error is captured two times in a row, only the first event is sent. To keep the previous behavior, remove the integration: `integrations: defaults => defaults.filter(integration => integration.name !== 'Dedupe')`. + +- **feat(remix): Instrument Remix 3 server requests via fetch-router ([#24801](https://github.com/getsentry/sentry-javascript/pull/24801))** + + On Remix 3, the SDK now adds the matched route as `http.route`, the response status and a low-cardinality name to the `http.server` span of each `fetch-router` request. Start the app with `--import @sentry/remix/v3/node` in place of `--import remix/node-tsx`. + +### Other Changes + +- chore(bundler-plugins): Allow magic-string 1.x ([#24768](https://github.com/getsentry/sentry-javascript/pull/24768)) +- feat(deps): Bump oxc-parser to 0.152.0 and sentry to 0.45.0 ([#24823](https://github.com/getsentry/sentry-javascript/pull/24823)) +- feat(elysia): Export `bunRuntimeMetricsIntegration` ([#24892](https://github.com/getsentry/sentry-javascript/pull/24892)) +- feat(react-router): Support request-scoped CSP nonces in createSentryHandleRequest ([#24826](https://github.com/getsentry/sentry-javascript/pull/24826)) +- fix(aws-serverless): Keep Lambda extension polling past 300s ([#24811](https://github.com/getsentry/sentry-javascript/pull/24811)) +- fix(browser-utils): Stop leaking DOM instrumentation listeners on mismatched removals ([#24727](https://github.com/getsentry/sentry-javascript/pull/24727)) +- fix(core): Resolve escape sequences in `fmt` / `parameterize` messages ([#24770](https://github.com/getsentry/sentry-javascript/pull/24770)) +- fix(deno): Flush buffered metrics and logs before process exit ([#24807](https://github.com/getsentry/sentry-javascript/pull/24807)) +- fix(node): End Express layer spans when `next` is called ([#24854](https://github.com/getsentry/sentry-javascript/pull/24854)) +- fix(node): Flush buffered metrics on process exit ([#24806](https://github.com/getsentry/sentry-javascript/pull/24806)) +- fix(nuxt): Fix CommonJS interop for force-inlined instrumented packages ([#24799](https://github.com/getsentry/sentry-javascript/pull/24799)) +- fix(server-utils): Preserve raw Anthropic response bodies ([#24902](https://github.com/getsentry/sentry-javascript/pull/24902)) +- fix(server-utils): Preserve raw Groq and Together response bodies ([#24906](https://github.com/getsentry/sentry-javascript/pull/24906)) +- fix(server-utils): Preserve raw OpenAI embeddings and conversation responses ([#24908](https://github.com/getsentry/sentry-javascript/pull/24908)) +- fix(server-utils): Preserve response bodies for OpenAI parse helpers ([#24783](https://github.com/getsentry/sentry-javascript/pull/24783)) +- fix(solidstart): Support `rolldownOptions` in instrumentation file plugin ([#24863](https://github.com/getsentry/sentry-javascript/pull/24863)) +- fix(sveltekit): Skip trace meta tags when prerendering ([#24777](https://github.com/getsentry/sentry-javascript/pull/24777)) +- fix(vue): Share one root render span debounce timer across components ([#24868](https://github.com/getsentry/sentry-javascript/pull/24868)) +- perf(server-utils): Avoid quadratic SQL sanitizer output handling ([#24834](https://github.com/getsentry/sentry-javascript/pull/24834)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#24822](https://github.com/getsentry/sentry-javascript/pull/24822)) +- chore: Add external contributor to CHANGELOG.md ([#24827](https://github.com/getsentry/sentry-javascript/pull/24827)) +- chore: Add external contributor to CHANGELOG.md ([#24835](https://github.com/getsentry/sentry-javascript/pull/24835)) +- chore: Add external contributor to CHANGELOG.md ([#24850](https://github.com/getsentry/sentry-javascript/pull/24850)) +- chore: Add external contributor to CHANGELOG.md ([#24851](https://github.com/getsentry/sentry-javascript/pull/24851)) +- chore: Add external contributor to CHANGELOG.md ([#24914](https://github.com/getsentry/sentry-javascript/pull/24914)) +- chore(deps): Update to Vitest 4 and Vite 8 ([#24746](https://github.com/getsentry/sentry-javascript/pull/24746)) +- chore(github): Add `external` label on PRs of external contributors ([#24825](https://github.com/getsentry/sentry-javascript/pull/24825)) +- chore(solidstart): Remove unused Vitest setup from e2e apps ([#24789](https://github.com/getsentry/sentry-javascript/pull/24789)) +- ci: shard Bun integration tests ([#24771](https://github.com/getsentry/sentry-javascript/pull/24771)) +- ci: shard Deno shared integration tests ([#24772](https://github.com/getsentry/sentry-javascript/pull/24772)) +- docs(core): Clarify error object dedupe and `dedupeIntegration` ([#24893](https://github.com/getsentry/sentry-javascript/pull/24893)) +- feat(deps): bump axios from 1.18.0 to 1.20.0 ([#24918](https://github.com/getsentry/sentry-javascript/pull/24918)) +- feat(deps): bump fast-uri from 3.1.7 to 3.1.8 ([#24889](https://github.com/getsentry/sentry-javascript/pull/24889)) +- feat(deps): bump fastify from 5.12.1 to 5.12.5 ([#24917](https://github.com/getsentry/sentry-javascript/pull/24917)) +- feat(deps): bump hono from 4.13.5 to 4.13.7 ([#24916](https://github.com/getsentry/sentry-javascript/pull/24916)) +- feat(deps): bump ip-address from 10.4.0 to 10.7.2 ([#24810](https://github.com/getsentry/sentry-javascript/pull/24810)) +- feat(deps): bump moment from 2.30.1 to 2.31.0 ([#24890](https://github.com/getsentry/sentry-javascript/pull/24890)) +- fix(deps): runtime dependency security fixes ([#24911](https://github.com/getsentry/sentry-javascript/pull/24911)) +- ref(hono): move shared Hono instrumentation into @sentry/server-utils ([#24496](https://github.com/getsentry/sentry-javascript/pull/24496)) +- test(browser): Fix flaky reportPageLoaded duration assertion ([#24814](https://github.com/getsentry/sentry-javascript/pull/24814)) +- test(bun): Run the auto-instrumentation suites with bundled scenarios ([#24612](https://github.com/getsentry/sentry-javascript/pull/24612)) +- test(cloudflare): Add shared streamed-span helpers to the integration test runner ([#24176](https://github.com/getsentry/sentry-javascript/pull/24176)) +- test(cloudflare): Assert Workers AI gen_ai spans in the send-to-sentry E2E test ([#24515](https://github.com/getsentry/sentry-javascript/pull/24515)) +- test(cloudflare): Port the binding suites to span streaming ([#24190](https://github.com/getsentry/sentry-javascript/pull/24190)) +- test(cloudflare): Port the http-server integration suites to span streaming ([#24195](https://github.com/getsentry/sentry-javascript/pull/24195)) +- test(cloudflare): Port the request handler suites to span streaming ([#24196](https://github.com/getsentry/sentry-javascript/pull/24196)) +- test(cloudflare): Port the tracing propagation suites to span streaming ([#24185](https://github.com/getsentry/sentry-javascript/pull/24185)) +- test(cloudflare): Port the tracing suites to span streaming ([#24179](https://github.com/getsentry/sentry-javascript/pull/24179)) +- test(cloudflare): Port the vite-autoinstrument suites to span streaming ([#24189](https://github.com/getsentry/sentry-javascript/pull/24189)) +- test(e2e): Add Bun Express test app ([#24306](https://github.com/getsentry/sentry-javascript/pull/24306)) +- test(e2e): Add trace test for background use cache revalidation ([#24740](https://github.com/getsentry/sentry-javascript/pull/24740)) +- test(e2e): Mark supabase-nextjs as optional ([#24898](https://github.com/getsentry/sentry-javascript/pull/24898)) +- test(e2e): Retry a hung SAM start in aws-serverless tests ([#24879](https://github.com/getsentry/sentry-javascript/pull/24879)) +- test(nextjs): Add tests for low-cardinality span names for cache spans ([#24819](https://github.com/getsentry/sentry-javascript/pull/24819)) +- test(nextjs): Add UI components for cached/dynamic content ([#24874](https://github.com/getsentry/sentry-javascript/pull/24874)) +- test(nuxt): Remove version pin for @nuxt/cli in Nuxt 5 E2E ([#24798](https://github.com/getsentry/sentry-javascript/pull/24798)) +- test(replay): De-flake mutationLimit large-mutations test ([#24784](https://github.com/getsentry/sentry-javascript/pull/24784)) +- test(sveltekit): Add missing prerender e2e test ([#24921](https://github.com/getsentry/sentry-javascript/pull/24921)) +- test(test-utils): Add fetchSpanAttributes to read span attributes via the sentry CLI ([#24514](https://github.com/getsentry/sentry-javascript/pull/24514)) +- test(test-utils): Extract Cloudflare Worker deploys into `test-utils/cloudflare` ([#24815](https://github.com/getsentry/sentry-javascript/pull/24815)) + +
+ +Work in this release was contributed by @nabi-noor, @LuccaRebelloToledo, @andasan, @breken-ai, @EmileBrunelle, and @diobriggs. Thank you for your contributions! + ## 11.1.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 07c0b4bccd47..36f08e3e384b 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -65,7 +65,7 @@ "@sentry/opentelemetry": "11.1.0", "@sentry/conventions": "0.24.0", "@supabase/supabase-js": "2.109.0", - "axios": "1.18.0", + "axios": "1.20.0", "babel-loader": "^10.1.1", "fflate": "0.8.3", "html-webpack-plugin": "^5.5.0", diff --git a/dev-packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts b/dev-packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts index a6ba46a1d758..764baebb2ad9 100644 --- a/dev-packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts @@ -1,6 +1,9 @@ import { expect } from '@playwright/test'; +import { EventType } from '@sentry/rrweb'; import { sentryTest } from '../../../../utils/fixtures'; import { + collectReplayRequests, + getReplayBreadcrumbs, getReplayRecordingContent, getReplaySnapshot, shouldSkipReplayTest, @@ -25,14 +28,20 @@ sentryTest( const [res0] = await Promise.all([waitForReplayRequest(page, 0), gotoPageAndClick()]); await forceFlushReplay(); - const [res1] = await Promise.all([ - waitForReplayRequest(page, (_event, res) => { - const parsed = getReplayRecordingContent(res); - return !!parsed.incrementalSnapshots.length || !!parsed.fullSnapshots.length; - }), - page.locator('#button-add').click(), - forceFlushReplay(), - ]); + // The `replay.mutations` breadcrumb is added asynchronously (once rrweb's + // MutationObserver fires) and can land in a different flush than the + // incremental snapshots and the `ui.click` breadcrumb. Collect across + // requests until the mutation breadcrumb has arrived rather than betting on + // a single request containing everything. + const requestsPromise = collectReplayRequests( + page, + recordingSnapshots => getReplayBreadcrumbs(recordingSnapshots, 'replay.mutations').length > 0, + ); + + await page.locator('#button-add').click(); + await forceFlushReplay(); + + const { replayRecordingSnapshots } = await requestsPromise; // replay should be stopped due to mutation limit let replay = await getReplaySnapshot(page); @@ -48,11 +57,21 @@ sentryTest( const replayData0 = getReplayRecordingContent(res0); expect(replayData0.fullSnapshots.length).toBe(1); + const fullSnapshots = replayRecordingSnapshots.filter(snapshot => snapshot.type === EventType.FullSnapshot); + const incrementalSnapshots = replayRecordingSnapshots.filter( + snapshot => snapshot.type === EventType.IncrementalSnapshot, + ); + // A still-in-flight envelope from the setup flush can re-deliver the + // `#noop` `ui.click` breadcrumb into the collected set, so assert on the + // unique categories rather than the raw (potentially duplicated) list. + const breadcrumbCategories = [ + ...new Set(getReplayBreadcrumbs(replayRecordingSnapshots).map(({ category }) => category)), + ].sort(); + // Breadcrumbs (click and mutation); - const replayData1 = getReplayRecordingContent(res1); - expect(replayData1.fullSnapshots.length).toBe(0); - expect(replayData1.incrementalSnapshots.length).toBeGreaterThan(0); - expect(replayData1.breadcrumbs.map(({ category }) => category).sort()).toEqual(['replay.mutations', 'ui.click']); + expect(fullSnapshots.length).toBe(0); + expect(incrementalSnapshots.length).toBeGreaterThan(0); + expect(breadcrumbCategories).toEqual(['replay.mutations', 'ui.click']); replay = await getReplaySnapshot(page); expect(replay.session).toBe(undefined); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts index d19476885c36..706166ef0dc4 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts @@ -33,9 +33,9 @@ sentryTest( [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' }, }); - // We wait for 2.5 seconds before calling Sentry.reportPageLoaded() - // the margins are to account for timing weirdness in CI to avoid flakes + // We wait for 2.5 seconds before calling Sentry.reportPageLoaded(). The span starts at navigation start, + // but the timeout only starts once the bundle has executed, so allow generous upper headroom for slow CI. expect(spanDurationSeconds).toBeGreaterThan(2); - expect(spanDurationSeconds).toBeLessThan(3); + expect(spanDurationSeconds).toBeLessThan(4); }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts index ce539cbd6d4b..9c8c0b48fb69 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded/default/test.ts @@ -34,9 +34,9 @@ sentryTest( ['sentry.idle_span_finish_reason']: 'reportPageLoaded', }); - // We wait for 2.5 seconds before calling Sentry.reportPageLoaded() - // the margins are to account for timing weirdness in CI to avoid flakes + // We wait for 2.5 seconds before calling Sentry.reportPageLoaded(). The span starts at navigation start, + // but the timeout only starts once the bundle has executed, so allow generous upper headroom for slow CI. expect(spanDurationSeconds).toBeGreaterThan(2); - expect(spanDurationSeconds).toBeLessThan(3); + expect(spanDurationSeconds).toBeLessThan(4); }, ); diff --git a/dev-packages/bun-integration-tests/node-suites/bun-build.ts b/dev-packages/bun-integration-tests/node-suites/bun-build.ts new file mode 100644 index 000000000000..3c86daba1baf --- /dev/null +++ b/dev-packages/bun-integration-tests/node-suites/bun-build.ts @@ -0,0 +1,38 @@ +// Bundles one Node suite scenario with `@sentry/bun/plugin`, so the plugin can inject the +// diagnostics channels into the libraries the scenario uses. The runner calls this script with the +// scenario path when `RUNTIME_BUILD_SCRIPT` points to it, and runs the printed output file instead. +import { sentryBunPlugin } from '@sentry/bun/plugin'; +import { dirname, join, relative } from 'node:path'; + +const NODE_SUITES_ROOT = join(import.meta.dir, '..', '..', 'node-integration-tests'); +const BUILD_ROOT = join(import.meta.dir, '..', 'build'); + +const entry = process.argv[2]; +if (!entry) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED no scenario path'); + process.exit(1); +} + +const result = await Bun.build({ + entrypoints: [entry], + target: 'bun', + // Mirrors the scenario's folder, so relative paths between scenarios keep their shape. + outdir: join(BUILD_ROOT, relative(NODE_SUITES_ROOT, dirname(entry))), + sourcemap: 'linked', + // The instrument file is preloaded unbundled, so the bundle must share its `@sentry/*` packages. + // knex requires the drivers of all its dialects, and the ones that are not installed must stay + // external so the bundle still builds. + external: ['@sentry/*', '@sentry-internal/*', 'better-sqlite3', 'oracledb', 'pg-query-stream', 'sqlite3'], + plugins: [sentryBunPlugin()], +}); + +const output = result.outputs.find(file => file.kind === 'entry-point'); +if (!result.success || !output) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); +} + +// eslint-disable-next-line no-console +console.log(`BUILD_OK ${output.path}`); diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index c06bd54b0c4c..c9a89b180612 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -75,9 +75,10 @@ const NO_OUTGOING_HTTP_INSTRUMENTATION = [ // `bun run` cannot inject the diagnostics channels into libraries, so framework, database and AI // instrumentation creates no spans. Apps must be built with `@sentry/bun/plugin`. // See https://github.com/getsentry/sentry-javascript/issues/23882 -const NO_AUTO_INSTRUMENTATION = [ +export const NO_AUTO_INSTRUMENTATION = [ 'suites/express/**', 'suites/fs-instrumentation/test.ts', + 'suites/hono/test.ts', 'suites/hono-sdk/test.ts', 'suites/pino/test.ts', 'suites/tracing/amqplib/test.ts', @@ -216,3 +217,56 @@ export const SENTRY_BUN_EXCLUDE = [ ...NO_NATIVE_NODE_FETCH_INTEGRATION, ...FETCH_INTEGRATION_DIFFERS, ]; + +// The build project (`node-suites-bun-build`) runs the suites of `NO_AUTO_INSTRUMENTATION` with the +// scenarios bundled by `@sentry/bun/plugin`. These do not run there. + +// On Bun the channel integrations subscribe at `init()` by design, and this suite checks that +// they wait until their module loads. +const BUN_BUILD_EAGER_SUBSCRIPTION = ['suites/tracing/orchestrion-lazy-registration/test.ts']; + +// The first `init()` has no DSN, so `bunHttpServerIntegration` is not set up, and the suite then +// adds only `httpIntegration`. On Bun that does not isolate requests, because Bun does not publish +// `http.server.request.start`. +const BUN_BUILD_NO_HTTP_SERVER_INTEGRATION = ['suites/express/multiple-init/test.ts']; + +// Some or all tests fail with the bundled scenarios, cause not investigated yet. In +// `express/tracing` only the request data tests fail: they set `httpIntegration` options, and with +// `@sentry/bun` the request body comes from `bunHttpServerIntegration`. +const BUN_BUILD_NOT_TRIAGED = [ + 'suites/express/sentry-trace/test.ts', + 'suites/express/tracing/test.ts', + 'suites/express/with-http/**', + 'suites/hono-sdk/test.ts', + 'suites/pino/test.ts', + 'suites/tracing/google-genai-v2/test.ts', + 'suites/tracing/google-genai/test.ts', + 'suites/tracing/langchain/v1/test.ts', + 'suites/tracing/mastra/test.ts', + 'suites/tracing/mcp-handler-exact-once/test.ts', + 'suites/tracing/mcp-server-streamed/test.ts', + 'suites/tracing/mongodb-v4/test.ts', + 'suites/tracing/mongodb-v5/test.ts', + 'suites/tracing/mongodb-v6/test.ts', + 'suites/tracing/mongodb-v7/test.ts', + 'suites/tracing/mongodb/test.ts', + 'suites/tracing/mongoose-tracing-channel/test.ts', + 'suites/tracing/mongoose-v5/test.ts', + 'suites/tracing/mongoose-v7/test.ts', + 'suites/tracing/mongoose-v8/test.ts', + 'suites/tracing/mongoose-v9/test.ts', + 'suites/tracing/mongoose/test.ts', + 'suites/tracing/mysql/test.ts', + 'suites/tracing/openai/test.ts', + 'suites/tracing/prisma-orm-v8/test.ts', + 'suites/tracing/together-ai/test.ts', + 'suites/tracing/vercelai/test.ts', + 'suites/tracing/vercelai/v6_v7/test.ts', +]; + +export const BUN_BUILD_EXCLUDE = [ + '**/node_modules/**', + ...BUN_BUILD_EAGER_SUBSCRIPTION, + ...BUN_BUILD_NO_HTTP_SERVER_INTEGRATION, + ...BUN_BUILD_NOT_TRIAGED, +]; diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 28001d684615..7e492c8ce9ee 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -16,7 +16,7 @@ "dependencies": { "@sentry/bun": "11.1.0", "@sentry/hono": "11.1.0", - "hono": "^4.13.5", + "hono": "^4.13.7", "mysql": "^2.18.1", "pg": "8.16.0" }, @@ -24,7 +24,7 @@ "@sentry-internal/node-integration-tests": "11.1.0", "@sentry-internal/test-utils": "11.1.0", "bun-types": "^1.2.9", - "vitest": "^3.2.7" + "vitest": "^4.1.11" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/bun-integration-tests/tsconfig.json b/dev-packages/bun-integration-tests/tsconfig.json index ba0be38e72df..5482ba1aab64 100644 --- a/dev-packages/bun-integration-tests/tsconfig.json +++ b/dev-packages/bun-integration-tests/tsconfig.json @@ -4,7 +4,7 @@ "include": ["suites/**/*.ts", "node-suites/**/*.ts", "*.ts"], "compilerOptions": { - "lib": ["ES2020"], + "lib": ["DOM", "ES2020"], "esModuleInterop": true, "types": ["bun-types"] } diff --git a/dev-packages/bun-integration-tests/vite.config.mts b/dev-packages/bun-integration-tests/vite.config.mts index e2ed86d0affb..f339cd917a02 100644 --- a/dev-packages/bun-integration-tests/vite.config.mts +++ b/dev-packages/bun-integration-tests/vite.config.mts @@ -1,7 +1,13 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; import baseConfig from '../../vite/vite.config'; -import { NODE_SUITES_EXCLUDE, SENTRY_BUN_EXCLUDE, SENTRY_NODE_EXCLUDE } from './node-suites/excludes'; +import { + BUN_BUILD_EXCLUDE, + NO_AUTO_INSTRUMENTATION, + NODE_SUITES_EXCLUDE, + SENTRY_BUN_EXCLUDE, + SENTRY_NODE_EXCLUDE, +} from './node-suites/excludes'; const NODE_SUITES_ROOT = fileURLToPath(new URL('../node-integration-tests', import.meta.url)); @@ -46,11 +52,9 @@ export default defineConfig({ env: { RUNTIME: 'bun' }, // Above the 30 second port timeout of the runner on Bun, so a slow start can still pass. testTimeout: 45_000, - poolOptions: { - threads: { - singleThread: true, - }, - }, + maxWorkers: 1, + // Vitest requires projects with a different `maxWorkers` to run in their own group. + sequence: { groupOrder: 1 }, }, }, { @@ -82,6 +86,24 @@ export default defineConfig({ }, }, }, + { + extends: true, + test: { + ...nodeSuitesTest, + // The auto-instrumentation suites, with each scenario bundled by `@sentry/bun/plugin` before + // it starts, as Bun apps must be built to get these spans. + // See https://github.com/getsentry/sentry-javascript/issues/23882 + name: 'node-suites-bun-build', + include: NO_AUTO_INSTRUMENTATION.map(glob => (glob.endsWith('/**') ? `${glob}/test.ts` : glob)), + exclude: BUN_BUILD_EXCLUDE, + env: { + RUNTIME: 'bun', + RUNTIME_PRELOAD: fileURLToPath(new URL('./node-suites/alias-sentry-bun.ts', import.meta.url)), + RUNTIME_BUILD_SCRIPT: fileURLToPath(new URL('./node-suites/bun-build.ts', import.meta.url)), + EXPECTED_SDK_NAME: 'sentry.javascript.bun', + }, + }, + }, ], }, }); diff --git a/dev-packages/bundler-plugin-integration-tests/package.json b/dev-packages/bundler-plugin-integration-tests/package.json index c567034183cd..09d3d950fd4f 100644 --- a/dev-packages/bundler-plugin-integration-tests/package.json +++ b/dev-packages/bundler-plugin-integration-tests/package.json @@ -17,7 +17,7 @@ "devDependencies": { "premove": "^4.0.0", "typescript": "~6.0.3", - "vitest": "^3.2.7" + "vitest": "^4.1.11" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/bundler-tests/package.json b/dev-packages/bundler-tests/package.json index 7d90bd591bdb..cee40955305b 100644 --- a/dev-packages/bundler-tests/package.json +++ b/dev-packages/bundler-tests/package.json @@ -15,8 +15,8 @@ "@rollup/plugin-node-resolve": "^16.0.3", "@sentry/browser": "11.1.0", "rollup": "^4.60.3", - "vite": "^6.4.3", - "vitest": "^3.2.7", + "vite": "^8.3.1", + "vitest": "^4.1.11", "webpack": "^5.0.0" }, "volta": { diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index bf09aeca2c22..c0478cd4fcc5 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -25,7 +25,7 @@ "@sentry/cloudflare": "11.1.0", "@sentry/core": "11.1.0", "@sentry/hono": "11.1.0", - "hono": "^4.13.5", + "hono": "^4.13.7", "openai": "5.18.1" }, "devDependencies": { @@ -36,8 +36,8 @@ "@sentry/conventions": "0.24.0", "eslint-plugin-regexp": "^3.1.0", "prisma": "6.15.0", - "vite": "7.3.5", - "vitest": "^3.2.7", + "vite": "^8.3.1", + "vitest": "^4.1.11", "wrangler": "4.86.0" }, "volta": { diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 56727828e288..1ce7493466bd 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -1,11 +1,12 @@ -import type { Envelope, EnvelopeItemType } from '@sentry/core'; +import type { Envelope, EnvelopeItemType, SerializedStreamedSpan } from '@sentry/core'; import { normalize } from '@sentry/core'; import { createBasicSentryServer } from '@sentry-internal/test-utils'; import { spawn, spawnSync } from 'child_process'; import { existsSync, readdirSync, readFileSync } from 'fs'; import { join } from 'path'; import { inspect } from 'util'; -import { expect } from 'vitest'; +import { expect, onTestFinished } from 'vitest'; +import { getSpansFromEnvelope } from './spanUtils'; const CLEANUP_STEPS = new Set<() => void>(); @@ -114,9 +115,11 @@ async function fetchWithRetry( throw new Error('fetchWithRetry: unreachable'); } -function deferredPromise( - done?: () => void, -): { resolve: (val: T) => void; reject: (reason?: unknown) => void; promise: Promise } { +function deferredPromise(): { + resolve: (val: T) => void; + reject: (reason?: unknown) => void; + promise: Promise; +} { let resolve; let reject; const promise = new Promise((res, rej) => { @@ -133,12 +136,15 @@ function deferredPromise( return { resolve, reject, - promise: promise.finally(() => done?.()), + promise, }; } type Expected = Envelope | ((envelope: Envelope) => void); +/** Either the name of the segment span, or a predicate over it. */ +type SegmentMatcher = string | ((segmentSpan: SerializedStreamedSpan) => boolean); + type StartResult = { completed(): Promise; /** Every non-ignored envelope received so far, matched or not, for count assertions. */ @@ -154,6 +160,25 @@ type StartResult = { expected: Expected | Expected[], options?: { headers?: Record; data?: BodyInit; expectError?: boolean }, ): Promise; + /** + * Accumulates spans across envelopes, grouped by trace, and resolves with the spans of the first + * trace that satisfies `isDone`. + * + * A trace reaches the mock server in more than one envelope: the span buffer flushes on a timer, so + * a segment that is still open when its children flush arrives separately, and a Durable Object or a + * service binding sends its own spans from its own isolate. Anything asserting on a whole trace has + * to accumulate rather than read a single envelope. + */ + collectStreamedSpans(isDone: (spansOfTrace: SerializedStreamedSpan[]) => boolean): Promise; + /** + * Accumulates the spans of a trace until its segment span has arrived. + * + * Only use this to assert on the segment span itself. The segment span ends last, but each + * envelope is its own request to the mock server, so the segment can still be *received* before + * the envelope carrying its children. A suite that asserts on the children has to wait for those + * children by name or by count through `collectStreamedSpans`. + */ + collectStreamedSpansUntilSegment(segment: SegmentMatcher): Promise; }; /** Creates a test runner */ @@ -213,17 +238,54 @@ export function createRunner(...paths: string[]) { return this; }, start: function (signal?: AbortSignal): StartResult { - const { resolve, reject, promise: isComplete } = deferredPromise(cleanupChildProcesses); + let child: ReturnType | undefined; + let childSubWorker: ReturnType | undefined; + let closeMockServer: (() => void) | undefined; + // True after `cleanupThisRunner` ran. The async startup below checks it, so a mock server that + // comes up after the teardown is closed and no `wrangler dev` is spawned. + let disposed = false; + + function cleanupThisRunner(): void { + disposed = true; + CLEANUP_STEPS.delete(cleanupThisRunner); + child?.kill(); + childSubWorker?.kill(); + closeMockServer?.(); + closeMockServer = undefined; + } + + // A suite that asserts on streamed spans never calls `completed()`, so `isComplete` never + // settles and its worker would stay alive until the vitest process exits. With one such suite + // per file, a full run ends up with dozens of `wrangler dev` processes competing for the + // machine, and the later suites time out. Tie the teardown to the test instead. + onTestFinished(cleanupThisRunner); + CLEANUP_STEPS.add(cleanupThisRunner); + + const { resolve, reject, promise: isComplete } = deferredPromise(); + + const spanWaiters: { + onSpans: (spans: SerializedStreamedSpan[]) => boolean; + resolve: () => void; + reject: (e: unknown) => void; + }[] = []; + let failure: unknown; // `reject` is called from background event handlers (child process `error`/`exit`, mock server // callbacks) that fire at arbitrary times relative to the test's `await` points. If `reject` runs // while nothing is awaiting `isComplete` yet (e.g. a child transiently exits while the test is // parked in `makeRequest`), the rejection has no handler attached and surfaces as an unhandled // promise rejection — which Vitest reports as a spurious "Unhandled error" that fails the whole - // suite. Attaching a no-op catch keeps the promise "handled"; the real rejection is still delivered + // suite. Attaching a catch keeps the promise "handled"; the real rejection is still delivered // to callers via `completed()`, so genuine failures still fail the test. - isComplete.catch(() => { - // handled in `completed()` + // + // A test that only asserts on streamed spans never calls `completed()`, so the same rejection is + // handed to the span waiters as well. Without it, a worker that fails to boot would surface as a + // Vitest timeout instead of the actual error. + isComplete.catch(e => { + failure = e; + for (const waiter of spanWaiters.splice(0)) { + waiter.reject(e); + } }); const expectedEnvelopeCount = expectedEnvelopes.length; @@ -240,8 +302,6 @@ export function createRunner(...paths: string[]) { workerPortPromise.catch(() => { // handled in `makeRequest` }); - let child: ReturnType | undefined; - let childSubWorker: ReturnType | undefined; /** Called after each expect callback to check if we're complete */ function expectCallbackCalled(): void { @@ -257,6 +317,44 @@ export function createRunner(...paths: string[]) { }); } + /** Resolves once `onSpans` returns true for the spans of an arriving span envelope. */ + function waitForSpans(onSpans: (spans: SerializedStreamedSpan[]) => boolean): Promise { + return new Promise((resolveWaiter, rejectWaiter) => { + if (failure) { + rejectWaiter(failure); + return; + } + spanWaiters.push({ onSpans, resolve: resolveWaiter, reject: rejectWaiter }); + }); + } + + /** + * Span waiters observe the envelope stream, they never consume from it: a suite can assert on + * streamed spans and on error envelopes at the same time. + */ + function notifySpanWaiters(envelope: Envelope): void { + const spans = getSpansFromEnvelope(envelope); + if (!spans.length) { + return; + } + + for (const waiter of spanWaiters.slice()) { + let done: boolean; + try { + done = waiter.onSpans(spans); + } catch (e) { + spanWaiters.splice(spanWaiters.indexOf(waiter), 1); + waiter.reject(e); + continue; + } + + if (done) { + spanWaiters.splice(spanWaiters.indexOf(waiter), 1); + waiter.resolve(); + } + } + } + function assertEnvelopeMatches(expected: Expected, envelope: Envelope): void { if (typeof expected === 'function') { expected(envelope); @@ -268,6 +366,8 @@ export function createRunner(...paths: string[]) { function newEnvelope(envelope: Envelope): void { if (process.env.DEBUG) log('newEnvelope', inspect(envelope, false, null, true)); + notifySpanWaiters(envelope); + const envelopeItemType = envelope[1][0][0].type; if (ignored.has(envelopeItemType)) { @@ -336,11 +436,12 @@ export function createRunner(...paths: string[]) { createBasicSentryServer(newEnvelope) .then(async ([mockServerPort, mockServerClose]) => { - if (mockServerClose) { - CLEANUP_STEPS.add(() => { - mockServerClose(); - }); + // The test can end before the mock server is up, e.g. when it throws right after `start()`. + if (disposed) { + mockServerClose(); + return; } + closeMockServer = mockServerClose; if (process.env.DEBUG) log('Starting scenario', testPath); @@ -402,6 +503,10 @@ export function createRunner(...paths: string[]) { }); await waitForReady(childSubWorker); + + if (disposed) { + return; + } } child = spawn( @@ -425,11 +530,6 @@ export function createRunner(...paths: string[]) { { stdio: ['ignore', 'pipe', 'inherit'], signal }, ); - CLEANUP_STEPS.add(() => { - child?.kill(); - childSubWorker?.kill(); - }); - childSubWorker?.on('error', onChildError); child.on('error', onChildError); @@ -509,6 +609,44 @@ export function createRunner(...paths: string[]) { await Promise.all(envelopePromises); return result; }, + collectStreamedSpans: async function ( + isDone: (spansOfTrace: SerializedStreamedSpan[]) => boolean, + ): Promise { + const spansByTrace = new Map(); + let matched: SerializedStreamedSpan[] = []; + + await waitForSpans(spans => { + for (const span of spans) { + const spansOfTrace = spansByTrace.get(span.trace_id); + if (spansOfTrace) { + spansOfTrace.push(span); + } else { + spansByTrace.set(span.trace_id, [span]); + } + } + + // Every trace is a candidate, so a trace that never satisfies `isDone` cannot hold up the + // one that does. Insertion order means the earliest-arriving trace wins a tie. + for (const spansOfTrace of spansByTrace.values()) { + if (isDone(spansOfTrace)) { + matched = spansOfTrace; + return true; + } + } + + return false; + }); + + return matched; + }, + collectStreamedSpansUntilSegment: function (segment: SegmentMatcher): Promise { + const matchesSegment = + typeof segment === 'string' ? (span: SerializedStreamedSpan) => span.name === segment : segment; + + return this.collectStreamedSpans(spansOfTrace => + spansOfTrace.some(span => span.is_segment && matchesSegment(span)), + ); + }, }; }, }; diff --git a/dev-packages/cloudflare-integration-tests/spanUtils.ts b/dev-packages/cloudflare-integration-tests/spanUtils.ts new file mode 100644 index 000000000000..f0a79b2c6e4b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/spanUtils.ts @@ -0,0 +1,18 @@ +import type { Envelope, SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; + +export { getSpanOp } from '@sentry-internal/test-utils'; + +/** + * The span v2 container of an envelope, or `undefined` when the envelope carries no span item. + */ +export function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer | undefined { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + return spanItem?.[1] as SerializedStreamedSpanContainer | undefined; +} + +/** + * The spans of an envelope, or an empty array when the envelope carries no span item. + */ +export function getSpansFromEnvelope(envelope: Envelope): SerializedStreamedSpan[] { + return getSpanContainer(envelope)?.items ?? []; +} diff --git a/dev-packages/cloudflare-integration-tests/suites/cache-client/test.ts b/dev-packages/cloudflare-integration-tests/suites/cache-client/test.ts index 23f2e3962e6d..451890dfe782 100644 --- a/dev-packages/cloudflare-integration-tests/suites/cache-client/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/cache-client/test.ts @@ -110,7 +110,7 @@ it('cacheClient: true - detached work events ARE captured', async ({ signal }) = }); it('cacheClient: false - repro #22545: detached work events are silently dropped', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); // Make the request that spawns detached work await runner.makeRequest('get', '/no-cache/detached?id=repro-1'); @@ -135,7 +135,7 @@ it('cacheClient: true - dedupe drops the same error across invocations', async ( // A shared client shares its dedupe state, so the same error captured by two separate // invocations is reported only once — the second is dropped as a duplicate. const runner = createRunner(__dirname) - .ignore('transaction', 'span') + .ignore('span') .unordered() .failOnUnexpected() .expect(errorEventExpectation('Same error', CAPTURE_MECHANISM)) @@ -159,7 +159,7 @@ it('cacheClient: true - dedupe drops the same error across invocations', async ( it('cacheClient: false - dedupe does not persist across invocations', async ({ signal }) => { // A fresh client per invocation means fresh dedupe state, so each invocation reports // the same error independently. - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); for (let i = 0; i < 3; i++) { await runner.makeRequestAndWaitForEnvelope( @@ -174,7 +174,7 @@ it('cacheClient: false - dedupe does not persist across invocations', async ({ s // also start reusing the isolation scope `setTag`/`setUser` write to. The uncached counterpart of // this test lives in the `durable-object-scope` suite. it('cacheClient: true - two consecutive invocations get different isolation scopes', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); await runner.makeRequestAndWaitForEnvelope('get', '/cache/scope?id=scope-shared&seed=1', (envelope: Envelope) => { const event = envelope[1]?.[0]?.[1] as Event; diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts index 115fffd92545..640ebc649121 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/index.ts @@ -9,7 +9,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts index 3f4912bc17f5..ab28f8c80d80 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts @@ -1,54 +1,37 @@ -import type { Envelope } from '@sentry/core'; +import type { Envelope, SerializedStreamedSpan } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; +import { getSpansFromEnvelope } from '../../spanUtils'; -function envelopeItemType(envelope: Envelope): string | undefined { - return envelope[1][0]?.[0]?.type as string | undefined; -} - -function envelopeItem(envelope: Envelope): Record { - return envelope[1][0]![1] as Record; -} - -function findD1Spans(envelope: Envelope): Array> { - if (envelopeItemType(envelope) !== 'transaction') return []; - const tx = envelopeItem(envelope); - const spans = (tx.spans as Array>) || []; - return spans.filter(s => (s.op as string) === 'db.query'); -} +// `cloudflare.d1.duration` is only an integer when the query happens to take a whole number of +// milliseconds, so the type can't be pinned down. +const NUMBER_ATTRIBUTE = { type: expect.stringMatching(/^(?:integer|double)$/), value: expect.any(Number) }; it('instruments D1 prepare().all() automatically via env', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('transaction'); - const d1Spans = findD1Spans(envelope); - expect(d1Spans.length).toBeGreaterThanOrEqual(1); - - const querySpan = d1Spans.find(s => s.description === 'SELECT * FROM users WHERE id = ?'); - expect(querySpan).toBeDefined(); - expect(querySpan).toEqual({ - data: { - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'all', - 'db.query.text': 'SELECT * FROM users WHERE id = ?', - 'db.query.summary': 'SELECT users', - 'cloudflare.d1.duration': expect.any(Number), - 'cloudflare.d1.rows_read': expect.any(Number), - 'cloudflare.d1.rows_written': expect.any(Number), - 'sentry.op': 'db.query', - 'sentry.origin': 'auto.db.cloudflare.d1', - }, - description: 'SELECT * FROM users WHERE id = ?', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - status: 'ok', - parent_span_id: expect.any(String), - span_id: expect.any(String), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.any(String), - }); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + // The D1 span is named after its query summary rather than the full query text. + const querySpan = spans.find(span => span.attributes['db.operation.name']?.value === 'all'); + expect(querySpan?.name).toBe('SELECT users'); + expect(querySpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(querySpan?.status).toBe('ok'); + expect(querySpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.cloudflare.d1' }, + 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, + 'db.operation.name': { type: 'string', value: 'all' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE id = ?' }, + 'db.query.summary': { type: 'string', value: 'SELECT users' }, + 'cloudflare.d1.duration': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_read': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_written': NUMBER_ATTRIBUTE, + }), + ); }) .start(signal); @@ -58,10 +41,10 @@ it('instruments D1 prepare().all() automatically via env', async ({ signal }) => it('captures error event when a D1 query references a non-existent table', async ({ signal }) => { const runner = createRunner(__dirname) - .ignore('transaction') + .ignore('span') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('event'); - const event = envelopeItem(envelope); + expect(envelope[1][0]?.[0]?.type).toBe('event'); + const event = envelope[1][0]![1] as Record; expect(event.level).toBe('error'); const values = (event.exception as { values: Array> })?.values; @@ -102,32 +85,26 @@ it('instruments D1 exec() automatically via env', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('transaction'); - const d1Spans = findD1Spans(envelope); - - const execSpan = d1Spans.find( - s => s.description === 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + const execSpan = spans.find(span => span.attributes['db.operation.name']?.value === 'exec'); + expect(execSpan?.name).toBe('CREATE TABLE users'); + expect(execSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(execSpan?.status).toBe('ok'); + expect(execSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.cloudflare.d1' }, + 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, + 'db.operation.name': { type: 'string', value: 'exec' }, + 'db.query.text': { + type: 'string', + value: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + }, + 'db.query.summary': { type: 'string', value: 'CREATE TABLE users' }, + }), ); - expect(execSpan).toBeDefined(); - expect(execSpan).toEqual({ - data: { - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'exec', - 'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', - 'db.query.summary': 'CREATE TABLE users', - 'sentry.op': 'db.query', - 'sentry.origin': 'auto.db.cloudflare.d1', - }, - description: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - status: 'ok', - parent_span_id: expect.any(String), - span_id: expect.any(String), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.any(String), - }); }) .start(signal); @@ -136,20 +113,30 @@ it('instruments D1 exec() automatically via env', async ({ signal }) => { }); it('instruments D1 withSession().batch() identically to db.batch()', async ({ signal }) => { - let directBatchSpan: Record | undefined; - let sessionBatchSpan: Record | undefined; + let directBatchSpan: SerializedStreamedSpan | undefined; + let sessionBatchSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .ignore('event') .expect((envelope: Envelope) => { - expect(envelopeItem(envelope).transaction).toBe('GET /batch'); + const spans = getSpansFromEnvelope(envelope); + // Both routes are raw URLs, so the streamed segment name keeps the method only and the + // request is identified through `url.path`. + expect(spans.find(span => span.is_segment)?.attributes['url.path']).toEqual({ + type: 'string', + value: '/batch', + }); - directBatchSpan = findD1Spans(envelope).find(s => s.description === 'D1 batch'); + directBatchSpan = spans.find(span => span.name === 'D1 batch'); }) .expect((envelope: Envelope) => { - expect(envelopeItem(envelope).transaction).toBe('GET /with-session/batch'); + const spans = getSpansFromEnvelope(envelope); + expect(spans.find(span => span.is_segment)?.attributes['url.path']).toEqual({ + type: 'string', + value: '/with-session/batch', + }); - sessionBatchSpan = findD1Spans(envelope).find(s => s.description === 'D1 batch'); + sessionBatchSpan = spans.find(span => span.name === 'D1 batch'); }) .unordered() .start(signal); @@ -161,16 +148,19 @@ it('instruments D1 withSession().batch() identically to db.batch()', async ({ si expect(directBatchSpan).toBeDefined(); expect(sessionBatchSpan).toBeDefined(); - const normalize = (span: Record): Record => { + // Ids and timestamps differ between the two requests, everything else must match. + const normalize = (span: SerializedStreamedSpan): Record => { const { span_id: _spanId, parent_span_id: _parentSpanId, start_timestamp: _start, - timestamp: _end, + end_timestamp: _end, trace_id: _traceId, + attributes, ...rest } = span; - return rest; + const { 'sentry.segment.id': _segmentId, ...restAttributes } = attributes; + return { ...rest, attributes: restAttributes }; }; expect(normalize(sessionBatchSpan!)).toEqual(normalize(directBatchSpan!)); @@ -180,30 +170,25 @@ it('instruments D1 batch() automatically via env', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('transaction'); - const d1Spans = findD1Spans(envelope); - - const batchSpan = d1Spans.find(s => s.description === 'D1 batch'); - expect(batchSpan).toBeDefined(); - expect(batchSpan).toEqual({ - data: { - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'batch', - 'db.query.text': 'INSERT INTO users (name) VALUES (?)\nINSERT INTO users (name) VALUES (?)', - 'db.operation.batch.size': 2, - 'sentry.op': 'db.query', - 'sentry.origin': 'auto.db.cloudflare.d1', - }, - description: 'D1 batch', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - status: 'ok', - parent_span_id: expect.any(String), - span_id: expect.any(String), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.any(String), - }); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + const batchSpan = spans.find(span => span.name === 'D1 batch'); + expect(batchSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(batchSpan?.status).toBe('ok'); + expect(batchSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'db.query' }, + 'sentry.origin': { type: 'string', value: 'auto.db.cloudflare.d1' }, + 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, + 'db.operation.name': { type: 'string', value: 'batch' }, + 'db.query.text': { + type: 'string', + value: 'INSERT INTO users (name) VALUES (?)\nINSERT INTO users (name) VALUES (?)', + }, + 'db.operation.batch.size': { type: 'integer', value: 2 }, + }), + ); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/index.ts b/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/index.ts index 801398bd0923..0844a32ddbd4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/index.ts @@ -17,5 +17,5 @@ const handler = { // This simulates scenarios where the module is re-evaluated or the handler // is wrapped multiple times. The SDK should handle this gracefully // without double-wrapping (which would cause duplicate error reports). -const once = Sentry.withSentry((env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static' }), handler); -export default Sentry.withSentry((env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static' }), once); +const once = Sentry.withSentry((env: Env) => ({ dsn: env.SENTRY_DSN }), handler); +export default Sentry.withSentry((env: Env) => ({ dsn: env.SENTRY_DSN }), once); diff --git a/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts b/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts index 89939f53c64c..7b6f456811db 100644 --- a/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/double-instrumentation/test.ts @@ -31,10 +31,9 @@ it('Only sends one error event when withSentry is called twice', async ({ signal { includeTransaction: false }, ), ) - // The http.server span produces a transaction envelope that is sent in parallel with the - // error event. Either can arrive first at the mock server, so ignore it here to keep the - // assertion focused on the error event. - .ignore('transaction') + // The http.server span is streamed in parallel with the error event. Either can arrive first + // at the mock server, so ignore it here to keep the assertion focused on the error event. + .ignore('span') .start(signal); await runner.makeRequest('get', '/error', { expectError: true }); await runner.completed(); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts index 5da3d134d60a..b99aeda4d302 100644 --- a/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts @@ -3,7 +3,7 @@ import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; it('two consecutive invocations get different isolation scopes', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=1', (envelope: Envelope) => { const event = envelope[1]?.[0]?.[1] as Event; @@ -23,7 +23,7 @@ it('two consecutive invocations get different isolation scopes', async ({ signal }); it('a nested direct call within one invocation shares the same isolation scope', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); await runner.makeRequestAndWaitForEnvelope('get', '/nested', (envelope: Envelope) => { const event = envelope[1]?.[0]?.[1] as Event; @@ -51,7 +51,7 @@ it('a nested direct call within one invocation shares the same isolation scope', }); it('a nested call into another instrumented handler shares the same isolation scope', async ({ signal }) => { - const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); + const runner = createRunner(__dirname).ignore('span').start(signal); await runner.makeRequestAndWaitForEnvelope('get', '/reentrant', (envelope: Envelope) => { const event = envelope[1]?.[0]?.[1] as Event; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts index c2a1ba92b8ce..8415c2dac61a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts @@ -19,7 +19,6 @@ class TestDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), TestDurableObjectBase, @@ -28,7 +27,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/index.ts b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/index.ts index 835348645e66..ce5e0ab46512 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/index.ts @@ -10,7 +10,6 @@ const app = new Hono<{ Bindings: Env }>(); app.use( sentry(app, { dsn: process.env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), ); diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts index b2ee4c937032..33ba266ed7cc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; -import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect'; +import { eventEnvelope } from '../../expect'; import { createRunner } from '../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../spanUtils'; it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { const runner = createRunner(__dirname) @@ -43,43 +44,23 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { { includeSamplingFields: true, includeSampleRand: true, sdk: 'hono' }, ), ) - .expect(envelope => { - const [, envelopeItems] = envelope; - const [itemHeader, itemPayload] = envelopeItems[0]; - - expect(itemHeader.type).toBe('transaction'); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(itemPayload).toMatchObject({ - type: 'transaction', - platform: 'javascript', - transaction: 'GET /error/:param', - contexts: { - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - op: 'http.server', - status: 'internal_error', - origin: 'auto.http.cloudflare', - }, - }, - request: expect.objectContaining({ - method: 'GET', - url: expect.stringContaining('/error/param-123'), + // The Hono route is a parametrized pattern, so the streamed segment keeps the full name. + expect(segmentSpan?.name).toBe('GET /error/:param'); + // Span v2 keeps the coarse `error` status on the span and the specific one as an attribute. + expect(segmentSpan?.status).toBe('error'); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.status.message': { type: 'string', value: 'internal_error' }, + 'sentry.origin': { type: 'string', value: 'auto.http.cloudflare' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.path': { type: 'string', value: '/error/param-123' }, }), - breadcrumbs: [ - { - timestamp: expect.any(Number), - category: 'console', - level: 'error', - message: 'Error: Test error from Hono app', - data: expect.objectContaining({ - logger: 'console', - arguments: [{ message: 'Test error from Hono app', name: 'Error', stack: expect.any(String) }], - }), - }, - ], - }); + ); }) .unordered() .start(signal); @@ -91,31 +72,20 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { it('Hono app captures parametrized names', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const [, envelopeItems] = envelope; - const [itemHeader, itemPayload] = envelopeItems[0]; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(itemHeader.type).toBe('transaction'); - - expect(itemPayload).toMatchObject({ - type: 'transaction', - platform: 'javascript', - transaction: 'GET /hello/:name', - contexts: { - trace: { - span_id: SHORT_UUID_MATCHER, - trace_id: UUID_MATCHER, - op: 'http.server', - status: 'ok', - origin: 'auto.http.cloudflare', - }, - }, - request: expect.objectContaining({ - method: 'GET', - url: expect.stringContaining('/hello/:name'), + expect(segmentSpan?.name).toBe('GET /hello/:name'); + expect(segmentSpan?.status).toBe('ok'); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.origin': { type: 'string', value: 'auto.http.cloudflare' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, + 'http.request.method': { type: 'string', value: 'GET' }, + 'url.path': { type: 'string', value: '/hello/:name' }, }), - }); + ); }) - .unordered() .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts index 3cf0ae743ecd..05ba6b6a7c71 100644 --- a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-disabled/index.ts @@ -7,7 +7,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', integrations: [Sentry.httpServerIntegration({ maxRequestBodySize: 'none' })], }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts index 01ca73ed8a98..a5aea20ed2d0 100644 --- a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-filtered/index.ts @@ -7,7 +7,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', integrations: integrations => integrations.filter(i => i.name !== 'HttpServer'), }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts index 2f8a2f54a176..44bd88fe8044 100644 --- a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-ignore/index.ts @@ -7,7 +7,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', integrations: [ Sentry.httpServerIntegration({ ignoreRequestBody: url => url.includes('/health') || url.includes('/upload'), diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts index 3096ab973b02..b4257939cfaf 100644 --- a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server-small/index.ts @@ -7,7 +7,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', integrations: [Sentry.httpServerIntegration({ maxRequestBodySize: 'small' })], }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts index e93051774b13..367a12f4e37f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/integrations/http-server/index.ts @@ -8,7 +8,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, attachStacktrace: false, - traceLifecycle: 'static', }), { async fetch(request, _env, _ctx) { diff --git a/dev-packages/cloudflare-integration-tests/suites/prisma/index.ts b/dev-packages/cloudflare-integration-tests/suites/prisma/index.ts index 64663e9e1838..6975b274370e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/prisma/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/prisma/index.ts @@ -11,7 +11,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, integrations: [Sentry.prismaIntegration()], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/prisma/test.ts b/dev-packages/cloudflare-integration-tests/suites/prisma/test.ts index 6cbba42facb4..b9a9bdea08a0 100644 --- a/dev-packages/cloudflare-integration-tests/suites/prisma/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/prisma/test.ts @@ -1,8 +1,8 @@ import { execSync } from 'node:child_process'; import { join } from 'node:path'; -import type { Envelope } from '@sentry/core'; import { beforeAll, expect, it } from 'vitest'; import { createRunner } from '../../runner'; +import { getSpanOp } from '../../spanUtils'; beforeAll(() => { // Generate the Prisma client (including the WASM query engine used on Workers) before wrangler @@ -10,76 +10,62 @@ beforeAll(() => { execSync(`yarn prisma generate --schema ${join(__dirname, 'schema.prisma')}`, { cwd: __dirname, stdio: 'inherit' }); }, 120_000); -function envelopeItemType(envelope: Envelope): string | undefined { - return envelope[1][0]?.[0]?.type as string | undefined; -} +it('captures Prisma spans for a D1 query via the @sentry/cloudflare prismaIntegration', async ({ signal }) => { + const runner = createRunner(__dirname).start(signal); -function envelopeItem(envelope: Envelope): Record { - return envelope[1][0]![1] as Record; -} + // The request produces one segment span and sixteen children. Waiting for all seventeen rather + // than for the segment: it ends last, but each envelope is its own request to the mock server, so + // it can arrive before the envelope carrying the children this test asserts on. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 17); -it('captures a transaction with Prisma spans for a D1 query via the @sentry/cloudflare prismaIntegration', async ({ - signal, -}) => { - const runner = createRunner(__dirname) - .ignore('event') - .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('transaction'); - - const transaction = envelopeItem(envelope); - const trace = (transaction.contexts as Record> | undefined)?.trace; + await runner.makeRequest('get', '/users'); - expect(transaction.transaction).toBe('GET /users'); - expect(trace?.op).toBe('http.server'); - expect(trace?.origin).toBe('auto.http.cloudflare'); - expect(trace?.status).toBe('ok'); + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment); - const createUserTableQuery = - 'CREATE TABLE IF NOT EXISTS User (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT)'; - const selectUsersQuery = - 'SELECT `main`.`User`.`id`, `main`.`User`.`email`, `main`.`User`.`name` FROM `main`.`User` WHERE 1=1 LIMIT ? OFFSET ?'; + // `/users` is a raw URL, so the streamed segment name keeps the method only. + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/users' }); + expect(segmentSpan?.status).toBe('ok'); - // Only the span shape is stable here - ids, timestamps, and parent ids are not. - const spans = ((transaction.spans as Array>) || []).map(span => ({ - description: span.description, - op: span.op, - origin: span.origin, - })); + // Only the span shape is stable here - ids, timestamps, and parent ids are not. + const childSpans = spans + .filter(span => !span.is_segment) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })); - expect(spans).toHaveLength(16); - expect(spans).toEqual( - expect.arrayContaining([ - { - description: createUserTableQuery, - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - }, - { - // The sanitizer strips the D1 adapter's traceparent comment and replaces the literals. - description: - 'SELECT `main`.`User`.`id`, `main`.`User`.`email`, `main`.`User`.`name` FROM `main`.`User` WHERE ?=? LIMIT ? OFFSET ?', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - }, - { description: 'prisma:client:connect', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:client:load_engine', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:client:operation', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:client:serialize', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:connect', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:connection', op: 'db', origin: 'auto.db.prisma' }, - { description: 'prisma:engine:query', op: undefined, origin: 'auto.db.prisma' }, - { description: selectUsersQuery, op: 'db', origin: 'auto.db.prisma' }, - { description: 'prisma:engine:js:query:args', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:js:query:sql', op: 'db', origin: 'auto.db.prisma' }, - { description: 'prisma:engine:js:query:result', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:serialize', op: undefined, origin: 'auto.db.prisma' }, - { description: 'prisma:engine:response_json_serialization', op: undefined, origin: 'auto.db.prisma' }, - ]), - ); - expect(spans.filter(span => span.description === 'prisma:engine:connection')).toHaveLength(2); - }) - .start(signal); + expect(childSpans).toHaveLength(16); + expect(childSpans).toEqual( + expect.arrayContaining([ + // Both D1 queries are named after their query summary rather than the full query text. + { name: 'CREATE TABLE User', op: 'db.query', origin: 'auto.db.cloudflare.d1' }, + { name: 'SELECT `main`.`User`', op: 'db.query', origin: 'auto.db.cloudflare.d1' }, + { name: 'prisma:client:connect', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:client:load_engine', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:client:operation', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:client:serialize', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:connect', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:connection', op: 'db', origin: 'auto.db.prisma' }, + { name: 'prisma:engine:query', op: undefined, origin: 'auto.db.prisma' }, + { name: 'SELECT `main`.`User`', op: 'db', origin: 'auto.db.prisma' }, + { name: 'prisma:engine:js:query:args', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:js:query:sql', op: 'db', origin: 'auto.db.prisma' }, + { name: 'prisma:engine:js:query:result', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:serialize', op: undefined, origin: 'auto.db.prisma' }, + { name: 'prisma:engine:response_json_serialization', op: undefined, origin: 'auto.db.prisma' }, + ]), + ); + expect(childSpans.filter(span => span.name === 'prisma:engine:connection')).toHaveLength(2); - await runner.makeRequest('get', '/users'); - await runner.completed(); + // The sanitizer strips the D1 adapter's traceparent comment and replaces the literals. The two + // `SELECT` spans share a name, so the D1 one is picked by its op. + expect( + spans.find(span => getSpanOp(span) === 'db.query' && span.name === 'SELECT `main`.`User`')?.attributes[ + 'db.query.text' + ], + ).toEqual({ + type: 'string', + value: + 'SELECT `main`.`User`.`id`, `main`.`User`.`email`, `main`.`User`.`name` FROM `main`.`User` WHERE ?=? LIMIT ? OFFSET ?', + }); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts index da2ff58a3899..635fcfc8721e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts @@ -7,7 +7,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', release: '1.0.0', environment: 'test', serverName: 'mi-servidor.com', diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts deleted file mode 100644 index 6065ce871c29..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; -import { - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, - SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, -} from '@sentry/core'; -import { - SENTRY_SEGMENT_NAME_SOURCE, - SENTRY_SDK_NAME, - SENTRY_SDK_VERSION, - SENTRY_SEGMENT_ID, - SENTRY_SEGMENT_NAME, - SENTRY_TRACE_LIFECYCLE, -} from '@sentry/conventions/attributes'; -import { expect, it } from 'vitest'; -import { createRunner } from '../../../runner'; - -const CLOUDFLARE_SDK = 'sentry.javascript.cloudflare'; - -function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { - const spanItem = envelope[1].find(item => item[0].type === 'span'); - expect(spanItem).toBeDefined(); - return spanItem![1] as SerializedStreamedSpanContainer; -} - -it('sends a streamed span envelope with correct envelope header', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - expect(getSpanContainer(envelope).items.length).toBeGreaterThan(0); - - expect(envelope[0]).toEqual( - expect.objectContaining({ - sent_at: expect.any(String), - sdk: { - name: CLOUDFLARE_SDK, - version: SDK_VERSION, - }, - trace: expect.objectContaining({ - public_key: 'public', - sample_rate: '1', - sampled: 'true', - trace_id: expect.stringMatching(/^[\da-f]{32}$/), - }), - }), - ); - }) - .start(signal); - - await runner.makeRequest('get', '/'); - await runner.completed(); -}); - -it('sends a streamed span envelope with correct spans for a manually started span with children', async ({ - signal, -}) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const container = getSpanContainer(envelope); - const spans = container.items; - - // Cloudflare `withSentry` wraps fetch in an http.server span (segment) around the scenario. - expect(spans.length).toBe(5); - - const segmentSpan = spans.find(s => !!s.is_segment); - expect(segmentSpan).toBeDefined(); - - const segmentSpanId = segmentSpan!.span_id; - const traceId = segmentSpan!.trace_id; - const segmentName = segmentSpan!.name; - - const parentTestSpan = spans.find(s => s.name === 'test-span'); - expect(parentTestSpan).toBeDefined(); - expect(parentTestSpan!.parent_span_id).toBe(segmentSpanId); - - const childSpan = spans.find(s => s.name === 'test-child-span'); - expect(childSpan).toBeDefined(); - expect(childSpan).toEqual({ - attributes: { - 'sentry.is_localhost': { value: true, type: 'boolean' }, - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { - type: 'string', - value: 'test-child', - }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - }, - name: 'test-child-span', - is_segment: false, - parent_span_id: parentTestSpan!.span_id, - trace_id: traceId, - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }); - - const inactiveSpan = spans.find(s => s.name === 'test-inactive-span'); - expect(inactiveSpan).toBeDefined(); - expect(inactiveSpan).toEqual({ - attributes: { - 'sentry.is_localhost': { value: true, type: 'boolean' }, - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - }, - links: [ - { - attributes: { - 'sentry.link.type': { - type: 'string', - value: 'some_relation', - }, - }, - sampled: true, - span_id: parentTestSpan!.span_id, - trace_id: traceId, - }, - ], - name: 'test-inactive-span', - is_segment: false, - parent_span_id: parentTestSpan!.span_id, - trace_id: traceId, - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }); - - const manualSpan = spans.find(s => s.name === 'test-manual-span'); - expect(manualSpan).toBeDefined(); - expect(manualSpan).toEqual({ - attributes: { - 'sentry.is_localhost': { value: true, type: 'boolean' }, - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - }, - name: 'test-manual-span', - is_segment: false, - parent_span_id: parentTestSpan!.span_id, - trace_id: traceId, - span_id: expect.stringMatching(/^[\da-f]{16}$/), - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }); - - expect(parentTestSpan).toEqual({ - attributes: { - 'sentry.is_localhost': { value: true, type: 'boolean' }, - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - }, - name: 'test-span', - is_segment: false, - parent_span_id: segmentSpanId, - trace_id: traceId, - span_id: parentTestSpan!.span_id, - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }); - - expect(segmentSpan).toEqual({ - attributes: { - 'sentry.is_localhost': { value: true, type: 'boolean' }, - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { - type: 'array', - value: expect.arrayContaining(['SpanStreaming']), - }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.server' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, - [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - 'server.address': { - type: 'string', - value: 'localhost', - }, - 'url.full': { - type: 'string', - value: expect.stringMatching(/^http:\/\/localhost:.+$/), - }, - 'url.path': { - type: 'string', - value: '/', - }, - 'url.port': { - type: 'string', - value: expect.stringMatching(/^\d{4,5}$/), - }, - 'url.scheme': { - type: 'string', - value: 'http:', - }, - 'user_agent.original': { - type: 'string', - value: 'node', - }, - 'http.request.header.accept': { - type: 'array', - value: ['*/*'], - }, - 'http.request.header.accept-encoding': { - type: 'array', - value: ['br, gzip'], - }, - 'http.request.header.accept-language': { - type: 'array', - value: ['*'], - }, - 'http.request.header.cf-connecting-ip': { - type: 'array', - value: ['127.0.0.1'], - }, - 'user.ip_address': { - type: 'string', - value: '127.0.0.1', - }, - 'http.request.header.host': { - type: 'array', - value: [expect.stringMatching(/^localhost:.+$/)], - }, - 'http.request.header.sec-fetch-mode': { - type: 'array', - value: ['cors'], - }, - 'http.request.header.user-agent': { - type: 'array', - value: ['node'], - }, - 'http.request.method': { - type: 'string', - value: 'GET', - }, - 'http.response.status_code': { - type: 'integer', - value: 200, - }, - 'cloud.provider': { - type: 'string', - value: 'cloudflare', - }, - 'culture.timezone': { - type: 'string', - value: expect.any(String), - }, - 'network.protocol.name': { - type: 'string', - value: 'http', - }, - 'network.protocol.version': { - type: 'string', - value: '1.1', - }, - }, - is_segment: true, - trace_id: traceId, - span_id: segmentSpanId, - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - name: 'GET /', - }); - }) - .start(signal); - - await runner.makeRequest('get', '/'); - await runner.completed(); -}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/index.ts similarity index 96% rename from dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts rename to dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/index.ts index 76039b6892ee..328cc01c19de 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/index.ts @@ -8,7 +8,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - traceLifecycle: 'stream', release: '1.0.0', }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts new file mode 100644 index 000000000000..cb73392fce05 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts @@ -0,0 +1,296 @@ +import { + SDK_VERSION, + SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, + SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, + SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, +} from '@sentry/core'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, +} from '@sentry/conventions/attributes'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpansFromEnvelope } from '../../../spanUtils'; + +const CLOUDFLARE_SDK = 'sentry.javascript.cloudflare'; + +it('sends a streamed span envelope with correct envelope header', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + expect(getSpansFromEnvelope(envelope).length).toBeGreaterThan(0); + + expect(envelope[0]).toEqual( + expect.objectContaining({ + sent_at: expect.any(String), + sdk: { + name: CLOUDFLARE_SDK, + version: SDK_VERSION, + }, + trace: expect.objectContaining({ + public_key: 'public', + sample_rate: '1', + sampled: 'true', + trace_id: expect.stringMatching(/^[\da-f]{32}$/), + }), + }), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/'); + await runner.completed(); +}); + +it('sends a streamed span envelope with correct spans for a manually started span with children', async ({ + signal, +}) => { + const runner = createRunner(__dirname).start(signal); + // Cloudflare `withSentry` wraps fetch in an http.server span (segment) around the scenario, so + // the trace holds five spans. Waiting for all of them rather than for the segment alone: the + // segment ends last but each envelope is its own request, so it can arrive before its children. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 5); + + await runner.makeRequest('get', '/'); + + const spans = await spansPromise; + + expect(spans.length).toBe(5); + + const segmentSpan = spans.find(s => !!s.is_segment); + expect(segmentSpan).toBeDefined(); + + const segmentSpanId = segmentSpan!.span_id; + const traceId = segmentSpan!.trace_id; + const segmentName = segmentSpan!.name; + + const parentTestSpan = spans.find(s => s.name === 'test-span'); + expect(parentTestSpan).toBeDefined(); + expect(parentTestSpan!.parent_span_id).toBe(segmentSpanId); + + const childSpan = spans.find(s => s.name === 'test-child-span'); + expect(childSpan).toBeDefined(); + expect(childSpan).toEqual({ + attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { + type: 'string', + value: 'test-child', + }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + }, + name: 'test-child-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const inactiveSpan = spans.find(s => s.name === 'test-inactive-span'); + expect(inactiveSpan).toBeDefined(); + expect(inactiveSpan).toEqual({ + attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + }, + links: [ + { + attributes: { + 'sentry.link.type': { + type: 'string', + value: 'some_relation', + }, + }, + sampled: true, + span_id: parentTestSpan!.span_id, + trace_id: traceId, + }, + ], + name: 'test-inactive-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + const manualSpan = spans.find(s => s.name === 'test-manual-span'); + expect(manualSpan).toBeDefined(); + expect(manualSpan).toEqual({ + attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + }, + name: 'test-manual-span', + is_segment: false, + parent_span_id: parentTestSpan!.span_id, + trace_id: traceId, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(parentTestSpan).toEqual({ + attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + }, + name: 'test-span', + is_segment: false, + parent_span_id: segmentSpanId, + trace_id: traceId, + span_id: parentTestSpan!.span_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }); + + expect(segmentSpan).toEqual({ + attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + type: 'array', + value: expect.arrayContaining(['SpanStreaming']), + }, + [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.server' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + 'server.address': { + type: 'string', + value: 'localhost', + }, + 'url.full': { + type: 'string', + value: expect.stringMatching(/^http:\/\/localhost:.+$/), + }, + 'url.path': { + type: 'string', + value: '/', + }, + 'url.port': { + type: 'string', + value: expect.stringMatching(/^\d{4,5}$/), + }, + 'url.scheme': { + type: 'string', + value: 'http:', + }, + 'user_agent.original': { + type: 'string', + value: 'node', + }, + 'http.request.header.accept': { + type: 'array', + value: ['*/*'], + }, + 'http.request.header.accept-encoding': { + type: 'array', + value: ['br, gzip'], + }, + 'http.request.header.accept-language': { + type: 'array', + value: ['*'], + }, + 'http.request.header.cf-connecting-ip': { + type: 'array', + value: ['127.0.0.1'], + }, + 'user.ip_address': { + type: 'string', + value: '127.0.0.1', + }, + 'http.request.header.host': { + type: 'array', + value: [expect.stringMatching(/^localhost:.+$/)], + }, + 'http.request.header.sec-fetch-mode': { + type: 'array', + value: ['cors'], + }, + 'http.request.header.user-agent': { + type: 'array', + value: ['node'], + }, + 'http.request.method': { + type: 'string', + value: 'GET', + }, + 'http.response.status_code': { + type: 'integer', + value: 200, + }, + 'cloud.provider': { + type: 'string', + value: 'cloudflare', + }, + 'culture.timezone': { + type: 'string', + value: expect.any(String), + }, + 'network.protocol.name': { + type: 'string', + value: 'http', + }, + 'network.protocol.version': { + type: 'string', + value: '1.1', + }, + }, + is_segment: true, + trace_id: traceId, + span_id: segmentSpanId, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + name: 'GET /', + }); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/index.ts b/dev-packages/cloudflare-integration-tests/suites/queue/index.ts index 55968af1f0fa..44d20be6e939 100644 --- a/dev-packages/cloudflare-integration-tests/suites/queue/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/queue/index.ts @@ -9,7 +9,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/queue/test.ts b/dev-packages/cloudflare-integration-tests/suites/queue/test.ts index 6d116ef37bbe..9093e6624998 100644 --- a/dev-packages/cloudflare-integration-tests/suites/queue/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/queue/test.ts @@ -1,35 +1,14 @@ import type { Envelope } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; - -function envelopeItemType(envelope: Envelope): string | undefined { - return envelope[1][0]?.[0]?.type as string | undefined; -} - -function envelopeItem(envelope: Envelope): Record { - return envelope[1][0]![1] as Record; -} - -function findPublishSpan(envelope: Envelope): Record | undefined { - if (envelopeItemType(envelope) !== 'transaction') return undefined; - const tx = envelopeItem(envelope); - const spans = (tx.spans as Array>) || []; - return spans.find(s => (s.op as string) === 'queue.publish'); -} - -function isConsumerTransaction(envelope: Envelope): boolean { - if (envelopeItemType(envelope) !== 'transaction') return false; - const tx = envelopeItem(envelope); - return tx.transaction === 'process test-queue'; -} +import { getSpansFromEnvelope } from '../../spanUtils'; it('captures errors thrown by the queue handler with the correct mechanism', async ({ signal }) => { const runner = createRunner(__dirname) - .ignore('transaction') + .ignore('span') .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('event'); - const event = envelopeItem(envelope); - expect(event).toMatchObject({ + expect(envelope[1][0]?.[0]?.type).toBe('event'); + expect(envelope[1][0]?.[1]).toMatchObject({ level: 'error', exception: { values: [ @@ -48,43 +27,44 @@ it('captures errors thrown by the queue handler with the correct mechanism', asy await runner.completed(); }); -it('emits a queue.publish span on env.MY_QUEUE.send and a queue.process transaction on the consumer', async ({ +it('emits a queue.publish span on env.MY_QUEUE.send and a queue.process segment span on the consumer', async ({ signal, }) => { const runner = createRunner(__dirname) .unordered() .expect((envelope: Envelope) => { - // Producer transaction must contain a queue.publish child span - const publishSpan = findPublishSpan(envelope); + // The producer trace carries a `queue.publish` child span. + const publishSpan = getSpansFromEnvelope(envelope).find(span => span.name === 'send MY_QUEUE'); + expect(publishSpan).toBeDefined(); - expect(publishSpan).toMatchObject({ - op: 'queue.publish', - description: 'send MY_QUEUE', - data: expect.objectContaining({ - 'messaging.system': 'cloudflare', - 'messaging.destination.name': 'MY_QUEUE', - 'messaging.operation.type': 'send', - 'messaging.operation.name': 'send', - 'sentry.origin': 'auto.faas.cloudflare.queue', + expect(publishSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.queue' }, + 'messaging.system': { type: 'string', value: 'cloudflare' }, + 'messaging.destination.name': { type: 'string', value: 'MY_QUEUE' }, + 'messaging.operation.type': { type: 'string', value: 'send' }, + 'messaging.operation.name': { type: 'string', value: 'send' }, }), - }); + ); }) .expect((envelope: Envelope) => { - expect(isConsumerTransaction(envelope)).toBe(true); - const tx = envelopeItem(envelope); - const trace = (tx.contexts as Record>).trace as Record; - expect(trace).toMatchObject({ - op: 'queue.process', - origin: 'auto.faas.cloudflare.queue', - data: expect.objectContaining({ - 'messaging.system': 'cloudflare', - 'messaging.destination.name': 'test-queue', - 'messaging.operation.type': 'process', - 'messaging.operation.name': 'process', - 'messaging.batch.message_count': 1, - 'faas.trigger': 'pubsub', + // The consumer runs in its own trace, so its segment span arrives in its own envelope. + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('process test-queue'); + expect(segmentSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.process' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.queue' }, + 'messaging.system': { type: 'string', value: 'cloudflare' }, + 'messaging.destination.name': { type: 'string', value: 'test-queue' }, + 'messaging.operation.type': { type: 'string', value: 'process' }, + 'messaging.operation.name': { type: 'string', value: 'process' }, + 'messaging.batch.message_count': { type: 'integer', value: 1 }, + 'faas.trigger': { type: 'string', value: 'pubsub' }, }), - }); + ); }) .start(signal); @@ -96,30 +76,26 @@ it('emits a queue.publish span with batch attributes on env.MY_QUEUE.sendBatch', const runner = createRunner(__dirname) .unordered() .expect((envelope: Envelope) => { - const publishSpan = findPublishSpan(envelope); + const publishSpan = getSpansFromEnvelope(envelope).find(span => span.name === 'send MY_QUEUE'); + expect(publishSpan).toBeDefined(); - expect(publishSpan).toMatchObject({ - op: 'queue.publish', - description: 'send MY_QUEUE', - data: expect.objectContaining({ - 'messaging.system': 'cloudflare', - 'messaging.destination.name': 'MY_QUEUE', - 'messaging.operation.type': 'send', - 'messaging.operation.name': 'send', - 'messaging.batch.message_count': 3, - 'sentry.origin': 'auto.faas.cloudflare.queue', + expect(publishSpan?.attributes).toEqual( + expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'queue.publish' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.queue' }, + 'messaging.system': { type: 'string', value: 'cloudflare' }, + 'messaging.destination.name': { type: 'string', value: 'MY_QUEUE' }, + 'messaging.operation.type': { type: 'string', value: 'send' }, + 'messaging.operation.name': { type: 'string', value: 'send' }, + 'messaging.batch.message_count': { type: 'integer', value: 3 }, }), - }); + ); }) .expect((envelope: Envelope) => { - expect(isConsumerTransaction(envelope)).toBe(true); - const tx = envelopeItem(envelope); - const trace = (tx.contexts as Record>).trace as Record; - expect(trace).toMatchObject({ - data: expect.objectContaining({ - 'messaging.batch.message_count': 3, - }), - }); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('process test-queue'); + expect(segmentSpan?.attributes['messaging.batch.message_count']).toEqual({ type: 'integer', value: 3 }); }) .start(signal); @@ -130,11 +106,11 @@ it('emits a queue.publish span with batch attributes on env.MY_QUEUE.sendBatch', it('calls non-instrumented Queue methods like metrics() on the underlying binding', async ({ signal }) => { const runner = createRunner(__dirname) .expect((envelope: Envelope) => { - expect(envelopeItemType(envelope)).toBe('transaction'); - expect(envelopeItem(envelope)).toMatchObject({ - transaction: 'GET /metrics', - contexts: { trace: { status: 'ok' } }, - }); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/metrics' }); + expect(segmentSpan?.status).toBe('ok'); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/r2/index.ts b/dev-packages/cloudflare-integration-tests/suites/r2/index.ts index 67341b3b8114..7f1da7308dba 100644 --- a/dev-packages/cloudflare-integration-tests/suites/r2/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/r2/index.ts @@ -9,7 +9,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/r2/test.ts b/dev-packages/cloudflare-integration-tests/suites/r2/test.ts index 689419c44b89..0b94c55f8bfc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/r2/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/r2/test.ts @@ -1,66 +1,35 @@ -import type { Envelope } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../runner'; - -function envelopeItemType(envelope: Envelope): string | undefined { - return envelope[1][0]?.[0]?.type as string | undefined; -} - -function envelopeItem(envelope: Envelope): Record { - return envelope[1][0]![1] as Record; -} - -function findSpans(envelope: Envelope, description: string): Array> { - if (envelopeItemType(envelope) !== 'transaction') return []; - const tx = envelopeItem(envelope); - const spans = (tx.spans as Array>) || []; - return spans.filter(s => s.description === description); -} - -function spanData(span: Record): Record { - return span.data as Record; -} +import { getSpansFromEnvelope } from '../../spanUtils'; it('emits r2_put and r2_get spans with correct attributes', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const putSpans = findSpans(envelope, 'r2_put'); - expect(putSpans).toHaveLength(1); - const putData = spanData(putSpans[0]!); - expect({ - op: putSpans[0]!.op, - description: putSpans[0]!.description, - 'cloudflare.r2.operation': putData['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': putData['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': putData['cloudflare.r2.request.key'], - 'sentry.origin': putData['sentry.origin'], - }).toEqual({ - op: 'object.put', - description: 'r2_put', - 'cloudflare.r2.operation': 'PutObject', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'test-key.txt', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); - - const getSpans = findSpans(envelope, 'r2_get'); - expect(getSpans).toHaveLength(1); - const getData = spanData(getSpans[0]!); - expect({ - op: getSpans[0]!.op, - description: getSpans[0]!.description, - 'cloudflare.r2.operation': getData['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': getData['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': getData['cloudflare.r2.request.key'], - 'sentry.origin': getData['sentry.origin'], - }).toEqual({ - op: 'object.get', - description: 'r2_get', - 'cloudflare.r2.operation': 'GetObject', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'test-key.txt', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + + expect(spans.filter(span => span.name === 'r2_put')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.put' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'PutObject' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'test-key.txt' }, + }), + }), + ]); + + expect(spans.filter(span => span.name === 'r2_get')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.get' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'GetObject' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'test-key.txt' }, + }), + }), + ]); }) .start(signal); @@ -70,25 +39,18 @@ it('emits r2_put and r2_get spans with correct attributes', async ({ signal }) = it('emits an r2_head span', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const spans = findSpans(envelope, 'r2_head'); - expect(spans).toHaveLength(1); - const data = spanData(spans[0]!); - expect({ - op: spans[0]!.op, - description: spans[0]!.description, - 'cloudflare.r2.operation': data['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': data['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': data['cloudflare.r2.request.key'], - 'sentry.origin': data['sentry.origin'], - }).toEqual({ - op: 'object.head', - description: 'r2_head', - 'cloudflare.r2.operation': 'HeadObject', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'head-key.txt', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); + .expect(envelope => { + expect(getSpansFromEnvelope(envelope).filter(span => span.name === 'r2_head')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.head' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'HeadObject' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'head-key.txt' }, + }), + }), + ]); }) .start(signal); @@ -98,25 +60,20 @@ it('emits an r2_head span', async ({ signal }) => { it('emits an r2_list span without a key attribute', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const spans = findSpans(envelope, 'r2_list'); - expect(spans).toHaveLength(1); - const data = spanData(spans[0]!); - expect({ - op: spans[0]!.op, - description: spans[0]!.description, - 'cloudflare.r2.operation': data['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': data['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': data['cloudflare.r2.request.key'], - 'sentry.origin': data['sentry.origin'], - }).toEqual({ - op: 'object.list', - description: 'r2_list', - 'cloudflare.r2.operation': 'ListObjects', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': undefined, - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); + .expect(envelope => { + const listSpans = getSpansFromEnvelope(envelope).filter(span => span.name === 'r2_list'); + + expect(listSpans).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.list' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'ListObjects' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + }), + }), + ]); + expect(listSpans[0]?.attributes['cloudflare.r2.request.key']).toBeUndefined(); }) .start(signal); @@ -126,25 +83,18 @@ it('emits an r2_list span without a key attribute', async ({ signal }) => { it('emits an r2_delete span', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const spans = findSpans(envelope, 'r2_delete'); - expect(spans).toHaveLength(1); - const data = spanData(spans[0]!); - expect({ - op: spans[0]!.op, - description: spans[0]!.description, - 'cloudflare.r2.operation': data['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': data['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': data['cloudflare.r2.request.key'], - 'sentry.origin': data['sentry.origin'], - }).toEqual({ - op: 'object.delete', - description: 'r2_delete', - 'cloudflare.r2.operation': 'DeleteObject', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'delete-me.txt', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); + .expect(envelope => { + expect(getSpansFromEnvelope(envelope).filter(span => span.name === 'r2_delete')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.delete' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'DeleteObject' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'delete-me.txt' }, + }), + }), + ]); }) .start(signal); @@ -154,66 +104,51 @@ it('emits an r2_delete span', async ({ signal }) => { it('emits spans for each multipart upload operation', async ({ signal }) => { const runner = createRunner(__dirname) - .expect((envelope: Envelope) => { - const createSpans = findSpans(envelope, 'r2_createMultipartUpload'); - expect(createSpans).toHaveLength(1); - const createData = spanData(createSpans[0]!); - expect({ - op: createSpans[0]!.op, - description: createSpans[0]!.description, - 'cloudflare.r2.operation': createData['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': createData['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': createData['cloudflare.r2.request.key'], - 'sentry.origin': createData['sentry.origin'], - }).toEqual({ - op: 'object.multipart_upload.create', - description: 'r2_createMultipartUpload', - 'cloudflare.r2.operation': 'CreateMultipartUpload', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'multipart.bin', - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); - - const uploadPartSpans = findSpans(envelope, 'r2_uploadPart'); + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + + expect(spans.filter(span => span.name === 'r2_createMultipartUpload')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.multipart_upload.create' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'CreateMultipartUpload' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'multipart.bin' }, + }), + }), + ]); + + const uploadPartSpans = spans.filter(span => span.name === 'r2_uploadPart'); expect(uploadPartSpans).toHaveLength(2); - const part0Data = spanData(uploadPartSpans[0]!); - expect({ - op: uploadPartSpans[0]!.op, - description: uploadPartSpans[0]!.description, - 'cloudflare.r2.operation': part0Data['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': part0Data['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': part0Data['cloudflare.r2.request.key'], - 'cloudflare.r2.request.part_number': part0Data['cloudflare.r2.request.part_number'], - 'sentry.origin': part0Data['sentry.origin'], - }).toEqual({ - op: 'object.upload_part', - description: 'r2_uploadPart', - 'cloudflare.r2.operation': 'UploadPart', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'multipart.bin', - 'cloudflare.r2.request.part_number': 1, - 'sentry.origin': 'auto.faas.cloudflare.r2', - }); - expect(spanData(uploadPartSpans[1]!)['cloudflare.r2.request.part_number']).toBe(2); - - const completeSpans = findSpans(envelope, 'r2_completeMultipartUpload'); - expect(completeSpans).toHaveLength(1); - const completeData = spanData(completeSpans[0]!); - expect({ - op: completeSpans[0]!.op, - description: completeSpans[0]!.description, - 'cloudflare.r2.operation': completeData['cloudflare.r2.operation'], - 'cloudflare.r2.bucket': completeData['cloudflare.r2.bucket'], - 'cloudflare.r2.request.key': completeData['cloudflare.r2.request.key'], - 'sentry.origin': completeData['sentry.origin'], - }).toEqual({ - op: 'object.multipart_upload.complete', - description: 'r2_completeMultipartUpload', - 'cloudflare.r2.operation': 'CompleteMultipartUpload', - 'cloudflare.r2.bucket': 'MY_BUCKET', - 'cloudflare.r2.request.key': 'multipart.bin', - 'sentry.origin': 'auto.faas.cloudflare.r2', + expect(uploadPartSpans[0]).toEqual( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.upload_part' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'UploadPart' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'multipart.bin' }, + 'cloudflare.r2.request.part_number': { type: 'integer', value: 1 }, + }), + }), + ); + expect(uploadPartSpans[1]?.attributes['cloudflare.r2.request.part_number']).toEqual({ + type: 'integer', + value: 2, }); + + expect(spans.filter(span => span.name === 'r2_completeMultipartUpload')).toEqual([ + expect.objectContaining({ + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'object.multipart_upload.complete' }, + 'sentry.origin': { type: 'string', value: 'auto.faas.cloudflare.r2' }, + 'cloudflare.r2.operation': { type: 'string', value: 'CompleteMultipartUpload' }, + 'cloudflare.r2.bucket': { type: 'string', value: 'MY_BUCKET' }, + 'cloudflare.r2.request.key': { type: 'string', value: 'multipart.bin' }, + }), + }), + ]); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts index 4bec21ba206b..1aad43f30de3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts @@ -24,7 +24,6 @@ const mockFetch: typeof fetch = async () => export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true, outputs: true } }, }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts index a02dc42ebd06..b68a41258aa0 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts @@ -12,8 +12,15 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // This test runs the `@anthropic-ai/sdk` on the Workers runtime (with a // canned fetch) to verify the instrumentation works end-to-end on Cloudflare, @@ -23,14 +30,14 @@ it('traces a basic message creation request with the anthropic SDK', async ({ si const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual({ + expect(genAiSpans[0]).toEqual({ trace_id: expect.any(String), span_id: expect.any(String), parent_span_id: expect.any(String), @@ -58,6 +65,13 @@ it('traces a basic message creation request with the anthropic SDK', async ({ si [GEN_AI_USAGE_INPUT_TOKENS]: { value: 10, type: 'integer' }, [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 15, type: 'integer' }, [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 25, type: 'integer' }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts index 5962c54cc0c1..bc47380e603f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/index.ts @@ -2,14 +2,13 @@ import * as Sentry from '@sentry/cloudflare'; interface Env { SENTRY_DSN: string; - STREAMED?: string; DB: D1Database; } export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: env.STREAMED === 'true' ? 'stream' : 'static', + tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts index 4dad579a374a..5b301a9f29ae 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { expect, it } from 'vitest'; +import type { Envelope, SerializedStreamedSpan } from '@sentry/core'; import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, @@ -14,216 +14,115 @@ import { SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; -it('D1 database queries create spans with correct attributes', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /init', - spans: [ - { - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'exec', - 'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', - 'db.query.summary': 'CREATE TABLE users', - }, - description: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - status: 'ok', - parent_span_id: expect.any(String), - span_id: expect.any(String), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.any(String), - }, - { - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'run', - 'db.query.text': 'INSERT INTO users (name) VALUES (?)', - 'db.query.summary': 'INSERT users', - 'cloudflare.d1.duration': expect.any(Number), - 'cloudflare.d1.rows_read': expect.any(Number), - 'cloudflare.d1.rows_written': expect.any(Number), - }, - description: 'INSERT INTO users (name) VALUES (?)', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - status: 'ok', - parent_span_id: expect.any(String), - span_id: expect.any(String), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.any(String), - }, - ], - }), - ); - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /query', - spans: [ - { - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.d1', - 'db.system.name': 'cloudflare-d1', - 'db.operation.name': 'first', - 'db.query.text': 'SELECT * FROM users WHERE name = ?', - 'db.query.summary': 'SELECT users', - }, - description: 'SELECT * FROM users WHERE name = ?', - op: 'db.query', - origin: 'auto.db.cloudflare.d1', - status: 'ok', - parent_span_id: expect.any(String), - span_id: expect.any(String), - start_timestamp: expect.any(Number), - timestamp: expect.any(Number), - trace_id: expect.any(String), - }, - ], - }), - ); - }) - .start(signal); - - await runner.makeRequest('get', '/init'); - await runner.makeRequest('get', '/query'); - await runner.completed(); -}); +/** The `db.query` spans of an envelope, paired with the segment span they belong to. */ +function getD1Spans(envelope: Envelope): { + segmentSpan: SerializedStreamedSpan; + d1Spans: SerializedStreamedSpan[]; +} { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan).toBeDefined(); -describe('with span streaming enabled', () => { - function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { - const spanItem = envelope[1].find(item => item[0].type === 'span'); - expect(spanItem).toBeDefined(); - return spanItem![1] as SerializedStreamedSpanContainer; - } + return { + segmentSpan: segmentSpan!, + d1Spans: spans.filter(span => getSpanOp(span) === 'db.query'), + }; +} - /** The `db.query` spans of an envelope, paired with the segment span they belong to. */ - function getD1Spans(envelope: Envelope): { - segmentSpan: SerializedStreamedSpanContainer['items'][number]; - d1Spans: SerializedStreamedSpanContainer['items']; - } { - const items = getSpanContainer(envelope).items; - const segmentSpan = items.find(item => item.is_segment); - expect(segmentSpan).toBeDefined(); +function commonAttributes(segmentSpan: SerializedStreamedSpan): SerializedStreamedSpan['attributes'] { + return { + [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, + [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, + [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, + [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan.span_id }, + [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentSpan.name }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db.query' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.d1' }, + 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, + }; +} - return { - segmentSpan: segmentSpan!, - d1Spans: items.filter(item => item.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value === 'db.query'), - }; - } +function commonSpanProps(segmentSpan: SerializedStreamedSpan): Record { + return { + is_segment: false, + parent_span_id: segmentSpan.span_id, + span_id: expect.stringMatching(/^[\da-f]{16}$/), + trace_id: segmentSpan.trace_id, + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + }; +} - function commonAttributes( - segmentSpan: SerializedStreamedSpanContainer['items'][number], - ): SerializedStreamedSpanContainer['items'][number]['attributes'] { - return { - [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, - [SENTRY_SDK_NAME]: { type: 'string', value: 'sentry.javascript.cloudflare' }, - [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan.span_id }, - [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentSpan.name }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db.query' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.d1' }, - 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, - }; - } +// `cloudflare.d1.duration` is only an integer when the query happens to take a whole +// number of milliseconds, so the type can't be pinned down. +const NUMBER_ATTRIBUTE = { type: expect.stringMatching(/^(?:integer|double)$/), value: expect.any(Number) }; - function commonSpanProps(segmentSpan: SerializedStreamedSpanContainer['items'][number]): Record { - return { - is_segment: false, - parent_span_id: segmentSpan.span_id, - span_id: expect.stringMatching(/^[\da-f]{16}$/), - trace_id: segmentSpan.trace_id, - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - }; - } - - // `cloudflare.d1.duration` is only an integer when the query happens to take a whole - // number of milliseconds, so the type can't be pinned down. - const NUMBER_ATTRIBUTE = { type: expect.stringMatching(/^(?:integer|double)$/), value: expect.any(Number) }; - - it('names D1 query spans after their query summary', async ({ signal }) => { - const runner = createRunner(__dirname) - .withWranglerArgs('--var', 'STREAMED:true') - .expect(envelope => { - const { segmentSpan, d1Spans } = getD1Spans(envelope); - // With span streaming, the server span name is low cardinality, so the request the - // envelope belongs to is only identifiable through `url.path`. - expect(segmentSpan.name).toBe('GET'); - expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/init' }); +it('names D1 query spans after their query summary', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const { segmentSpan, d1Spans } = getD1Spans(envelope); + // With span streaming, the server span name is low cardinality, so the request the + // envelope belongs to is only identifiable through `url.path`. + expect(segmentSpan.name).toBe('GET'); + expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/init' }); - expect(d1Spans).toEqual([ - { - name: 'CREATE TABLE users', - attributes: { - 'sentry.is_localhost': { value: true, type: 'boolean' }, - ...commonAttributes(segmentSpan), - 'db.operation.name': { type: 'string', value: 'exec' }, - 'db.query.text': { - type: 'string', - value: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', - }, - 'db.query.summary': { type: 'string', value: 'CREATE TABLE users' }, + expect(d1Spans).toEqual([ + { + name: 'CREATE TABLE users', + attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, + ...commonAttributes(segmentSpan), + 'db.operation.name': { type: 'string', value: 'exec' }, + 'db.query.text': { + type: 'string', + value: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', }, - ...commonSpanProps(segmentSpan), + 'db.query.summary': { type: 'string', value: 'CREATE TABLE users' }, }, - { - name: 'INSERT users', - attributes: { - 'sentry.is_localhost': { value: true, type: 'boolean' }, - ...commonAttributes(segmentSpan), - 'db.operation.name': { type: 'string', value: 'run' }, - 'db.query.text': { type: 'string', value: 'INSERT INTO users (name) VALUES (?)' }, - 'db.query.summary': { type: 'string', value: 'INSERT users' }, - 'cloudflare.d1.duration': NUMBER_ATTRIBUTE, - 'cloudflare.d1.rows_read': NUMBER_ATTRIBUTE, - 'cloudflare.d1.rows_written': NUMBER_ATTRIBUTE, - }, - ...commonSpanProps(segmentSpan), + ...commonSpanProps(segmentSpan), + }, + { + name: 'INSERT users', + attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, + ...commonAttributes(segmentSpan), + 'db.operation.name': { type: 'string', value: 'run' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO users (name) VALUES (?)' }, + 'db.query.summary': { type: 'string', value: 'INSERT users' }, + 'cloudflare.d1.duration': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_read': NUMBER_ATTRIBUTE, + 'cloudflare.d1.rows_written': NUMBER_ATTRIBUTE, }, - ]); - }) - .expect(envelope => { - const { segmentSpan, d1Spans } = getD1Spans(envelope); - expect(segmentSpan.name).toBe('GET'); - expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/query' }); + ...commonSpanProps(segmentSpan), + }, + ]); + }) + .expect(envelope => { + const { segmentSpan, d1Spans } = getD1Spans(envelope); + expect(segmentSpan.name).toBe('GET'); + expect(segmentSpan.attributes['url.path']).toEqual({ type: 'string', value: '/query' }); - expect(d1Spans).toEqual([ - { - name: 'SELECT users', - attributes: { - 'sentry.is_localhost': { value: true, type: 'boolean' }, - ...commonAttributes(segmentSpan), - 'db.operation.name': { type: 'string', value: 'first' }, - 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE name = ?' }, - 'db.query.summary': { type: 'string', value: 'SELECT users' }, - }, - ...commonSpanProps(segmentSpan), + expect(d1Spans).toEqual([ + { + name: 'SELECT users', + attributes: { + 'sentry.is_localhost': { value: true, type: 'boolean' }, + ...commonAttributes(segmentSpan), + 'db.operation.name': { type: 'string', value: 'first' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE name = ?' }, + 'db.query.summary': { type: 'string', value: 'SELECT users' }, }, - ]); - }) - .start(signal); + ...commonSpanProps(segmentSpan), + }, + ]); + }) + .start(signal); - await runner.makeRequest('get', '/init'); - await runner.makeRequest('get', '/query'); - await runner.completed(); - }); + await runner.makeRequest('get', '/init'); + await runner.makeRequest('get', '/query'); + await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts index 0b990a4bb7e4..2f914b401772 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/index.ts @@ -10,7 +10,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts index d0bfde77e4cc..f9916e5c03c7 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; import { eventEnvelope } from '../../../expect'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('omits the span name from the DSC for url-source spans when tracing is enabled', async ({ signal }) => { const runner = createRunner(__dirname) @@ -32,22 +33,18 @@ it('omits the span name from the DSC for url-source spans when tracing is enable { includeSamplingFields: true, includeSampleRand: true, includeTransaction: false }, ), ) - // Transaction event: proves we are NOT in TwP — the span is recorded with a `url` source and - // carries the name on the event itself, even though it is intentionally absent from the DSC. + // Span envelope: proves we are NOT in TwP. The segment span is recorded with a `url` source, + // and its own envelope header omits the transaction from the DSC as well. .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /error', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ 'sentry.segment.name.source': 'url' }), - }), - }), - }), + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(envelope[0]).toEqual( + expect.not.objectContaining({ trace: expect.objectContaining({ transaction: expect.anything() }) }), ); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.segment.name.source']?.value).toBe('url'); + // A `url` source is high cardinality, so the streamed span name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); }) .unordered() .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts index 4b239a05ee13..4c3d3a70186b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts @@ -25,7 +25,6 @@ class SyncAlarmDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), SyncAlarmDurableObjectBase, @@ -34,7 +33,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/test.ts index d328dd1ae7ed..b4377bf1e98c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/test.ts @@ -1,51 +1,42 @@ import { expect, it } from 'vitest'; -import type { TransactionEvent } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('sync alarm links to the trace that scheduled it via sentry.previous_trace', async ({ signal }) => { - let setAlarmTransaction: TransactionEvent | undefined; - let alarmTransaction: TransactionEvent | undefined; + let setAlarmSpan: SerializedStreamedSpan | undefined; + let alarmSpan: SerializedStreamedSpan | undefined; const testId = Date.now().toString(); const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: expect.stringContaining('/set-alarm'), - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // `/set-alarm` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/set-alarm' }); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'setAlarm', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); - setAlarmTransaction = transactionEvent; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('setAlarm'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + setAlarmSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'alarm', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'function', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); - alarmTransaction = transactionEvent; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('alarm'); + expect(getSpanOp(segmentSpan!)).toBe('function'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + alarmSpan = segmentSpan; }) .unordered() .start(signal); @@ -56,12 +47,11 @@ it('sync alarm links to the trace that scheduled it via sentry.previous_trace', // This is the key assertion: even though the alarm handler is synchronous, // sentry.previous_trace should still be set because we await the linkPromise // before teardown in the sync path - const traceData = alarmTransaction!.contexts?.trace?.data as Record | undefined; - const previousTrace = traceData?.['sentry.previous_trace'] as string | undefined; + const previousTrace = alarmSpan?.attributes['sentry.previous_trace']?.value as string | undefined; expect(previousTrace).toBeDefined(); expect(previousTrace).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); const [linkedTraceId] = previousTrace!.split('-'); - expect(linkedTraceId).toBe(setAlarmTransaction!.contexts?.trace?.trace_id); + expect(linkedTraceId).toBe(setAlarmSpan?.trace_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts index b5318b8c32a2..ba53ce2f9737 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts @@ -23,7 +23,6 @@ class AlarmDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), AlarmDurableObjectBase, @@ -32,7 +31,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts index 9bcfa2b0cadc..df890661ad33 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts @@ -1,51 +1,42 @@ import { expect, it } from 'vitest'; -import type { TransactionEvent } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('alarm links to the trace that scheduled it via sentry.previous_trace', async ({ signal }) => { - let setAlarmTransaction: TransactionEvent | undefined; - let alarmTransaction: TransactionEvent | undefined; + let setAlarmSpan: SerializedStreamedSpan | undefined; + let alarmSpan: SerializedStreamedSpan | undefined; const testId = Date.now().toString(); const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: expect.stringContaining('/set-alarm'), - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // `/set-alarm` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/set-alarm' }); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'setAlarm', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); - setAlarmTransaction = transactionEvent; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('setAlarm'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + setAlarmSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'alarm', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'function', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); - alarmTransaction = transactionEvent; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('alarm'); + expect(getSpanOp(segmentSpan!)).toBe('function'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + alarmSpan = segmentSpan; }) .unordered() .start(signal); @@ -53,12 +44,11 @@ it('alarm links to the trace that scheduled it via sentry.previous_trace', async await runner.makeRequest('get', `/set-alarm?id=${testId}`); await runner.completed(); - const traceData = alarmTransaction!.contexts?.trace?.data as Record | undefined; - const previousTrace = traceData?.['sentry.previous_trace'] as string | undefined; + const previousTrace = alarmSpan?.attributes['sentry.previous_trace']?.value as string | undefined; expect(previousTrace).toBeDefined(); expect(previousTrace).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); const [linkedTraceId] = previousTrace!.split('-'); - expect(linkedTraceId).toBe(setAlarmTransaction!.contexts?.trace?.trace_id); + expect(linkedTraceId).toBe(setAlarmSpan?.trace_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts index deac1c937381..6781334ca1bb 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts @@ -35,7 +35,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -44,7 +43,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts index 576d14127e13..d42e5ed44191 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts @@ -1,8 +1,8 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; -// Regression for #23040 — a Durable Object using native private fields must stay functional when +// Regression for #23040. A Durable Object using native private fields must stay functional when // instrumented with Sentry. Native RPC dispatch (Durable Object facets, // the Agents SDK bootstrap) invokes prototype methods with the stored instance as the receiver, // so the instrumented instance must not be a Proxy: a Proxy does not carry the private-field @@ -12,34 +12,23 @@ it('keeps native private fields working when a prototype method is invoked with }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'bootstrap', - }), - ); + expect(segmentSpan?.name).toBe('bootstrap'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /prototype-dispatch', - }), - ); + // `/prototype-dispatch` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/prototype-dispatch' }); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); }) .unordered() .start(signal); @@ -53,37 +42,22 @@ it('keeps native private fields working when a prototype method is invoked with it('propagates trace and preserves the result for a regular RPC method call', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.durable_object', - }), - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'setName', - }), - ); + expect(segmentSpan?.name).toBe('setName'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /rpc/set-name', - }), - ); + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/rpc/set-name' }); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); }) .unordered() .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts index c155720c3ba6..14e0de4e5e16 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts @@ -30,7 +30,6 @@ class TestDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), TestDurableObjectBase, @@ -39,7 +38,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts index f8e8b61131a5..e3f78b8f7a0d 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/test.ts @@ -1,59 +1,42 @@ +import type { Envelope } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // Regression test for https://github.com/getsentry/sentry-javascript/issues/20030 // When a Durable Object method calls Sentry.startSpan multiple times, those spans -// must appear as children of the DO transaction. The first invocation always worked; +// must appear as children of the DO segment span. The first invocation always worked; // the second invocation on the same DO instance previously lost its child spans // because the client was disposed after the first call. it('sends child spans on repeated Durable Object calls', async ({ signal }) => { - function assertDoWorkEnvelope(envelope: unknown): void { - const transactionEvent = (envelope as any)[1]?.[0]?.[1]; + function assertDoWorkEnvelope(envelope: Envelope): void { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'doWork', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - }), - ); + expect(segmentSpan?.name).toBe('doWork'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); // All 5 child spans should be present - expect(transactionEvent.spans).toHaveLength(5); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ description: 'task-1', op: 'task' }), - expect.objectContaining({ description: 'task-2', op: 'task' }), - expect.objectContaining({ description: 'task-3', op: 'task' }), - expect.objectContaining({ description: 'task-4', op: 'task' }), - expect.objectContaining({ description: 'task-5', op: 'task' }), - ]); + const taskSpans = spans.filter(span => getSpanOp(span) === 'task'); + expect(taskSpans).toHaveLength(5); + expect(taskSpans.map(span => span.name).sort()).toEqual(['task-1', 'task-2', 'task-3', 'task-4', 'task-5']); - // All child spans share the root trace_id - const rootTraceId = transactionEvent.contexts?.trace?.trace_id; - expect(rootTraceId).toBeDefined(); - for (const span of transactionEvent.spans) { - expect(span.trace_id).toBe(rootTraceId); + // All child spans share the segment trace_id + for (const span of taskSpans) { + expect(span.trace_id).toBe(segmentSpan?.trace_id); } } - function assertOuterRequestEnvelope(envelope: unknown): void { - const transactionEvent = (envelope as any)[1]?.[0]?.[1]; + function assertOuterRequestEnvelope(envelope: Envelope): void { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'GET /', - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.cloudflare', - }), - }), - }), - ); + expect(segmentSpan?.name).toBe('GET /'); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); } const runner = createRunner(__dirname).start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts index 29d580ca714e..4c8706dfa68d 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/index.ts @@ -30,7 +30,6 @@ class SqlDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), SqlDurableObjectBase, @@ -39,7 +38,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts index 8e185df57558..e9feaa6215f4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sql/test.ts @@ -1,7 +1,8 @@ -import type { Envelope, TransactionEvent } from '@sentry/core'; +import type { Envelope } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { getSpansFromEnvelope } from '../../../spanUtils'; const flushMarkerMatcher = (envelope: Envelope): void => { const [, items] = envelope; @@ -14,57 +15,53 @@ const flushMarkerMatcher = (envelope: Envelope): void => { it('instruments SQL exec operations on Durable Object storage', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent | undefined; - const spans = transactionEvent?.spans ?? []; + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /exec', - }), - ); + // `/exec` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/exec' }); - const sqlSpans = spans.filter(s => s.origin === 'auto.db.cloudflare.durable_object.sql'); + const sqlSpans = spans.filter( + span => span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value === 'auto.db.cloudflare.durable_object.sql', + ); expect(sqlSpans).toHaveLength(3); expect(sqlSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - description: 'CREATE TABLE users', - op: 'db.query', - origin: 'auto.db.cloudflare.durable_object.sql', - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', - 'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', - 'db.query.summary': 'CREATE TABLE users', - 'cloudflare.durable_object.query.bindings': 0, + name: 'CREATE TABLE users', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db.query' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object.sql' }, + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'exec' }, + 'db.query.text': { + type: 'string', + value: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)', + }, + 'db.query.summary': { type: 'string', value: 'CREATE TABLE users' }, + 'cloudflare.durable_object.query.bindings': { type: 'integer', value: 0 }, }), }), expect.objectContaining({ - description: 'INSERT users', - op: 'db.query', - origin: 'auto.db.cloudflare.durable_object.sql', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', - 'db.query.text': 'INSERT INTO users (name) VALUES (?)', - 'db.query.summary': 'INSERT users', - 'cloudflare.durable_object.query.bindings': 1, + name: 'INSERT users', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'exec' }, + 'db.query.text': { type: 'string', value: 'INSERT INTO users (name) VALUES (?)' }, + 'db.query.summary': { type: 'string', value: 'INSERT users' }, + 'cloudflare.durable_object.query.bindings': { type: 'integer', value: 1 }, }), }), expect.objectContaining({ - description: 'SELECT users', - op: 'db.query', - origin: 'auto.db.cloudflare.durable_object.sql', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', - 'db.query.text': 'SELECT * FROM users', - 'db.query.summary': 'SELECT users', - 'cloudflare.durable_object.query.bindings': 0, + name: 'SELECT users', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'exec' }, + 'db.query.text': { type: 'string', value: 'SELECT * FROM users' }, + 'db.query.summary': { type: 'string', value: 'SELECT users' }, + 'cloudflare.durable_object.query.bindings': { type: 'integer', value: 0 }, }), }), ]), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts index 5f4fb0dd8cd8..175283d26c39 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/index.ts @@ -24,7 +24,6 @@ class SyncKvDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), SyncKvDurableObjectBase, @@ -33,7 +32,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts index 4a3064871f93..1051890b8f94 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts @@ -1,7 +1,8 @@ -import type { Envelope, TransactionEvent } from '@sentry/core'; +import type { Envelope } from '@sentry/core'; import { expect, it } from 'vitest'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; const flushMarkerMatcher = (envelope: Envelope): void => { const [, items] = envelope; @@ -15,55 +16,44 @@ it('instruments sync KV operations on Durable Object storage', async ({ signal } const runner = createRunner(__dirname) .unordered() .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent | undefined; - const spans = transactionEvent?.spans ?? []; + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'GET /', - }), - ); + expect(segmentSpan?.name).toBe('GET /'); + + const kvSpans = spans.filter(span => getSpanOp(span) === 'db'); - expect(spans).toHaveLength(4); - expect(spans).toEqual( + expect(kvSpans).toHaveLength(4); + expect(kvSpans).toEqual( expect.arrayContaining([ expect.objectContaining({ - description: 'durable_object_storage_kv_put', - op: 'db', - origin: 'auto.db.cloudflare.durable_object', - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object', - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'put', + name: 'durable_object_storage_kv_put', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.durable_object' }, + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'put' }, }), }), expect.objectContaining({ - description: 'durable_object_storage_kv_get', - op: 'db', - origin: 'auto.db.cloudflare.durable_object', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'get', + name: 'durable_object_storage_kv_get', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'get' }, }), }), expect.objectContaining({ - description: 'durable_object_storage_kv_list', - op: 'db', - origin: 'auto.db.cloudflare.durable_object', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'list', + name: 'durable_object_storage_kv_list', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'list' }, }), }), expect.objectContaining({ - description: 'durable_object_storage_kv_delete', - op: 'db', - origin: 'auto.db.cloudflare.durable_object', - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'delete', + name: 'durable_object_storage_kv_delete', + attributes: expect.objectContaining({ + 'db.system.name': { type: 'string', value: 'cloudflare-durable-object-sql' }, + 'db.operation.name': { type: 'string', value: 'delete' }, }), }), ]), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts index 26af95ddcef1..21a3400f3feb 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -44,7 +44,6 @@ class TestDurableObjectBase extends DurableObject { export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), TestDurableObjectBase, @@ -53,7 +52,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts index 84d37a9da80e..f48e7a1f1f5a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -1,26 +1,18 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('traces a durable object method', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - data: expect.objectContaining({ - 'sentry.op': 'rpc', - 'sentry.origin': 'auto.faas.cloudflare.durable_object', - }), - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'sayHello', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('sayHello'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); }) .unordered() .start(signal); @@ -34,21 +26,9 @@ it('handles consecutive RPC calls without throwing "RPC receiver does not implem signal, }) => { const runner = createRunner(__dirname) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'sayHello', - }), - ); - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'sayHello', - }), - ); + .expectN(2, envelope => { + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.name).toBe('sayHello'); }) .unordered() .start(signal); @@ -73,20 +53,12 @@ it('handles consecutive RPC calls without throwing "RPC receiver does not implem it('allows RPC methods to access private class fields', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'setGreeting', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.name).toBe('setGreeting'); }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'sayHello', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.name).toBe('sayHello'); }) .unordered() .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts index c1caea111147..79a3de5a428e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts @@ -42,7 +42,6 @@ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true } }, }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts index 0139c52439a1..9e8f12d922ba 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -19,8 +19,15 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // This test runs the `@google/genai` SDK on the Workers runtime (with a // canned global fetch) to verify the instrumentation works end-to-end on @@ -30,15 +37,15 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(3); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(3); const byName = (name: string): SerializedStreamedSpan => - container.items.find((span: SerializedStreamedSpan) => span.name === name); + genAiSpans.find(span => span.name === name) as SerializedStreamedSpan; expect(byName('chat gemini-1.5-pro')).toEqual({ trace_id: expect.any(String), @@ -85,6 +92,13 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [GEN_AI_USAGE_INPUT_TOKENS]: { value: 8, type: 'integer' }, [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 12, type: 'integer' }, [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 20, type: 'integer' }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); @@ -118,6 +132,13 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 12, type: 'integer' }, [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 20, type: 'integer' }, [GEN_AI_RESPONSE_TEXT]: { value: 'Hello from Google GenAI!', type: 'string' }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); @@ -137,6 +158,13 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [GEN_AI_OPERATION_NAME]: { value: 'embeddings', type: 'string' }, [GEN_AI_REQUEST_MODEL]: { value: 'text-embedding-004', type: 'string' }, [GEN_AI_EMBEDDINGS_INPUT]: { value: 'Hello world', type: 'string' }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts index f0016eaf507b..973f54053571 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/headers/index.ts @@ -8,7 +8,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', propagateTraceparent: true, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/index.ts similarity index 94% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/index.ts index 89b990b60865..69d880d9b5da 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0, - traceLifecycle: 'stream', ignoreSpans: ['ignored-child'], tracePropagationTargets: [env.SERVER_URL], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/test.ts similarity index 66% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/test.ts index d9f587a14ccb..76394ced63a2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/test.ts @@ -1,8 +1,7 @@ -import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; import { createTestServer } from '@sentry-internal/test-utils'; import { expect, it } from 'vitest'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('preserves a positive sampling decision across an ignored child span', async ({ signal }) => { const [serverUrl, closeTestServer] = await createTestServer() @@ -17,14 +16,14 @@ it('preserves a positive sampling decision across an ignored child span', async const runner = createRunner(__dirname) .withServerUrl(serverUrl) .expect(envelope => { - const container = getSpanContainer(envelope); - const serverSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); - const fetchSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.client'); + const spans = getSpansFromEnvelope(envelope); + const serverSpan = spans.find(span => getSpanOp(span) === 'http.server'); + const fetchSpan = spans.find(span => getSpanOp(span) === 'http.client'); expect(serverSpan?.is_segment).toBe(true); expect(serverSpan?.trace_id).toBe('12345678901234567890123456789012'); expect(fetchSpan?.parent_span_id).toBe(serverSpan?.span_id); - expect(container.items.some(item => item.name === 'ignored-child')).toBe(false); + expect(spans.some(span => span.name === 'ignored-child')).toBe(false); }) .start(signal); @@ -43,9 +42,3 @@ it('preserves a positive sampling decision across an ignored child span', async closeTestServer(); } }); - -function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { - const spanItem = envelope[1].find(item => item[0].type === 'span'); - expect(spanItem).toBeDefined(); - return spanItem![1] as SerializedStreamedSpanContainer; -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-child/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/index.ts similarity index 94% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/index.ts index eee940bc8f3a..21e062199a80 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0, - traceLifecycle: 'stream', ignoreSpans: [{ attributes: { 'sentry.op': 'http.client' } }], tracePropagationTargets: [env.SERVER_URL], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/test.ts similarity index 70% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/test.ts index d7dc66316b88..0c390f63d877 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/test.ts @@ -1,8 +1,7 @@ -import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; import { createTestServer } from '@sentry-internal/test-utils'; import { expect, it } from 'vitest'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('preserves a positive sampling decision when the outgoing fetch span is ignored', async ({ signal }) => { let outgoingSentryTrace: string | string[] | undefined; @@ -19,13 +18,13 @@ it('preserves a positive sampling decision when the outgoing fetch span is ignor const runner = createRunner(__dirname) .withServerUrl(serverUrl) .expect(envelope => { - const container = getSpanContainer(envelope); - const serverSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); + const spans = getSpansFromEnvelope(envelope); + const serverSpan = spans.find(span => getSpanOp(span) === 'http.server'); expect(serverSpan?.is_segment).toBe(true); expect(serverSpan?.trace_id).toBe('12345678901234567890123456789012'); expect(outgoingSentryTrace).toBe(`12345678901234567890123456789012-${serverSpan?.span_id}-1`); - expect(container.items.some(item => item.attributes[SENTRY_OP]?.value === 'http.client')).toBe(false); + expect(spans.some(span => getSpanOp(span) === 'http.client')).toBe(false); }) .start(signal); @@ -44,9 +43,3 @@ it('preserves a positive sampling decision when the outgoing fetch span is ignor closeTestServer(); } }); - -function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { - const spanItem = envelope[1].find(item => item[0].type === 'span'); - expect(spanItem).toBeDefined(); - return spanItem![1] as SerializedStreamedSpanContainer; -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-http-client/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/index.ts similarity index 93% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/index.ts index 457c8dce5ed6..08b098578610 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0, - traceLifecycle: 'stream', ignoreSpans: [{ op: 'http.server' }], tracePropagationTargets: [env.SERVER_URL], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/test.ts similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/test.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans/continued-trace-segment/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts index d24257f17cf2..57d10efa3b31 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts @@ -22,7 +22,6 @@ class EchoHeadersDurableObjectBase extends DurableObject { export const EchoHeadersDurableObject = instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), EchoHeadersDurableObjectBase, @@ -31,7 +30,6 @@ export const EchoHeadersDurableObject = instrumentDurableObjectWithSentry( export default withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['ECHO_HEADERS_DO'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts index ae38568e34ab..53a5651214b9 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; +import type { SerializedStreamedSpan } from '@sentry/core'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; type EchoedHeaders = { sentryTrace: string | null; @@ -13,56 +14,26 @@ type EchoedHeaders = { const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/; -type ScenarioPath = '/via-init' | '/via-request' | '/via-request-and-init' | '/with-preset-sentry-baggage'; - -function startStubFetchScenario(path: ScenarioPath, signal: AbortSignal) { - let mainTraceId: string | undefined; - let mainSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; - - const traceBase = { - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }; +function startStubFetchScenario(signal: AbortSignal) { + let mainSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; + // The worker and the Durable Object stream their spans from separate isolates, so each one + // arrives in its own envelope. const { makeRequest, completed } = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining(traceBase), - }), - transaction: `GET ${path}`, - }), - ); - expect(parentSpanId).toBeUndefined(); - - mainTraceId = transactionEvent.contexts?.trace?.trace_id as string; - mainSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.parent_span_id).toBeUndefined(); + mainSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining(traceBase), - }), - transaction: `GET ${path}`, - }), - ); - expect(parentSpanId).toBeDefined(); - - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = parentSpanId as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.parent_span_id).toBeDefined(); + doSpan = segmentSpan; }) .unordered() .start(signal); @@ -71,18 +42,20 @@ function startStubFetchScenario(path: ScenarioPath, signal: AbortSignal) { makeRequest, async completedWithTraceCheck(): Promise { await completed(); - expect(mainTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(mainTraceId).toBe(doTraceId); - expect(mainSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(mainSpanId); + + // Both routes are raw URLs, so the streamed segment name keeps the method only. + expect(mainSpan?.name).toBe('GET'); + expect(doSpan?.name).toBe('GET'); + expect(mainSpan?.attributes['sentry.origin']?.value).toBe('auto.http.cloudflare'); + expect(doSpan?.attributes['sentry.origin']?.value).toBe('auto.http.cloudflare'); + expect(doSpan?.trace_id).toBe(mainSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(mainSpan?.span_id); }, }; } it('stub.fetch: headers in init (URL string + init)', async ({ signal }) => { - const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-init', signal); + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario(signal); const body = await makeRequest('get', '/via-init'); await completedWithTraceCheck(); @@ -95,7 +68,7 @@ it('stub.fetch: headers in init (URL string + init)', async ({ signal }) => { }); it('stub.fetch: headers on Request (URL from incoming request)', async ({ signal }) => { - const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request', signal); + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario(signal); const body = await makeRequest('get', '/via-request'); await completedWithTraceCheck(); @@ -108,7 +81,7 @@ it('stub.fetch: headers on Request (URL from incoming request)', async ({ signal }); it('stub.fetch: Request + init — only init headers are sent', async ({ signal }) => { - const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request-and-init', signal); + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario(signal); const body = await makeRequest('get', '/via-request-and-init'); await completedWithTraceCheck(); @@ -121,7 +94,7 @@ it('stub.fetch: Request + init — only init headers are sent', async ({ signal }); it('stub.fetch: does not append SDK baggage when the Request already includes Sentry baggage', async ({ signal }) => { - const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/with-preset-sentry-baggage', signal); + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario(signal); const body = await makeRequest('get', '/with-preset-sentry-baggage'); await completedWithTraceCheck(); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts index e157960aa080..006027d1b3a9 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts @@ -29,7 +29,6 @@ const mockFetch: typeof fetch = async () => export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts index f282f44aa7fd..7b4de87424a4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts @@ -11,9 +11,16 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // This test runs the `@langchain/openai` model (backed by the // `openai` SDK, with a canned fetch) on the Workers runtime to verify the @@ -23,14 +30,14 @@ it('traces a LangChain chat model invocation', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual({ + expect(genAiSpans[0]).toEqual({ trace_id: expect.any(String), span_id: expect.any(String), parent_span_id: expect.any(String), @@ -54,6 +61,13 @@ it('traces a LangChain chat model invocation', async ({ signal }) => { [GEN_AI_RESPONSE_MODEL]: { value: 'gpt-3.5-turbo', type: 'string' }, [GEN_AI_RESPONSE_ID]: { value: 'chatcmpl-mock123', type: 'string' }, [GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE]: { value: 'stop', type: 'string' }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts index 6b22dcd4388a..8bf8f8acd720 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts @@ -8,7 +8,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true, outputs: true } }, }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts index 6615c79430b4..87008d27d870 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts @@ -1,5 +1,4 @@ import { expect, it } from 'vitest'; -import type { SerializedStreamedSpan } from '@sentry/core'; import { GEN_AI_AGENT_NAME, GEN_AI_INPUT_MESSAGES, @@ -11,6 +10,7 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // These tests are not exhaustive because the instrumentation is // already tested in the node integration tests and we merely @@ -21,22 +21,15 @@ it('traces langgraph compile and invoke operations', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - // Span container item (second item in same envelope) - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); + expect(genAiSpans.map(span => span.name).sort()).toEqual(['invoke_agent weather_assistant']); - expect(container.items).toHaveLength(1); - expect(container.items.map((span: SerializedStreamedSpan) => span.name).sort()).toEqual([ - 'invoke_agent weather_assistant', - ]); - - const invokeAgentSpan = container.items.find( - (span: SerializedStreamedSpan) => span.name === 'invoke_agent weather_assistant', - ); + const invokeAgentSpan = genAiSpans.find(span => span.name === 'invoke_agent weather_assistant'); expect(invokeAgentSpan).toBeDefined(); expect(invokeAgentSpan!.status).toBe('ok'); expect(invokeAgentSpan!.attributes[GEN_AI_OPERATION_NAME]).toEqual({ diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts index 6b9cb7ee2bed..89c7664c830e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts @@ -29,7 +29,6 @@ const mockFetch: typeof fetch = async () => export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, dataCollection: { genAI: { inputs: true, outputs: false } }, }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index 409941359399..0a031031a7d7 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -12,8 +12,15 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // This test runs the `openai` SDK on the Workers runtime (with a canned // fetch) to verify the instrumentation works end-to-end on Cloudflare, not just @@ -23,17 +30,17 @@ it('traces a basic chat completion request with the openai SDK', async ({ signal const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual({ + expect(genAiSpans[0]).toEqual({ trace_id: expect.any(String), span_id: expect.any(String), - parent_span_id: expect.any(String), + parent_span_id: segmentSpan?.span_id, name: 'chat gpt-3.5-turbo', start_timestamp: expect.any(Number), end_timestamp: expect.any(Number), @@ -61,6 +68,13 @@ it('traces a basic chat completion request with the openai SDK', async ({ signal [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 15, type: 'integer' }, [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 25, type: 'integer' }, [GEN_AI_RESPONSE_FINISH_REASONS]: { value: '["stop"]', type: 'string' }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/index.ts index 3fe6e7fbb09a..d1a28a83b8a2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - traceLifecycle: 'static', // Deliberately left unset — the global tracer provider stays OTel's noop, so the spans below // must not reach Sentry. }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/test.ts index 79d23f259e3c..9142a26fc3b3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/disabled/test.ts @@ -1,33 +1,27 @@ import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { Event } from '@sentry/core'; import { expect, it } from 'vitest'; -import { SHORT_UUID_MATCHER } from '../../../../expect'; import { createRunner } from '../../../../runner'; +import { getSpansFromEnvelope } from '../../../../spanUtils'; it('drops spans emitted through @opentelemetry/api when `enableOpenTelemetrySetup` is not enabled', async ({ signal, }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + const childSpans = spans.filter(span => !span.is_segment); - expect(transactionEvent.transaction).toBe('GET /'); + expect(segmentSpan?.name).toBe('GET /'); - // Only the Sentry span survives; it re-parents onto the request span because the noop OTel + // Only the Sentry span survives. It re-parents onto the segment span, because the noop OTel // span it was nested under never became a real parent. - expect(transactionEvent.spans).toEqual([ - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sentry child', - parent_span_id: transactionEvent.contexts?.trace?.span_id, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: transactionEvent.contexts?.trace?.trace_id, - origin: 'manual', - }, - ]); + expect(childSpans).toHaveLength(1); + expect(childSpans[0]?.name).toBe('sentry child'); + expect(childSpans[0]?.parent_span_id).toBe(segmentSpan?.span_id); + expect(childSpans[0]?.trace_id).toBe(segmentSpan?.trace_id); + expect(childSpans[0]?.status).toBe('ok'); + expect(childSpans[0]?.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'manual' }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/index.ts index 5cd8e1547a7e..98011aa2c857 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/index.ts @@ -9,7 +9,6 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - traceLifecycle: 'static', enableOpenTelemetrySetup: true, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/test.ts index 564dd31f7684..9b9ccec84e1c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/enabled/test.ts @@ -1,102 +1,63 @@ import { SENTRY_KIND, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { Event } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { expect, it } from 'vitest'; -import { SHORT_UUID_MATCHER } from '../../../../expect'; import { createRunner } from '../../../../runner'; +import { getSpansFromEnvelope } from '../../../../spanUtils'; it('captures spans emitted through @opentelemetry/api and nests them with Sentry spans', async ({ signal }) => { + let spans: SerializedStreamedSpan[] = []; + const runner = createRunner(__dirname) .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('GET /'); - - const requestSpanId = event.contexts?.trace?.span_id; - const traceId = event.contexts?.trace?.trace_id; - - // Cloudflare installs no OTel context manager, so `context.active()` never carries a span. - // Neither tracer API passes an explicit context here, so both fall back to the Sentry active - // span — the incoming request span — and everything stays in the request transaction. - const otelParentSpanId = event.spans?.[1]?.span_id; - const sentryChildSpanId = event.spans?.[2]?.span_id; + const envelopeSpans = getSpansFromEnvelope(envelope); - // Spans are ordered by start time. - expect(event.spans).toEqual([ - { - data: { [SENTRY_ORIGIN]: 'manual', 'test.attribute': 'inactive' }, - description: 'otel inactive', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual', [SENTRY_KIND]: 'client', 'test.attribute': 'parent' }, - description: 'otel parent', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - // Below the OTel parent the two APIs interleave correctly: the tracer publishes its active - // span on the Sentry scope, so the Sentry span picks it up as parent and the next OTel span - // picks up the Sentry one in turn. - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sentry child', - parent_span_id: otelParentSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual', 'test.attribute': 'grandchild' }, - description: 'otel grandchild', - parent_span_id: sentryChildSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - // Without an OTel context manager `context.with` cannot restore anything, so the tracer has to - // fork the scope itself. Otherwise the finished `otel parent` would stay active and both of - // these would hang off it instead of the request span. - { - data: { [SENTRY_ORIGIN]: 'manual', 'test.attribute': 'after' }, - description: 'otel after active', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sentry after active', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - ]); + expect(envelopeSpans).toHaveLength(7); + spans = envelopeSpans; }) .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); + + // Start timestamps tie at millisecond resolution, so the spans are matched by name rather than + // by order. + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); + + const otelInactive = spans.find(span => span.name === 'otel inactive'); + const otelParent = spans.find(span => span.name === 'otel parent'); + const sentryChild = spans.find(span => span.name === 'sentry child'); + const otelGrandchild = spans.find(span => span.name === 'otel grandchild'); + const otelAfterActive = spans.find(span => span.name === 'otel after active'); + const sentryAfterActive = spans.find(span => span.name === 'sentry after active'); + + for (const span of spans.filter(span => !span.is_segment)) { + expect(span.trace_id).toBe(segmentSpan?.trace_id); + expect(span.status).toBe('ok'); + expect(span.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'manual' }); + } + + // Cloudflare installs no OTel context manager, so `context.active()` never carries a span. + // Neither tracer API passes an explicit context here, so both fall back to the Sentry active + // span, the incoming request span, and everything stays under the segment. + expect(otelInactive?.parent_span_id).toBe(segmentSpan?.span_id); + expect(otelInactive?.attributes['test.attribute']).toEqual({ type: 'string', value: 'inactive' }); + + expect(otelParent?.parent_span_id).toBe(segmentSpan?.span_id); + expect(otelParent?.attributes['test.attribute']).toEqual({ type: 'string', value: 'parent' }); + expect(otelParent?.attributes[SENTRY_KIND]).toEqual({ type: 'string', value: 'client' }); + + // Below the OTel parent the two APIs interleave correctly: the tracer publishes its active span + // on the Sentry scope, so the Sentry span picks it up as parent and the next OTel span picks up + // the Sentry one in turn. + expect(sentryChild?.parent_span_id).toBe(otelParent?.span_id); + expect(otelGrandchild?.parent_span_id).toBe(sentryChild?.span_id); + expect(otelGrandchild?.attributes['test.attribute']).toEqual({ type: 'string', value: 'grandchild' }); + + // Without an OTel context manager `context.with` cannot restore anything, so the tracer has to + // fork the scope itself. Otherwise the finished `otel parent` would stay active and both of these + // would hang off it instead of the segment span. + expect(otelAfterActive?.parent_span_id).toBe(segmentSpan?.span_id); + expect(otelAfterActive?.attributes['test.attribute']).toEqual({ type: 'string', value: 'after' }); + expect(sentryAfterActive?.parent_span_id).toBe(segmentSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts index 3c5e20237fa0..84e9f42a2316 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/index.ts @@ -14,7 +14,6 @@ export default { { options: { dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, enableOpenTelemetrySetup: true, }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts index 7354baf802e2..4277c029c56b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/internal-request-handler/test.ts @@ -1,60 +1,39 @@ import { SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { Event } from '@sentry/core'; import { expect, it } from 'vitest'; -import { SHORT_UUID_MATCHER } from '../../../../expect'; import { createRunner } from '../../../../runner'; +import { getSpanOp } from '../../../../spanUtils'; it('captures spans emitted through @opentelemetry/api inside _INTERNAL_wrapRequestHandler', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('GET /'); - expect(event.contexts?.trace?.op).toBe('http.server'); + const runner = createRunner(__dirname).start(signal); - const requestSpanId = event.contexts?.trace?.span_id; - const traceId = event.contexts?.trace?.trace_id; - const handleSpanId = event.spans?.[0]?.span_id; - const sentryChildSpanId = event.spans?.[1]?.span_id; - - // Spans are ordered by start time. - expect(event.spans).toEqual([ - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sveltekit.handle.root', - parent_span_id: requestSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual' }, - description: 'sentry child', - parent_span_id: handleSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - { - data: { [SENTRY_ORIGIN]: 'manual', 'http.route': '/' }, - description: 'sveltekit.resolve', - parent_span_id: sentryChildSpanId, - span_id: SHORT_UUID_MATCHER, - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: traceId, - origin: 'manual', - }, - ]); - }) - .start(signal); + // The segment span arrives in its own envelope. It ends after the children it wraps, but each + // envelope is its own request to the mock server, so it can still be received first. Waiting for + // the three children by name rather than for the segment keeps the assertions below reliable. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => + ['sveltekit.handle.root', 'sentry child', 'sveltekit.resolve'].every(name => + spansOfTrace.some(span => span.name === name), + ) && spansOfTrace.some(span => span.is_segment), + ); await runner.makeRequest('get', '/'); - await runner.completed(); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + + const handleSpan = spans.find(span => span.name === 'sveltekit.handle.root'); + const sentryChild = spans.find(span => span.name === 'sentry child'); + const resolveSpan = spans.find(span => span.name === 'sveltekit.resolve'); + + for (const span of spans.filter(span => !span.is_segment)) { + expect(span.trace_id).toBe(segmentSpan?.trace_id); + expect(span.status).toBe('ok'); + expect(span.attributes[SENTRY_ORIGIN]).toEqual({ type: 'string', value: 'manual' }); + } + + expect(handleSpan?.parent_span_id).toBe(segmentSpan?.span_id); + expect(sentryChild?.parent_span_id).toBe(handleSpan?.span_id); + expect(resolveSpan?.parent_span_id).toBe(sentryChild?.span_id); + expect(resolveSpan?.attributes['http.route']).toEqual({ type: 'string', value: '/' }); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts index 1cfcb50fb0b5..db0f305f8084 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/opentelemetry-tracer/subpath-request-handler/index.ts @@ -13,7 +13,6 @@ export default { async fetch(request, env, ctx) { const options: CloudflareOptions = { dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, enableOpenTelemetrySetup: true, }; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/index.ts index 1b56811556cd..6ec278fc9ace 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/index.ts @@ -16,7 +16,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -25,7 +24,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/test.ts index 48abf6f3d609..70045a795c93 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/test.ts @@ -1,135 +1,75 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('propagates trace from worker to durable object over stub.fetch() when rpcTracePropagationBindings is empty', async ({ signal, }) => { - let workerTraceId: string | undefined; - let workerSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let workerSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /hello', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + // `/hello` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/hello' }); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /', - }), - ); - workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.name).toBe('GET /'); + workerSpan = segmentSpan; }) .unordered() .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); - expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBe(workerTraceId); - - expect(workerSpanId).toBeDefined(); - expect(doParentSpanId).toBe(workerSpanId); + expect(workerSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(workerSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(workerSpan?.span_id); }); it('propagates trace from queue handler to durable object over stub.fetch() when rpcTracePropagationBindings is empty', async ({ signal, }) => { - let queueTraceId: string | undefined; - let queueSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let queueSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /hello', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/hello' }); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'queue.process', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.queue', - }), - origin: 'auto.faas.cloudflare.queue', - }), - }), - transaction: 'process my-queue', - }), - ); - queueTraceId = transactionEvent.contexts?.trace?.trace_id as string; - queueSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('process my-queue'); + expect(getSpanOp(segmentSpan!)).toBe('queue.process'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.queue', + }); + queueSpan = segmentSpan; }) - // Also expect the fetch transaction from the /queue/send request + // Also expect the fetch span from the /queue/send request .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /queue/send', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/queue/send' }); }) .unordered() .start(signal); @@ -137,70 +77,43 @@ it('propagates trace from queue handler to durable object over stub.fetch() when await runner.makeRequest('get', '/queue/send'); await runner.completed(); - expect(queueTraceId).toBeDefined(); - expect(doTraceId).toBe(queueTraceId); - - expect(queueSpanId).toBeDefined(); - expect(doParentSpanId).toBe(queueSpanId); + expect(queueSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(queueSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(queueSpan?.span_id); }); it('propagates trace from scheduled handler to durable object over stub.fetch() when rpcTracePropagationBindings is empty', async ({ signal, }) => { - let scheduledTraceId: string | undefined; - let scheduledSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let scheduledSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .withWranglerArgs('--test-scheduled') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /hello', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/hello' }); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'function', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.scheduled', - }), - origin: 'auto.faas.cloudflare.scheduled', - }), - }), - }), - ); - scheduledTraceId = transactionEvent.contexts?.trace?.trace_id as string; - scheduledSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('function'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.scheduled', + }); + scheduledSpan = segmentSpan; }) .unordered() .start(signal); await runner.makeRequest('get', '/__scheduled?cron=*+*+*+*+*'); await runner.completed(); - expect(scheduledTraceId).toBeDefined(); - expect(doTraceId).toBe(scheduledTraceId); - - expect(scheduledSpanId).toBeDefined(); - expect(doParentSpanId).toBe(scheduledSpanId); + expect(scheduledSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(scheduledSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(scheduledSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/index.ts index bff88cbdcdd7..9ddb6ac416e9 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/index.ts @@ -25,7 +25,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -34,7 +33,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, // Both targets are written in a casing the bindings do not use, and the regex carries the `g` // flag, which makes `test()` stateful unless the SDK normalizes it away. diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/test.ts index feb00eae6a1e..e7ee022bc503 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/test.ts @@ -1,13 +1,15 @@ import { expect, it } from 'vitest'; -import type { Envelope, Event } from '@sentry/core'; +import type { Envelope, SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('propagates trace over RPC when the binding casing differs from rpcTracePropagationBindings', async ({ signal }) => { - const transactionsByName = new Map(); + const segmentSpansByName = new Map(); const collect = (envelope: Envelope): void => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - transactionsByName.set(transactionEvent.transaction as string, transactionEvent); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan).toBeDefined(); + segmentSpansByName.set(segmentSpan!.name, segmentSpan!); }; const runner = createRunner(__dirname) @@ -23,16 +25,18 @@ it('propagates trace over RPC when the binding casing differs from rpcTracePropa await runner.completed(); - const worker = transactionsByName.get('GET /rpc/all'); - expect(worker?.contexts?.trace?.op).toBe('http.server'); + // `/rpc/all` is a raw URL, so the streamed segment name keeps the method only. + const worker = segmentSpansByName.get('GET'); + expect(getSpanOp(worker!)).toBe('http.server'); + expect(worker?.attributes['url.path']).toEqual({ type: 'string', value: '/rpc/all' }); // `sayHello` comes from the string target, `alpha` and `beta` from the regex target. `beta` is the // one a stateful `g` regex would miss, because `alpha` already advanced its `lastIndex`. for (const methodName of ['sayHello', 'alpha', 'beta']) { - const durableObject = transactionsByName.get(methodName); + const durableObject = segmentSpansByName.get(methodName); - expect(durableObject?.contexts?.trace?.op).toBe('rpc'); - expect(durableObject?.contexts?.trace?.trace_id).toBe(worker?.contexts?.trace?.trace_id); - expect(durableObject?.contexts?.trace?.parent_span_id).toBe(worker?.contexts?.trace?.span_id); + expect(getSpanOp(durableObject!)).toBe('rpc'); + expect(durableObject?.trace_id).toBe(worker?.trace_id); + expect(durableObject?.parent_span_id).toBe(worker?.span_id); } }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts index 75b9e36cd377..cada6d7ac40c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts @@ -15,7 +15,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -24,7 +23,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts index 38e320c7a41d..e5a23a362d68 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts @@ -1,34 +1,22 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('does not trace an RPC method call when rpcTracePropagationBindings is empty', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server' }), - }), - transaction: 'GET /rpc/hello', - }), - ); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/rpc/hello' }); }) - // Ordered: a `sayHello` transaction from the receiver would arrive here and fail this - // expectation. Without the trailing Sentry argument the receiver never traces the call. + // Ordered: a `sayHello` span from the receiver would arrive here and fail this expectation. + // Without the trailing Sentry argument the receiver never traces the call. .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server' }), - }), - transaction: 'GET /sentinel', - }), - ); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/sentinel' }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts index 50bbdf5a8a2c..9bbf994c23bc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts @@ -19,7 +19,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -28,7 +27,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts index f86348ab6fbc..e52aeaa20ce4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts @@ -1,53 +1,32 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('propagates trace from worker to durable object via RPC method call', async ({ signal }) => { - let workerTraceId: string | undefined; - let workerSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let workerSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.durable_object', - }), - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'sayHello', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('sayHello'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /rpc/hello', - }), - ); - workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + // `/rpc/hello` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/rpc/hello' }); + workerSpan = segmentSpan; }) .unordered() .start(signal); @@ -57,53 +36,29 @@ it('propagates trace from worker to durable object via RPC method call', async ( await runner.completed(); - expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(workerTraceId).toBe(doTraceId); - - expect(workerSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(workerSpanId); + expect(workerSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(workerSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(workerSpan?.span_id); }); it('propagates trace for RPC method with multiple arguments', async ({ signal }) => { - let workerTraceId: string | undefined; - let workerSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let workerSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - }), - }), - transaction: 'multiply', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + expect(segmentSpan?.name).toBe('multiply'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - }), - }), - transaction: 'GET /rpc/multiply', - }), - ); - workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/rpc/multiply' }); + workerSpan = segmentSpan; }) .unordered() .start(signal); @@ -113,11 +68,7 @@ it('propagates trace for RPC method with multiple arguments', async ({ signal }) await runner.completed(); - expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(workerTraceId).toBe(doTraceId); - - expect(workerSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(workerSpanId); + expect(workerSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(workerSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(workerSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts index 42b5ec00a180..a3cebae5e9bd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts @@ -16,7 +16,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -25,7 +24,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts index 4b1b24e4cf48..22208e487578 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts @@ -1,133 +1,71 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('propagates trace from worker to durable object', async ({ signal }) => { - let workerTraceId: string | undefined; - let workerSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let workerSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /hello', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + // `/hello` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/hello' }); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /', - }), - ); - workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.name).toBe('GET /'); + workerSpan = segmentSpan; }) .unordered() .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); - expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(workerTraceId).toBe(doTraceId); - - expect(workerSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(workerSpanId); + expect(workerSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(workerSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(workerSpan?.span_id); }); it('propagates trace from queue handler to durable object', async ({ signal }) => { - let queueTraceId: string | undefined; - let queueSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let queueSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /hello', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/hello' }); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'queue.process', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.queue', - }), - origin: 'auto.faas.cloudflare.queue', - }), - }), - transaction: 'process my-queue', - }), - ); - queueTraceId = transactionEvent.contexts?.trace?.trace_id as string; - queueSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('process my-queue'); + expect(getSpanOp(segmentSpan!)).toBe('queue.process'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.queue', + }); + queueSpan = segmentSpan; }) - // Also expect the fetch transaction from the /queue/send request + // Also expect the fetch span from the /queue/send request .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /queue/send', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/queue/send' }); }) .unordered() .start(signal); @@ -135,72 +73,41 @@ it('propagates trace from queue handler to durable object', async ({ signal }) = await runner.makeRequest('get', '/queue/send'); await runner.completed(); - expect(queueTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(queueTraceId).toBe(doTraceId); - - expect(queueSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(queueSpanId); + expect(queueSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(queueSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(queueSpan?.span_id); }); it('propagates trace from scheduled handler to durable object', async ({ signal }) => { - let scheduledTraceId: string | undefined; - let scheduledSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let scheduledSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .withWranglerArgs('--test-scheduled') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /hello', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/hello' }); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'function', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.scheduled', - }), - origin: 'auto.faas.cloudflare.scheduled', - }), - }), - }), - ); - scheduledTraceId = transactionEvent.contexts?.trace?.trace_id as string; - scheduledSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('function'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.scheduled', + }); + scheduledSpan = segmentSpan; }) .unordered() .start(signal); await runner.makeRequest('get', '/__scheduled?cron=*+*+*+*+*'); await runner.completed(); - expect(scheduledTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(scheduledTraceId).toBe(doTraceId); - - expect(scheduledSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(scheduledSpanId); + expect(scheduledSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(scheduledSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(scheduledSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts index 15162528648c..95de55198929 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts @@ -13,7 +13,6 @@ const myWorker = { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), myWorker, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts index 93f2d260e213..539984eb5f48 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts @@ -8,7 +8,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['ANOTHER_WORKER'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts index 878b307ca5f4..6fd5fa551512 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts @@ -1,62 +1,30 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp } from '../../../../spanUtils'; it('propagates trace from worker to worker via service binding', async ({ signal }) => { - let workerTraceId: string | undefined; - let workerSpanId: string | undefined; - let subWorkerTraceId: string | undefined; - let subWorkerParentSpanId: string | undefined; + const runner = createRunner(__dirname).start(signal); + + // The worker and the sub-worker stream from separate isolates, so the two segment spans of the + // trace arrive in separate envelopes. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment && getSpanOp(span) === 'http.server').length === 2, + ); - const runner = createRunner(__dirname) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /', - }), - ); - workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workerSpanId = transactionEvent.contexts?.trace?.span_id as string; - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /hello', - }), - ); - subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; - }) - .unordered() - .start(signal); await runner.makeRequest('get', '/'); - await runner.completed(); - expect(workerTraceId).toBeDefined(); - expect(subWorkerTraceId).toBeDefined(); - expect(workerTraceId).toBe(subWorkerTraceId); + const segmentSpans = (await spansPromise).filter(span => span.is_segment); + const worker = segmentSpans.find(span => !span.parent_span_id); + const subWorker = segmentSpans.find(span => span.parent_span_id); + + expect(worker?.name).toBe('GET /'); + expect(worker?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + // `/hello` is a raw URL, so the streamed segment name keeps the method only. + expect(subWorker?.name).toBe('GET'); + expect(subWorker?.attributes['url.path']).toEqual({ type: 'string', value: '/hello' }); + expect(subWorker?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); - expect(workerSpanId).toBeDefined(); - expect(subWorkerParentSpanId).toBeDefined(); - expect(subWorkerParentSpanId).toBe(workerSpanId); + expect(subWorker?.trace_id).toBe(worker?.trace_id); + expect(subWorker?.parent_span_id).toBe(worker?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts index 6979952ed448..b5dce37cab86 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts @@ -15,7 +15,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -24,7 +23,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts index b5d5984c6a12..c38bce0872fe 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts @@ -8,7 +8,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['SUB_WORKER'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts index a6f5818b8489..5701294f9826 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts @@ -1,80 +1,44 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('propagates trace from worker to worker to durable object (3 levels deep)', async ({ signal }) => { - let mainWorkerTraceId: string | undefined; - let mainWorkerSpanId: string | undefined; - let subWorkerTraceId: string | undefined; - let subWorkerSpanId: string | undefined; - let subWorkerParentSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let mainWorkerSpan: SerializedStreamedSpan | undefined; + let subWorkerSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - // Main worker HTTP server transaction - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /chain', - }), - ); - mainWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - mainWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // Main worker HTTP server segment span + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + // `/chain` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/chain' }); + mainWorkerSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - // Sub-worker HTTP server transaction (from service binding fetch) - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /call-do', - }), - ); - subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - subWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; - subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + // Sub-worker HTTP server segment span (from service binding fetch) + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/call-do' }); + subWorkerSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - // Durable Object RPC transaction - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.durable_object', - }), - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'computeAnswer', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // Durable Object RPC segment span + expect(segmentSpan?.name).toBe('computeAnswer'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + doSpan = segmentSpan; }) .unordered() .start(signal); @@ -84,20 +48,13 @@ it('propagates trace from worker to worker to durable object (3 levels deep)', a await runner.completed(); - // All three transactions should share the same trace_id - expect(mainWorkerTraceId).toBeDefined(); - expect(subWorkerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(mainWorkerTraceId).toBe(subWorkerTraceId); - expect(subWorkerTraceId).toBe(doTraceId); + // All three segment spans should share the same trace_id + expect(mainWorkerSpan?.trace_id).toBeDefined(); + expect(subWorkerSpan?.trace_id).toBe(mainWorkerSpan?.trace_id); + expect(doSpan?.trace_id).toBe(subWorkerSpan?.trace_id); // Verify the parent-child relationships form a chain: // Main Worker -> Sub Worker -> DO - expect(mainWorkerSpanId).toBeDefined(); - expect(subWorkerParentSpanId).toBeDefined(); - expect(subWorkerParentSpanId).toBe(mainWorkerSpanId); - - expect(subWorkerSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(subWorkerSpanId); + expect(subWorkerSpan?.parent_span_id).toBe(mainWorkerSpan?.span_id); + expect(doSpan?.parent_span_id).toBe(subWorkerSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts index 46508f2791be..bdc3f479716e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts @@ -44,7 +44,6 @@ class MySubWorkerEntrypointBase extends BaseEntrypoint { export const BindingEntrypoint = Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, initialScope: { tags: { initial_scope: 'applied' } }, beforeSend(event) { @@ -61,7 +60,6 @@ export const BindingEntrypoint = Sentry.withSentry( export const NoPropagationEntrypoint = Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, transportOptions: { fetch: fetch.bind(globalThis) }, }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts index a34ea3edb338..58ddec5080f3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts @@ -23,14 +23,13 @@ class LoopbackEntrypointBase extends WorkerEntrypoint { } export const LoopbackEntrypoint = Sentry.withSentry( - (env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 0 }), + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0 }), LoopbackEntrypointBase, ); export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, // Targeted by binding name. Two bindings are deliberately left out: // `SUB_WORKER_UNINSTRUMENTED`, whose receiver has no Sentry to strip a trailing metadata diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts index 623ea9779fcc..4ff5eeed7fbb 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts @@ -1,57 +1,35 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; +import type { Event, SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; + +// Every route here is a raw URL, so the streamed segment name keeps the method only and the route +// is identified through `url.path`. it('propagates trace from Worker (ExportedHandler) to WorkerEntrypoint via service binding fetch', async ({ signal, }) => { - let workerTraceId: string | undefined; - let workerSpanId: string | undefined; - let entrypointTraceId: string | undefined; - let entrypointParentSpanId: string | undefined; + let workerSpan: SerializedStreamedSpan | undefined; + let entrypointSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - // Main worker HTTP server transaction - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /call-entrypoint', - }), - ); - workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // Main worker HTTP server segment span + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/call-entrypoint' }); + workerSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - // WorkerEntrypoint HTTP server transaction (from service binding fetch) - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /answer', - }), - ); - entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string; - entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // WorkerEntrypoint HTTP server segment span (from service binding fetch) + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/answer' }); + entrypointSpan = segmentSpan; }) .unordered() .start(signal); @@ -61,55 +39,32 @@ it('propagates trace from Worker (ExportedHandler) to WorkerEntrypoint via servi await runner.completed(); - // Both transactions should share the same trace_id - expect(workerTraceId).toBeDefined(); - expect(entrypointTraceId).toBeDefined(); - expect(workerTraceId).toBe(entrypointTraceId); + // Both segment spans should share the same trace_id + expect(workerSpan?.trace_id).toBeDefined(); + expect(entrypointSpan?.trace_id).toBe(workerSpan?.trace_id); // Verify the parent-child relationship: Worker -> WorkerEntrypoint - expect(workerSpanId).toBeDefined(); - expect(entrypointParentSpanId).toBeDefined(); - expect(entrypointParentSpanId).toBe(workerSpanId); + expect(entrypointSpan?.parent_span_id).toBe(workerSpan?.span_id); }); it('propagates trace for request with query params from Worker to WorkerEntrypoint', async ({ signal }) => { - let workerTraceId: string | undefined; - let workerSpanId: string | undefined; - let entrypointTraceId: string | undefined; - let entrypointParentSpanId: string | undefined; + let workerSpan: SerializedStreamedSpan | undefined; + let entrypointSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - }), - }), - transaction: 'GET /call-entrypoint-greet', - }), - ); - workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/call-entrypoint-greet' }); + workerSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - }), - }), - transaction: 'GET /greet', - }), - ); - entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string; - entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/greet' }); + entrypointSpan = segmentSpan; }) .unordered() .start(signal); @@ -119,81 +74,44 @@ it('propagates trace for request with query params from Worker to WorkerEntrypoi await runner.completed(); - expect(workerTraceId).toBeDefined(); - expect(entrypointTraceId).toBeDefined(); - expect(workerTraceId).toBe(entrypointTraceId); - - expect(workerSpanId).toBeDefined(); - expect(entrypointParentSpanId).toBeDefined(); - expect(entrypointParentSpanId).toBe(workerSpanId); + expect(workerSpan?.trace_id).toBeDefined(); + expect(entrypointSpan?.trace_id).toBe(workerSpan?.trace_id); + expect(entrypointSpan?.parent_span_id).toBe(workerSpan?.span_id); }); it('instruments inherited custom WorkerEntrypoint RPC methods and strips metadata', async ({ signal }) => { - let callerTraceId: string | undefined; - let callerSpanId: string | undefined; - let receiverGetTraceId: string | undefined; - let receiverGetParentSpanId: string | undefined; + let callerSpan: SerializedStreamedSpan | undefined; + let receiverGetSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - origin: 'auto.http.cloudflare', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - }), - }), - transaction: 'GET /call-entrypoint-rpc', - }), - ); - callerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - callerSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/call-entrypoint-rpc' }); + callerSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.worker_entrypoint', - data: expect.objectContaining({ - 'sentry.op': 'rpc', - 'sentry.origin': 'auto.faas.cloudflare.worker_entrypoint', - }), - }), - }), - transaction: 'get', - }), - ); - receiverGetTraceId = transactionEvent.contexts?.trace?.trace_id as string; - receiverGetParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('get'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.worker_entrypoint', + }); + receiverGetSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - origin: 'auto.faas.cloudflare.worker_entrypoint', - data: expect.objectContaining({ - 'sentry.op': 'rpc', - 'sentry.origin': 'auto.faas.cloudflare.worker_entrypoint', - }), - }), - }), - transaction: 'inherited', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('inherited'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.worker_entrypoint', + }); }) .unordered() .start(signal); @@ -206,13 +124,9 @@ it('instruments inherited custom WorkerEntrypoint RPC methods and strips metadat await runner.completed(); - expect(receiverGetTraceId).toBeDefined(); - expect(callerTraceId).toBeDefined(); - expect(receiverGetTraceId).toBe(callerTraceId); - - expect(receiverGetParentSpanId).toBeDefined(); - expect(callerSpanId).toBeDefined(); - expect(receiverGetParentSpanId).toBe(callerSpanId); + expect(receiverGetSpan?.trace_id).toBeDefined(); + expect(receiverGetSpan?.trace_id).toBe(callerSpan?.trace_id); + expect(receiverGetSpan?.parent_span_id).toBe(callerSpan?.span_id); }); it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ signal }) => { @@ -228,12 +142,15 @@ it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ sig expect(event.tags?.before_send).toBe('applied'); }) .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('throwError'); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.name).toBe('throwError'); }) .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('GET /call-entrypoint-rpc-error'); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.attributes['url.path']).toEqual({ + type: 'string', + value: '/call-entrypoint-rpc-error', + }); }) .unordered() .start(signal); @@ -250,8 +167,8 @@ it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ sig it('does not change RPC method arguments for a binding left off the allowlist', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - expect(transactionEvent.transaction).toBe('GET /call-uninstrumented-rpc'); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/call-uninstrumented-rpc' }); }) .start(signal); @@ -266,22 +183,14 @@ it('does not inject RPC trace metadata into receiver calls when rpcTracePropagat }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /call-entrypoint-rpc-no-propagation', - }), - ); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ + type: 'string', + value: '/call-entrypoint-rpc-no-propagation', + }); }) .start(signal); @@ -305,8 +214,8 @@ it('captures errors from loopback WorkerEntrypoint RPC without trace propagation }); }) .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('GET /call-loopback-rpc-error'); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/call-loopback-rpc-error' }); }) .unordered() .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts index 30190e3c0603..b97179565a3a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts @@ -15,7 +15,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -44,7 +43,6 @@ class MyWorkerEntrypointBase extends WorkerEntrypoint { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyWorkerEntrypointBase, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts index f0bef1e2e402..104d73f79db5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts @@ -1,36 +1,24 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('does not trace an RPC method call when rpcTracePropagationBindings is empty (WorkerEntrypoint)', async ({ signal, }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server' }), - }), - transaction: 'GET /rpc/hello', - }), - ); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/rpc/hello' }); }) - // Ordered: a `sayHello` transaction from the receiver would arrive here and fail this - // expectation. Without the trailing Sentry argument the receiver never traces the call. + // Ordered: a `sayHello` span from the receiver would arrive here and fail this expectation. + // Without the trailing Sentry argument the receiver never traces the call. .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server' }), - }), - transaction: 'GET /sentinel', - }), - ); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/sentinel' }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts index 5c74e8824ee3..b9fd238d684e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts @@ -19,7 +19,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -48,7 +47,6 @@ class MyWorkerEntrypointBase extends WorkerEntrypoint { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts index 2dd17269ae23..42b6ebc70338 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts @@ -1,53 +1,32 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('propagates trace from WorkerEntrypoint to durable object via this.env RPC call', async ({ signal }) => { - let workerTraceId: string | undefined; - let workerSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let workerSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.durable_object', - }), - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'sayHello', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('sayHello'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /rpc/hello', - }), - ); - workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + // `/rpc/hello` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/rpc/hello' }); + workerSpan = segmentSpan; }) .unordered() .start(signal); @@ -57,53 +36,29 @@ it('propagates trace from WorkerEntrypoint to durable object via this.env RPC ca await runner.completed(); - expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(workerTraceId).toBe(doTraceId); - - expect(workerSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(workerSpanId); + expect(workerSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(workerSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(workerSpan?.span_id); }); it('propagates trace for RPC method with multiple arguments via this.env', async ({ signal }) => { - let workerTraceId: string | undefined; - let workerSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let workerSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - }), - }), - transaction: 'multiply', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + expect(segmentSpan?.name).toBe('multiply'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + doSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - }), - }), - transaction: 'GET /rpc/multiply', - }), - ); - workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/rpc/multiply' }); + workerSpan = segmentSpan; }) .unordered() .start(signal); @@ -113,11 +68,7 @@ it('propagates trace for RPC method with multiple arguments via this.env', async await runner.completed(); - expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(workerTraceId).toBe(doTraceId); - - expect(workerSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(workerSpanId); + expect(workerSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(workerSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(workerSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts index f3576ebf5210..f9cba22b1bb6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts @@ -15,7 +15,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -39,7 +38,6 @@ class MySubWorkerEntrypointBase extends WorkerEntrypoint { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts index 654d37c411fe..25dcf4c4770b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts @@ -23,7 +23,6 @@ class MyWorkerEntrypointBase extends WorkerEntrypoint { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['SUB_WORKER'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts index 474624fa2145..506f7529b677 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts @@ -1,82 +1,46 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('propagates trace from WorkerEntrypoint to WorkerEntrypoint to durable object (3 levels deep)', async ({ signal, }) => { - let mainWorkerTraceId: string | undefined; - let mainWorkerSpanId: string | undefined; - let subWorkerTraceId: string | undefined; - let subWorkerSpanId: string | undefined; - let subWorkerParentSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let mainWorkerSpan: SerializedStreamedSpan | undefined; + let subWorkerSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - // Main worker HTTP server transaction - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /chain', - }), - ); - mainWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - mainWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // Main worker HTTP server segment span + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + // `/chain` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/chain' }); + mainWorkerSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); - // Sub-worker HTTP server transaction (from service binding fetch) - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /call-do', - }), - ); - subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; - subWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; - subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + // Sub-worker HTTP server segment span (from service binding fetch) + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/call-do' }); + subWorkerSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - - // Durable Object RPC transaction - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'rpc', - data: expect.objectContaining({ - 'sentry.origin': 'auto.faas.cloudflare.durable_object', - }), - origin: 'auto.faas.cloudflare.durable_object', - }), - }), - transaction: 'computeAnswer', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // Durable Object RPC segment span + expect(segmentSpan?.name).toBe('computeAnswer'); + expect(getSpanOp(segmentSpan!)).toBe('rpc'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.durable_object', + }); + doSpan = segmentSpan; }) .unordered() .start(signal); @@ -86,20 +50,13 @@ it('propagates trace from WorkerEntrypoint to WorkerEntrypoint to durable object await runner.completed(); - // All three transactions should share the same trace_id - expect(mainWorkerTraceId).toBeDefined(); - expect(subWorkerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(mainWorkerTraceId).toBe(subWorkerTraceId); - expect(subWorkerTraceId).toBe(doTraceId); + // All three segment spans should share the same trace_id + expect(mainWorkerSpan?.trace_id).toBeDefined(); + expect(subWorkerSpan?.trace_id).toBe(mainWorkerSpan?.trace_id); + expect(doSpan?.trace_id).toBe(subWorkerSpan?.trace_id); // Verify the parent-child relationships form a chain: // Main WorkerEntrypoint -> Sub WorkerEntrypoint -> DO - expect(mainWorkerSpanId).toBeDefined(); - expect(subWorkerParentSpanId).toBeDefined(); - expect(subWorkerParentSpanId).toBe(mainWorkerSpanId); - - expect(subWorkerSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(subWorkerSpanId); + expect(subWorkerSpan?.parent_span_id).toBe(mainWorkerSpan?.span_id); + expect(doSpan?.parent_span_id).toBe(subWorkerSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts index 8badbd2fd65e..91e7450307ca 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts @@ -17,7 +17,6 @@ class MyDurableObjectBase extends DurableObject { export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyDurableObjectBase, @@ -37,7 +36,6 @@ class MyWorkflowBase extends WorkflowEntrypoint { export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, // The workflow is itself a caller: `run` reaches the Durable Object through `this.env`. rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], @@ -48,7 +46,6 @@ export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, rpcTracePropagationBindings: ['MY_WORKFLOW'], }), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts index 1e33349b5bb0..525b1d404cb7 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts @@ -1,63 +1,40 @@ import { expect, it } from 'vitest'; -import type { Event } from '@sentry/core'; +import type { SerializedStreamedSpan } from '@sentry/core'; import { createRunner } from '../../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; it('traces a workflow that calls a durable object with the same trace id', async ({ signal }) => { - let workflowTraceId: string | undefined; - let workflowSpanId: string | undefined; - let doTraceId: string | undefined; - let doParentSpanId: string | undefined; + let workflowSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'function', - data: expect.objectContaining({ - 'sentry.op': 'function', - 'sentry.origin': 'auto.faas.cloudflare.workflow', - }), - origin: 'auto.faas.cloudflare.workflow', - }), - }), - transaction: 'workflow-env-test', - }), - ); - workflowTraceId = transactionEvent.contexts?.trace?.trace_id as string; - workflowSpanId = transactionEvent.contexts?.trace?.span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + expect(segmentSpan?.name).toBe('workflow-env-test'); + expect(getSpanOp(segmentSpan!)).toBe('function'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ + type: 'string', + value: 'auto.faas.cloudflare.workflow', + }); + workflowSpan = segmentSpan; }) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as Event; - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - data: expect.objectContaining({ - 'sentry.origin': 'auto.http.cloudflare', - }), - origin: 'auto.http.cloudflare', - }), - }), - transaction: 'GET /workflow-test', - }), - ); - doTraceId = transactionEvent.contexts?.trace?.trace_id as string; - doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // `/workflow-test` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/workflow-test' }); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + doSpan = segmentSpan; }) .unordered() .start(signal); await runner.makeRequest('get', '/workflow/trigger'); await runner.completed(); - expect(workflowTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(workflowTraceId).toBe(doTraceId); - - expect(workflowSpanId).toBeDefined(); - expect(doParentSpanId).toBeDefined(); - expect(doParentSpanId).toBe(workflowSpanId); + expect(workflowSpan?.trace_id).toBeDefined(); + expect(doSpan?.trace_id).toBe(workflowSpan?.trace_id); + expect(doSpan?.parent_span_id).toBe(workflowSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/index.ts deleted file mode 100644 index eda2688ae645..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as Sentry from '@sentry/cloudflare'; - -interface Env { - SENTRY_DSN: string; -} - -export default Sentry.withSentry( - (env: Env) => ({ - dsn: env.SENTRY_DSN, - traceLifecycle: 'stream', - tracesSampleRate: 1.0, - }), - { - async fetch(_request, _env, _ctx) { - return new Response('OK'); - }, - async scheduled(_controller, _env, _ctx) { - await new Promise(resolve => setTimeout(resolve, 10)); - }, - } satisfies ExportedHandler, -); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/test.ts deleted file mode 100644 index f19dd39fa430..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { expect, it } from 'vitest'; -import { createRunner } from '../../../runner'; - -function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { - const spanItem = envelope[1].find(item => item[0].type === 'span'); - expect(spanItem).toBeDefined(); - return spanItem![1] as SerializedStreamedSpanContainer; -} - -it('keeps the cron out of the scheduled span name when span streaming is enabled', async ({ signal }) => { - const runner = createRunner(__dirname) - .withWranglerArgs('--test-scheduled') - .expect(envelope => { - const segmentSpan = getSpanContainer(envelope).items.find(span => !!span.is_segment); - - expect(segmentSpan).toBeDefined(); - expect(segmentSpan!.name).toBe('scheduled'); - expect(segmentSpan!.attributes).toEqual( - expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'function' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.faas.cloudflare.scheduled' }, - 'code.function.name': { type: 'string', value: 'scheduled' }, - // Relay infers the description from the span name, so the original, cron-bearing name is - // preserved explicitly to keep it visible in the UI. - 'sentry.description': { type: 'string', value: expect.stringMatching(/^Scheduled Cron/) }, - 'faas.trigger': { type: 'string', value: 'timer' }, - }), - ); - }) - .start(signal); - - await runner.makeRequest('get', '/__scheduled'); - await runner.completed(); -}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/wrangler.jsonc deleted file mode 100644 index 76f688a57488..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/wrangler.jsonc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "scheduled-streamed-worker", - "compatibility_date": "2025-06-17", - "main": "index.ts", - "compatibility_flags": ["nodejs_compat"], - "triggers": { - "crons": ["* * * * *"], - }, -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts index 8e36aa1f8ea4..4b778ce5eab1 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/index.ts @@ -7,7 +7,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts index 7043172e6284..6cb7888217b0 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts @@ -7,37 +7,34 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, } from '@sentry/core'; import { createRunner } from '../../../runner'; +import { getSpansFromEnvelope } from '../../../spanUtils'; -it('Scheduled handler creates transaction with correct attributes', async ({ signal }) => { +it('Scheduled handler creates a segment span with correct attributes', async ({ signal }) => { const runner = createRunner(__dirname) .withWranglerArgs('--test-scheduled') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( + const spans = getSpansFromEnvelope(envelope); + + expect(spans).toHaveLength(1); + expect(spans[0]).toEqual( expect.objectContaining({ - type: 'transaction', - transaction: expect.stringMatching(/^Scheduled Cron/), - transaction_info: { source: 'task' }, - spans: [], - contexts: expect.objectContaining({ - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - op: 'function', - origin: 'auto.faas.cloudflare.scheduled', - status: 'ok', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled', - [SENTRY_SEGMENT_NAME_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - 'sentry.description': expect.stringMatching(/^Scheduled Cron/), - 'code.function.name': 'scheduled', - 'faas.cron': expect.any(String), - 'faas.time': expect.any(String), - 'faas.trigger': 'timer', - }, - }, + name: 'scheduled', + span_id: expect.any(String), + trace_id: expect.any(String), + is_segment: true, + status: 'ok', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'function' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.faas.cloudflare.scheduled' }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'task' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + 'code.function.name': { type: 'string', value: 'scheduled' }, + // Relay infers the description from the span name, so the original, cron-bearing name is + // preserved explicitly to keep it visible in the UI. + 'sentry.description': { type: 'string', value: expect.stringMatching(/^Scheduled Cron/) }, + 'faas.cron': { type: 'string', value: expect.any(String) }, + 'faas.time': { type: 'string', value: expect.any(String) }, + 'faas.trigger': { type: 'string', value: 'timer' }, }), }), ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts index 619fe9a58cfb..e48428c9c732 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts @@ -12,7 +12,6 @@ const aiBinding = new MockAi(); export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, // Responses only. Asserting that prompts are *absent* is what makes this suite fail if the // binding is ever wrapped before the SDK is initialized again, since the fallback collects both. diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts index fdca58aee958..726f91a5e467 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -10,10 +10,17 @@ import { GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, + SENTRY_SDK_NAME, + SENTRY_SDK_VERSION, + SENTRY_SEGMENT_ID, + SENTRY_SEGMENT_NAME, + SENTRY_TRACE_LIFECYCLE, } from '@sentry/conventions/attributes'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; import { expect, it } from 'vitest'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // These tests are not exhaustive because the instrumentation is // already tested in the core unit tests and we merely want to test @@ -23,14 +30,14 @@ it('traces a basic Workers AI text generation request', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + expect(segmentSpan?.name).toBe('GET /'); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual( + expect(genAiSpans[0]).toEqual( expect.objectContaining({ name: 'chat @cf/meta/llama-3.1-8b-instruct', status: 'ok', @@ -55,6 +62,13 @@ it('traces a basic Workers AI text generation request', async ({ signal }) => { type: 'string', value: 'The capital of France is Paris.', }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }), ); @@ -68,14 +82,16 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /stream'); + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + // `/stream` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/stream' }); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); - expect(container.items[0]).toEqual( + expect(genAiSpans[0]).toEqual( expect.objectContaining({ name: 'chat @cf/meta/llama-3.1-8b-instruct', status: 'ok', @@ -100,6 +116,13 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = type: 'string', value: 'The capital of France is Paris.', }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }), ); @@ -115,8 +138,8 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = // Sentry exactly once, with the `auto.http.cloudflare` mechanism. it('bubbles up Workers AI errors to be captured by the top-level handler', async ({ signal }) => { const runner = createRunner(__dirname) - // A failing run still produces a (sampled) transaction; we only care about the error event here. - .ignore('transaction') + // A failing run still streams its (sampled) spans; we only care about the error event here. + .ignore('span') .expect(envelope => { const errorEvent = envelope[1]?.[0]?.[1] as any; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts index 9abcc3967842..dce6c1d58ced 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/index.ts @@ -22,7 +22,6 @@ class MyWorkflowBase extends WorkflowEntrypoint { export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), MyWorkflowBase, @@ -31,7 +30,6 @@ export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts index 1224da14d358..f54038aaa714 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workflow/test.ts @@ -7,69 +7,36 @@ import { } from '@sentry/core'; import { createRunner } from '../../../runner'; -it('Workflow steps create transactions with correct attributes', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'step-one', - transaction_info: { source: 'task' }, - spans: [], - contexts: expect.objectContaining({ - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - op: 'function', - origin: 'auto.faas.cloudflare.workflow', - status: 'ok', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow', - [SENTRY_SEGMENT_NAME_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - 'code.function.name': 'step-one', - 'workflow.step.name': 'step-one', - 'cloudflare.workflow.attempt': 1, - }, - }, - }), - }), - ); - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1]; - expect(transactionEvent).toEqual( - expect.objectContaining({ - type: 'transaction', - transaction: 'step-two', - transaction_info: { source: 'task' }, - spans: [], - contexts: expect.objectContaining({ - trace: { - span_id: expect.any(String), - trace_id: expect.any(String), - op: 'function', - origin: 'auto.faas.cloudflare.workflow', - status: 'ok', - data: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.workflow', - [SENTRY_SEGMENT_NAME_SOURCE]: 'task', - [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, - 'code.function.name': 'step-two', - 'workflow.step.name': 'step-two', - 'cloudflare.workflow.attempt': 1, - }, - }, - }), - }), - ); - }) - .unordered() - .start(signal); +it('Workflow steps create segment spans with correct attributes', async ({ signal }) => { + const runner = createRunner(__dirname).start(signal); + // Both steps run in one trace, but each step flushes its own span, so they arrive in separate + // envelopes. The trigger request runs in its own trace and does not match here. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => + ['step-one', 'step-two'].every(stepName => spansOfTrace.some(span => span.name === stepName)), + ); await runner.makeRequest('get', '/workflow/trigger'); - await runner.completed(); + + const spans = await spansPromise; + + for (const stepName of ['step-one', 'step-two']) { + expect(spans.find(span => span.name === stepName)).toEqual( + expect.objectContaining({ + name: stepName, + span_id: expect.any(String), + trace_id: expect.any(String), + is_segment: true, + status: 'ok', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'function' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.faas.cloudflare.workflow' }, + [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'task' }, + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, + 'code.function.name': { type: 'string', value: stepName }, + 'workflow.step.name': { type: 'string', value: stepName }, + 'cloudflare.workflow.attempt': { type: 'integer', value: 1 }, + }), + }), + ); + } }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/test.ts index 42e7a536a9a9..78d8445fbafd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/test.ts @@ -1,50 +1,49 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// The Durable Object, reached from inside the entrypoint, emits an `http.server` -// transaction whose only children are the two -// `auto.db.cloudflare.durable_object` storage spans (`get` + `put`) — present -// only when the class was auto-instrumented. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -function expectPlainTransaction(name: string) { - return (transactionEvent: TransactionEvent): void => { - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); - expect(transactionEvent.transaction).toBe(name); - expect(transactionEvent.spans).toHaveLength(0); - }; -} +import { getSpanOp } from '../../../spanUtils'; // A single request fans out through the whole auto-wrapped chain: default // handler (`/chain`) → self-bound `CounterEntrypoint` (`/work`) → `Counter` -// Durable Object. All three transactions arrive only if the build-time transform -// wrapped the default export, the entrypoint, and the DO — and it proves a DO -// invoked from *within* an auto-instrumented entrypoint is itself instrumented. +// Durable Object. All three segment spans arrive only if the build-time +// transform wrapped the default export, the entrypoint, and the DO — and it +// proves a DO invoked from *within* an auto-instrumented entrypoint is itself +// instrumented. +// +// Every route here is a raw URL, so the streamed segment names keep the method +// only and each hop is identified by its `url.path` attribute. it('auto-instruments a Durable Object invoked from within a WorkerEntrypoint', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectPlainTransaction('GET /chain')(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectPlainTransaction('GET /work')(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // Each hop streams from its own isolate, so the three segment spans of the trace arrive in + // separate envelopes. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 3, + ); await runner.makeRequest('get', '/chain'); - await runner.completed(); + + const spans = await spansPromise; + const chainSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/chain'); + const entrypointSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/work'); + const durableObjectSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/increment'); + + expect(getSpanOp(chainSpan!)).toBe('http.server'); + expect(chainSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(entrypointSpan!)).toBe('http.server'); + expect(entrypointSpan?.parent_span_id).toBe(chainSpan?.span_id); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.parent_span_id).toBe(entrypointSpan?.span_id); + + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/index.ts index 3e2e640e26ef..075aa006aa21 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/index.ts @@ -32,7 +32,7 @@ class CounterImpl extends DurableObject { // untouched (no double-wrap) while still auto-wrapping the plain entrypoint and // default export in the same file. export const Counter = Sentry.instrumentDurableObjectWithSentry( - (env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }), + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), CounterImpl, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts index 70879a90ca71..1630042291f6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts @@ -1,56 +1,69 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A Durable Object invoked via `fetch` emits an `http.server` transaction whose -// only children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`). Here they come from the manual wrap — the assertion also -// proves the transform did NOT double-wrap (a double-wrap would nest proxies or -// break the build). -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -function expectPlainTransaction(name: string) { - return (transactionEvent: TransactionEvent): void => { - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); - expect(transactionEvent.transaction).toBe(name); - expect(transactionEvent.spans).toHaveLength(0); - }; -} +import { getSpanOp } from '../../../spanUtils'; // The Durable Object is manually wrapped with // `Sentry.instrumentDurableObjectWithSentry`; the `GreeterEntrypoint` and the // default export are plain. The transform must skip the manual DO (no // double-wrap) yet still auto-wrap the entrypoint and default handler — so the -// manual DO transaction (with storage spans) and both auto-wrapped transactions -// all arrive exactly once. +// manual DO span (with storage children) and both auto-wrapped spans all arrive +// exactly once. +// +// Every route here is a raw URL, so the streamed segment names keep the method +// only and each hop is identified by its `url.path` attribute. it('leaves a manually wrapped Durable Object untouched while auto-wrapping a sibling WorkerEntrypoint', async ({ signal, }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectPlainTransaction('GET /call-entrypoint')(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectPlainTransaction('GET /greet')(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectPlainTransaction('GET /increment')(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // Each hop streams from its own isolate, so the segment spans of a trace arrive in separate + // envelopes. + const entrypointSpansPromise = runner.collectStreamedSpans(spansOfTrace => { + const paths = spansOfTrace.filter(span => span.is_segment).map(span => span.attributes['url.path']?.value); + return paths.includes('/call-entrypoint') && paths.includes('/greet'); + }); await runner.makeRequest('get', '/call-entrypoint'); + + const entrypointTraceSpans = await entrypointSpansPromise; + const callEntrypointSpan = entrypointTraceSpans.find( + span => span.is_segment && span.attributes['url.path']?.value === '/call-entrypoint', + ); + const greetSpan = entrypointTraceSpans.find( + span => span.is_segment && span.attributes['url.path']?.value === '/greet', + ); + + expect(getSpanOp(callEntrypointSpan!)).toBe('http.server'); + expect(callEntrypointSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(greetSpan!)).toBe('http.server'); + expect(greetSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(greetSpan?.parent_span_id).toBe(callEntrypointSpan?.span_id); + + const durableObjectSpansPromise = runner.collectStreamedSpans( + spansOfTrace => + spansOfTrace.filter(span => span.is_segment).length === 2 && + spansOfTrace.some(span => span.attributes['url.path']?.value === '/increment'), + ); + await runner.makeRequest('get', '/increment'); - await runner.completed(); + + const durableObjectTraceSpans = await durableObjectSpansPromise; + const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = durableObjectTraceSpans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + // The storage pair comes from the manual wrap here. Exactly two of them also rules out a + // double-wrap, which would nest proxies or break the build. + expect( + durableObjectTraceSpans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts index e50bceea5be2..cf4768c16c01 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts @@ -1,55 +1,67 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A Durable Object invoked via `fetch` emits an `http.server` transaction whose -// only children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present only when the class was auto-instrumented. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// A plain `http.server` transaction with no child spans, identified by its -// transaction name. Used for the two main-worker entries and the entrypoint — -// asserting the name keeps each expectation disjoint under unordered matching. -function expectPlainTransaction(name: string) { - return (transactionEvent: TransactionEvent): void => { - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); - expect(transactionEvent.transaction).toBe(name); - expect(transactionEvent.spans).toHaveLength(0); - }; -} +import { getSpanOp } from '../../../spanUtils'; // A single worker exports a plain `WorkerEntrypoint`, a plain `DurableObject`, // and a plain default handler. The runner builds it with the Sentry Vite plugin -// (auto-instrumentation on) and serves the output — so every transaction below -// only arrives if the build-time transform wrapped all three: `withSentry` for -// the default export, the self-bound `GreeterEntrypoint`, and `Counter` via +// (auto-instrumentation on) and serves the output — so every span below only +// arrives if the build-time transform wrapped all three: `withSentry` for the +// default export, the self-bound `GreeterEntrypoint`, and `Counter` via // `instrumentDurableObjectWithSentry`. +// +// Every route here is a raw URL, so the streamed segment names keep the method +// only and each hop is identified by its `url.path` attribute. it('auto-instruments a WorkerEntrypoint and a Durable Object exported from the same worker', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectPlainTransaction('GET /call-entrypoint')(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectPlainTransaction('GET /greet')(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectPlainTransaction('GET /increment')(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // Each hop streams from its own isolate, so the segment spans of a trace arrive in separate + // envelopes. + const entrypointSpansPromise = runner.collectStreamedSpans(spansOfTrace => { + const paths = spansOfTrace.filter(span => span.is_segment).map(span => span.attributes['url.path']?.value); + return paths.includes('/call-entrypoint') && paths.includes('/greet'); + }); await runner.makeRequest('get', '/call-entrypoint'); + + const entrypointTraceSpans = await entrypointSpansPromise; + const callEntrypointSpan = entrypointTraceSpans.find( + span => span.is_segment && span.attributes['url.path']?.value === '/call-entrypoint', + ); + const greetSpan = entrypointTraceSpans.find( + span => span.is_segment && span.attributes['url.path']?.value === '/greet', + ); + + expect(getSpanOp(callEntrypointSpan!)).toBe('http.server'); + expect(callEntrypointSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(greetSpan!)).toBe('http.server'); + expect(greetSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(greetSpan?.parent_span_id).toBe(callEntrypointSpan?.span_id); + + const durableObjectSpansPromise = runner.collectStreamedSpans( + spansOfTrace => + spansOfTrace.filter(span => span.is_segment).length === 2 && + spansOfTrace.some(span => span.attributes['url.path']?.value === '/increment'), + ); + await runner.makeRequest('get', '/increment'); - await runner.completed(); + + const durableObjectTraceSpans = await durableObjectSpansPromise; + const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = durableObjectTraceSpans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. + expect( + durableObjectTraceSpans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/test.ts index 00ebb3b19fd9..4bd63673a387 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/default-export/test.ts @@ -1,18 +1,21 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; // The worker entry is a plain, unwrapped `export default {...}`. The runner // detects `vite.config.mts`, runs `vite build`, and serves the generated output -// — so this transaction only arrives if the build-time transform wrapped the -// default export with `withSentry`. +// — so this span only arrives if the build-time transform wrapped the default +// export with `withSentry`. it('auto-instruments a plain default-export handler', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent.transaction).toBe('GET /hello'); - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); + const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); + + // `/hello` is a raw URL, so the streamed segment name keeps the method only. + expect(segmentSpan?.name).toBe('GET'); + expect(segmentSpan?.attributes['url.path']).toEqual({ type: 'string', value: '/hello' }); + expect(getSpanOp(segmentSpan!)).toBe('http.server'); + expect(segmentSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/index.ts index d51f25fcdeb6..15ab3a07d5c3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/index.ts @@ -19,7 +19,7 @@ class CounterImpl extends DurableObject { // leave it untouched — no second wrap — while still wrapping the plain default // export below. export const Counter = Sentry.instrumentDurableObjectWithSentry( - (env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }), + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), CounterImpl, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts index bfefd563f474..e538a69912a9 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts @@ -1,59 +1,43 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A fetch-invoked Durable Object emits an `http.server` transaction whose only -// children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present only when the class is instrumented. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// The main worker transaction just forwards to the DO, so it carries no child -// spans. The empty-spans assertion keeps it disjoint from the DO transaction. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} +import { getSpanOp } from '../../../spanUtils'; // The Durable Object is already wrapped manually with // `Sentry.instrumentDurableObjectWithSentry`. The transform must recognize the // existing wrap and NOT wrap it again (a double-wrap would either break the // build or nest proxies), while still auto-wrapping the plain default export. -// We therefore expect exactly one storage-bearing DO transaction (from the -// manual wrap) and one child-less main-worker transaction (from the auto wrap). +// We therefore expect exactly one storage-bearing DO segment span (from the +// manual wrap) and one child-less main-worker segment span (from the auto wrap). it('leaves a manually wrapped Durable Object untouched and still wraps the default export', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The worker and the Durable Object stream from separate isolates, so the two segment spans of + // the trace arrive in separate envelopes. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); await runner.makeRequest('get', '/increment'); - await runner.completed(); + + const spans = await spansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. Exactly two of them also rules out a double-wrap. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/index.ts index 580a58ca73c5..a936b747202d 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/index.ts @@ -19,7 +19,7 @@ class ManualImpl extends DurableObject { // Manually wrapped — the transform must leave this alone. export const Manual = Sentry.instrumentDurableObjectWithSentry( - (env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }), + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), ManualImpl, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts index 6cb366444117..972039a27bda 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts @@ -1,64 +1,45 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A fetch-invoked Durable Object emits an `http.server` transaction whose only -// children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present only when the class is instrumented (whether by the -// manual wrap or the build-time auto-wrap). -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// The main worker transaction just forwards to the DO, so it carries no child -// spans. The empty-spans assertion keeps it disjoint from the DO transactions. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} +import { getSpanOp } from '../../../spanUtils'; // One DO (`Manual`) is wrapped by hand, the other (`Auto`) is a plain inline // export. Both are bound in wrangler. The transform must skip the manual one and -// auto-wrap only `Auto` — so both endpoints report a storage-bearing DO -// transaction (one from the manual wrap, one from the auto wrap) without +// auto-wrap only `Auto` — so both endpoints report a storage-bearing DO segment +// span (one from the manual wrap, one from the auto wrap) without // double-instrumenting `Manual`. it('wraps only the unwrapped Durable Object when a sibling is manually wrapped', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - // One storage-bearing DO transaction from the manual wrap, one from the auto wrap. - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - // One child-less main worker transaction per request. - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // Each request runs in its own trace, and inside a trace the worker and the Durable Object stream + // from separate isolates. One collector per request therefore waits for that trace's two segment + // spans. + for (const path of ['/manual', '/auto']) { + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); + + await runner.makeRequest('get', path); + + const spans = await spansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['url.path']).toEqual({ type: 'string', value: path }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); - await runner.makeRequest('get', '/manual'); - await runner.makeRequest('get', '/auto'); - await runner.completed(); + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. Exactly two of them also rules out a double-wrap of `Manual`. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); + } }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts index 6ecb348b4382..eaa634bf2073 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts @@ -1,61 +1,43 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A fetch-invoked Durable Object emits an `http.server` transaction whose only -// children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present only when the class was actually auto-instrumented. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// The main worker transaction just forwards to the DO, so it carries no child -// spans. The empty-spans assertion keeps it disjoint from the DO transactions. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} +import { getSpanOp } from '../../../spanUtils'; // Two Durable Object classes are bound in wrangler. Hitting both must produce a -// storage-bearing DO transaction for each, proving the transform wrapped every +// storage-bearing DO segment span for each, proving the transform wrapped every // configured class rather than stopping after the first match. it('auto-instruments multiple Durable Object classes in one entry', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - // One storage-bearing DO transaction per configured class. - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - // One child-less main worker transaction per request. - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // Each request runs in its own trace, and inside a trace the worker and the Durable Object stream + // from separate isolates. One collector per request therefore waits for that trace's two segment + // spans. + for (const path of ['/increment-a', '/increment-b']) { + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); + + await runner.makeRequest('get', path); + + const spans = await spansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['url.path']).toEqual({ type: 'string', value: path }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); - await runner.makeRequest('get', '/increment-a'); - await runner.makeRequest('get', '/increment-b'); - await runner.completed(); + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); + } }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/counter.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/counter.ts index 0692079bfd9f..c06b3753cfca 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/counter.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/counter.ts @@ -17,6 +17,6 @@ class CounterImpl extends DurableObject { // The Durable Object is manually instrumented here, in a module *separate* from // the worker entry. The entry only imports and re-exports the wrapped class. export const Counter = Sentry.instrumentDurableObjectWithSentry( - (env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }), + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), CounterImpl, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts index bfe302220126..63d164ff5c84 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts @@ -1,45 +1,6 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A fetch-invoked Durable Object emits an `http.server` transaction whose only -// children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present only when the class is instrumented. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// The main worker transaction just forwards to the DO, so it carries no child -// spans. The empty-spans assertion keeps it disjoint from the DO transaction. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} +import { getSpanOp } from '../../../spanUtils'; // `Counter` is manually wrapped with `instrumentDurableObjectWithSentry` in a // separate module (`./counter`), imported into the entry, and re-exported via a @@ -52,12 +13,35 @@ function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { it('does not double-instrument an imported, already-wrapped Durable Object and still wraps the default export', async ({ signal, }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The worker and the Durable Object stream from separate isolates, so the two segment spans of + // the trace arrive in separate envelopes. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); await runner.makeRequest('get', '/increment'); - await runner.completed(); + + const spans = await spansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. Exactly two of them also rules out a double-wrap. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts index d2e70b59d426..8d9cf9740b68 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts @@ -3,6 +3,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; // `rpcTracePropagationBindings` is deliberately absent, the Vite plugin derives `COUNTER` on its own. export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts index 20483653eeec..050b1dd3ef26 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts @@ -1,31 +1,24 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { getSpanOp } from '../../../spanUtils'; it('propagates the trace over a Durable Object RPC call without configuring the binding', async ({ signal }) => { - let workerTraceId: string | undefined; - let doTraceId: string | undefined; + const runner = createRunner(__dirname).start(signal); - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent.contexts?.trace?.op).toBe('rpc'); - doTraceId = transactionEvent.contexts?.trace?.trace_id; - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - workerTraceId = transactionEvent.contexts?.trace?.trace_id; - }) - .start(signal); + // The worker and the Durable Object stream from separate isolates, so the two segment spans of + // the trace arrive in separate envelopes. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); // `argumentCount` proves the receiver stripped the metadata argument again. const response = await runner.makeRequest<{ count: number; argumentCount: number }>('get', '/increment'); expect(response).toEqual({ count: 1, argumentCount: 1 }); - await runner.completed(); + const segmentSpans = (await spansPromise).filter(span => span.is_segment); + const workerSpan = segmentSpans.find(span => getSpanOp(span) === 'http.server'); + const durableObjectSpan = segmentSpans.find(span => getSpanOp(span) === 'rpc'); - expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBe(workerTraceId); + expect(workerSpan).toBeDefined(); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts index e943483b6542..a54d5dfa0308 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts @@ -1,57 +1,41 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A fetch-invoked Durable Object emits an `http.server` transaction whose only -// children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present only when the class was actually auto-instrumented. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// The main worker transaction just forwards to the DO, so it carries no child -// spans. The empty-spans assertion keeps it disjoint from the DO transaction. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} +import { getSpanOp } from '../../../spanUtils'; // The wrangler binding references `Counter`, which is only an exported *alias* // of the local `CounterImpl` class (`export { CounterImpl as Counter }`). The // transform resolves the alias and wraps the local class, so the DO storage // spans only arrive if the aliased-specifier form was handled. it('auto-instruments a Durable Object exported via an aliased specifier', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The worker and the Durable Object stream from separate isolates, so the two segment spans of + // the trace arrive in separate envelopes. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); await runner.makeRequest('get', '/increment'); - await runner.completed(); + + const spans = await spansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts index 1935635262e3..7b02c87d3df9 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts @@ -1,57 +1,41 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A fetch-invoked Durable Object emits an `http.server` transaction whose only -// children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present only when the class was actually auto-instrumented. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// The main worker transaction just forwards to the DO, so it carries no child -// spans. The empty-spans assertion keeps it disjoint from the DO transaction. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} +import { getSpanOp } from '../../../spanUtils'; // The Durable Object is exported through a specifier list (`export { Counter }`) // instead of an inline `export class`. The transform renames the class to // `__SENTRY_ORIGINAL_Counter__` and rebinds `Counter` to the wrapped class, so // the DO storage spans only arrive if that specifier form was handled. it('auto-instruments a Durable Object exported via a specifier list', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The worker and the Durable Object stream from separate isolates, so the two segment spans of + // the trace arrive in separate envelopes. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); await runner.makeRequest('get', '/increment'); - await runner.completed(); + + const spans = await spansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/index.ts index 65dac0f7e3ad..895b61a4c8e2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/index.ts @@ -20,7 +20,7 @@ class CounterImpl extends DurableObject { // existing `instrumentDurableObjectWithSentry` call — matched by the DO-kind // wrapper method, not the workflow one — and leave it untouched (no double-wrap). export const Counter = Sentry.instrumentDurableObjectWithSentry( - (env: Env) => ({ dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0 }), + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), CounterImpl, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts index 390b25ea64a2..4abc814568d3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts @@ -1,71 +1,50 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A fetch-invoked Durable Object emits an `http.server` transaction whose only -// children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present here because the class was manually wrapped. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// A workflow step runs in its own invocation and reports a `function` / -// `auto.faas.cloudflare.workflow` transaction named after the step — present -// only because the transform auto-wrapped the Workflow class. -function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent.transaction).toBe('step-one'); - expect(transactionEvent.contexts?.trace?.op).toBe('function'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow'); -} - -// The main worker transaction for `/increment` just forwards to the DO, so it -// carries no child spans. The empty-spans assertion keeps it disjoint from the -// DO and workflow transactions regardless of arrival order. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} +import { getSpanOp } from '../../../spanUtils'; // The Durable Object is wrapped by hand with `instrumentDurableObjectWithSentry` // while the Workflow sibling is a plain inline export. The transform must match // the manual wrap by its DO-kind method and skip it (no double-wrap) while still // auto-wrapping the Workflow with `instrumentWorkflowWithSentry`. We therefore -// expect a storage-bearing DO transaction (manual wrap) and a `step-one` -// transaction (auto wrap), plus the child-less main worker transaction. +// expect a storage-bearing DO segment span (manual wrap) and a `step-one` +// segment span (auto wrap). it('leaves a manually wrapped Durable Object untouched and still auto-wraps a Workflow sibling', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The workflow step runs in its own trace, separate from either request. + const stepSpansPromise = runner.collectStreamedSpansUntilSegment('step-one'); + // The worker and the Durable Object stream from separate isolates, so the two segment spans of + // the `/increment` trace arrive in separate envelopes. + const durableObjectSpansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); await runner.makeRequest('get', '/increment'); + + const spans = await durableObjectSpansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. Exactly two of them also rules out a double-wrap. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); + await runner.makeRequest('get', '/workflow/trigger'); - await runner.completed(); + + const stepSpan = (await stepSpansPromise).find(span => span.name === 'step-one'); + expect(getSpanOp(stepSpan!)).toBe('function'); + expect(stepSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.faas.cloudflare.workflow' }); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts index a9d88f6d64bd..ffefbcade6c3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts @@ -1,70 +1,49 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A fetch-invoked Durable Object emits an `http.server` transaction whose only -// children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present only when the class was auto-instrumented. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// A workflow step runs in its own invocation and reports a `function` / -// `auto.faas.cloudflare.workflow` transaction named after the step — present -// only when the Workflow class was wrapped with `instrumentWorkflowWithSentry`. -function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent.transaction).toBe('step-one'); - expect(transactionEvent.contexts?.trace?.op).toBe('function'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow'); -} - -// The main worker transaction for `/increment` just forwards to the DO, so it -// carries no child spans. The empty-spans assertion keeps it disjoint from the -// DO and workflow transactions regardless of arrival order. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} +import { getSpanOp } from '../../../spanUtils'; // A Durable Object and a Workflow are both exported through a single specifier // list (`export { Counter, MyWorkflow }`) instead of inline `export class`. The // transform renames each local class and rebinds the exported name to the -// kind-specific wrapper, so the DO storage spans and the `step-one` workflow -// transaction only arrive if the specifier form was handled for both kinds. +// kind-specific wrapper, so the DO storage spans and the `step-one` segment span +// only arrive if the specifier form was handled for both kinds. it('auto-instruments a Durable Object and a Workflow exported via a specifier list', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The workflow step runs in its own trace, separate from either request. + const stepSpansPromise = runner.collectStreamedSpansUntilSegment('step-one'); + // The worker and the Durable Object stream from separate isolates, so the two segment spans of + // the `/increment` trace arrive in separate envelopes. + const durableObjectSpansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); await runner.makeRequest('get', '/increment'); + + const spans = await durableObjectSpansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); + await runner.makeRequest('get', '/workflow/trigger'); - await runner.completed(); + + const stepSpan = (await stepSpansPromise).find(span => span.name === 'step-one'); + expect(getSpanOp(stepSpan!)).toBe('function'); + expect(stepSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.faas.cloudflare.workflow' }); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts index e77bb20128d5..9861748a30ce 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts @@ -1,69 +1,48 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; - -// A fetch-invoked Durable Object emits an `http.server` transaction whose only -// children are the two `auto.db.cloudflare.durable_object` storage spans -// (`get` + `put`) — present only when the class was auto-instrumented. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// A workflow step runs in its own invocation and reports a `function` / -// `auto.faas.cloudflare.workflow` transaction named after the step — present -// only when the Workflow class was wrapped with `instrumentWorkflowWithSentry`. -function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent.transaction).toBe('step-one'); - expect(transactionEvent.contexts?.trace?.op).toBe('function'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow'); -} - -// The main worker transaction for `/increment` just forwards to the DO, so it -// carries no child spans. The empty-spans assertion keeps it disjoint from the -// DO and workflow transactions regardless of arrival order. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} +import { getSpanOp } from '../../../spanUtils'; // A single entry exports both a Durable Object and a Workflow, neither wrapped by // hand. The transform must wrap each with its kind-specific helper: hitting the -// DO yields a storage-bearing DO transaction and triggering the workflow yields a -// `step-one` transaction. Both only arrive if both classes were auto-wrapped. +// DO yields a storage-bearing DO segment span and triggering the workflow yields +// a `step-one` segment span. Both only arrive if both classes were auto-wrapped. it('auto-instruments a Durable Object and a Workflow in the same entry', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The workflow step runs in its own trace, separate from either request. + const stepSpansPromise = runner.collectStreamedSpansUntilSegment('step-one'); + // The worker and the Durable Object stream from separate isolates, so the two segment spans of + // the `/increment` trace arrive in separate envelopes. + const durableObjectSpansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); await runner.makeRequest('get', '/increment'); + + const spans = await durableObjectSpansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an + // instrumented Durable Object. + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); + await runner.makeRequest('get', '/workflow/trigger'); - await runner.completed(); + + const stepSpan = (await stepSpansPromise).find(span => span.name === 'step-one'); + expect(getSpanOp(stepSpan!)).toBe('function'); + expect(stepSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.faas.cloudflare.workflow' }); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts index 42a78ea2840a..c7012293e240 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts @@ -1,62 +1,47 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { getSpanOp } from '../../../spanUtils'; -// A Durable Object invoked via `fetch` produces its own `http.server` / -// `auto.http.cloudflare` transaction (its `fetch` is wrapped with +// A Durable Object invoked via `fetch` gets its own `http.server` / +// `auto.http.cloudflare` segment span (its `fetch` is wrapped with // `wrapRequestHandler`, not the faas wrapper used for alarms/websockets/RPC). // The proof the class was auto-instrumented is the pair of // `auto.db.cloudflare.durable_object` storage spans (`get` + `put`) it emits — // absent entirely when the class is left unwrapped. -function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(2); - expect(transactionEvent.spans).toEqual([ - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_get', - origin: 'auto.db.cloudflare.durable_object', - }), - expect.objectContaining({ - op: 'db', - description: 'durable_object_storage_put', - origin: 'auto.db.cloudflare.durable_object', - }), - ]); -} - -// The main worker transaction just forwards to the DO, so it carries no child -// spans. The empty-spans assertion keeps it disjoint from the DO transaction, so -// neither can satisfy the other's expectation regardless of arrival order. -function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { - expect(transactionEvent).toEqual( - expect.objectContaining({ - contexts: expect.objectContaining({ - trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), - }), - }), - ); - expect(transactionEvent.spans).toHaveLength(0); -} - +// // The worker is built by the Sentry Vite plugin (auto-instrumentation on). The // runner detects `vite.config.mts`, runs `vite build`, and serves the generated -// output — so these transactions only arrive if the build-time transform wrapped -// both the default handler (`withSentry`) and the `Counter` Durable Object +// output — so these spans only arrive if the build-time transform wrapped both +// the default handler (`withSentry`) and the `Counter` Durable Object // (`instrumentDurableObjectWithSentry`). it('auto-instruments the default handler and a Durable Object', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The worker and the Durable Object stream from separate isolates, so the two segment spans of + // the trace arrive in separate envelopes. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); await runner.makeRequest('get', '/increment'); - await runner.completed(); + + const spans = await spansPromise; + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + + expect(getSpanOp(workerSpan!)).toBe('http.server'); + expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + + expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); + expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); + expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + + expect( + spans + .filter(span => span.parent_span_id === durableObjectSpan?.span_id) + .map(span => ({ name: span.name, op: getSpanOp(span), origin: span.attributes['sentry.origin']?.value })), + ).toEqual([ + { name: 'durable_object_storage_get', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + { name: 'durable_object_storage_put', op: 'db', origin: 'auto.db.cloudflare.durable_object' }, + ]); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/test.ts index de3e96f9850d..02c432918863 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/test.ts @@ -1,30 +1,35 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { getSpanOp } from '../../../spanUtils'; // The worker is built by the Sentry Vite plugin (auto-instrumentation on). The // runner detects `vite.config.mts`, runs `vite build`, and serves the generated -// output — so these transactions only arrive if the build-time transform wrapped -// both the default handler and the self-bound `GreeterEntrypoint`. +// output — so these spans only arrive if the build-time transform wrapped both +// the default handler and the self-bound `GreeterEntrypoint`. it('auto-instruments the default handler and a self-bound WorkerEntrypoint', async ({ signal }) => { - const runner = createRunner(__dirname) - .unordered() - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - // Main worker's http.server transaction — proves `withSentry` wrapped the - // unwrapped default export. - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.transaction).toBe('GET /call-entrypoint'); - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - // The entrypoint's own http.server transaction — proves the auto-wrap - // identified and wrapped the named `WorkerEntrypoint`. - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.transaction).toBe('GET /greet'); - }) - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The default handler and the entrypoint stream from separate isolates, so the two segment spans + // of the trace arrive in separate envelopes. + const spansPromise = runner.collectStreamedSpans( + spansOfTrace => spansOfTrace.filter(span => span.is_segment).length === 2, + ); await runner.makeRequest('get', '/call-entrypoint'); - await runner.completed(); + + const segmentSpans = (await spansPromise).filter(span => span.is_segment); + // Both routes are raw URLs, so the streamed segment names keep the method only and the route is + // read from `url.path`. + const workerSpan = segmentSpans.find(span => span.attributes['url.path']?.value === '/call-entrypoint'); + const entrypointSpan = segmentSpans.find(span => span.attributes['url.path']?.value === '/greet'); + + // Main worker span — proves `withSentry` wrapped the unwrapped default export. + expect(workerSpan?.name).toBe('GET'); + expect(getSpanOp(workerSpan!)).toBe('http.server'); + + // The entrypoint's own span — proves the auto-wrap identified and wrapped the named + // `WorkerEntrypoint`. + expect(entrypointSpan?.name).toBe('GET'); + expect(getSpanOp(entrypointSpan!)).toBe('http.server'); + expect(entrypointSpan?.parent_span_id).toBe(workerSpan?.span_id); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/instrument.server.ts index 4355b90010d6..e577550ae0b8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/instrument.server.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/instrument.server.ts @@ -2,6 +2,5 @@ import { defineCloudflareOptions } from '@sentry/cloudflare'; export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1.0, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/test.ts index f2771ae8f4f5..089f1b345982 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/test.ts @@ -1,32 +1,30 @@ -import type { TransactionEvent } from '@sentry/core'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { getSpanOp } from '../../../spanUtils'; // The worker is built by the Sentry Vite plugin (auto-instrumentation on). The // runner detects `vite.config.mts`, runs `vite build`, and serves the generated -// output — so these transactions only arrive if the build-time transform wrapped +// output — so these spans only arrive if the build-time transform wrapped // `MyWorkflow` with `instrumentWorkflowWithSentry` and the default export with // `withSentry`. -// -// The workflow step and the triggering request are separate executions whose -// envelopes race, so both are expected `unordered`. it('auto-instruments a Workflow class', async ({ signal }) => { - const runner = createRunner(__dirname) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent.transaction).toBe('step-one'); - expect(transactionEvent.contexts?.trace?.op).toBe('function'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow'); - }) - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; - expect(transactionEvent.transaction).toBe('GET /workflow/trigger'); - expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); - expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); - }) - .unordered() - .start(signal); + const runner = createRunner(__dirname).start(signal); + + // The workflow step and the triggering request are separate executions, each with its own trace. + const stepSpansPromise = runner.collectStreamedSpansUntilSegment('step-one'); + const triggerSpansPromise = runner.collectStreamedSpansUntilSegment( + span => span.attributes['url.path']?.value === '/workflow/trigger', + ); await runner.makeRequest('get', '/workflow/trigger'); - await runner.completed(); + + const stepSpan = (await stepSpansPromise).find(span => span.name === 'step-one'); + expect(getSpanOp(stepSpan!)).toBe('function'); + expect(stepSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.faas.cloudflare.workflow' }); + + const triggerSpan = (await triggerSpansPromise).find(span => span.is_segment); + // `/workflow/trigger` is a raw URL, so the streamed segment name keeps the method only. + expect(triggerSpan?.name).toBe('GET'); + expect(getSpanOp(triggerSpan!)).toBe('http.server'); + expect(triggerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/client/main.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/client/main.ts index 7d443658fd7b..f79f6693a500 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/client/main.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/client-build/client/main.ts @@ -1,3 +1,4 @@ +/// import { streamText } from 'ai'; // The browser bundle also pulls in an orchestrion-instrumented module (`ai`). diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts index e251a1f66cf7..40d3e3bbceee 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/index.ts @@ -9,7 +9,6 @@ interface Env { export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', tracesSampleRate: 1, }), { diff --git a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts index 556f5babbe80..3e886958978f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite/diagnostics-channel/vercelai-6/test.ts @@ -7,57 +7,44 @@ import { } from '@sentry/conventions/attributes'; import { createRunner } from '../../../../runner'; -it('captures a transaction with Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', async ({ signal }) => { - const runner = createRunner(__dirname) - .ignore('event') - .expect(envelope => { - const transactionEvent = envelope[1]?.[0]?.[1] as any; - expect(transactionEvent.transaction).toBe('GET /'); +it('captures Vercel AI v6 spans via @sentry/cloudflare vercelAIIntegration', async ({ signal }) => { + const runner = createRunner(__dirname).ignore('event').start(signal); - const container = envelope[1]?.[1]?.[1] as any; - expect(container).toBeDefined(); - - expect(container.items).toHaveLength(2); - expect(container.items).toEqual( - expect.arrayContaining([ - { - trace_id: expect.any(String), - span_id: expect.any(String), - parent_span_id: expect.any(String), - name: 'invoke_agent', - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - is_segment: false, - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'gen_ai.invoke_agent' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, - [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'invoke_agent' }, - [GEN_AI_USAGE_INPUT_TOKENS]: { type: 'integer', value: 10 }, - [GEN_AI_USAGE_OUTPUT_TOKENS]: { type: 'integer', value: 20 }, - [GEN_AI_USAGE_TOTAL_TOKENS]: { type: 'integer', value: 30 }, - }), - }, - { - trace_id: expect.any(String), - span_id: expect.any(String), - parent_span_id: expect.any(String), - name: 'generate_content mock-model-id', - start_timestamp: expect.any(Number), - end_timestamp: expect.any(Number), - status: 'ok', - is_segment: false, - attributes: expect.objectContaining({ - 'sentry.op': { type: 'string', value: 'gen_ai.generate_content' }, - 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, - [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'generate_content' }, - }), - }, - ]), - ); - }) - .start(signal); + // The request produces one segment span and the two gen_ai children. Waiting for all three rather + // than for the segment: it ends last, but each envelope is its own request to the mock server, so + // it can arrive before the envelope carrying the children this test asserts on. + const spansPromise = runner.collectStreamedSpans(spansOfTrace => spansOfTrace.length === 3); await runner.makeRequest('get', '/'); - await runner.completed(); + + const spans = await spansPromise; + + expect(spans.filter(span => !span.is_segment)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'invoke_agent', + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'gen_ai.invoke_agent' }, + 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, + [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'invoke_agent' }, + [GEN_AI_USAGE_INPUT_TOKENS]: { type: 'integer', value: 10 }, + [GEN_AI_USAGE_OUTPUT_TOKENS]: { type: 'integer', value: 20 }, + [GEN_AI_USAGE_TOTAL_TOKENS]: { type: 'integer', value: 30 }, + }), + }), + expect.objectContaining({ + name: 'generate_content mock-model-id', + is_segment: false, + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { type: 'string', value: 'gen_ai.generate_content' }, + 'sentry.origin': { type: 'string', value: 'auto.vercelai.channel' }, + [GEN_AI_OPERATION_NAME]: { type: 'string', value: 'generate_content' }, + }), + }), + ]), + ); + expect(spans.filter(span => !span.is_segment)).toHaveLength(2); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts b/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts index d82cea5ef733..442c8073cf92 100644 --- a/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/workflows/step-context/index.ts @@ -50,7 +50,6 @@ class StepContextTestWorkflowBase extends WorkflowEntrypoint ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', }), StepContextTestWorkflowBase, ); @@ -58,7 +57,6 @@ export const StepContextTestWorkflow = Sentry.instrumentWorkflowWithSentry( export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, - traceLifecycle: 'static', }), { async fetch(request, env, _ctx) { diff --git a/dev-packages/cloudflare-integration-tests/vite.config.mts b/dev-packages/cloudflare-integration-tests/vite.config.mts index a93b595b6ecd..600cd6a118ee 100644 --- a/dev-packages/cloudflare-integration-tests/vite.config.mts +++ b/dev-packages/cloudflare-integration-tests/vite.config.mts @@ -26,11 +26,7 @@ export default defineConfig({ // overhead is significantly less. pool: 'threads', // Run tests sequentially to avoid port conflicts with wrangler dev processes - poolOptions: { - threads: { - singleThread: true, - }, - }, + maxWorkers: 1, sequence: { shuffle: true, }, diff --git a/dev-packages/deno-integration-tests/package.json b/dev-packages/deno-integration-tests/package.json index 6283ecc2e050..0dc7d2177228 100644 --- a/dev-packages/deno-integration-tests/package.json +++ b/dev-packages/deno-integration-tests/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "@sentry-internal/node-integration-tests": "11.1.0", - "vitest": "^3.2.7" + "vitest": "^4.1.11" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/lib/getTestMatrix.mjs b/dev-packages/e2e-tests/lib/getTestMatrix.mjs index b8be7af5f528..7433dc989ead 100644 --- a/dev-packages/e2e-tests/lib/getTestMatrix.mjs +++ b/dev-packages/e2e-tests/lib/getTestMatrix.mjs @@ -85,9 +85,17 @@ function addIncludesForTestApp(testApp, includes, { optionalMode }) { } variants.forEach(variant => { + // Allow skipping an individual variant (e.g. one blocked by an upstream bug) while keeping the + // others. `sentryTest.skip` above skips the whole app; this is the per-variant equivalent. + if (variant.skip) { + return; + } + + // Don't leak the `skip` flag into the matrix include. + const { skip: _skip, ...variantConfig } = variant; includes.push({ 'test-application': testApp, - ...variant, + ...variantConfig, }); }); } diff --git a/dev-packages/e2e-tests/run.ts b/dev-packages/e2e-tests/run.ts index e2c56813a3bf..317aa42a3a64 100644 --- a/dev-packages/e2e-tests/run.ts +++ b/dev-packages/e2e-tests/run.ts @@ -13,6 +13,7 @@ interface SentryTestVariant { 'build-command': string; 'assert-command'?: string; label?: string; + skip?: boolean; } interface PackageJson { @@ -82,7 +83,13 @@ async function getVariantBuildCommand( packageJsonPath: string, variantLabel: string, testAppPath: string, -): Promise<{ buildCommand: string; assertCommand: string; testLabel: string; matchedVariantLabel?: string }> { +): Promise<{ + buildCommand: string; + assertCommand: string; + testLabel: string; + matchedVariantLabel?: string; + skip?: boolean; +}> { try { const packageJsonContent = await readFile(packageJsonPath, 'utf-8'); const packageJson: PackageJson = JSON.parse(packageJsonContent); @@ -100,6 +107,7 @@ async function getVariantBuildCommand( assertCommand: matchingVariant['assert-command'] || 'pnpm test:assert', testLabel: matchingVariant.label || testAppPath, matchedVariantLabel: matchingVariant.label, + skip: matchingVariant.skip, }; } diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts index 0c15ef39d04e..0d4d123f05c3 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts @@ -2,12 +2,18 @@ import { test as base, expect } from '@playwright/test'; import { App } from 'aws-cdk-lib'; import { LocalLambdaStack, SAM_PORT, getHostIp } from '../src/stack'; import { writeFileSync } from 'node:fs'; -import { execSync, spawn } from 'node:child_process'; +import { type ChildProcess, execSync, spawn } from 'node:child_process'; import { LambdaClient } from '@aws-sdk/client-lambda'; const DOCKER_NETWORK_NAME = 'lambda-test-network'; const SAM_TEMPLATE_FILE = 'sam.template.yml'; +// A healthy SAM start takes about 20s on CI, but SAM occasionally hangs on its first start while a +// fresh one comes up in seconds, so give up early and retry instead of waiting out one long timeout. +const SAM_START_TIMEOUT_MS = 90_000; +const SAM_START_ATTEMPTS = 2; +const SAM_OUTPUT_MAX_CHARS = 20_000; + /** Major Node for SAM `--invoke-image`; default matches root `package.json` `volta.node` and `pull-sam-image.sh`. */ const DEFAULT_NODE_VERSION_MAJOR = '20'; @@ -55,34 +61,23 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien console.log(`[testEnvironment fixture] Running SAM with args: ${args.join(' ')}`); - const samProcess = spawn('sam', args, { - stdio: process.env.DEBUG ? 'inherit' : 'ignore', - env: envForSamChild(), - }); + let samProcess: ChildProcess | undefined; try { - await LocalLambdaStack.waitForStack(); + samProcess = await startSam(args); await use(stack); } finally { console.log('[testEnvironment fixture] Tearing down AWS Lambda test infrastructure'); - samProcess.kill('SIGTERM'); - await new Promise(resolve => { - samProcess.once('exit', resolve); - setTimeout(() => { - if (!samProcess.killed) { - samProcess.kill('SIGKILL'); - } - resolve(void 0); - }, 5000); - }); - + if (samProcess) { + await stopSam(samProcess); + } removeDockerNetwork(); } }, // Own timeout so slow SAM stack startup does not eat into the first test's budget. - { scope: 'worker', auto: true, timeout: 240_000 }, + { scope: 'worker', auto: true, timeout: 300_000 }, ], lambdaClient: async ({}, use) => { const lambdaClient = new LambdaClient({ @@ -98,6 +93,54 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien }, }); +async function startSam(args: string[]): Promise { + for (let attempt = 1; ; attempt++) { + let output = ''; + const samProcess = spawn('sam', args, { + stdio: process.env.DEBUG ? 'inherit' : ['ignore', 'pipe', 'pipe'], + env: envForSamChild(), + }); + const collectOutput = (chunk: Buffer): void => { + output = (output + chunk.toString()).slice(-SAM_OUTPUT_MAX_CHARS); + }; + samProcess.stdout?.on('data', collectOutput); + samProcess.stderr?.on('data', collectOutput); + + try { + await LocalLambdaStack.waitForStack(SAM_START_TIMEOUT_MS); + return samProcess; + } catch (error) { + console.warn(`[testEnvironment fixture] SAM output of failed start attempt ${attempt}:\n${output}`); + await stopSam(samProcess); + + if (attempt >= SAM_START_ATTEMPTS) { + throw error; + } + console.warn(`[testEnvironment fixture] Restarting SAM (attempt ${attempt + 1}/${SAM_START_ATTEMPTS})`); + } + } +} + +async function stopSam(samProcess: ChildProcess): Promise { + if (samProcess.exitCode === null && samProcess.signalCode === null) { + samProcess.kill('SIGTERM'); + await new Promise(resolve => { + const timer = setTimeout(() => { + samProcess.kill('SIGKILL'); + resolve(void 0); + }, 5000); + samProcess.once('exit', () => { + clearTimeout(timer); + resolve(void 0); + }); + }); + } + + // A SAM process killed mid-start leaves its runtime containers behind, which would keep the + // docker network in use and hold on to their ports. + removeNetworkContainers(); +} + /** Avoid forcing linux/amd64 on Apple Silicon when `DOCKER_DEFAULT_PLATFORM` is set globally. */ function envForSamChild(): NodeJS.ProcessEnv { const env = { ...process.env }; @@ -128,6 +171,15 @@ function createDockerNetwork() { } } +function removeNetworkContainers() { + const containerIds = execSync(`docker ps -aq --filter network=${DOCKER_NETWORK_NAME}`, { encoding: 'utf-8' }) + .split('\n') + .filter(Boolean); + if (containerIds.length) { + execSync(`docker rm -f ${containerIds.join(' ')}`, { stdio: 'ignore' }); + } +} + function removeDockerNetwork() { try { execSync(`docker network rm ${DOCKER_NETWORK_NAME}`); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts index 0c15ef39d04e..0d4d123f05c3 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts @@ -2,12 +2,18 @@ import { test as base, expect } from '@playwright/test'; import { App } from 'aws-cdk-lib'; import { LocalLambdaStack, SAM_PORT, getHostIp } from '../src/stack'; import { writeFileSync } from 'node:fs'; -import { execSync, spawn } from 'node:child_process'; +import { type ChildProcess, execSync, spawn } from 'node:child_process'; import { LambdaClient } from '@aws-sdk/client-lambda'; const DOCKER_NETWORK_NAME = 'lambda-test-network'; const SAM_TEMPLATE_FILE = 'sam.template.yml'; +// A healthy SAM start takes about 20s on CI, but SAM occasionally hangs on its first start while a +// fresh one comes up in seconds, so give up early and retry instead of waiting out one long timeout. +const SAM_START_TIMEOUT_MS = 90_000; +const SAM_START_ATTEMPTS = 2; +const SAM_OUTPUT_MAX_CHARS = 20_000; + /** Major Node for SAM `--invoke-image`; default matches root `package.json` `volta.node` and `pull-sam-image.sh`. */ const DEFAULT_NODE_VERSION_MAJOR = '20'; @@ -55,34 +61,23 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien console.log(`[testEnvironment fixture] Running SAM with args: ${args.join(' ')}`); - const samProcess = spawn('sam', args, { - stdio: process.env.DEBUG ? 'inherit' : 'ignore', - env: envForSamChild(), - }); + let samProcess: ChildProcess | undefined; try { - await LocalLambdaStack.waitForStack(); + samProcess = await startSam(args); await use(stack); } finally { console.log('[testEnvironment fixture] Tearing down AWS Lambda test infrastructure'); - samProcess.kill('SIGTERM'); - await new Promise(resolve => { - samProcess.once('exit', resolve); - setTimeout(() => { - if (!samProcess.killed) { - samProcess.kill('SIGKILL'); - } - resolve(void 0); - }, 5000); - }); - + if (samProcess) { + await stopSam(samProcess); + } removeDockerNetwork(); } }, // Own timeout so slow SAM stack startup does not eat into the first test's budget. - { scope: 'worker', auto: true, timeout: 240_000 }, + { scope: 'worker', auto: true, timeout: 300_000 }, ], lambdaClient: async ({}, use) => { const lambdaClient = new LambdaClient({ @@ -98,6 +93,54 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien }, }); +async function startSam(args: string[]): Promise { + for (let attempt = 1; ; attempt++) { + let output = ''; + const samProcess = spawn('sam', args, { + stdio: process.env.DEBUG ? 'inherit' : ['ignore', 'pipe', 'pipe'], + env: envForSamChild(), + }); + const collectOutput = (chunk: Buffer): void => { + output = (output + chunk.toString()).slice(-SAM_OUTPUT_MAX_CHARS); + }; + samProcess.stdout?.on('data', collectOutput); + samProcess.stderr?.on('data', collectOutput); + + try { + await LocalLambdaStack.waitForStack(SAM_START_TIMEOUT_MS); + return samProcess; + } catch (error) { + console.warn(`[testEnvironment fixture] SAM output of failed start attempt ${attempt}:\n${output}`); + await stopSam(samProcess); + + if (attempt >= SAM_START_ATTEMPTS) { + throw error; + } + console.warn(`[testEnvironment fixture] Restarting SAM (attempt ${attempt + 1}/${SAM_START_ATTEMPTS})`); + } + } +} + +async function stopSam(samProcess: ChildProcess): Promise { + if (samProcess.exitCode === null && samProcess.signalCode === null) { + samProcess.kill('SIGTERM'); + await new Promise(resolve => { + const timer = setTimeout(() => { + samProcess.kill('SIGKILL'); + resolve(void 0); + }, 5000); + samProcess.once('exit', () => { + clearTimeout(timer); + resolve(void 0); + }); + }); + } + + // A SAM process killed mid-start leaves its runtime containers behind, which would keep the + // docker network in use and hold on to their ports. + removeNetworkContainers(); +} + /** Avoid forcing linux/amd64 on Apple Silicon when `DOCKER_DEFAULT_PLATFORM` is set globally. */ function envForSamChild(): NodeJS.ProcessEnv { const env = { ...process.env }; @@ -128,6 +171,15 @@ function createDockerNetwork() { } } +function removeNetworkContainers() { + const containerIds = execSync(`docker ps -aq --filter network=${DOCKER_NETWORK_NAME}`, { encoding: 'utf-8' }) + .split('\n') + .filter(Boolean); + if (containerIds.length) { + execSync(`docker rm -f ${containerIds.join(' ')}`, { stdio: 'ignore' }); + } +} + function removeDockerNetwork() { try { execSync(`docker network rm ${DOCKER_NETWORK_NAME}`); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/.gitignore b/dev-packages/e2e-tests/test-applications/bun-express/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/bun-express/build.ts b/dev-packages/e2e-tests/test-applications/bun-express/build.ts new file mode 100644 index 000000000000..a2c2eaefd2a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/build.ts @@ -0,0 +1,20 @@ +// @ts-ignore -- subpath export resolved by Bun at runtime; the package +// tsconfig's node module resolution can't see `exports` subpaths. +import { sentryBunPlugin } from '@sentry/bun/plugin'; +import { join } from 'path'; + +void (async () => { + const result = await Bun.build({ + entrypoints: [join(__dirname, 'src/app.ts')], + target: 'bun', + outdir: join(__dirname, 'dist'), + external: ['@sentry/bun', 'express'], + plugins: [sentryBunPlugin()], + }); + + if (!result.success) { + // oxlint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); + } +})(); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/package.json b/dev-packages/e2e-tests/test-applications/bun-express/package.json new file mode 100644 index 000000000000..cd2634fe22f4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/package.json @@ -0,0 +1,26 @@ +{ + "name": "bun-express", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun run dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install && bun run build.ts", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/bun": "latest || *", + "express": "^5.1.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/express": "^5.0.0", + "bun-types": "^1.2.9" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/bun-express/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/bun-express/playwright.config.mjs new file mode 100644 index 000000000000..ce719004da38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/playwright.config.mjs @@ -0,0 +1,5 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig({ + startCommand: 'pnpm start', +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/src/app.ts b/dev-packages/e2e-tests/test-applications/bun-express/src/app.ts new file mode 100644 index 000000000000..97c8b53a6c26 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/src/app.ts @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/bun'; +import express from 'express'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1, +}); + +const app = express(); + +app.get('/test-param/:id', (request, response) => { + response.json({ id: request.params.id }); +}); + +app.get('/test-exception/:id', request => { + throw new Error(`This is an exception with id ${request.params.id}`); +}); + +app.use((_error, _request, response, _next) => { + response.status(500).send('Internal Server Error'); +}); + +app.listen(3030, () => { + // oxlint-disable-next-line no-console + console.log('Bun Express app listening on port 3030'); +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/bun-express/start-event-proxy.mjs new file mode 100644 index 000000000000..61525a11957e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'bun-express', +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/bun-express/tests/errors.test.ts new file mode 100644 index 000000000000..1fd5c04cbf06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/tests/errors.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment, waitForError } from '@sentry-internal/test-utils'; + +test('captures an error thrown in an Express route', async ({ baseURL }) => { + const errorEventPromise = waitForError('bun-express', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + const spansPromise = collectStreamedSpansUntilSegment('bun-express', 'GET /test-exception/:id'); + + const response = await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + const spans = await spansPromise; + + expect(response.status).toBe(500); + expect(errorEvent.exception?.values).toEqual([ + expect.objectContaining({ + value: 'This is an exception with id 123', + mechanism: { + type: 'auto.http.express', + handled: false, + }, + }), + ]); + expect(errorEvent.transaction).toBe('GET /test-exception/:id'); + expect(errorEvent.contexts?.runtime?.name).toBe('bun'); + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan?.name).toBe('GET /test-exception/:id'); + expect(rootSpan?.status).toBe('error'); + expect(rootSpan?.attributes['http.response.status_code']?.value).toBe(500); + expect(errorEvent.contexts?.trace?.trace_id).toBe(rootSpan?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/bun-express/tests/spans.test.ts new file mode 100644 index 000000000000..050e42ab35a8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/tests/spans.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; + +test('instruments an Express route under Bun', async ({ baseURL }) => { + const spansPromise = collectStreamedSpansUntilSegment('bun-express', 'GET /test-param/:id'); + + const response = await fetch(`${baseURL}/test-param/123`); + const spans = await spansPromise; + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ id: '123' }); + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan?.name).toBe('GET /test-param/:id'); + expect(rootSpan && getSpanOp(rootSpan)).toBe('http.server'); + expect(rootSpan?.status).toBe('ok'); + expect(rootSpan?.attributes['sentry.segment.name.source']?.value).toBe('route'); + + expect(spans).toContainEqual( + expect.objectContaining({ + name: '/test-param/:id', + attributes: expect.objectContaining({ + 'sentry.origin': { value: 'auto.http.express', type: 'string' }, + 'sentry.op': { value: 'handler', type: 'string' }, + 'http.route': { value: '/test-param/:id', type: 'string' }, + 'express.type': { value: 'request_handler', type: 'string' }, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/bun-express/tsconfig.json b/dev-packages/e2e-tests/test-applications/bun-express/tsconfig.json new file mode 100644 index 000000000000..ef784cd1e0b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/bun-express/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "types": ["bun-types"], + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "build.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts deleted file mode 100644 index 514927322439..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function wrangler(args: string[], env: Record = {}): void { - execFileSync('pnpm', ['exec', 'wrangler', ...args], { - cwd: __dirname, - env: { ...process.env, ...env }, - stdio: ['ignore', 'inherit', 'inherit'], - }); -} - -/** - * Workflow names are unique per Cloudflare account, so every worker gets its own. The Vite build writes the - * config wrangler deploys from, and `.wrangler/deploy/config.json` points to it. - */ -function nameWorkflowsAfterWorker(name: string): void { - const redirect: { configPath: string } = JSON.parse( - readFileSync(join(__dirname, '.wrangler/deploy/config.json'), 'utf8'), - ); - const configPath = join(__dirname, '.wrangler/deploy', redirect.configPath); - const config: { workflows?: { name: string }[] } = JSON.parse(readFileSync(configPath, 'utf8')); - - for (const workflow of config.workflows ?? []) { - workflow.name = name; - } - writeFileSync(configPath, JSON.stringify(config, null, 2)); -} - -/** Deploys the worker under `name` and returns its workers.dev URL. */ -export function deployWorker(name: string, dsn: string): string { - nameWorkflowsAfterWorker(name); - const outputDir = mkdtempSync(join(tmpdir(), 'wrangler-output-')); - const outputFile = join(outputDir, 'output.ndjson'); - - try { - wrangler(['deploy', '--name', name, '--var', `E2E_TEST_DSN:${dsn}`], { WRANGLER_OUTPUT_FILE_PATH: outputFile }); - - const url = readFileSync(outputFile, 'utf8') - .split('\n') - .filter(Boolean) - .map(line => JSON.parse(line) as { type?: string; targets?: string[] }) - .find(entry => entry.type === 'deploy') - ?.targets?.find(target => target.endsWith('.workers.dev')); - - if (!url) { - throw new Error(`Could not find the workers.dev URL in the wrangler deploy output for ${name}.`); - } - - return url; - } finally { - rmSync(outputDir, { recursive: true, force: true }); - } -} - -export function deleteWorker(name: string): void { - wrangler(['delete', '--name', name, '--force']); -} - -/** - * CI keeps its Workers: one per ref, overwritten by the next run of the same ref and deleted by the - * cleanup workflow once a PR closes. Local runs delete theirs unless `E2E_KEEP_WORKER` is set. - */ -export function keepsWorker(): boolean { - return Boolean(process.env.GITHUB_ACTIONS || process.env.E2E_KEEP_WORKER); -} - -/** A freshly created workers.dev route can take a moment to become reachable. */ -export async function waitForWorker(url: string): Promise { - const deadline = Date.now() + 60_000; - - while (Date.now() < deadline) { - try { - // The SDK does not trace HEAD requests, so the probe leaves no spans behind in Sentry. - const response = await fetch(url, { method: 'HEAD' }); - - if (response.ok) { - return; - } - } catch { - // DNS for the new subdomain may not have propagated yet. - } - - await new Promise(resolve => setTimeout(resolve, 2_000)); - } - - throw new Error(`Worker at ${url} did not become reachable within 60s.`); -} - -/** - * Sends a request until the Worker itself answers it, and returns the body of that answer. - * - * On the first deployment of a Worker name, Cloudflare has answered a request with a 500 while - * Workers Logs had no invocation for it. `status` is the status the Worker answers with. A Worker - * that threw answers with status 500 and Cloudflare error code 1101, which sets it apart from a 500 - * that did not come from the Worker. - */ -export async function fetchFromWorker(url: string, status: number, init?: RequestInit): Promise { - const deadline = Date.now() + 60_000; - let lastAnswer = 'no answer'; - - while (Date.now() < deadline) { - try { - const response = await fetch(url, init); - const body = await response.text(); - // Cloudflare sends its error page as HTML to some clients (Node's fetch among them) and as - // `error code: ` plain text to others, so the code is read from either format. - const errorCode = /cf-error-code">(\d+)<|^error code: (\d+)/.exec(body)?.slice(1).find(Boolean); - - if (response.status === status && (status !== 500 || errorCode === '1101')) { - return body; - } - - lastAnswer = `${response.status}, cf-ray ${response.headers.get('cf-ray')}, error code ${errorCode ?? 'none'}, body: ${body.slice(0, 200)}`; - } catch (error) { - lastAnswer = String(error); - } - - console.log(`The Worker did not answer ${url}: ${lastAnswer}`); - await new Promise(resolve => setTimeout(resolve, 2_000)); - } - - throw new Error(`The Worker did not answer ${url} with status ${status} within 60s. Last answer: ${lastAnswer}`); -} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts index b33f187e1d94..552ccce37a94 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.ts @@ -1,61 +1,3 @@ -import { randomBytes } from 'node:crypto'; -import { existsSync } from 'node:fs'; -import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker'; +import { createWorkerGlobalSetup } from '@sentry-internal/test-utils/cloudflare'; -const WORKER_PREFIX = 'e2e-send-to-sentry'; - -/** - * In CI the name follows the ref, so `develop`, `master` and every PR get a stable Worker that the - * next run of the same ref overwrites. Pull request refs look like `123/merge` and merge queue refs - * like `gh-readonly-queue//pr-123-`; both map to the PR's Worker. - */ -export function getWorkerName(): string { - if (!process.env.GITHUB_ACTIONS) { - return `${WORKER_PREFIX}-local-${randomBytes(3).toString('hex')}`; - } - - const { GITHUB_EVENT_NAME, GITHUB_REF_NAME = '' } = process.env; - const prNumber = - GITHUB_EVENT_NAME === 'pull_request' ? GITHUB_REF_NAME.split('/')[0] : /\/pr-(\d+)-/.exec(GITHUB_REF_NAME)?.[1]; - const ref = prNumber ? `pr-${prNumber}` : GITHUB_REF_NAME; - // Worker names allow lowercase alphanumerics and dashes only, up to 63 characters. - const slug = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-'); - - return `${WORKER_PREFIX}-${slug}`.slice(0, 63).replace(/-+$/, ''); -} - -export default async function globalSetup(): Promise { - if (!existsSync(new URL('.wrangler/deploy/config.json', import.meta.url))) { - throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.'); - } - const { CLOUDFLARE_ACCOUNT_ID, E2E_TEST_DSN } = process.env; - if (!E2E_TEST_DSN) { - throw new Error('E2E_TEST_DSN must be set to deploy the test worker.'); - } - - // Wrangler authenticates with `CLOUDFLARE_API_TOKEN` (CI) or a `wrangler login` session (local), - // but it cannot pick an account on its own outside of a terminal. - if (!CLOUDFLARE_ACCOUNT_ID) { - throw new Error('CLOUDFLARE_ACCOUNT_ID must be set to deploy the test worker.'); - } - - const workerName = getWorkerName(); - const workerUrl = deployWorker(workerName, E2E_TEST_DSN); - process.env.E2E_TEST_WORKER_NAME = workerName; - - try { - await waitForWorker(workerUrl); - } catch (error) { - if (!keepsWorker()) { - try { - deleteWorker(workerName); - } catch (deleteError) { - // The unreachable worker is the failure to report, not the cleanup. - console.error(`Failed to delete worker ${workerName}:`, deleteError); - } - } - throw error; - } - - process.env.E2E_TEST_WORKER_URL = workerUrl; -} +export default createWorkerGlobalSetup({ workerPrefix: 'e2e-send-to-sentry' }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts index 9f012cd51101..c7bcfe696e66 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.ts @@ -1,24 +1 @@ -import { deleteWorker, keepsWorker } from './deployed-worker'; - -export default function globalTeardown(): void { - const workerName = process.env.E2E_TEST_WORKER_NAME; - - if (!workerName) { - return; - } - - if (keepsWorker()) { - console.log(`Keeping worker ${workerName} at ${process.env.E2E_TEST_WORKER_URL}`); - return; - } - - try { - deleteWorker(workerName); - } catch (error) { - // A leaked worker is not an SDK failure, so it must not fail a run whose tests passed. - console.error( - `Failed to delete worker ${workerName}, delete it with \`wrangler delete --name ${workerName}\`:`, - error, - ); - } -} +export { workerGlobalTeardown as default } from '@sentry-internal/test-utils/cloudflare'; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts index 91e214f15493..dde66a7e3685 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts @@ -1,16 +1,16 @@ -import { defineConfig } from '@playwright/test'; +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -export default defineConfig({ - testDir: './tests', +const config = getPlaywrightConfig(undefined, { + // The tests read what arrived in Sentry through the sentry CLI, so there is no event proxy to start. + webServer: undefined, // The worker is deployed once for the whole run and deleted again afterwards. globalSetup: './global-setup.ts', globalTeardown: './global-teardown.ts', /* Spans take ~2min to become queryable via the trace endpoint. */ timeout: 210_000, - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: 0, // Every test spends most of its time polling Sentry, so run them all at once. + fullyParallel: true, workers: '100%', - reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', }); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts index aaf72be7640b..15557b010a50 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts @@ -1,4 +1,5 @@ interface Env { E2E_TEST_DSN: string; SLEEP_WORKFLOW: Workflow; + AI: Ai; } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts index 93f1c24f124e..324c9161cf69 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts @@ -27,6 +27,10 @@ export default { } case '/test-unhandled-error': throw new Error('E2E test unhandled error'); + case '/test-workers-ai': { + await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { prompt: 'Say hi', max_tokens: 5 }); + return Response.json({ traceId: spanContext?.traceId }); + } case '/test-span': return Response.json({ spanId: spanContext?.spanId, traceId: spanContext?.traceId }); case '/test-workflow-sleep': { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts index 8578ecb7bee7..2397db37089f 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -2,13 +2,14 @@ import { randomBytes } from 'node:crypto'; import { expect, test } from '@playwright/test'; import { EVENT_POLLING_OPTIONS, + fetchSpanAttributes, fetchTrace, findErrorInTrace, findSpanInTrace, flattenTrace, traceTarget, } from '@sentry-internal/test-utils/cli'; -import { fetchFromWorker } from '../deployed-worker'; +import { fetchFromWorker } from '@sentry-internal/test-utils/cloudflare'; // Set by global-setup.ts once the worker for this run is deployed. const workerUrl = process.env.E2E_TEST_WORKER_URL; @@ -73,3 +74,22 @@ test('Sends the spans of Workflow steps before the Workflow goes to sleep', asyn ); expect(['running', 'waiting']).toContain(status); }); + +test('Sends a Workers AI gen_ai span to Sentry', async () => { + const { traceId }: { traceId: string } = JSON.parse(await fetchFromWorker(`${workerUrl}/test-workers-ai`, 200)); + + console.log(`Polling for gen_ai.chat span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + await expect + .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.chat')?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + // Sentry stores `gen_ai.response.text` as `gen_ai.output.messages`. + await expect + .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + 'gen_ai.input.messages': expect.stringContaining('Say hi'), + 'gen_ai.output.messages': expect.stringContaining('"role":"assistant"'), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json index 657ecc4ef24a..c49bedb6b3e3 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.node.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node"] }, - "include": ["tests/**/*", "deployed-worker.ts", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] + "include": ["tests/**/*", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc index d6b15b1dbf5e..f1c0857ea5dd 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc @@ -6,6 +6,7 @@ "compatibility_date": "2026-05-20", "compatibility_flags": ["nodejs_compat"], "workers_dev": true, + "ai": { "binding": "AI" }, // Workers Logs keep the invocations of the last 7 days, so a failed CI run can still be inspected. "observability": { "enabled": true }, // Workflow names are unique per account, so deployWorker() renames it to the worker name. 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 617e904f0bf6..660ab6ae4628 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 @@ -14,7 +14,7 @@ }, "devDependencies": { "rollup": "^4.35.0", - "vitest": "^3.2.7", + "vitest": "^4.1.11", "@sentry/rollup-plugin": "^5.3.0" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts index 3ba3afae4fc8..0d9c81a7753c 100644 --- a/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts +++ b/dev-packages/e2e-tests/test-applications/debug-id-sourcemaps/tests/server.test.ts @@ -28,56 +28,52 @@ function splitFrameContext(frame: SerializedFrame): Record { }; } -test( - 'Find symbolicated event on sentry', - async ({ expect }) => { - const eventId = childProcess.execSync(`node ${path.join(__dirname, '..', 'dist', 'app.js')}`, { - encoding: 'utf-8', - }); - - console.log(`Polling for error eventId: ${eventId}`); +test('Find symbolicated event on sentry', { timeout: EVENT_POLLING_TIMEOUT }, async ({ expect }) => { + const eventId = childProcess.execSync(`node ${path.join(__dirname, '..', 'dist', 'app.js')}`, { + encoding: 'utf-8', + }); - let timedOut = false; - setTimeout(() => { - timedOut = true; - }, EVENT_POLLING_TIMEOUT); + console.log(`Polling for error eventId: ${eventId}`); - while (!timedOut) { - await new Promise(resolve => setTimeout(resolve, 2000)); // poll every two seconds - const response = await fetch(`https://sentry.io/api/0/organizations/${sentryTestOrgSlug}/eventids/${eventId}/`, { - headers: { Authorization: `Bearer ${authToken}` }, - }); + let timedOut = false; + setTimeout(() => { + timedOut = true; + }, EVENT_POLLING_TIMEOUT); - // This is org scoped, so the auth token needs `org:read` on top of the project scopes. - // That never resolves by waiting, so fail loudly rather than timing out. - if (response.status === 401 || response.status === 403) { - throw new Error( - `Event lookup was rejected with ${response.status}: ${await response.text()}. ` + - 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', - ); - } + while (!timedOut) { + await new Promise(resolve => setTimeout(resolve, 2000)); // poll every two seconds + const response = await fetch(`https://sentry.io/api/0/organizations/${sentryTestOrgSlug}/eventids/${eventId}/`, { + headers: { Authorization: `Bearer ${authToken}` }, + }); - // A 404 means the event has not landed yet and a 429 is the shared test org being rate limited. - // Both clear up by polling again. - if (!response.ok) { - expect([404, 429]).toContain(response.status); - continue; - } + // This is org scoped, so the auth token needs `org:read` on top of the project scopes. + // That never resolves by waiting, so fail loudly rather than timing out. + if (response.status === 401 || response.status === 403) { + throw new Error( + `Event lookup was rejected with ${response.status}: ${await response.text()}. ` + + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', + ); + } - const { event } = await response.json(); - const exception = event.entries.find((entry: { type: string }) => entry.type === 'exception'); - const frames: SerializedFrame[] = exception.data.values[0].stacktrace.frames; - const topFrame = frames[frames.length - 1]; + // A 404 means the event has not landed yet and a 429 is the shared test org being rate limited. + // Both clear up by polling again. + if (!response.ok) { + expect([404, 429]).toContain(response.status); + continue; + } - if (topFrame === undefined) { - throw new Error('Symbolicated event has no stack frames.'); - } + const { event } = await response.json(); + const exception = event.entries.find((entry: { type: string }) => entry.type === 'exception'); + const frames: SerializedFrame[] = exception.data.values[0].stacktrace.frames; + const topFrame = frames[frames.length - 1]; - expect(splitFrameContext(topFrame)).toMatchSnapshot(); - return; + if (topFrame === undefined) { + throw new Error('Symbolicated event has no stack frames.'); } - throw new Error('Test timed out'); - }, - { timeout: EVENT_POLLING_TIMEOUT }, -); + expect(splitFrameContext(topFrame)).toMatchSnapshot(); + return; + } + + throw new Error('Test timed out'); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore b/dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore new file mode 100644 index 000000000000..534f51704346 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/.gitignore @@ -0,0 +1,36 @@ +# prod +dist/ + +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ +.wrangler + +# env +.env +.env.production +.dev.vars + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# test +test-results + +# misc +.DS_Store diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json new file mode 100644 index 000000000000..d7748bc95ecb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/deno.json @@ -0,0 +1,11 @@ +{ + "imports": { + "@sentry/hono": "npm:@sentry/hono", + "@sentry/hono/deno": "npm:@sentry/hono/deno", + "@sentry/deno": "npm:@sentry/deno", + "@sentry/core": "npm:@sentry/core", + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "hono": "npm:hono@^4.13.1" + }, + "nodeModulesDir": "manual" +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json new file mode 100644 index 000000000000..ab3b68ce1f5d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json @@ -0,0 +1,52 @@ +{ + "name": "hono-4-legacy", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev:cf": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", + "dev:bun": "bun src/entry.bun.ts", + "dev:deno": "deno run --allow-net --allow-env --allow-read src/entry.deno.ts", + "build": "wrangler deploy --dry-run", + "test:build": "pnpm install && pnpm build", + "test:assert": "TEST_ENV=production playwright test" + }, + "dependencies": { + "@sentry/bun": "latest || *", + "@sentry/cloudflare": "latest || *", + "@sentry/deno": "latest || *", + "@sentry/hono": "latest || *", + "@sentry/node": "latest || *", + "@hono/node-server": "^2.0.5", + "hono": "^4.13.1" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@cloudflare/workers-types": "^4.20240725.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "tsx": "4.21.0", + "typescript": "^5.5.2", + "wrangler": "^4.61.0" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "assert-command": "RUNTIME=node pnpm test:assert", + "label": "hono-4-legacy (node)" + }, + { + "assert-command": "RUNTIME=bun pnpm test:assert", + "label": "hono-4-legacy (bun)" + }, + { + "assert-command": "RUNTIME=deno pnpm test:assert", + "label": "hono-4-legacy (deno)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts new file mode 100644 index 000000000000..5087b887e1f2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/playwright.config.ts @@ -0,0 +1,30 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { RUNTIME, type Runtime } from './tests/constants'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const APP_PORT = 38787; + +const startCommands: Record = { + cloudflare: `pnpm dev:cf --port ${APP_PORT}`, + node: `pnpm dev:node`, + bun: `pnpm dev:bun`, + deno: `pnpm dev:deno`, +}; + +const config = getPlaywrightConfig( + { + startCommand: startCommands[RUNTIME], + port: APP_PORT, + }, + { + workers: '100%', + retries: 0, + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts new file mode 100644 index 000000000000..6466b79717f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.bun.ts @@ -0,0 +1,25 @@ +import { Hono } from 'hono'; +import { sentry } from '@sentry/hono/bun'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +app.use( + sentry(app, { + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), + }), +); + +addRoutes(app); + +const port = Number(process.env.PORT || 38787); + +export default { + port, + fetch: app.fetch, +}; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts new file mode 100644 index 000000000000..e348dde56226 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.cloudflare.ts @@ -0,0 +1,18 @@ +import { Hono } from 'hono'; +import { sentry } from '@sentry/hono/cloudflare'; +import { addRoutes } from './routes'; + +const app = new Hono<{ Bindings: { E2E_TEST_DSN: string } }>(); + +app.use( + sentry(app, env => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', + })), +); + +addRoutes(app); + +export default app; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts new file mode 100644 index 000000000000..15bd12a74111 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.deno.ts @@ -0,0 +1,21 @@ +import { Hono } from 'hono'; +import { sentry } from '@sentry/hono/deno'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +app.use( + sentry(app, { + dsn: Deno.env.get('E2E_TEST_DSN'), + environment: 'qa', + dataCollection: {}, + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', + }), +); + +addRoutes(app); + +const port = Number(Deno.env.get('PORT') || 38787); + +Deno.serve({ port }, app.fetch); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts new file mode 100644 index 000000000000..898a92e08be4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/entry.node.ts @@ -0,0 +1,16 @@ +import { Hono } from 'hono'; +import { sentry } from '@sentry/hono/node'; +import { serve } from '@hono/node-server'; +import { addRoutes } from './routes'; + +const app = new Hono(); + +app.use(sentry(app)); + +addRoutes(app); + +const port = Number(process.env.PORT || 38787); + +serve({ fetch: app.fetch, port }, () => { + console.log(`Hono (Node) listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts new file mode 100644 index 000000000000..3d3c93040c25 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/instrument.node.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/hono/node'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts new file mode 100644 index 000000000000..9304a980dff3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/middleware.ts @@ -0,0 +1,34 @@ +import type { MiddlewareHandler } from 'hono'; + +// Defined as anonymous function expressions so the function name is inferred from the `const` +// binding. A named expression (`const middlewareA = async function middlewareA() {}`) collides with +// the binding when bundled, and Bun renames the inner function (→ `middlewareA2`), which would then +// surface as the middleware span name. The inferred name stays stable across all runtimes. +export const middlewareA: MiddlewareHandler = async function (c, next) { + // Add some delay + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); +}; + +export const middlewareB: MiddlewareHandler = async function (_c, next) { + // Add some delay + await new Promise(resolve => setTimeout(resolve, 60)); + await next(); +}; + +let failingMiddlewareCount = 0; +export const failingMiddleware: MiddlewareHandler = async function (_c, _next) { + // Each throw gets a unique suffix so the Dedupe integration doesn't collapse the identical errors + // that several tests (and their retries) trigger through this shared middleware — otherwise only the + // first would be reported and the other tests' `waitForError` would time out. Tests match on the + // stable `Middleware error` prefix. + throw new Error(`Middleware error #${(failingMiddlewareCount += 1)}`); +}; + +// Intentionally a NAMED function expression (unlike the anonymous ones above) so the named-function +// path stays covered: the span name is taken from the function's own name. Under bundled Bun the inner +// name collides with the `const` binding and is suffixed (→ `namedMiddleware2`), so the test matches on +// the `namedMiddleware` prefix rather than an exact name. +export const namedMiddleware: MiddlewareHandler = async function namedMiddleware(_c, next) { + await next(); +}; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts new file mode 100644 index 000000000000..b8f2fd96fe93 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-errors.ts @@ -0,0 +1,45 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const errorRoutes = new Hono(); + +// Middleware that throws a 5xx HTTPException (should be captured) +errorRoutes.use('/middleware-http-exception/*', async (_c, _next) => { + throw new HTTPException(503, { message: 'Service Unavailable from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception', c => c.text('should not reach')); + +// Middleware that throws a 4xx HTTPException (should NOT be captured) +errorRoutes.use('/middleware-http-exception-4xx/*', async (_c, _next) => { + throw new HTTPException(401, { message: 'Unauthorized from middleware' }); +}); + +errorRoutes.get('/middleware-http-exception-4xx', c => c.text('should not reach')); + +// Sub-app with a custom onError handler that swallows errors +const subAppWithOnError = new Hono(); + +subAppWithOnError.onError((err, c) => { + return c.text(`Handled by onError: ${err.message}`, 500); +}); + +subAppWithOnError.get('/fail', () => { + throw new Error('Error caught by custom onError'); +}); + +errorRoutes.route('/custom-on-error', subAppWithOnError); + +// Nested sub-apps: parent mounts child, child route throws +const childApp = new Hono(); + +childApp.get('/error', () => { + throw new Error('Nested child app error'); +}); + +const parentApp = new Hono(); +parentApp.route('/child', childApp); + +errorRoutes.route('/nested', parentApp); + +export { errorRoutes }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts new file mode 100644 index 000000000000..6f21c1cac33b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-middleware.ts @@ -0,0 +1,85 @@ +import { Hono } from 'hono'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from '../middleware'; + +const middlewareRoutes = new Hono(); + +middlewareRoutes.get('/named', c => c.json({ middleware: 'named' })); +middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' })); +middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' })); +middlewareRoutes.get('/error', c => c.text('should not reach')); +middlewareRoutes.get('/param/:id', c => c.json({ paramId: c.req.param('id') })); +middlewareRoutes.get('/declared', c => c.json({ middleware: 'declared' })); + +// Self-contained sub-app registering its own middleware via .use() +const subAppWithMiddleware = new Hono(); + +subAppWithMiddleware.use('/named/*', middlewareA); +subAppWithMiddleware.use('/declared/*', namedMiddleware); +subAppWithMiddleware.use('/anonymous/*', async (c, next) => { + c.header('X-Custom', 'anonymous'); + await next(); +}); +subAppWithMiddleware.use('/multi/*', middlewareA, middlewareB); +subAppWithMiddleware.use('/error/*', failingMiddleware); +subAppWithMiddleware.use('/param/*', middlewareA); + +// .all() handler (1 parameter) — should NOT be wrapped as middleware by patchRoute. +subAppWithMiddleware.all('/all-handler', async function allCatchAll(c) { + return c.json({ handler: 'all' }); +}); + +subAppWithMiddleware.route('/', middlewareRoutes); + +// Sub-app with inline middleware for different registration styles. +// patchRoute wraps non-last handlers per method+path group as middleware. +const subAppWithInlineMiddleware = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware[method]( + '/direct', + async function inlineMiddleware(_c, next) { + await next(); + }, + c => c.text(`${method} direct response`), + ); + + subAppWithInlineMiddleware[method]('/direct/separately', async function inlineSeparateMiddleware(_c, next) { + await next(); + }); + subAppWithInlineMiddleware[method]('/direct/separately', c => c.text(`${method} direct separate response`)); +}); + +// .all(): .all('/path', mw, handler) +subAppWithInlineMiddleware.all( + '/all', + async function inlineMiddlewareAll(_c, next) { + await next(); + }, + c => c.text('all response'), +); +subAppWithInlineMiddleware.all('/all/separately', async function inlineSeparateMiddlewareAll(_c, next) { + await next(); +}); +subAppWithInlineMiddleware.all('/all/separately', c => c.text('all separate response')); + +// .on() registration for each HTTP method +METHODS.forEach(method => { + subAppWithInlineMiddleware.on( + method, + '/on', + async function inlineMiddlewareOn(_c, next) { + await next(); + }, + c => c.text(`${method} on response`), + ); + + subAppWithInlineMiddleware.on(method, '/on/separately', async function inlineSeparateMiddlewareOn(_c, next) { + await next(); + }); + subAppWithInlineMiddleware.on(method, '/on/separately', c => c.text(`${method} on separate response`)); +}); + +export { middlewareRoutes, subAppWithMiddleware, subAppWithInlineMiddleware }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts new file mode 100644 index 000000000000..ed9f0a3e131b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-multi-fetch.ts @@ -0,0 +1,118 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +const ITEMS: Record = { + 'self-watering-plant': { name: 'Self-Watering Plant', stock: 5, price: 2500 }, + 'solar-powered-cyberdeck': { name: 'Solar-Powered Cyberdeck', stock: 0, price: 5000 }, +}; + +// Inventory service — standalone sub-app used as a data source. +// Mounted on the main app AND called internally via .request() from the storefront. +const inventoryApp = new Hono(); + +inventoryApp.get('/item/:productId', c => { + const productId = c.req.param('productId'); + const item = ITEMS[productId]; + if (!item) { + throw new HTTPException(404, { message: `Item ${productId} not found` }); + } + return c.json({ productId, ...item }); +}); + +inventoryApp.get('/item/:productId/stock', c => { + const productId = c.req.param('productId'); + const item = ITEMS[productId]; + if (!item) { + throw new HTTPException(404, { message: `Stock check failed: ${productId}` }); + } + return c.json({ productId, inStock: item.stock > 0, quantity: item.stock }); +}); + +// Simulates an inner-route failure (a plain 5xx Error, not an HTTPException) reached only via an +// internal .request() — used by the storefront's `/degraded` route below. Each throw gets a unique +// suffix so the Dedupe integration doesn't collapse repeats across test retries. +let dbErrorCount = 0; +inventoryApp.get('/item/:productId/db-error', () => { + throw new Error(`inventory db is down #${(dbErrorCount += 1)}`); +}); + +// Storefront service — orchestrates internal .request() calls to inventoryApp. +const storefrontApp = new Hono(); + +storefrontApp.use('/*', async function storefrontAuth(_c, next) { + await new Promise(resolve => setTimeout(resolve, 10)); + await next(); +}); + +// Single internal fetch: look up one product +storefrontApp.get('/product/:productId', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}`); + if (!res.ok) { + throw new HTTPException(404, { message: 'Product not found' }); + } + const item = await res.json(); + return c.json({ product: item, source: 'storefront' }); +}); + +// Parallel internal fetches: compare two products via Promise.all +storefrontApp.get('/compare/:productId1/:productId2', async c => { + const [res1, res2] = await Promise.all([ + inventoryApp.request(`/item/${c.req.param('productId1')}`), + inventoryApp.request(`/item/${c.req.param('productId2')}`), + ]); + if (!res1.ok || !res2.ok) { + throw new HTTPException(404, { message: 'One or more products not found' }); + } + const [item1, item2] = (await Promise.all([res1.json(), res2.json()])) as [ + Record, + Record, + ]; + return c.json({ + items: [item1, item2], + priceDifference: Math.abs(item1.price - item2.price), + }); +}); + +// Sequential chained fetches: look up item, then check its stock +storefrontApp.get('/product/:productId/availability', async c => { + const itemRes = await inventoryApp.request(`/item/${c.req.param('productId')}`); + if (!itemRes.ok) { + throw new HTTPException(404, { message: 'Product not found' }); + } + const item: Record = await itemRes.json(); + + const stockRes = await inventoryApp.request(`/item/${item.productId}/stock`); + const stock: Record = await stockRes.json(); + + return c.json({ + product: item.name, + available: stock.inStock, + quantity: stock.quantity, + }); +}); + +// Degraded response: the inner route throws, but the outer handler swallows the failed internal +// response and returns a 200 instead of propagating. The inner error should still reach Sentry (auto +// instrumentation); the outer request stays healthy. +storefrontApp.get('/product/:productId/degraded', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}/db-error`); + if (!res.ok) { + return c.json({ product: null, degraded: true }, 200); + } + return c.json({ product: await res.json() }); +}); + +// Error propagation: internal 404 causes the handler to throw a plain Error +storefrontApp.get('/product-or-throw/:productId', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}`); + if (!res.ok) { + throw new Error(`Failed to fetch product: ${c.req.param('productId')}`); + } + return c.json({ product: await res.json() }); +}); + +const multiFetchRoutes = new Hono(); +multiFetchRoutes.route('/inventory', inventoryApp); +multiFetchRoutes.route('/storefront', storefrontApp); + +export { multiFetchRoutes }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts new file mode 100644 index 000000000000..ac6fea1320cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/route-groups/test-route-patterns.ts @@ -0,0 +1,33 @@ +import { Hono } from 'hono'; + +const routePatterns = new Hono(); + +const METHODS = ['get', 'post', 'put', 'delete', 'patch'] as const; + +// Direct method registration for each HTTP method (sync handlers) +METHODS.forEach(method => { + routePatterns[method]('/', c => c.text(`${method} response`)); +}); + +// Async handler +routePatterns.get('/async', async c => { + await new Promise(resolve => setTimeout(resolve, 10)); + return c.text('async response'); +}); + +// Dedicated route for query_string test to avoid transaction name collisions +routePatterns.get('/query-test', c => c.text('query test response')); + +// Dedicated route for request data extraction tests to avoid transaction name collisions +routePatterns.get('/request-data', c => c.text('request data response')); +routePatterns.post('/request-data', c => c.text('request data response')); + +// .all() registration +routePatterns.all('/all', c => c.text('all handler response')); + +// .on() registration +METHODS.forEach(method => { + routePatterns.on(method, '/on', c => c.text(`${method} on response`)); +}); + +export { routePatterns }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts new file mode 100644 index 000000000000..008a1ca6fd53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/src/routes.ts @@ -0,0 +1,134 @@ +import { type Hono as HonoType, Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from './middleware'; +import { errorRoutes } from './route-groups/test-errors'; +import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware'; +import { multiFetchRoutes } from './route-groups/test-multi-fetch'; +import { routePatterns } from './route-groups/test-route-patterns'; + +export function addRoutes(app: HonoType<{ Bindings?: { E2E_TEST_DSN: string } }>): void { + app.get('/', c => { + return c.text('Hello Hono!'); + }); + + app.get('/test-param/:paramId', c => { + return c.json({ paramId: c.req.param('paramId') }); + }); + + app.get('/error/:cause', c => { + throw new Error('This is a test error for Sentry!', { + cause: c.req.param('cause'), + }); + }); + + app.get('/linked-error', () => { + const cause = new Error('Failure 1'); + const errorCause = new Error('Failure 2', { cause }); + throw new Error('Failure 3', { cause: errorCause }); + }); + + app.get('/http-exception/:code', c => { + // oxlint-disable-next-line typescript/no-explicit-any + const code = Number(c.req.param('code')) as any; + throw new HTTPException(code, { message: `HTTPException ${code}` }); + }); + + // Root-app middleware: registered on the patched main app instance + app.use('/test-middleware/named/*', middlewareA); + app.use('/test-middleware/anonymous/*', async (c, next) => { + c.header('X-Custom', 'anonymous'); + await next(); + }); + app.use('/test-middleware/multi/*', middlewareA, middlewareB); + app.use('/test-middleware/error/*', failingMiddleware); + app.use('/test-middleware/param/*', middlewareA); + app.use('/test-middleware/declared/*', namedMiddleware); + app.route('/test-middleware', middlewareRoutes); + + // Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching + app.route('/test-subapp-middleware', subAppWithMiddleware); + + // Inline middleware patterns: direct method, .all(), .on() with inline/separate middleware + app.route('/test-inline-middleware', subAppWithInlineMiddleware); + + // Inline middleware on the main app via HTTP method registration (not .use()). + app.get( + '/test-main-inline/get', + async function mainInlineGet(_c, next) { + await next(); + }, + c => c.text('main inline get'), + ); + app.post( + '/test-main-inline/post', + async function mainInlinePost(_c, next) { + await next(); + }, + c => c.text('main inline post'), + ); + app.all( + '/test-main-inline/all', + async function mainInlineAll(_c, next) { + await next(); + }, + c => c.text('main inline all'), + ); + app.query( + '/test-main-inline/query', + async function mainInlineQuery(_c, next) { + await next(); + }, + async c => { + const body = await c.req.json<{ value: string }>(); + return c.json({ method: c.req.method, value: body.value }); + }, + ); + + // Combined: .use() middleware + inline middleware via .get() on the same path. + app.use('/test-main-inline/combined/*', middlewareA); + app.get( + '/test-main-inline/combined/resource', + async function combinedInlineMw(_c, next) { + await next(); + }, + c => c.text('combined response'), + ); + + // Route patterns: HTTP methods, .all(), .on(), sync/async, errors + app.route('/test-routes', routePatterns); + + // Error-specific routes: onError handler, nested sub-apps, middleware HTTPException + app.route('/test-errors', errorRoutes); + + // Multi-fetch routes: storefront sub-app calls inventoryApp via .request() + app.route('/test-multi-fetch', multiFetchRoutes); + + // .basePath() with sub-app mounting via .route() + const apiSubApp = new Hono(); + apiSubApp.use(async function apiAuth(_c, next) { + await next(); + }); + apiSubApp.get('/users', c => c.json({ users: [{ id: 1, name: 'Alice' }] })); + apiSubApp.get('/users/:userId', c => c.json({ userId: c.req.param('userId') })); + + app.basePath('/test-basepath').route('/v1', apiSubApp); + + app.use(async function trailingMiddleware(_c, next) { + // Trailing middleware to make sure the route names are resolved correctly (not `/*`). + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); + }); + + // .use() on the cloned instance returned by .basePath() — the clone has its own + // .use class field, so this tests whether middleware instrumentation propagates. + app + .basePath('/test-basepath-mw') + .use(async function basepathMiddleware(_c, next) { + await new Promise(resolve => setTimeout(resolve, 50)); + await next(); + }) + .get('/hello', c => c.json({ greeting: 'world' })); + + // .get() registered on the root app after .basePath()/.route() chains + app.get('/test-late-get', c => c.json({ registered: 'after-chains' })); +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs new file mode 100644 index 000000000000..8f6b794b1827 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'hono-4-legacy', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts new file mode 100644 index 000000000000..c7ad43b6b945 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/basepath-and-late-routes.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +test.describe('basePath with sub-app routes', () => { + test('traces GET on a sub-app mounted via .basePath().route()', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-basepath/v1/users', + ); + + const response = await fetch(`${baseURL}/test-basepath/v1/users`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ users: [{ id: 1, name: 'Alice' }] }); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-basepath/v1/users'); + expect(getSpanOp(segment)).toBe('http.server'); + }); + + test('traces parameterized route under .basePath().route()', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === 'GET /test-basepath/v1/users/:userId', + ); + + const response = await fetch(`${baseURL}/test-basepath/v1/users/42`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ userId: '42' }); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-basepath/v1/users/:userId'); + expect(getSpanOp(segment)).toBe('http.server'); + }); +}); + +// TODO: this test is currently skipped because we do not yet support middleware registered on new instances (e.g. here via .basePath(..).use(...)). +test.skip('.basePath() middleware instrumentation', () => { + test('creates middleware span for .use() on .basePath() clone', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-basepath-mw/hello', + ); + + const response = await fetch(`${baseURL}/test-basepath-mw/hello`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ greeting: 'world' }); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-basepath-mw/hello', + )!; + expect(segment.name).toBe('GET /test-basepath-mw/hello'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'basepathMiddleware'); + + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + }); +}); + +test('traces .get() route registered after .basePath()/.route() chains', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /test-late-get', + ); + + const response = await fetch(`${baseURL}/test-late-get`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ registered: 'after-chains' }); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-late-get'); + expect(getSpanOp(segment)).toBe('http.server'); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts new file mode 100644 index 000000000000..2aee60fc6be8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/constants.ts @@ -0,0 +1,5 @@ +export type Runtime = 'cloudflare' | 'node' | 'bun' | 'deno'; + +export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime; + +export const APP_NAME = 'hono-4-legacy'; diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts new file mode 100644 index 000000000000..e3c44c67d4d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/errors.test.ts @@ -0,0 +1,329 @@ +import { expect, test } from '@playwright/test'; +import { + waitForError, + waitForStreamedSpan, + getSpanOp, + collectStreamedSpansUntilSegment, +} from '@sentry-internal/test-utils'; +import { APP_NAME, RUNTIME } from './constants'; + +test.describe('route handler errors', () => { + test('captures error with mechanism and trace correlation', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'This is a test error for Sentry!'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/error/'), + ); + + const response = await fetch(`${baseURL}/error/test-cause`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const segmentEvent = await segmentPromise; + + expect(segmentEvent.name).toBe('GET /error/:cause'); + + expect(errorEvent.exception?.values).toHaveLength(1); + + const exception = errorEvent.exception?.values?.[0]; + expect(exception?.value).toBe('This is a test error for Sentry!'); + expect(exception?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + + expect(errorEvent.transaction).toBe('GET /error/:cause'); + expect(errorEvent.request?.method).toBe('GET'); + expect(errorEvent.request?.url).toContain('/error/test-cause'); + expect(errorEvent.request?.headers).toBeDefined(); + + expect(errorEvent.contexts?.trace?.trace_id).toBe(segmentEvent?.trace_id); + }); + + test('captures three linked errors', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.some(exception => exception.value === 'Failure 3'); + }); + + const response = await fetch(`${baseURL}/linked-error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values).toHaveLength(3); + + const firstCause = errorEvent.exception?.values?.[0]; + expect(firstCause?.value).toBe('Failure 1'); + expect(firstCause?.mechanism).toEqual({ + exception_id: 2, + handled: true, + parent_id: 1, + source: 'cause', + type: 'chained', + }); + + const secondCause = errorEvent.exception?.values?.[1]; + expect(secondCause?.value).toBe('Failure 2'); + expect(secondCause?.mechanism).toEqual({ + exception_id: 1, + handled: true, + parent_id: 0, + source: 'cause', + type: 'chained', + }); + + const capturedError = errorEvent.exception?.values?.[2]; + expect(capturedError?.value).toBe('Failure 3'); + expect(capturedError?.mechanism).toEqual({ + exception_id: 0, + handled: false, + type: 'auto.http.hono.context_error', + }); + + expect(errorEvent.transaction).toBe('GET /linked-error'); + }); +}); + +test.describe('HTTPException errors', () => { + test('captures 5xx HTTPException', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'HTTPException 500'; + }); + + const response = await fetch(`${baseURL}/http-exception/500`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('HTTPException 500'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + }); + + // On Node/Bun, httpServerSpansIntegration drops transactions for 3xx/4xx responses (ignoreStatusCodes), so we just use a request guard. + // On Cloudflare the transaction is available, and we additionally verify its name. + [301, 302].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; + } + return false; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + (RUNTIME === 'cloudflare' + ? getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/http-exception/') + : getSpanOp(segment) === 'http.server' && segment.name === 'GET /'), + ); + + const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); + expect(response.status).toBe(code); + + if (RUNTIME !== 'cloudflare') { + // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses + await fetch(`${baseURL}/`); + } + + const segment = await segmentPromise; + + if (RUNTIME === 'cloudflare') { + expect(segment.name).toBe('GET /http-exception/:code'); + } + + expect(errorEventOccurred).toBe(false); + }); + }); + + [401, 403, 404].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; + } + return false; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + (RUNTIME === 'cloudflare' + ? getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/http-exception/') + : getSpanOp(segment) === 'http.server' && segment.name === 'GET /'), + ); + + const response = await fetch(`${baseURL}/http-exception/${code}`); + expect(response.status).toBe(code); + + if (RUNTIME !== 'cloudflare') { + // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses + await fetch(`${baseURL}/`); + } + + const segment = await segmentPromise; + + if (RUNTIME === 'cloudflare') { + expect(segment.name).toBe('GET /http-exception/:code'); + } + + expect(errorEventOccurred).toBe(false); + }); + }); +}); + +test.describe('middleware errors', () => { + test('captures 5xx HTTPException thrown in middleware with error span status', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Service Unavailable from middleware'; + }); + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception'), + ); + + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception`); + expect(response.status).toBe(503); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Service Unavailable from middleware'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.type).toBe('auto.http.hono.context_error'); + expect(errorEvent.exception?.values?.[0]?.mechanism?.handled).toBe(false); + expect(errorEvent.transaction).toBe('GET /test-errors/middleware-http-exception'); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + !!segment.name?.includes('/test-errors/middleware-http-exception'), + )!; + const middlewareSpan = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .find(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpan?.status).toBe('error'); + }); + + test('does not capture 4xx HTTPException thrown in middleware', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === 'Unauthorized from middleware') { + errorEventOccurred = true; + } + return false; + }); + + const segmentPromise = collectStreamedSpansUntilSegment(APP_NAME, segment => { + if (RUNTIME === 'cloudflare') { + return ( + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception-4xx') + ); + } + return getSpanOp(segment) === 'http.server' && segment.name === 'GET /'; + }); + + const response = await fetch(`${baseURL}/test-errors/middleware-http-exception-4xx`); + expect(response.status).toBe(401); + + if (RUNTIME !== 'cloudflare') { + await fetch(`${baseURL}/`); + } + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find(segment => { + if (!segment.is_segment) return false; + if (RUNTIME === 'cloudflare') { + return ( + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception-4xx') + ); + } + return getSpanOp(segment) === 'http.server' && segment.name === 'GET /'; + })!; + + if (RUNTIME === 'cloudflare') { + expect(segment.name).toBe('GET /test-errors/middleware-http-exception-4xx'); + + const middlewareSpan = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .find(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpan?.status).not.toBe('error'); + } + + expect(errorEventOccurred).toBe(false); + }); +}); + +test.describe('nested sub-app errors', () => { + test('captures error from nested child sub-app', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Nested child app error'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/nested/child/error'), + ); + + const response = await fetch(`${baseURL}/test-errors/nested/child/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + const segment = await segmentPromise; + + expect(segment.name).toBe('GET /test-errors/nested/child/error'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Nested child app error'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + expect(errorEvent.request?.method).toBe('GET'); + expect(errorEvent.request?.url).toContain('/test-errors/nested/child/error'); + expect(errorEvent.request?.headers).toBeDefined(); + }); +}); + +test.describe('custom onError handler', () => { + test('captures error even when onError handles the response', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Error caught by custom onError'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/custom-on-error/fail'), + ); + + const response = await fetch(`${baseURL}/test-errors/custom-on-error/fail`); + expect(response.status).toBe(500); + + const body = await response.text(); + expect(body).toContain('Handled by onError'); + + const errorEvent = await errorPromise; + const segment = await segmentPromise; + + expect(segment.name).toBe('GET /test-errors/custom-on-error/fail'); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('Error caught by custom onError'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.http.hono.context_error', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts new file mode 100644 index 000000000000..d760f12e6451 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/middleware.test.ts @@ -0,0 +1,442 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const SCENARIOS = [ + { + name: 'root app middleware', + prefix: '/test-middleware', + }, + { + name: 'sub-app middleware (route group)', + prefix: '/test-subapp-middleware', + }, +] as const; + +for (const { name, prefix } of SCENARIOS) { + test.describe(name, () => { + test('creates a span for named middleware', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/named`), + ); + + const response = await fetch(`${baseURL}${prefix}/named`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/named`), + )!; + expect(segment.name).toBe(`GET ${prefix}/named`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'middlewareA'); + + expect(middlewareSpan).toEqual( + expect.objectContaining({ + name: 'middlewareA', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.middleware.hono', type: 'string' }, + }), + }), + ); + expect(middlewareSpan?.status).not.toBe('error'); + + const durationMs = (middlewareSpan!.end_timestamp - middlewareSpan!.start_timestamp) * 1000; + expect(durationMs).toBeGreaterThanOrEqual(49); + }); + + test('creates a span for anonymous middleware', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/anonymous`), + ); + + const response = await fetch(`${baseURL}${prefix}/anonymous`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/anonymous`), + )!; + expect(segment.name).toBe(`GET ${prefix}/anonymous`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const anonymousSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === ''); + expect(anonymousSpan).toBeDefined(); + expect(anonymousSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(anonymousSpan?.status).not.toBe('error'); + }); + + test('creates a span for a named-function middleware (name from the function binding)', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`), + ); + + const response = await fetch(`${baseURL}${prefix}/declared`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`), + )!; + expect(segment.name).toBe(`GET ${prefix}/declared`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + // Named function expression: the span name comes from the function's own name. A bundler (Bun) + // may append a numeric suffix when the inner name collides with the `const` binding, so match on + // the `namedMiddleware` prefix rather than an exact name. + const middlewareSpan = spans.find( + span => getSpanOp(span) === 'middleware' && !!span.name?.match(/^namedMiddleware\d*$/), + ); + + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(middlewareSpan?.status).not.toBe('error'); + }); + + test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/multi`), + ); + + const response = await fetch(`${baseURL}${prefix}/multi`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/multi`), + )!; + expect(segment.name).toBe(`GET ${prefix}/multi`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const middlewareSpans = spans.sort((a, b) => (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0)); + + expect(middlewareSpans).toHaveLength(2); + expect(middlewareSpans[0].name).toBe('middlewareA'); + expect(middlewareSpans[1].name).toBe('middlewareB'); + + expect(middlewareSpans[0]?.parent_span_id).toBe(middlewareSpans[1]?.parent_span_id); + + // middlewareA has a 50ms delay, middlewareB has a 60ms delay + const aDurationMs = (middlewareSpans[0].end_timestamp - middlewareSpans[0]?.start_timestamp) * 1000; + const bDurationMs = (middlewareSpans[1].end_timestamp - middlewareSpans[1]?.start_timestamp) * 1000; + expect(aDurationMs).toBeGreaterThanOrEqual(49); + expect(bDurationMs).toBeGreaterThanOrEqual(59); + }); + + test('captures error thrown in middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); + }); + + const response = await fetch(`${baseURL}${prefix}/error`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toMatch(/^Middleware error/); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ + handled: false, + type: 'auto.http.hono.context_error', + }), + ); + + // The transaction name on the error event determines the culprit shown in Sentry. + expect(errorEvent.transaction).toBe(`GET ${prefix}/error`); + }); + + test('sets error status on middleware span when middleware throws', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/error`), + ); + + await fetch(`${baseURL}${prefix}/error`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/error`), + )!; + expect(segment.name).toBe(`GET ${prefix}/error`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const failingSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.status === 'error'); + + expect(failingSpan).toBeDefined(); + expect(failingSpan?.status).toBe('error'); + }); + + test('uses parameterized route in span name', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/param/`), + ); + + const response = await fetch(`${baseURL}${prefix}/param/42`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/param/`), + )!; + expect(segment.name).toBe(`GET ${prefix}/param/:id`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'middlewareA'); + expect(middlewareSpan).toBeDefined(); + }); + + test('includes request data on error events from middleware', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); + }); + + await fetch(`${baseURL}${prefix}/error`); + + const errorEvent = await errorPromise; + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`${prefix}/error`), + }), + ); + }); + }); +} + +test.describe('.all() handler in sub-app', () => { + test('does not create middleware span for .all() route handler', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-subapp-middleware/all-handler'), + ); + + const response = await fetch(`${baseURL}/test-subapp-middleware/all-handler`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ handler: 'all' }); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + !!segment.name?.includes('/test-subapp-middleware/all-handler'), + )!; + expect(segment.name).toBe('GET /test-subapp-middleware/all-handler'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + // No middleware is called for this route, so there should be no spans. + expect(spans).toEqual([]); + }); +}); + +const INLINE_PREFIX = '/test-inline-middleware'; + +const REGISTRATION_STYLES = [ + { name: 'direct method (.get())', path: '/direct' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +const MIDDLEWARE_STYLES = [ + { name: 'inline', path: '' }, + { name: 'separately registered', path: '/separately' }, +] as const; + +test.describe('inline middleware spans (sub-app)', () => { + for (const { name: regName, path: regPath } of REGISTRATION_STYLES) { + for (const { name: mwName, path: mwPath } of MIDDLEWARE_STYLES) { + test(`creates middleware span for ${mwName} middleware via ${regName}`, async ({ baseURL }) => { + const fullPath = `${INLINE_PREFIX}${regPath}${mwPath}`; + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(fullPath), + ); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(fullPath), + )!; + expect(segment.name).toBe(`GET ${fullPath}`); + + const EXPECTED_DESCRIPTIONS: Record> = { + '/direct': { '': 'inlineMiddleware', '/separately': 'inlineSeparateMiddleware' }, + '/all': { '': 'inlineMiddlewareAll', '/separately': 'inlineSeparateMiddlewareAll' }, + '/on': { '': 'inlineMiddlewareOn', '/separately': 'inlineSeparateMiddlewareOn' }, + }; + const expectedDescription = EXPECTED_DESCRIPTIONS[regPath]![mwPath]!; + + const inlineSpan = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .find(s => s.name === expectedDescription); + expect(inlineSpan).toBeDefined(); + expect(getSpanOp(inlineSpan!)).toBe('middleware'); + expect(inlineSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(inlineSpan?.status).not.toBe('error'); + }); + } + } +}); + +const MAIN_INLINE_PREFIX = '/test-main-inline'; + +const MAIN_INLINE_CASES = [ + { name: '.get()', path: '/get', method: 'GET', expectedMiddlewareName: 'mainInlineGet' }, + { name: '.post()', path: '/post', method: 'POST', expectedMiddlewareName: 'mainInlinePost' }, + { name: '.all()', path: '/all', method: 'GET', expectedMiddlewareName: 'mainInlineAll' }, +] as const; + +test.describe('inline middleware spans (main app)', () => { + test('creates middleware span for inline middleware via .query()', async ({ baseURL }) => { + const fullPath = `${MAIN_INLINE_PREFIX}/query`; + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `QUERY ${fullPath}`, + ); + + const response = await fetch(`${baseURL}${fullPath}`, { + method: 'QUERY', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 'query-body' }), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ method: 'QUERY', value: 'query-body' }); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `QUERY ${fullPath}`, + )!; + expect(segment.name).toBe(`QUERY ${fullPath}`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes['sentry.segment.name.source']?.value).toBe('route'); + expect(segment.attributes['http.request.method']?.value).toBe('QUERY'); + + const middlewareSpans = segmentSpans + .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) + .filter(span => getSpanOp(span) === 'middleware'); + expect(middlewareSpans).toHaveLength(1); + expect(middlewareSpans[0]).toEqual( + expect.objectContaining({ + name: 'mainInlineQuery', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.middleware.hono', type: 'string' }, + }), + }), + ); + }); + + MAIN_INLINE_CASES.forEach(({ name, path, method, expectedMiddlewareName }) => { + test(`creates middleware span for inline middleware via ${name}`, async ({ baseURL }) => { + const fullPath = `${MAIN_INLINE_PREFIX}${path}`; + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `${method} ${fullPath}`, + ); + + const response = await fetch(`${baseURL}${fullPath}`, { method }); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `${method} ${fullPath}`, + )!; + expect(segment.name).toBe(`${method} ${fullPath}`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const inlineSpan = spans.find(s => s.name === expectedMiddlewareName); + + expect(inlineSpan).toBeDefined(); + expect(getSpanOp(inlineSpan!)).toBe('middleware'); + expect(inlineSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(inlineSpan?.status).not.toBe('error'); + + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toHaveLength(1); + }); + }); + + test('creates spans for both .use() middleware and inline middleware via .get()', async ({ baseURL }) => { + const fullPath = `${MAIN_INLINE_PREFIX}/combined/resource`; + + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${fullPath}`, + ); + + const response = await fetch(`${baseURL}${fullPath}`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${fullPath}`, + )!; + expect(segment.name).toBe(`GET ${fullPath}`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + + expect(middlewareSpans).toHaveLength(2); + + const [spanA, spanB] = middlewareSpans.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')); + expect(spanA.name).toBe('combinedInlineMw'); + expect(getSpanOp(spanA!)).toBe('middleware'); + expect(spanA.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(spanA?.status).not.toBe('error'); + + expect(spanB.name).toBe('middlewareA'); + expect(getSpanOp(spanB!)).toBe('middleware'); + expect(spanB.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(spanB?.status).not.toBe('error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts new file mode 100644 index 000000000000..eecd5b99bca4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/multi-fetch.test.ts @@ -0,0 +1,383 @@ +import { expect, test } from '@playwright/test'; +import { + waitForError, + waitForStreamedSpan, + getSpanOp, + collectStreamedSpansUntilSegment, +} from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const STOREFRONT = '/test-multi-fetch/storefront'; +const INVENTORY = '/test-multi-fetch/inventory'; + +test.describe('multi-fetch: internal .request() calls between sub-apps', () => { + test.describe('single internal fetch', () => { + test('returns enriched product data and creates span with parameterized route', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.product).toEqual( + expect.objectContaining({ + productId: 'self-watering-plant', + name: 'Self-Watering Plant', + stock: 5, + price: 2500, + }), + ); + expect(body.source).toBe('storefront'); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/product/:productId`); + expect(getSpanOp(segment)).toBe('http.server'); + }); + + test('creates storefrontAuth middleware span', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product/:productId`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/solar-powered-cyberdeck`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId`, + )!; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const middlewareSpan = spans.find(span => getSpanOp(span) === 'middleware' && span.name === 'storefrontAuth'); + + expect(middlewareSpan).toEqual( + expect.objectContaining({ + name: 'storefrontAuth', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'middleware', type: 'string' }, + 'sentry.origin': { value: 'auto.middleware.hono', type: 'string' }, + }), + }), + ); + expect(middlewareSpan?.status).not.toBe('error'); + }); + }); + + test.describe('parallel internal fetches', () => { + test('aggregates data from two concurrent .request() calls', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/compare/:productId1/:productId2`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/compare/self-watering-plant/solar-powered-cyberdeck`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body.items).toHaveLength(2); + expect(body.items[0].productId).toBe('self-watering-plant'); + expect(body.items[1].productId).toBe('solar-powered-cyberdeck'); + expect(body.priceDifference).toBe(2500); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/compare/:productId1/:productId2`); + }); + }); + + test.describe('sequential chained fetches', () => { + test('composes data from item lookup followed by stock check', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/availability`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ product: 'Self-Watering Plant', available: true, quantity: 5 }); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/product/:productId/availability`); + expect(getSpanOp(segment)).toBe('http.server'); + }); + + test('reports out-of-stock item as unavailable', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/solar-powered-cyberdeck/availability`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual({ product: 'Solar-Powered Cyberdeck', available: false, quantity: 0 }); + + await segmentPromise; + }); + }); + + test.describe('error propagation from internal fetch', () => { + test('captures error when handler throws after failed internal .request()', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Failed to fetch product: nonexistent'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + ); + + const response = await fetch(`${baseURL}${STOREFRONT}/product-or-throw/nonexistent`); + expect(response.status).toBe(500); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toBe('Failed to fetch product: nonexistent'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(expect.objectContaining({ handled: false })); + expect(errorEvent.transaction).toBe(`GET ${STOREFRONT}/product-or-throw/:productId`); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${STOREFRONT}/product-or-throw/:productId`); + expect(segment?.status).toBe('error'); + }); + + test('error event includes request data', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Failed to fetch product: missing'; + }); + + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/missing`); + + const errorEvent = await errorPromise; + expect(errorEvent.request).toEqual( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`${STOREFRONT}/product-or-throw/missing`), + }), + ); + }); + }); + + test.describe('error inside internal fetch (degraded response)', () => { + // The manual `sentry()` middleware only instruments the main app's request lifecycle. An error + // thrown solely inside an internal sub-app `.request()` — whose failed response the outer handler + // swallows — is therefore NOT captured here, unlike the orchestrion auto-instrumentation in the + // `hono-4` app, which instruments every dispatched context. We assert only that the outer request + // stays healthy. + test('degrades to a 200 when the inner route fails', async ({ baseURL }) => { + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/degraded`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ product: null, degraded: true }); + }); + }); + + test.describe('inventory sub-app direct access', () => { + test('creates its own span when accessed directly via HTTP', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${INVENTORY}/item/:productId`, + ); + + const response = await fetch(`${baseURL}${INVENTORY}/item/self-watering-plant`); + expect(response.status).toBe(200); + + const body = await response.json(); + expect(body).toEqual(expect.objectContaining({ productId: 'self-watering-plant', name: 'Self-Watering Plant' })); + + const segment = await segmentPromise; + expect(segment.name).toBe(`GET ${INVENTORY}/item/:productId`); + expect(getSpanOp(segment)).toBe('http.server'); + }); + }); + + test.describe('trace propagation through internal .request() calls', () => { + test('single internal fetch produces an internal-request child span', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product/:productId`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId`, + )!; + const traceId = segment?.trace_id; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans.filter( + s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request', + ); + + expect(internalRequestSpans).toHaveLength(1); + expect(internalRequestSpans[0]).toEqual( + expect.objectContaining({ + trace_id: traceId, + attributes: expect.objectContaining({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.hono.internal_request', type: 'string' }, + }), + }), + ); + expect(internalRequestSpans[0].name).toContain('GET /item/self-watering-plant'); + }); + + test('parallel internal fetches produce two sibling internal-request spans', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/compare/:productId1/:productId2`, + ); + + await fetch(`${baseURL}${STOREFRONT}/compare/self-watering-plant/solar-powered-cyberdeck`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/compare/:productId1/:productId2`, + )!; + const traceId = segment?.trace_id; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans.filter( + s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request', + ); + + expect(internalRequestSpans).toHaveLength(2); + + expect(internalRequestSpans[0]?.parent_span_id).toBe(internalRequestSpans[1]?.parent_span_id); + + expect(internalRequestSpans[0]?.trace_id).toBe(traceId); + expect(internalRequestSpans[1]?.trace_id).toBe(traceId); + + expect(internalRequestSpans[0].attributes['sentry.origin']?.value).toBe('auto.http.hono.internal_request'); + expect(internalRequestSpans[1].attributes['sentry.origin']?.value).toBe('auto.http.hono.internal_request'); + }); + + test('sequential chained fetches produce two ordered internal-request spans', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/availability`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product/:productId/availability`, + )!; + const traceId = segment?.trace_id; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans + .filter(s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request') + .sort( + (a: { start_timestamp?: number }, b: { start_timestamp?: number }) => + (a.start_timestamp ?? 0) - (b.start_timestamp ?? 0), + ); + + expect(internalRequestSpans).toHaveLength(2); + + // Sequential: second span starts at or after first span ends (with tolerance for clock precision) + expect(internalRequestSpans[1].start_timestamp).toBeGreaterThanOrEqual( + internalRequestSpans[0].end_timestamp! - 0.001, + ); + + expect(internalRequestSpans[0]?.trace_id).toBe(traceId); + expect(internalRequestSpans[1]?.trace_id).toBe(traceId); + }); + + test('internal-request span has no error status for internal 4xx HTTPException', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => + getSpanOp(segment) === 'http.server' && segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/ghost`); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + )!; + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + const internalRequestSpans = spans.filter( + s => s.attributes['sentry.origin']?.value === 'auto.http.hono.internal_request', + ); + + expect(internalRequestSpans).toHaveLength(1); + expect(internalRequestSpans[0]?.status).not.toBe('error'); + }); + + test('error from failed internal fetch is correlated with the storefront trace', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return event.exception?.values?.[0]?.value === 'Failed to fetch product: ghost'; + }); + + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, + ); + + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/ghost`); + + const [errorEvent, segment] = await Promise.all([errorPromise, segmentPromise]); + + expect(errorEvent.contexts?.trace?.trace_id).toBe(segment?.trace_id); + expect(errorEvent.contexts?.trace?.span_id).toBeDefined(); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts new file mode 100644 index 000000000000..bfebb9f5b6ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/route-patterns.test.ts @@ -0,0 +1,178 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp, collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; +import { APP_NAME } from './constants'; + +const PREFIX = '/test-routes'; + +const REGISTRATION_STYLES = [ + { name: 'direct method', path: '' }, + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, +] as const; + +test.describe('HTTP methods', () => { + ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].forEach(method => { + test(`sends transaction for ${method}`, async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `${method} ${PREFIX}`, + ); + + const response = await fetch(`${baseURL}${PREFIX}`, { method }); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `${method} ${PREFIX}`, + )!; + expect(segment.name).toBe(`${method} ${PREFIX}`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); + }); + }); +}); + +test.describe('route registration styles', () => { + REGISTRATION_STYLES.forEach(({ name, path }) => { + test(`${name} sends transaction with route source`, async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}${path}`, + ); + + const response = await fetch(`${baseURL}${PREFIX}${path}`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}${path}`, + )!; + expect(segment.name).toBe(`GET ${PREFIX}${path}`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); + }); + }); + + [ + { name: '.all()', path: '/all' }, + { name: '.on()', path: '/on' }, + ].forEach(({ name, path }) => { + test(`${name} responds to POST`, async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `POST ${PREFIX}${path}`, + ); + + const response = await fetch(`${baseURL}${PREFIX}${path}`, { method: 'POST' }); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `POST ${PREFIX}${path}`, + )!; + expect(segment.name).toBe(`POST ${PREFIX}${path}`); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); + }); + }); +}); + +test.describe('request data extraction', () => { + test('includes method, url, and headers on span', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}/request-data`, + ); + + const response = await fetch(`${baseURL}${PREFIX}/request-data`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.attributes['http.request.method']?.value).toBe('GET'); + expect(segment.attributes['url.full']?.value).toContain(PREFIX); + expect(segment.attributes['http.request.header.host']).toBeDefined(); + }); + + test('includes query_string when present', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && + getSpanOp(segment) === 'http.server' && + segment.name === `GET ${PREFIX}/query-test` && + segment.attributes['url.query']?.value === 'foo=bar&baz=42', + ); + + const response = await fetch(`${baseURL}${PREFIX}/query-test?foo=bar&baz=42`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + + expect(segment.attributes['http.request.method']?.value).toBe('GET'); + expect(segment.attributes['url.full']?.value).toContain(`${PREFIX}/query-test`); + expect(segment.attributes['url.query']?.value).toBe('foo=bar&baz=42'); + }); + + test('includes request data for POST with headers', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `POST ${PREFIX}/request-data`, + ); + + const response = await fetch(`${baseURL}${PREFIX}/request-data`, { + method: 'POST', + headers: { 'X-Custom-Header': 'test-value' }, + }); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.attributes['http.request.method']?.value).toBe('POST'); + expect(segment.attributes['url.full']?.value).toContain(PREFIX); + expect(segment.attributes['http.request.header.x-custom-header']?.value).toEqual(['test-value']); + }); +}); + +test('async handler sends span', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}/async`, + ); + + const response = await fetch(`${baseURL}${PREFIX}/async`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === `GET ${PREFIX}/async`, + )!; + expect(segment.name).toBe(`GET ${PREFIX}/async`); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment.attributes?.['sentry.segment.name.source']?.value).toBe('route'); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + const middlewareSpans = spans.filter(s => getSpanOp(s) === 'middleware'); + expect(middlewareSpans).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts new file mode 100644 index 000000000000..23ed5c7837fc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tests/tracing.test.ts @@ -0,0 +1,144 @@ +import { expect, test } from '@playwright/test'; +import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; +import { APP_NAME, RUNTIME } from './constants'; + +test('sends a span for the index route', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /'); + expect(getSpanOp(segment)).toBe('http.server'); +}); + +test('sends a span for a parameterized route', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-param/'), + ); + + const response = await fetch(`${baseURL}/test-param/123`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /test-param/:paramId'); + expect(getSpanOp(segment)).toBe('http.server'); +}); + +test('attaches HTTP connection info to the server span', async ({ baseURL, page }) => { + page.on('console', msg => { + console.log(`PAGE LOG: ${msg.text()}`); + }); + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + const data = segment.attributes ?? {}; + + expect(data['client.address']?.value).toEqual(expect.any(String)); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + + if (RUNTIME !== 'deno') { + // Only exposed in `hono/deno` + expect(data['network.transport']?.value).toBeUndefined(); + } else { + expect(data['network.transport']?.value).toMatch(/tcp/); + } + + if (RUNTIME === 'node' || RUNTIME === 'bun') { + // Node (@hono/node-server) and Bun expose socket-level port and address family. + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + expect(data['network.type']?.value).toMatch(/^ipv[46]$/); + } else if (RUNTIME === 'deno') { + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + } else if (RUNTIME === 'cloudflare') { + // Cloudflare Workers expose no port, address family, or transport. + // This could change in the future and checking for the absence of these fields allows us to notice if/when that happens. + expect(data['client.port']?.value).toBeUndefined(); + expect(data['network.peer.port']?.value).toBeUndefined(); + expect(data['network.type']?.value).toBeUndefined(); + } else { + throw new Error(`No tests for runtime: ${RUNTIME}`); + } +}); + +// Regression guard against connection info attributes. +// The conninfo middleware must only *add* attributes, never replace or clear existing ones. +// These are the baseline attributes the server transaction carries *without* the conninfo feature +test("preserves the baseline server.*, client.* and network.* server span attributes that the SDK sends without Hono's conninfo", async ({ + baseURL, +}) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); + + const response = await fetch(`${baseURL}/`); + expect(response.status).toBe(200); + + const segment = await segmentPromise; + const data = segment.attributes ?? {}; + + if (RUNTIME === 'node') { + expect(data['server.address']?.value).toBe('localhost'); + expect(data['server.port']?.value).toBe(Number(new URL(baseURL!).port)); + expect(data['client.address']?.value).toEqual(expect.any(String)); + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.type']?.value).toMatch(/^ipv[46]$/); + expect(data['network.protocol.name']?.value).toBe('http'); + expect(data['network.protocol.version']?.value).toBe('1.1'); + expect(data['network.transport']?.value).toBeUndefined(); + expect(data['network.local.port']?.value).toBe(data['server.port']?.value); + expect(data['network.local.address']?.value).toEqual(expect.any(String)); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + } else if (RUNTIME === 'bun') { + expect(data['client.address']?.value).toEqual(expect.any(String)); + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + expect(data['network.type']?.value).toMatch(/^ipv[46]$/); + } else if (RUNTIME === 'cloudflare') { + expect(data['server.address']?.value).toBe('localhost'); + expect(data['client.address']?.value).toBe('::1'); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.protocol.name']?.value).toBe('http'); + expect(data['network.protocol.version']?.value).toBe('1.1'); + } else if (RUNTIME === 'deno') { + expect(data['server.address']?.value).toBe('localhost'); + expect(data['client.address']?.value).toEqual(expect.any(String)); + expect(data['client.port']?.value).toEqual(expect.any(Number)); + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); + expect(data['network.transport']?.value).toBe('tcp'); + expect(data['network.protocol.name']?.value).toBe('http'); + } else { + throw new Error(`No tests for runtime: ${RUNTIME}`); + } +}); + +test('sends a span for a route that throws', async ({ baseURL }) => { + const segmentPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/error/'), + ); + + await fetch(`${baseURL}/error/test-cause`); + + const segment = await segmentPromise; + expect(segment.name).toBe('GET /error/:cause'); + expect(getSpanOp(segment)).toBe('http.server'); + expect(segment?.status).toBe('error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json new file mode 100644 index 000000000000..3c4abeff44d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ESNext"], + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "types": ["@cloudflare/workers-types"] + } +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc new file mode 100644 index 000000000000..a3c646c03aa9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "hono-4-legacy", + "main": "src/entry.cloudflare.ts", + "compatibility_date": "2026-04-20", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts new file mode 100644 index 000000000000..fd337d063992 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/build-bun.ts @@ -0,0 +1,28 @@ +// Builds `src/entry.bun.ts` with the orchestrion `bun build` plugin, emitting `dist/entry.bun.js` +// for the server to run. The plugin injects the `orchestrion:hono:context` diagnostics +// channel into the bundled `hono`, which the `honoIntegration` default subscribes to. + +// @ts-ignore -- subpath export resolved by Bun at runtime; the package tsconfig's node module +// resolution can't see `exports` subpaths. +import { sentryBunPlugin } from '@sentry/bun/plugin'; +import { join } from 'path'; + +void (async () => { + const result = await Bun.build({ + entrypoints: [join(__dirname, 'src/entry.bun.ts')], + target: 'bun', + outdir: join(__dirname, 'dist'), + // `@sentry/bun` (and its deps) stay external, so we don't bundle the whole SDK stack. + external: ['@sentry/bun'], + plugins: [sentryBunPlugin()], + }); + + if (!result.success) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); + } + + // eslint-disable-next-line no-console + console.log('BUILD_OK'); +})(); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/deno.json b/dev-packages/e2e-tests/test-applications/hono-4/deno.json index d7748bc95ecb..7a7e681c4a82 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/deno.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/deno.json @@ -1,7 +1,5 @@ { "imports": { - "@sentry/hono": "npm:@sentry/hono", - "@sentry/hono/deno": "npm:@sentry/hono/deno", "@sentry/deno": "npm:@sentry/deno", "@sentry/core": "npm:@sentry/core", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index db3b18166812..a3fd78b9ec67 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -4,30 +4,35 @@ "version": "0.0.0", "private": true, "scripts": { - "dev:cf": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", - "dev:bun": "bun src/entry.bun.ts", - "dev:deno": "deno run --allow-net --allow-env --allow-read src/entry.deno.ts", - "build": "wrangler deploy --dry-run", - "test:build": "pnpm install && pnpm build", - "test:assert": "TEST_ENV=production playwright test" + "dev:bun": "bun run dist/entry.bun.js", + "dev:deno": "deno run --allow-net --allow-env --allow-read --preload=npm:@sentry/deno/import src/entry.deno.ts", + "dev:cloudflare": "wrangler dev --config ./dist/hono_4/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test:build": "pnpm install", + "test": "TEST_ENV=production playwright test", + "test:assert": "pnpm test:assert:node", + "test:assert:node": "RUNTIME=node pnpm test", + "test:assert:bun": "bun run build-bun.ts && RUNTIME=bun pnpm test", + "test:assert:deno": "RUNTIME=deno pnpm test", + "test:assert:cloudflare": "vite build && RUNTIME=cloudflare pnpm test" }, "dependencies": { "@sentry/bun": "latest || *", "@sentry/cloudflare": "latest || *", "@sentry/deno": "latest || *", - "@sentry/hono": "latest || *", "@sentry/node": "latest || *", "@hono/node-server": "^2.0.5", "hono": "^4.13.1" }, "devDependencies": { "@playwright/test": "~1.63.0", + "@cloudflare/vite-plugin": "^1.47.0", "@cloudflare/workers-types": "^4.20240725.0", "@sentry-internal/test-utils": "link:../../../test-utils", "tsx": "4.21.0", "typescript": "^5.5.2", - "wrangler": "^4.61.0" + "vite": "^8.1.5", + "wrangler": "^4.114.0" }, "volta": { "node": "24.15.0", @@ -36,16 +41,16 @@ "sentryTest": { "variants": [ { - "assert-command": "RUNTIME=node pnpm test:assert", - "label": "hono-4 (node)" - }, - { - "assert-command": "RUNTIME=bun pnpm test:assert", + "assert-command": "pnpm test:assert:bun", "label": "hono-4 (bun)" }, { - "assert-command": "RUNTIME=deno pnpm test:assert", + "assert-command": "pnpm test:assert:deno", "label": "hono-4 (deno)" + }, + { + "assert-command": "pnpm test:assert:cloudflare", + "label": "hono-4 (cloudflare)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts index 5087b887e1f2..b8c04df05aee 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/playwright.config.ts @@ -10,7 +10,7 @@ if (!testEnv) { const APP_PORT = 38787; const startCommands: Record = { - cloudflare: `pnpm dev:cf --port ${APP_PORT}`, + cloudflare: `pnpm dev:cloudflare --port ${APP_PORT}`, node: `pnpm dev:node`, bun: `pnpm dev:bun`, deno: `pnpm dev:deno`, diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts index e057eb78d4c5..b47f15997480 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.bun.ts @@ -1,18 +1,11 @@ +// Import the Sentry init first so `honoIntegration` is set up (and subscribed to Hono's per-request +// Context channel) before any request is handled. +import './instrument.bun'; import { Hono } from 'hono'; -import { sentry } from '@sentry/hono/bun'; import { addRoutes } from './routes'; const app = new Hono(); -app.use( - sentry(app, { - dsn: process.env.E2E_TEST_DSN, - environment: 'qa', - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', - }), -); - addRoutes(app); const port = Number(process.env.PORT || 38787); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts index e348dde56226..7fb84f59947a 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.cloudflare.ts @@ -1,18 +1,8 @@ import { Hono } from 'hono'; -import { sentry } from '@sentry/hono/cloudflare'; import { addRoutes } from './routes'; const app = new Hono<{ Bindings: { E2E_TEST_DSN: string } }>(); -app.use( - sentry(app, env => ({ - dsn: env.E2E_TEST_DSN, - environment: 'qa', - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', - })), -); - addRoutes(app); export default app; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts index 15bd12a74111..cc2411fd6cf9 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.deno.ts @@ -1,19 +1,11 @@ +// Import the Sentry init first so `honoIntegration` is set up (and subscribed to Hono's per-request +// Context channel) before any request is handled. +import './instrument.deno'; import { Hono } from 'hono'; -import { sentry } from '@sentry/hono/deno'; import { addRoutes } from './routes'; const app = new Hono(); -app.use( - sentry(app, { - dsn: Deno.env.get('E2E_TEST_DSN'), - environment: 'qa', - dataCollection: {}, - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', - }), -); - addRoutes(app); const port = Number(Deno.env.get('PORT') || 38787); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts index 898a92e08be4..c67b3a206ef2 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/entry.node.ts @@ -1,16 +1,14 @@ -import { Hono } from 'hono'; -import { sentry } from '@sentry/hono/node'; import { serve } from '@hono/node-server'; +import { Hono } from 'hono'; import { addRoutes } from './routes'; const app = new Hono(); -app.use(sentry(app)); - addRoutes(app); const port = Number(process.env.PORT || 38787); serve({ fetch: app.fetch, port }, () => { + // eslint-disable-next-line no-console console.log(`Hono (Node) listening on port ${port}`); }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts new file mode 100644 index 000000000000..9cb58d8c2aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.bun.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/bun'; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts new file mode 100644 index 000000000000..49c9d1d452f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.deno.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/deno'; + +Sentry.init({ + dsn: Deno.env.get('E2E_TEST_DSN'), + environment: 'qa', + dataCollection: {}, + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +}); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts index 82f2a3864125..5c3e586fd48b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.node.ts @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/hono/node'; +import * as Sentry from '@sentry/node'; Sentry.init({ dsn: process.env.E2E_TEST_DSN, diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts new file mode 100644 index 000000000000..d1990a0ea930 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/instrument.server.ts @@ -0,0 +1,8 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions<{ E2E_TEST_DSN: string }>(env => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', +})); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts index cc7bfae9896d..9304a980dff3 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/middleware.ts @@ -1,17 +1,34 @@ import type { MiddlewareHandler } from 'hono'; -export const middlewareA: MiddlewareHandler = async function middlewareA(c, next) { +// Defined as anonymous function expressions so the function name is inferred from the `const` +// binding. A named expression (`const middlewareA = async function middlewareA() {}`) collides with +// the binding when bundled, and Bun renames the inner function (→ `middlewareA2`), which would then +// surface as the middleware span name. The inferred name stays stable across all runtimes. +export const middlewareA: MiddlewareHandler = async function (c, next) { // Add some delay await new Promise(resolve => setTimeout(resolve, 50)); await next(); }; -export const middlewareB: MiddlewareHandler = async function middlewareB(_c, next) { +export const middlewareB: MiddlewareHandler = async function (_c, next) { // Add some delay await new Promise(resolve => setTimeout(resolve, 60)); await next(); }; -export const failingMiddleware: MiddlewareHandler = async function failingMiddleware(_c, _next) { - throw new Error('Middleware error'); +let failingMiddlewareCount = 0; +export const failingMiddleware: MiddlewareHandler = async function (_c, _next) { + // Each throw gets a unique suffix so the Dedupe integration doesn't collapse the identical errors + // that several tests (and their retries) trigger through this shared middleware — otherwise only the + // first would be reported and the other tests' `waitForError` would time out. Tests match on the + // stable `Middleware error` prefix. + throw new Error(`Middleware error #${(failingMiddlewareCount += 1)}`); +}; + +// Intentionally a NAMED function expression (unlike the anonymous ones above) so the named-function +// path stays covered: the span name is taken from the function's own name. Under bundled Bun the inner +// name collides with the `const` binding and is suffixed (→ `namedMiddleware2`), so the test matches on +// the `namedMiddleware` prefix rather than an exact name. +export const namedMiddleware: MiddlewareHandler = async function namedMiddleware(_c, next) { + await next(); }; diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts index d82201b7cdb3..6f21c1cac33b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-middleware.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { failingMiddleware, middlewareA, middlewareB } from '../middleware'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from '../middleware'; const middlewareRoutes = new Hono(); @@ -8,11 +8,13 @@ middlewareRoutes.get('/anonymous', c => c.json({ middleware: 'anonymous' })); middlewareRoutes.get('/multi', c => c.json({ middleware: 'multi' })); middlewareRoutes.get('/error', c => c.text('should not reach')); middlewareRoutes.get('/param/:id', c => c.json({ paramId: c.req.param('id') })); +middlewareRoutes.get('/declared', c => c.json({ middleware: 'declared' })); // Self-contained sub-app registering its own middleware via .use() const subAppWithMiddleware = new Hono(); subAppWithMiddleware.use('/named/*', middlewareA); +subAppWithMiddleware.use('/declared/*', namedMiddleware); subAppWithMiddleware.use('/anonymous/*', async (c, next) => { c.header('X-Custom', 'anonymous'); await next(); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts index 58eb5b504640..ed9f0a3e131b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/route-groups/test-multi-fetch.ts @@ -28,6 +28,14 @@ inventoryApp.get('/item/:productId/stock', c => { return c.json({ productId, inStock: item.stock > 0, quantity: item.stock }); }); +// Simulates an inner-route failure (a plain 5xx Error, not an HTTPException) reached only via an +// internal .request() — used by the storefront's `/degraded` route below. Each throw gets a unique +// suffix so the Dedupe integration doesn't collapse repeats across test retries. +let dbErrorCount = 0; +inventoryApp.get('/item/:productId/db-error', () => { + throw new Error(`inventory db is down #${(dbErrorCount += 1)}`); +}); + // Storefront service — orchestrates internal .request() calls to inventoryApp. const storefrontApp = new Hono(); @@ -83,6 +91,17 @@ storefrontApp.get('/product/:productId/availability', async c => { }); }); +// Degraded response: the inner route throws, but the outer handler swallows the failed internal +// response and returns a 200 instead of propagating. The inner error should still reach Sentry (auto +// instrumentation); the outer request stays healthy. +storefrontApp.get('/product/:productId/degraded', async c => { + const res = await inventoryApp.request(`/item/${c.req.param('productId')}/db-error`); + if (!res.ok) { + return c.json({ product: null, degraded: true }, 200); + } + return c.json({ product: await res.json() }); +}); + // Error propagation: internal 404 causes the handler to throw a plain Error storefrontApp.get('/product-or-throw/:productId', async c => { const res = await inventoryApp.request(`/item/${c.req.param('productId')}`); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts index b095618fb52b..008a1ca6fd53 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/src/routes.ts @@ -1,6 +1,6 @@ import { type Hono as HonoType, Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; -import { failingMiddleware, middlewareA, middlewareB } from './middleware'; +import { failingMiddleware, middlewareA, middlewareB, namedMiddleware } from './middleware'; import { errorRoutes } from './route-groups/test-errors'; import { middlewareRoutes, subAppWithInlineMiddleware, subAppWithMiddleware } from './route-groups/test-middleware'; import { multiFetchRoutes } from './route-groups/test-multi-fetch'; @@ -42,6 +42,7 @@ export function addRoutes(app: HonoType<{ Bindings?: { E2E_TEST_DSN: string } }> app.use('/test-middleware/multi/*', middlewareA, middlewareB); app.use('/test-middleware/error/*', failingMiddleware); app.use('/test-middleware/param/*', middlewareA); + app.use('/test-middleware/declared/*', namedMiddleware); app.route('/test-middleware', middlewareRoutes); // Sub-app middleware: registered on the sub-app, wrapped at mount time by route() patching diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts index c7ad43b6b945..6295dc4c9e32 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/basepath-and-late-routes.test.ts @@ -42,8 +42,7 @@ test.describe('basePath with sub-app routes', () => { }); }); -// TODO: this test is currently skipped because we do not yet support middleware registered on new instances (e.g. here via .basePath(..).use(...)). -test.skip('.basePath() middleware instrumentation', () => { +test.describe('.basePath() middleware instrumentation', () => { test('creates middleware span for .use() on .basePath() clone', async ({ baseURL }) => { const segmentPromise = collectStreamedSpansUntilSegment( APP_NAME, diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts index e3c44c67d4d6..b315233ec15b 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -5,7 +5,7 @@ import { getSpanOp, collectStreamedSpansUntilSegment, } from '@sentry-internal/test-utils'; -import { APP_NAME, RUNTIME } from './constants'; +import { APP_NAME } from './constants'; test.describe('route handler errors', () => { test('captures error with mechanism and trace correlation', async ({ baseURL }) => { @@ -103,81 +103,37 @@ test.describe('HTTPException errors', () => { }); }); - // On Node/Bun, httpServerSpansIntegration drops transactions for 3xx/4xx responses (ignoreStatusCodes), so we just use a request guard. - // On Cloudflare the transaction is available, and we additionally verify its name. - [301, 302].forEach(code => { - test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { - let errorEventOccurred = false; - - waitForError(APP_NAME, event => { - if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { - errorEventOccurred = true; - } - return false; - }); - - const segmentPromise = waitForStreamedSpan( - APP_NAME, - segment => - segment.is_segment && - (RUNTIME === 'cloudflare' - ? getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/http-exception/') - : getSpanOp(segment) === 'http.server' && segment.name === 'GET /'), - ); - - const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); - expect(response.status).toBe(code); - - if (RUNTIME !== 'cloudflare') { - // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses - await fetch(`${baseURL}/`); - } - - const segment = await segmentPromise; - - if (RUNTIME === 'cloudflare') { - expect(segment.name).toBe('GET /http-exception/:code'); + // 3xx/4xx responses must not be captured as errors. Some runtimes drop the transaction for those + // status codes (httpServerSpansIntegration's ignoreStatusCodes), so instead of waiting on the + // HTTPException route's own (possibly dropped) transaction, we wait on a defined 2xx route's + // transaction as a flush guard — that one is produced on every runtime — then assert no error was + // captured. (Parametrized route naming is covered by tracing.test.ts on 2xx routes.) + const expectHttpExceptionNotCaptured = async (baseURL: string, code: number): Promise => { + let errorEventOccurred = false; + waitForError(APP_NAME, event => { + if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { + errorEventOccurred = true; } - - expect(errorEventOccurred).toBe(false); + return false; }); - }); - [401, 403, 404].forEach(code => { - test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { - let errorEventOccurred = false; - - waitForError(APP_NAME, event => { - if (event.exception?.values?.[0]?.value === `HTTPException ${code}`) { - errorEventOccurred = true; - } - return false; - }); - - const segmentPromise = waitForStreamedSpan( - APP_NAME, - segment => - segment.is_segment && - (RUNTIME === 'cloudflare' - ? getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/http-exception/') - : getSpanOp(segment) === 'http.server' && segment.name === 'GET /'), - ); - - const response = await fetch(`${baseURL}/http-exception/${code}`); - expect(response.status).toBe(code); - - if (RUNTIME !== 'cloudflare') { - // Simple request guard for non-Cloudflare runtimes since the other transaction is dropped for 4xx responses - await fetch(`${baseURL}/`); - } + const guardPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); - const segment = await segmentPromise; + const response = await fetch(`${baseURL}/http-exception/${code}`, { redirect: 'manual' }); + expect(response.status).toBe(code); - if (RUNTIME === 'cloudflare') { - expect(segment.name).toBe('GET /http-exception/:code'); - } + await fetch(`${baseURL}/`); + await guardPromise; + + expect(errorEventOccurred).toBe(false); + }; - expect(errorEventOccurred).toBe(false); + [301, 302, 401, 403, 404].forEach(code => { + test(`does not capture ${code} HTTPException`, async ({ baseURL }) => { + await expectHttpExceptionNotCaptured(baseURL!, code); }); }); }); @@ -226,41 +182,18 @@ test.describe('middleware errors', () => { return false; }); - const segmentPromise = collectStreamedSpansUntilSegment(APP_NAME, segment => { - if (RUNTIME === 'cloudflare') { - return ( - getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception-4xx') - ); - } - return getSpanOp(segment) === 'http.server' && segment.name === 'GET /'; - }); + // Guard on a defined 2xx route's transaction (produced on every runtime) rather than the 4xx + // route's own transaction, which some runtimes drop — then assert no error was captured. + const guardPromise = waitForStreamedSpan( + APP_NAME, + segment => segment.is_segment && getSpanOp(segment) === 'http.server' && segment.name === 'GET /', + ); const response = await fetch(`${baseURL}/test-errors/middleware-http-exception-4xx`); expect(response.status).toBe(401); - if (RUNTIME !== 'cloudflare') { - await fetch(`${baseURL}/`); - } - - const segmentSpans = await segmentPromise; - const segment = segmentSpans.find(segment => { - if (!segment.is_segment) return false; - if (RUNTIME === 'cloudflare') { - return ( - getSpanOp(segment) === 'http.server' && !!segment.name?.includes('/test-errors/middleware-http-exception-4xx') - ); - } - return getSpanOp(segment) === 'http.server' && segment.name === 'GET /'; - })!; - - if (RUNTIME === 'cloudflare') { - expect(segment.name).toBe('GET /test-errors/middleware-http-exception-4xx'); - - const middlewareSpan = segmentSpans - .filter(span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id) - .find(s => getSpanOp(s) === 'middleware'); - expect(middlewareSpan?.status).not.toBe('error'); - } + await fetch(`${baseURL}/`); + await guardPromise; expect(errorEventOccurred).toBe(false); }); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts index 111ff3bfe34a..d760f12e6451 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/middleware.test.ts @@ -78,6 +78,38 @@ for (const { name, prefix } of SCENARIOS) { expect(anonymousSpan?.status).not.toBe('error'); }); + test('creates a span for a named-function middleware (name from the function binding)', async ({ baseURL }) => { + const segmentPromise = collectStreamedSpansUntilSegment( + APP_NAME, + segment => getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`), + ); + + const response = await fetch(`${baseURL}${prefix}/declared`); + expect(response.status).toBe(200); + + const segmentSpans = await segmentPromise; + const segment = segmentSpans.find( + segment => + segment.is_segment && getSpanOp(segment) === 'http.server' && !!segment.name?.includes(`${prefix}/declared`), + )!; + expect(segment.name).toBe(`GET ${prefix}/declared`); + + const spans = segmentSpans.filter( + span => !span.is_segment && span.attributes['sentry.segment.id']?.value === segment.span_id, + ); + + // Named function expression: the span name comes from the function's own name. A bundler (Bun) + // may append a numeric suffix when the inner name collides with the `const` binding, so match on + // the `namedMiddleware` prefix rather than an exact name. + const middlewareSpan = spans.find( + span => getSpanOp(span) === 'middleware' && !!span.name?.match(/^namedMiddleware\d*$/), + ); + + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.attributes['sentry.origin']?.value).toBe('auto.middleware.hono'); + expect(middlewareSpan?.status).not.toBe('error'); + }); + test('multiple middleware are sibling spans under the same parent', async ({ baseURL }) => { const segmentPromise = collectStreamedSpansUntilSegment( APP_NAME, @@ -115,14 +147,17 @@ for (const { name, prefix } of SCENARIOS) { test('captures error thrown in middleware', async ({ baseURL }) => { const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error'; + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); }); const response = await fetch(`${baseURL}${prefix}/error`); expect(response.status).toBe(500); const errorEvent = await errorPromise; - expect(errorEvent.exception?.values?.[0]?.value).toBe('Middleware error'); + expect(errorEvent.exception?.values?.[0]?.value).toMatch(/^Middleware error/); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( expect.objectContaining({ handled: false, @@ -184,7 +219,10 @@ for (const { name, prefix } of SCENARIOS) { test('includes request data on error events from middleware', async ({ baseURL }) => { const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Middleware error' && !!event.request?.url?.includes(prefix); + return ( + !!event.exception?.values?.[0]?.value?.startsWith('Middleware error') && + !!event.request?.url?.includes(prefix) + ); }); await fetch(`${baseURL}${prefix}/error`); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts index 29298c7b8f89..b98a7e6df7ee 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/multi-fetch.test.ts @@ -183,6 +183,24 @@ test.describe('multi-fetch: internal .request() calls between sub-apps', () => { }); }); + test.describe('error inside internal fetch (degraded response)', () => { + test('reports the inner-route error to Sentry even though the outer handler returns 200', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => { + return !!event.exception?.values?.[0]?.value?.startsWith('inventory db is down'); + }); + + const response = await fetch(`${baseURL}${STOREFRONT}/product/self-watering-plant/degraded`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ product: null, degraded: true }); + + const errorEvent = await errorPromise; + expect(errorEvent.exception?.values?.[0]?.value).toMatch(/^inventory db is down/); + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect.objectContaining({ handled: false, type: 'auto.http.hono.context_error' }), + ); + }); + }); + test.describe('inventory sub-app direct access', () => { test('creates its own span when accessed directly via HTTP', async ({ baseURL }) => { const segmentPromise = waitForStreamedSpan( @@ -347,8 +365,11 @@ test.describe('multi-fetch: internal .request() calls between sub-apps', () => { }); test('error from failed internal fetch is correlated with the storefront trace', async ({ baseURL }) => { + // Use a param unique to this test: the `no error status` test above fetches `/ghost` too, and its + // identical `Failed to fetch product: ghost` error would otherwise be dropped by the Dedupe + // integration, so this test's `waitForError` would never fire. const errorPromise = waitForError(APP_NAME, event => { - return event.exception?.values?.[0]?.value === 'Failed to fetch product: ghost'; + return event.exception?.values?.[0]?.value === 'Failed to fetch product: phantom'; }); const segmentPromise = waitForStreamedSpan( @@ -359,7 +380,7 @@ test.describe('multi-fetch: internal .request() calls between sub-apps', () => { segment.name === `GET ${STOREFRONT}/product-or-throw/:productId`, ); - await fetch(`${baseURL}${STOREFRONT}/product-or-throw/ghost`); + await fetch(`${baseURL}${STOREFRONT}/product-or-throw/phantom`); const [errorEvent, segment] = await Promise.all([errorPromise, segmentPromise]); diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts index 23ed5c7837fc..9350259d7e5c 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/tracing.test.ts @@ -1,6 +1,78 @@ import { expect, test } from '@playwright/test'; import { waitForStreamedSpan, getSpanOp } from '@sentry-internal/test-utils'; -import { APP_NAME, RUNTIME } from './constants'; +import { APP_NAME, RUNTIME, type Runtime } from './constants'; + +const anyString = expect.any(String) as unknown; +const anyNumber = expect.any(Number) as unknown; +const ipvType = expect.stringMatching(/^ipv[46]$/) as unknown; + +// Per-runtime `http.server` connection-info expectations. Each runtime's `getConnInfo` helper exposes +// a different set of fields, so the expected attribute values are declared here once (keyed by +// runtime) instead of branching inside the tests. `undefined` means the attribute must be absent. +// Relational checks (`network.peer.*` mirroring `client.*`) are asserted in the tests, since they +// hold on every runtime (including when both sides are absent). +// +// - `added`: attributes Hono's conninfo middleware contributes. +// - `baseline`: attributes the SDK sends independent of conninfo (regression guard that conninfo only +// adds, never clobbers). +const CONN_INFO: Record; baseline: Record }> = { + node: { + added: { + 'client.address': anyString, + 'client.port': anyNumber, + 'network.type': ipvType, + 'network.transport': undefined, + }, + baseline: { + 'server.address': 'localhost', + 'server.port': anyNumber, + 'client.port': anyNumber, + 'network.type': ipvType, + 'network.protocol.name': 'http', + 'network.protocol.version': '1.1', + 'network.transport': undefined, + 'network.local.address': anyString, + 'network.local.port': anyNumber, + }, + }, + bun: { + added: { + 'client.address': anyString, + 'client.port': anyNumber, + 'network.type': ipvType, + 'network.transport': undefined, + }, + baseline: { 'client.port': anyNumber, 'network.type': ipvType }, + }, + deno: { + // Only `hono/deno` exposes `network.transport`. + added: { 'client.address': anyString, 'client.port': anyNumber, 'network.transport': expect.stringMatching(/tcp/) }, + baseline: { + 'server.address': 'localhost', + 'client.port': anyNumber, + 'network.transport': 'tcp', + 'network.protocol.name': 'http', + }, + }, + cloudflare: { + // Cloudflare Workers expose no client address, port, address family, or transport: there is no + // `getConnInfo` on Workers and nothing else populates `client.address`, so all of these are + // absent. Asserting `undefined` here lets us notice if that ever changes. + added: { + 'client.address': undefined, + 'client.port': undefined, + 'network.type': undefined, + 'network.transport': undefined, + }, + baseline: { + 'server.address': 'localhost', + 'network.protocol.name': 'http', + 'network.protocol.version': '1.1', + }, + }, +}; + +const connInfo = CONN_INFO[RUNTIME]; test('sends a span for the index route', async ({ baseURL }) => { const segmentPromise = waitForStreamedSpan( @@ -45,33 +117,14 @@ test('attaches HTTP connection info to the server span', async ({ baseURL, page const segment = await segmentPromise; const data = segment.attributes ?? {}; - expect(data['client.address']?.value).toEqual(expect.any(String)); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - - if (RUNTIME !== 'deno') { - // Only exposed in `hono/deno` - expect(data['network.transport']?.value).toBeUndefined(); - } else { - expect(data['network.transport']?.value).toMatch(/tcp/); + for (const [key, expected] of Object.entries(connInfo.added)) { + expect(data[key]?.value).toEqual(expected); } - if (RUNTIME === 'node' || RUNTIME === 'bun') { - // Node (@hono/node-server) and Bun expose socket-level port and address family. - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - expect(data['network.type']?.value).toMatch(/^ipv[46]$/); - } else if (RUNTIME === 'deno') { - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - } else if (RUNTIME === 'cloudflare') { - // Cloudflare Workers expose no port, address family, or transport. - // This could change in the future and checking for the absence of these fields allows us to notice if/when that happens. - expect(data['client.port']?.value).toBeUndefined(); - expect(data['network.peer.port']?.value).toBeUndefined(); - expect(data['network.type']?.value).toBeUndefined(); - } else { - throw new Error(`No tests for runtime: ${RUNTIME}`); - } + // conninfo must only *add* attributes, never replace: peer mirrors client on every runtime + // (including when both are absent). + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); }); // Regression guard against connection info attributes. @@ -91,42 +144,13 @@ test("preserves the baseline server.*, client.* and network.* server span attrib const segment = await segmentPromise; const data = segment.attributes ?? {}; - if (RUNTIME === 'node') { - expect(data['server.address']?.value).toBe('localhost'); - expect(data['server.port']?.value).toBe(Number(new URL(baseURL!).port)); - expect(data['client.address']?.value).toEqual(expect.any(String)); - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.type']?.value).toMatch(/^ipv[46]$/); - expect(data['network.protocol.name']?.value).toBe('http'); - expect(data['network.protocol.version']?.value).toBe('1.1'); - expect(data['network.transport']?.value).toBeUndefined(); - expect(data['network.local.port']?.value).toBe(data['server.port']?.value); - expect(data['network.local.address']?.value).toEqual(expect.any(String)); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - } else if (RUNTIME === 'bun') { - expect(data['client.address']?.value).toEqual(expect.any(String)); - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - expect(data['network.type']?.value).toMatch(/^ipv[46]$/); - } else if (RUNTIME === 'cloudflare') { - expect(data['server.address']?.value).toBe('localhost'); - expect(data['client.address']?.value).toBe('::1'); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - expect(data['network.protocol.name']?.value).toBe('http'); - expect(data['network.protocol.version']?.value).toBe('1.1'); - } else if (RUNTIME === 'deno') { - expect(data['server.address']?.value).toBe('localhost'); - expect(data['client.address']?.value).toEqual(expect.any(String)); - expect(data['client.port']?.value).toEqual(expect.any(Number)); - expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); - expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); - expect(data['network.transport']?.value).toBe('tcp'); - expect(data['network.protocol.name']?.value).toBe('http'); - } else { - throw new Error(`No tests for runtime: ${RUNTIME}`); + for (const [key, expected] of Object.entries(connInfo.baseline)) { + expect(data[key]?.value).toEqual(expected); } + + // Relational checks that hold on every runtime (both sides absent → still equal). + expect(data['network.peer.address']?.value).toBe(data['client.address']?.value); + expect(data['network.peer.port']?.value).toBe(data['client.port']?.value); }); test('sends a span for a route that throws', async ({ baseURL }) => { diff --git a/dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts b/dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/hono-4/vite.config.ts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11-static/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-11-static/src/instrument.ts index aa4c76f13ee5..0713640263be 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11-static/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11-static/src/instrument.ts @@ -10,4 +10,6 @@ Sentry.init({ // We expect the app to send a lot of events in a short time bufferSize: 1000, }, + // The cron job throws the same error on every run, which Dedupe would drop after the first run. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/instrument.ts index 4f16ebb36d11..e1f373de1730 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-11/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/instrument.ts @@ -9,4 +9,6 @@ Sentry.init({ // We expect the app to send a lot of events in a short time bufferSize: 1000, }, + // The cron job throws the same error on every run, which Dedupe would drop after the first run. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-12/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-12/src/instrument.ts index 4f16ebb36d11..e1f373de1730 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-12/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-12/src/instrument.ts @@ -9,4 +9,6 @@ Sentry.init({ // We expect the app to send a lot of events in a short time bufferSize: 1000, }, + // The cron job throws the same error on every run, which Dedupe would drop after the first run. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-fastify/src/instrument.ts index 4f16ebb36d11..e1f373de1730 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/src/instrument.ts @@ -9,4 +9,6 @@ Sentry.init({ // We expect the app to send a lot of events in a short time bufferSize: 1000, }, + // The cron job throws the same error on every run, which Dedupe would drop after the first run. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx index a61f15ee7e3f..ed7d8d1716d8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // The awaited param puts the request id into the cache key, so a fresh id is a guaranteed miss // and the entry cannot come from a build-time fill. `children` passes through as an uncached hole. @@ -15,11 +16,14 @@ export default async function CachedMidLayout({ const { id } = await params; await new Promise(resolve => setTimeout(resolve, 100)); return ( -
-

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

- {children} -
+
+

Cached layout, dynamic page

+ +

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

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

Dynamic leaf: {Date.now()}

; + return ( + +

Dynamic leaf: {Date.now()}

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

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

+ +

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

+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx index 412587fd8f33..4b9b4cffc9bf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx @@ -1,7 +1,16 @@ import type { ReactNode } from 'react'; import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function DynamicLayout({ children }: { children: ReactNode }) { await headers(); - return
{children}
; + return ( +
+

Dynamic layout, cached leaf

+ +

request-time: {Date.now()}

+ {children} +
+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx index 2b0b77b6e3a9..7920ef335dc2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // Hard-expires after 2s while the page's cached component lives for hours, so one request can // refill the layout while hitting the component: two cached levels with different origin traces. @@ -14,11 +15,14 @@ export default async function ShortLivedLayout({ cacheLife({ revalidate: 1, expire: 2 }); const { id } = await params; return ( -
-

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

- {children} -
+
+

Mixed lifetimes

+ +

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

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

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

+ +

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

+
); } export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; await headers(); - return ; + return ( + +

request-time: {Date.now()}

+ +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx index ec8d5e21d947..606b9f01a2b2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx @@ -1,6 +1,11 @@ import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function Page() { await headers(); - return

Route a: {Date.now()}

; + return ( + +

Route a: {Date.now()}

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

Route b: {Date.now()}

; + return ( + +

Route b: {Date.now()}

+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx index 220fdcf92ca8..16fa37dbd71e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // One cache entry (keyed by id) shared by the sibling routes `a` and `b` below. export default async function SharedLayout({ @@ -14,11 +15,14 @@ export default async function SharedLayout({ const { id } = await params; await new Promise(resolve => setTimeout(resolve, 100)); return ( -
-

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

- {children} -
+
+

Shared layout across sibling routes

+ +

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

+ {children} +
+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts new file mode 100644 index 000000000000..0332efe522b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/api/use-cache-swr/route.ts @@ -0,0 +1,15 @@ +import { cacheLife } from 'next/cache'; +import type { NextRequest } from 'next/server'; + +async function getSwrValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + // After `revalidate` (2s), a read serves the stale value and triggers a background refill. + // `expire` is long so the entry stays valid for the whole test. + cacheLife({ stale: 5, revalidate: 2, expire: 300 }); + return { id, createdAt: Date.now() }; +} + +export async function GET(request: NextRequest) { + const id = request.nextUrl.searchParams.get('id') ?? 'default-id'; + return Response.json(await getSwrValue(id)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx index c8b8858f7785..c3e4050539bb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cached-sibling-components/page.tsx @@ -1,14 +1,17 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function CachedSection({ id, label }: { id: string; label: string }) { 'use cache'; cacheLife('hours'); await new Promise(resolve => setTimeout(resolve, 100)); return ( -
- {label}:{id}:{Date.now()} -
+ +
+ {label}:{id}:{Date.now()} +
+
); } @@ -18,17 +21,21 @@ async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: s // separate entries (the props are part of the cache key). const { id = 'default-id' } = await searchParams; return ( - <> + +

request-time: {Date.now()}

- +
); } export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Cached sibling components

+ Loading...}> + + +
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/layout.tsx index c8f9cee0b787..5ffd72655346 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/layout.tsx @@ -1,7 +1,12 @@ export default function Layout({ children }: { children: React.ReactNode }) { return ( - {children} + +
+ ← Cache scenarios +
+ {children} + ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx index 481d7e5e46e6..1115c4054b3f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/nested-caches/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function getSlowChangingValue(id: string): Promise<{ id: string; createdAt: number }> { 'use cache'; @@ -14,20 +15,34 @@ async function ShortLivedSection({ id }: { id: string }) { // delayed request refills the component and reads the nested entry as a hit inside that fill. cacheLife({ revalidate: 1, expire: 2 }); const nested = await getSlowChangingValue(id); - return
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
; + return ( + + +
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
+
+
+ ); } async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the // cache handler instead of serving prerendered output. const { id = 'default-id' } = await searchParams; - return ; + return ( + +

request-time: {Date.now()}

+ +
+ ); } export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Nested caches with different lifetimes

+ Loading...}> + + +
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx index 433db8283beb..bea2cf1d875f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx @@ -1,3 +1,120 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type Scenario = { name: string; href: (id: string) => string; description: string }; + +const groups: { title: string; scenarios: Scenario[] }[] = [ + { + title: 'Route handlers (JSON)', + scenarios: [ + { + name: '/api/use-cache', + href: id => `/api/use-cache?id=${id}`, + description: 'cached function that lives for hours. The second request with the same id is a hit.', + }, + { + name: '/api/use-cache-expiring', + href: id => `/api/use-cache-expiring?id=${id}`, + description: 'the entry expires after 2 seconds. Request again after 3 seconds for a refill.', + }, + { + name: '/api/use-cache-swr', + href: id => `/api/use-cache-swr?id=${id}`, + description: + 'the entry goes stale after 2 seconds. A stale read serves the old value and refills in the background.', + }, + ], + }, + { + title: 'Rendered pages', + scenarios: [ + { + name: '/use-cache-page', + href: id => `/use-cache-page?id=${id}`, + description: 'a cached data function inside a dynamic hole.', + }, + { + name: '/cached-sibling-components', + href: id => `/cached-sibling-components?id=${id}`, + description: 'two cached sibling components. Each section is its own cache entry.', + }, + { + name: '/nested-caches', + href: id => `/nested-caches?id=${id}`, + description: + 'a cached component that expires after 2 seconds and calls a cached function that lives for hours.', + }, + ], + }, + { + title: 'Nested layouts', + scenarios: [ + { + name: '/cached-mid-layout/[id]', + href: id => `/cached-mid-layout/${id}`, + description: 'a cached layout above a dynamic page.', + }, + { + name: '/dynamic-layouts/[id]/layout-cached-leaf', + href: id => `/dynamic-layouts/${id}/layout-cached-leaf`, + description: 'dynamic layouts above a cached leaf component.', + }, + { + name: '/mixed-lifetimes/[id]', + href: id => `/mixed-lifetimes/${id}`, + description: 'a cached layout that expires after 2 seconds around a cached component that lives for hours.', + }, + { + name: '/shared-layout/[id]/a', + href: id => `/shared-layout/${id}/a`, + description: 'route a fills the layout entry that routes a and b share.', + }, + { + name: '/shared-layout/[id]/b', + href: id => `/shared-layout/${id}/b`, + description: 'route b hits the layout entry that route a filled.', + }, + ], + }, + { + title: 'Pageload', + scenarios: [ + { + name: '/pageload-tracing', + href: () => '/pageload-tracing', + description: 'a prerendered shell with a dynamic hole. Check the pageload and server trace connection here.', + }, + ], + }, +]; + +// The links are plain `` elements so that every click is a full document request with its own +// server trace. `` would navigate through the client-side router instead. export default function Page() { - return

Next 16 CacheComponents test app

; + const [id, setId] = useState(''); + // Generated after hydration so the prerendered shell does not bake in one fixed id. + useEffect(() => setId(crypto.randomUUID()), []); + + return ( +
+

Next 16 Cache Components scenarios

+

+ Every link carries a random id that becomes part of the cache key. The first click fills the cache. A second + click on the same link, or a reload of the target page, hits it. Reload this page to get fresh ids. +

+ {groups.map(group => ( +
+

{group.title}

+
+
+ ))} +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx index 1e41b4436e20..3450928fe090 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/use-cache-page/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function getCachedPageData(id: string): Promise<{ id: string; createdAt: number }> { 'use cache'; @@ -10,9 +11,12 @@ async function getCachedPageData(id: string): Promise<{ id: string; createdAt: n export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Cached data function in a page

+ Loading...}> + + +
); } @@ -21,5 +25,12 @@ async function CachedContent({ searchParams }: { searchParams: Promise<{ id?: st // cache handler instead of serving prerendered output. const { id = 'default-id' } = await searchParams; const data = await getCachedPageData(id); - return
{JSON.stringify(data)}
; + return ( + +

request-time: {Date.now()}

+ +
{JSON.stringify(data)}
+
+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/components/scenarioBox.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/components/scenarioBox.tsx new file mode 100644 index 000000000000..cf2d318d64cc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/components/scenarioBox.tsx @@ -0,0 +1,34 @@ +import type { CSSProperties, ReactNode } from 'react'; + +const box: CSSProperties = { + padding: '8px 12px', + margin: '12px 0', + borderRadius: 8, +}; + +const badge: CSSProperties = { + display: 'inline-block', + fontSize: 12, + fontWeight: 600, + color: '#fff', + padding: '1px 8px', + borderRadius: 4, +}; + +export function CachedBox({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ Cached · {label} + {children} +
+ ); +} + +export function DynamicBox({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ Dynamic · {label} + {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts new file mode 100644 index 000000000000..50f88a41bdbb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// Background stale-while-revalidate refills. Target behavior: the revalidation gets its own trace, +// separate from the request trace that served the stale value. The revalidation trace links back to +// that request, and its `cache.put` becomes the `cache_origin` for future hits. + +/* + +Trace1 (fill) Trace2 (stale hit) Trace3 (hit) +|- put key:A <----link-----o get hit=true key:A |- get hit=true key:A + ^ | + | link (type TBD in the spec) | +TraceR: cache.revalidate --o (own trace) | +| | +|- put key:A <----------------------link-------------------o cache_origin + +*/ + +// Not implemented yet. Unlike cacheOriginLinks-nesting.spec.ts, this test cannot document the +// target behavior with `test.fail()`: it would wait for a `cache.revalidate` trace that never +// arrives and time out, and Playwright reports a timeout as a real failure even under +// `test.fail()`. So the test is `test.fixme()` until the SDK emits the revalidation trace. + +test('runs background revalidation in its own trace linked to the triggering request', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'SWR revalidation timing only holds in production'); + test.fixme(); + + const id = crypto.randomUUID(); + + const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-swr' && + !!transactionEvent.spans?.some(span => span.op === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const fillTx = await fillTxPromise; + + // Sleep past `revalidate` (2s) but not `expire`, so the next read serves the stale value and triggers a background refill. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const staleHitTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-swr' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + const revalidationTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'cache.revalidate'; + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const staleHitTx = await staleHitTxPromise; + + const fillPutSpan = fillTx.spans?.find(span => span.op === 'cache.put'); + expect(fillPutSpan).toBeDefined(); + + // The stale response still came from the original fill. + const staleHitGetSpan = staleHitTx.spans?.find(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true); + expect(staleHitGetSpan).toBeDefined(); + expect(staleHitGetSpan?.links).toEqual([ + { + trace_id: fillTx.contexts?.trace?.trace_id, + span_id: fillPutSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); + + // The visitor never waited for the refill, so the refill is not part of the serving trace: the + // background revalidation is its own trace, linked back to the request that triggered it. + const revalidationTx = await revalidationTxPromise; + expect(revalidationTx.contexts?.trace?.trace_id).not.toBe(staleHitTx.contexts?.trace?.trace_id); + expect(revalidationTx.contexts?.trace?.links).toEqual([ + { + trace_id: staleHitTx.contexts?.trace?.trace_id, + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + sampled: true, + // No link type for cache spans specced yet, so assert only the link target. + attributes: { 'sentry.link.type': expect.any(String) }, + }, + ]); + + const revalidationPutSpan = revalidationTx.spans?.find(span => span.op === 'cache.put'); + expect(revalidationPutSpan).toBeDefined(); + + // The revalidation's `cache.put` becomes the `cache_origin` for future hits. + const hitAfterRefillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/use-cache-swr' && + !!transactionEvent.spans?.some(span => span.op === 'cache.get' && span.data?.['cache.hit'] === true) + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const hitAfterRefillTx = await hitAfterRefillTxPromise; + + const hitAfterRefillGetSpan = hitAfterRefillTx.spans?.find( + span => span.op === 'cache.get' && span.data?.['cache.hit'] === true, + ); + expect(hitAfterRefillGetSpan).toBeDefined(); + expect(hitAfterRefillGetSpan?.links).toEqual([ + { + trace_id: revalidationTx.contexts?.trace?.trace_id, + span_id: revalidationPutSpan!.span_id, + sampled: true, + attributes: { 'sentry.link.type': 'cache_origin' }, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx index a61f15ee7e3f..ed7d8d1716d8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/cached-mid-layout/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // The awaited param puts the request id into the cache key, so a fresh id is a guaranteed miss // and the entry cannot come from a build-time fill. `children` passes through as an uncached hole. @@ -15,11 +16,14 @@ export default async function CachedMidLayout({ const { id } = await params; await new Promise(resolve => setTimeout(resolve, 100)); return ( -
-

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

- {children} -
+
+

Cached layout, dynamic page

+ +

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

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

Dynamic leaf: {Date.now()}

; + return ( + +

Dynamic leaf: {Date.now()}

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

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

+ +

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

+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx index 412587fd8f33..4b9b4cffc9bf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/dynamic-layouts/[id]/layout.tsx @@ -1,7 +1,16 @@ import type { ReactNode } from 'react'; import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function DynamicLayout({ children }: { children: ReactNode }) { await headers(); - return
{children}
; + return ( +
+

Dynamic layout, cached leaf

+ +

request-time: {Date.now()}

+ {children} +
+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx index 2b0b77b6e3a9..7920ef335dc2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/mixed-lifetimes/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // Hard-expires after 2s while the page's cached component lives for hours, so one request can // refill the layout while hitting the component: two cached levels with different origin traces. @@ -14,11 +15,14 @@ export default async function ShortLivedLayout({ cacheLife({ revalidate: 1, expire: 2 }); const { id } = await params; return ( -
-

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

- {children} -
+
+

Mixed lifetimes

+ +

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

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

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

+ +

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

+
); } export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; await headers(); - return ; + return ( + +

request-time: {Date.now()}

+ +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx index ec8d5e21d947..606b9f01a2b2 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/a/page.tsx @@ -1,6 +1,11 @@ import { headers } from 'next/headers'; +import { DynamicBox } from '@/components/scenarioBox'; export default async function Page() { await headers(); - return

Route a: {Date.now()}

; + return ( + +

Route a: {Date.now()}

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

Route b: {Date.now()}

; + return ( + +

Route b: {Date.now()}

+
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx index 220fdcf92ca8..16fa37dbd71e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/(cached-nesting)/shared-layout/[id]/layout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox } from '@/components/scenarioBox'; // One cache entry (keyed by id) shared by the sibling routes `a` and `b` below. export default async function SharedLayout({ @@ -14,11 +15,14 @@ export default async function SharedLayout({ const { id } = await params; await new Promise(resolve => setTimeout(resolve, 100)); return ( -
-

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

- {children} -
+
+

Shared layout across sibling routes

+ +

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

+ {children} +
+
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts new file mode 100644 index 000000000000..0332efe522b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/api/use-cache-swr/route.ts @@ -0,0 +1,15 @@ +import { cacheLife } from 'next/cache'; +import type { NextRequest } from 'next/server'; + +async function getSwrValue(id: string): Promise<{ id: string; createdAt: number }> { + 'use cache'; + // After `revalidate` (2s), a read serves the stale value and triggers a background refill. + // `expire` is long so the entry stays valid for the whole test. + cacheLife({ stale: 5, revalidate: 2, expire: 300 }); + return { id, createdAt: Date.now() }; +} + +export async function GET(request: NextRequest) { + const id = request.nextUrl.searchParams.get('id') ?? 'default-id'; + return Response.json(await getSwrValue(id)); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx index c8b8858f7785..c3e4050539bb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/cached-sibling-components/page.tsx @@ -1,14 +1,17 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function CachedSection({ id, label }: { id: string; label: string }) { 'use cache'; cacheLife('hours'); await new Promise(resolve => setTimeout(resolve, 100)); return ( -
- {label}:{id}:{Date.now()} -
+ +
+ {label}:{id}:{Date.now()} +
+
); } @@ -18,17 +21,21 @@ async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: s // separate entries (the props are part of the cache key). const { id = 'default-id' } = await searchParams; return ( - <> + +

request-time: {Date.now()}

- +
); } export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Cached sibling components

+ Loading...}> + + +
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx index c8f9cee0b787..5ffd72655346 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/layout.tsx @@ -1,7 +1,12 @@ export default function Layout({ children }: { children: React.ReactNode }) { return ( - {children} + +
+ ← Cache scenarios +
+ {children} + ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx index 481d7e5e46e6..1115c4054b3f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/nested-caches/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react'; import { cacheLife } from 'next/cache'; +import { CachedBox, DynamicBox } from '@/components/scenarioBox'; async function getSlowChangingValue(id: string): Promise<{ id: string; createdAt: number }> { 'use cache'; @@ -14,20 +15,34 @@ async function ShortLivedSection({ id }: { id: string }) { // delayed request refills the component and reads the nested entry as a hit inside that fill. cacheLife({ revalidate: 1, expire: 2 }); const nested = await getSlowChangingValue(id); - return
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
; + return ( + + +
{JSON.stringify({ ...nested, renderedAt: Date.now() })}
+
+
+ ); } async function DynamicContent({ searchParams }: { searchParams: Promise<{ id?: string }> }) { // Awaiting searchParams makes this hole dynamic, so every request renders it and consults the // cache handler instead of serving prerendered output. const { id = 'default-id' } = await searchParams; - return ; + return ( + +

request-time: {Date.now()}

+ +
+ ); } export default function Page({ searchParams }: { searchParams: Promise<{ id?: string }> }) { return ( - Loading...}> - - +
+

Nested caches with different lifetimes

+ Loading...}> + + +
); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx index 433db8283beb..8f7d5cf7558e 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/app/page.tsx @@ -1,3 +1,115 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type Scenario = { name: string; href: (id: string) => string; description: string }; + +const groups: { title: string; scenarios: Scenario[] }[] = [ + { + title: 'Route handlers (JSON)', + scenarios: [ + { + name: '/api/use-cache', + href: id => `/api/use-cache?id=${id}`, + description: 'cached function that lives for hours. The second request with the same id is a hit.', + }, + { + name: '/api/use-cache-expiring', + href: id => `/api/use-cache-expiring?id=${id}`, + description: 'the entry expires after 2 seconds. Request again after 3 seconds for a refill.', + }, + { + name: '/api/use-cache-swr', + href: id => `/api/use-cache-swr?id=${id}`, + description: + 'the entry goes stale after 2 seconds. A stale read serves the old value and refills in the background.', + }, + ], + }, + { + title: 'Rendered pages', + scenarios: [ + { + name: '/cached-sibling-components', + href: id => `/cached-sibling-components?id=${id}`, + description: 'two cached sibling components. Each section is its own cache entry.', + }, + { + name: '/nested-caches', + href: id => `/nested-caches?id=${id}`, + description: + 'a cached component that expires after 2 seconds and calls a cached function that lives for hours.', + }, + ], + }, + { + title: 'Nested layouts', + scenarios: [ + { + name: '/cached-mid-layout/[id]', + href: id => `/cached-mid-layout/${id}`, + description: 'a cached layout above a dynamic page.', + }, + { + name: '/dynamic-layouts/[id]/layout-cached-leaf', + href: id => `/dynamic-layouts/${id}/layout-cached-leaf`, + description: 'dynamic layouts above a cached leaf component.', + }, + { + name: '/mixed-lifetimes/[id]', + href: id => `/mixed-lifetimes/${id}`, + description: 'a cached layout that expires after 2 seconds around a cached component that lives for hours.', + }, + { + name: '/shared-layout/[id]/a', + href: id => `/shared-layout/${id}/a`, + description: 'route a fills the layout entry that routes a and b share.', + }, + { + name: '/shared-layout/[id]/b', + href: id => `/shared-layout/${id}/b`, + description: 'route b hits the layout entry that route a filled.', + }, + ], + }, + { + title: 'Pageload', + scenarios: [ + { + name: '/pageload-tracing', + href: () => '/pageload-tracing', + description: 'a prerendered shell with a dynamic hole. Check the pageload and server trace connection here.', + }, + ], + }, +]; + +// The links are plain `` elements so that every click is a full document request with its own +// server trace. `` would navigate through the client-side router instead. export default function Page() { - return

Next 16 CacheComponents test app

; + const [id, setId] = useState(''); + // Generated after hydration so the prerendered shell does not bake in one fixed id. + useEffect(() => setId(crypto.randomUUID()), []); + + return ( +
+

Next 16 Cache Components scenarios (span streaming)

+

+ Every link carries a random id that becomes part of the cache key. The first click fills the cache. A second + click on the same link, or a reload of the target page, hits it. Reload this page to get fresh ids. +

+ {groups.map(group => ( +
+

{group.title}

+
+
+ ))} +
+ ); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/components/scenarioBox.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/components/scenarioBox.tsx new file mode 100644 index 000000000000..cf2d318d64cc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/components/scenarioBox.tsx @@ -0,0 +1,34 @@ +import type { CSSProperties, ReactNode } from 'react'; + +const box: CSSProperties = { + padding: '8px 12px', + margin: '12px 0', + borderRadius: 8, +}; + +const badge: CSSProperties = { + display: 'inline-block', + fontSize: 12, + fontWeight: 600, + color: '#fff', + padding: '1px 8px', + borderRadius: 4, +}; + +export function CachedBox({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ Cached · {label} + {children} +
+ ); +} + +export function DynamicBox({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ Dynamic · {label} + {children} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts new file mode 100644 index 000000000000..b6aa21aa89c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-revalidation.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-utils'; + +// Background stale-while-revalidate refills. Target behavior: the revalidation gets its own trace, +// separate from the request trace that served the stale value. The revalidation trace links back to +// that request, and its `cache.put` becomes the `cache_origin` for future hits. + +/* + +Trace1 (fill) Trace2 (stale hit) Trace3 (hit) +|- put key:A <----link-----o get hit=true key:A |- get hit=true key:A + ^ | + | link (type TBD in the spec) | +TraceR: cache.revalidate --o (own trace) | +| | +|- put key:A <----------------------link-------------------o cache_origin + +*/ + +// Not implemented yet. Unlike cacheOriginLinks-nesting.spec.ts, this test cannot document the +// target behavior with `test.fail()`: it would wait for a `cache.revalidate` trace that never +// arrives and time out, and Playwright reports a timeout as a real failure even under +// `test.fail()`. So the test is `test.fixme()` until the SDK emits the revalidation trace. + +test('runs background revalidation in its own trace linked to the triggering request', async ({ request }) => { + test.skip(process.env.TEST_ENV !== 'production', 'SWR revalidation timing only holds in production'); + test.fixme(); + + const id = crypto.randomUUID(); + + const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-swr' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const fillSpans = await fillSpansPromise; + + // Sleep past `revalidate` (2s) but not `expire`, so the next read serves the stale value and + // triggers a background refill. + await new Promise(resolve => setTimeout(resolve, 3_000)); + + const staleHitSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-swr' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) + ); + }); + + // The visitor never waited for the refill, so the refill is not part of the serving trace: the + // background revalidation is its own trace with a `cache.revalidate` segment. + const revalidationSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => getSpanOp(span) === 'cache.revalidate' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const staleHitSpans = await staleHitSpansPromise; + + const fillPutSpan = findCacheSpan(fillSpans, 'cache.put'); + expect(fillPutSpan).toBeDefined(); + + // The stale response still came from the original fill. + const staleHitGetSpan = findCacheSpan(staleHitSpans, 'cache.get', true); + expect(staleHitGetSpan).toBeDefined(); + expect(staleHitGetSpan?.links).toEqual([ + { + trace_id: fillPutSpan!.trace_id, + span_id: fillPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); + + const revalidationSpans = await revalidationSpansPromise; + const revalidationSegment = revalidationSpans.find(span => span.is_segment && getSpanOp(span) === 'cache.revalidate'); + expect(revalidationSegment).toBeDefined(); + expect(revalidationSegment!.trace_id).not.toBe(staleHitGetSpan!.trace_id); + expect(revalidationSegment!.links).toEqual([ + { + trace_id: staleHitGetSpan!.trace_id, + span_id: expect.stringMatching(/^[0-9a-f]{16}$/), + sampled: true, + // No link type for cache spans specced yet, so assert only the link target. + attributes: { 'sentry.link.type': { value: expect.any(String), type: 'string' } }, + }, + ]); + + const revalidationPutSpan = findCacheSpan(revalidationSpans, 'cache.put'); + expect(revalidationPutSpan).toBeDefined(); + + // The revalidation's `cache.put` becomes the `cache_origin` for future hits. + const hitAfterRefillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache-swr' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get' && span.attributes['cache.hit']?.value === true) && + spansOfTrace.every(span => span.trace_id !== staleHitGetSpan!.trace_id) + ); + }); + + await request.get(`/api/use-cache-swr?id=${id}`); + const hitAfterRefillSpans = await hitAfterRefillSpansPromise; + + const hitAfterRefillGetSpan = findCacheSpan(hitAfterRefillSpans, 'cache.get', true); + expect(hitAfterRefillGetSpan).toBeDefined(); + expect(hitAfterRefillGetSpan?.links).toEqual([ + { + trace_id: revalidationPutSpan!.trace_id, + span_id: revalidationPutSpan!.span_id, + sampled: true, + attributes: CACHE_ORIGIN_LINK_ATTRIBUTES, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts new file mode 100644 index 000000000000..2704de1a11f6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/useCacheSpans.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { findCacheSpan } from './cacheOriginLinks-utils'; + +test('uses low-cardinality names for `use cache` spans', async ({ request }) => { + // A fresh id makes the request a guaranteed cache miss (the id is part of the cache key), so the + // trace contains both a `cache.get` and a `cache.put` span. + const id = crypto.randomUUID(); + + const spansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { + return ( + spansOfTrace.some(span => span.name === 'GET /api/use-cache' && span.is_segment) && + spansOfTrace.some(span => getSpanOp(span) === 'cache.get') && + spansOfTrace.some(span => getSpanOp(span) === 'cache.put') + ); + }); + + await request.get(`/api/use-cache?id=${id}`); + const spans = await spansPromise; + + // With span streaming the span name is the low-cardinality op; the key digest stays on + // `cache.key`, where Relay reads the span description from. + const getSpan = findCacheSpan(spans, 'cache.get'); + expect(getSpan).toBeDefined(); + expect(getSpan!.name).toBe('cache.get'); + const cacheKeyDigest = getSpan!.attributes['cache.key']?.value; + expect(cacheKeyDigest).toEqual([expect.stringMatching(/^[0-9a-f]{12}$/)]); + + const putSpan = findCacheSpan(spans, 'cache.put'); + expect(putSpan).toBeDefined(); + expect(putSpan!.name).toBe('cache.put'); + expect(putSpan!.attributes['cache.key']?.value).toEqual(cacheKeyDigest); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts index 71ad1709ad55..b6f2dd975b39 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/src/mastra/index.ts @@ -26,6 +26,11 @@ export const mastra = new Mastra({ port: 4111, apiRoutes: [manualRoute], }, + // No `bundler.externals` override: `mastra build` defaults to externalizing all non-workspace deps, + // so Hono (and the other instrumented modules) stay unbundled and the `--import` orchestrion hook + // can transform them in prod. In `mastra dev` the bundler inlines its own Hono-based server + // framework into the entry regardless of any `externals` config, so Hono is not + // orchestrion-instrumented in dev — the tests assert the un-routed span name there. }); /** diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts index e3aa759000cc..f287cc794217 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/manual-route.test.ts @@ -3,6 +3,12 @@ import { collectStreamedSpans, getSpanOp, SerializedStreamedSpan } from '@sentry const APP = 'node-mastra'; +// In `mastra dev`, Mastra inlines its Hono-based server into the dev bundle, so the `--import` +// orchestrion hook cannot transform Hono and the request span is not route-enriched: its name is the +// bare method and it carries no `http.route`/route name source. In prod (`mastra build`/`start`) Hono +// is external and fully instrumented, so the span is named after the matched route. +const IS_DEV = process.env.TEST_ENV === 'development'; + const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; // A plain custom route registered on the Mastra (Hono) server — see the @@ -21,19 +27,17 @@ test('wraps a custom Mastra route in an http.server span with correct attributes await res.json(); const spans = await spansPromise; - const serverSpan = spans.find(isManualRouteServerSpan); + const serverSpan = spans.find(isManualRouteServerSpan)!; expect(serverSpan).toBeDefined(); - expect(getSpanOp(serverSpan!)).toBe('http.server'); - expect(serverSpan!.name).toBe('GET'); - expect(attrValue(serverSpan!, 'http.request.method')).toBe('GET'); - expect(attrValue(serverSpan!, 'http.response.status_code')).toBe(200); - expect(String(attrValue(serverSpan!, 'url.full') ?? '')).toContain('/manual-route'); - - // Codifies current behavior: the transaction name is derived from the URL path, - // not a route pattern. Mastra serves custom routes through Hono, which Sentry - // does not route-instrument the way it does Express — so there is no `http.route` - // attribute and the name source is `url` (an Express route would give `route`). - expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('url'); - expect(attrValue(serverSpan!, 'http.route')).toBeUndefined(); + expect(getSpanOp(serverSpan)).toBe('http.server'); + expect(attrValue(serverSpan, 'http.request.method')).toBe('GET'); + expect(attrValue(serverSpan, 'http.response.status_code')).toBe(200); + expect(String(attrValue(serverSpan, 'url.full') ?? '')).toContain('/manual-route'); + expect(serverSpan.name).toBe(IS_DEV ? 'GET' : 'GET /manual-route'); + + if (!IS_DEV) { + expect(attrValue(serverSpan, 'sentry.segment.name.source')).toBe('route'); + expect(attrValue(serverSpan, 'http.route')).toBe('/manual-route'); + } }); diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts index ef68fdf82b9a..7927a8f9531a 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-mastra/tests/mastra.test.ts @@ -4,6 +4,12 @@ import { runAgentTurn } from './utils'; const APP = 'node-mastra'; +// In `mastra dev`, Mastra inlines its Hono-based server into the dev bundle, so the `--import` +// orchestrion hook cannot transform Hono and the root request span is not route-enriched: its name is +// the bare method and it carries no `http.route`/route name source. In prod (`mastra build`/`start`) +// Hono is external and fully instrumented, so the span is named after the matched route. +const IS_DEV = process.env.TEST_ENV === 'development'; + const attrValue = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; const MASTRA_ORIGIN = 'auto.ai.mastra'; @@ -34,11 +40,16 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs // across envelopes until the agent/model spans have also arrived. Tool spans are // matched by op alone (not origin), so a double-instrumented span is collected // too and caught by the assertions below. + const isGenerateServerSpan = (span: SerializedStreamedSpan): boolean => + isOp('http.server')(span) && String(attrValue(span, 'url.full') ?? '').includes('/api/agents/'); + const traceSpansPromise = collectStreamedSpans( APP, spansOfTrace => spansOfTrace.some(callsTool('get_weather')) && - ['gen_ai.invoke_agent', 'gen_ai.chat'].every(op => spansOfTrace.some(isOp(op))), + ['gen_ai.invoke_agent', 'gen_ai.chat'].every(op => spansOfTrace.some(isOp(op))) && + // The root `http.server` span ends (and streams) after its children, so wait for it too. + spansOfTrace.some(isGenerateServerSpan), ); await runAgentTurn(baseURL!, 'What is the weather in Paris?', { thread, resource: 'e2e-user' }); @@ -96,6 +107,24 @@ test('captures Mastra agent spans (invoke_agent, chat, execute_tool) with inputs expect(attrValue(invokeAgent!, 'gen_ai.conversation.id')).toBe(thread); expect(attrValue(chat!, 'gen_ai.conversation.id')).toBe(thread); expect(attrValue(executeTool!, 'gen_ai.conversation.id')).toBe(thread); + + // http.server span: Mastra serves the agent through its internal Hono server, so the incoming + // `POST /api/agents/weatherAgent/generate` request is the root `http.server` span of this trace, + // and the AI spans above are its children. + const serverSpan = traceSpans.find(isGenerateServerSpan); + expect(serverSpan).toBeDefined(); + expect(getSpanOp(serverSpan!)).toBe('http.server'); + expect(attrValue(serverSpan!, 'http.request.method')).toBe('POST'); + expect(attrValue(serverSpan!, 'http.response.status_code')).toBe(200); + expect(String(attrValue(serverSpan!, 'url.full') ?? '')).toContain('/api/agents/weatherAgent/generate'); + + if (IS_DEV) { + expect(serverSpan!.name).toBe('POST'); + } else { + expect(attrValue(serverSpan!, 'sentry.segment.name.source')).toBe('route'); + expect(attrValue(serverSpan!, 'http.route')).toMatch(/^\/api\/agents\/:[^/]+\/generate$/); + expect(serverSpan!.name).toMatch(/^POST \/api\/agents\/:[^/]+\/generate$/); + } }); test('captures a Mastra tool error as an issue and marks the tool span', async ({ baseURL }) => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.server.config.ts index f08dea23ae03..688c63d7f00c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/sentry.server.config.ts @@ -6,4 +6,6 @@ Sentry.init({ tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server debug: !!process.env.DEBUG, + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts index 626a375fe574..d954cf92b242 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/sentry.server.config.ts @@ -8,4 +8,6 @@ Sentry.init({ // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. // Leaving it `undefined` otherwise keeps the SDK's default (no provider). enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml index a50a0097e5ae..c87cae49aab6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/docker-compose.yml @@ -17,6 +17,22 @@ services: retries: 30 start_period: 10s + mongo: + image: mongo:8 + restart: always + container_name: e2e-tests-nuxt-4-mongo + ports: + - '27017:27017' + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mongosh --quiet --eval "db.adminCommand(''ping'').ok"'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + redis: image: redis:7 restart: always diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs index cb48d539c466..4d0386045cb1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/global-setup.mjs @@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { - // Start MySQL + Redis via Docker Compose. `--wait` blocks until the + // Start MySQL, MongoDB, and Redis via Docker Compose. `--wait` blocks until the // healthchecks in docker-compose.yml pass, so the app can connect immediately. execSync('docker compose up -d --wait', { cwd: __dirname, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index 9eb23775a79b..ac1ea1939999 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -23,6 +23,7 @@ "@pinia/nuxt": "^0.5.5", "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", "ioredis": "5.10.1", + "mongoose": "^9.10.2", "mysql": "^2.18.1", "nuxt": "^4.1.2" }, @@ -31,8 +32,7 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json", - "node": "22.20.0" + "extends": "../../package.json" }, "sentryTest": { "variants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts index 50ea077473eb..acce04cc9d04 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/sentry.server.config.ts @@ -7,4 +7,6 @@ Sentry.init({ // Opt into the Sentry OpenTelemetry tracer provider in the "(tracer provider)" e2e variant. // Leaving it `undefined` otherwise keeps the SDK's default (no provider). enableOpenTelemetrySetup: process.env.E2E_TEST_OTEL_SETUP === 'true' ? true : undefined, + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts new file mode 100644 index 000000000000..6a203cceaa9e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-mongoose.ts @@ -0,0 +1,19 @@ +import { defineEventHandler } from '#imports'; +import mongoose from 'mongoose'; + +const BlogPost = mongoose.model('BlogPost', new mongoose.Schema({ title: String })); + +// SCRAM-SHA-1 on purpose: this handshake lazily `require()`s the `crypto` builtin inside a try +// block, which only works when the bundled driver gets a real interop for builtin requires (#24775). +const MONGO_URL = 'mongodb://root:docker@127.0.0.1:27017/test?authSource=admin&authMechanism=SCRAM-SHA-1'; + +export default defineEventHandler(async () => { + if (mongoose.connection.readyState !== mongoose.ConnectionStates.connected) { + await mongoose.connect(MONGO_URL); + } + + await new BlogPost({ title: 'test-post' }).save(); + const found = await BlogPost.findOne({ title: 'test-post' }); + + return { title: found?.title }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts index a093dd9e8d67..5b1bccedab4d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/db-drivers.test.ts @@ -67,6 +67,46 @@ test('Instruments ioredis automatically', async ({ baseURL }) => { ); }); +// The Nitro bundle force-inlines the instrumented drivers while their CommonJS dependencies and +// Node builtins stay external, and every `require()` across that boundary needs working interop. +test('Instruments mongoose automatically', async ({ baseURL }) => { + const spansPromise = collectRequestSpans('/api/db-mongoose'); + + const response = await fetch(`${baseURL}/api/db-mongoose`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ title: 'test-post' }); + + const spans = await spansPromise; + + const rootSpan = spans.find(span => span.is_segment); + expect(rootSpan).toBeDefined(); + expect(getSpanOp(rootSpan!)).toBe('http.server'); + + const mongooseSpans = spans.filter( + span => span.attributes['sentry.origin']?.value === 'auto.db.mongoose.diagnostic_channel', + ); + expect(mongooseSpans).toHaveLength(2); + + const saveSpan = mongooseSpans.find(span => span.name === 'save blogposts'); + expect(saveSpan?.status).toBe('ok'); + expect(saveSpan?.is_segment).toBe(false); + expect(saveSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(saveSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(saveSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(saveSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(saveSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'save' }); + + const findOneSpan = mongooseSpans.find(span => span.name === 'findOne blogposts'); + expect(findOneSpan?.status).toBe('ok'); + expect(findOneSpan?.is_segment).toBe(false); + expect(findOneSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(findOneSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(findOneSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(findOneSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(findOneSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'findOne' }); + expect(findOneSpan?.attributes['db.query.text']).toEqual({ type: 'string', value: '{"title":"?"}' }); +}); + test('Instruments mysql automatically', async ({ baseURL }) => { const spansPromise = collectRequestSpans('/api/db-mysql'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml new file mode 100644 index 000000000000..a392ebd433af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/docker-compose.yml @@ -0,0 +1,16 @@ +services: + mongo: + image: mongo:8 + restart: always + container_name: e2e-tests-nuxt-5-mongo + ports: + - '27017:27017' + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: docker + healthcheck: + test: ['CMD-SHELL', 'mongosh --quiet --eval "db.adminCommand(''ping'').ok"'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs new file mode 100644 index 000000000000..fbc228dc4e55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start MongoDB via Docker Compose. `--wait` blocks until the + // healthcheck in docker-compose.yml passes, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json index 36aea2e0b856..6a38a24d8f3c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/package.json @@ -19,9 +19,6 @@ }, "//": [ "Currently, we need to install the latest version of Nitro and the Nuxt nightlies as those contain Nuxt v5", - "@nuxt/cli is pinned. The CLI nightlies register nitro runtime plugins that live inside the CLI package: https://github.com/nuxt/cli/pull/1570", - "Nuxt's impound guard blocks imports from that package in the dev server runtime, so every SSR request fails in dev mode.", - "TODO: remove the @nuxt/cli override when impound allows the CLI runtime plugins", "TODO: remove nitro from dependencies", "Nuxt generates `fetch.server.mjs` as a virtual module that imports `ofetch`, and since a virtual id is neither absolute nor on disk, Vite resolves from its `root` (the srcDir) instead of the importer's folder.", "This fails because `ofetch` is a dependency of `nuxt` rather than the app, so pnpm leaves it in `.pnpm/`", @@ -30,6 +27,7 @@ "dependencies": { "@pinia/nuxt": "^0.11.3", "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "mongoose": "^9.10.2", "ofetch": "^2.0.0-alpha.3", "nitro": "latest", "nuxt": "npm:nuxt-nightly@5x", @@ -39,17 +37,11 @@ "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils" }, - "pnpm": { - "overrides": { - "@nuxt/cli": "npm:@nuxt/cli-nightly@4.0.0-20260927-110353-6eaa0eb" - } - }, "sentryTest": { "optional": true }, "volta": { "extends": "../../package.json", - "node": "22.20.0", "pnpm": "11.10.0" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts index 80df201381ce..308691754a0b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/playwright.config.ts @@ -23,4 +23,8 @@ const config = getPlaywrightConfig({ startCommand: getStartCommand(), }); -export default config; +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts index 26519911072b..58d541b5228b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/sentry.server.config.ts @@ -4,4 +4,6 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1.0, // Capture 100% of the transactions tunnel: 'http://localhost:3031/', // proxy server + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts new file mode 100644 index 000000000000..5bed443ac90a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/db-mongoose.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; +import { defineHandler } from 'nitro'; + +const BlogPost = mongoose.model('BlogPost', new mongoose.Schema({ title: String })); + +// SCRAM-SHA-1 on purpose: this handshake lazily `require()`s the `crypto` builtin inside a try +// block, which only works when the bundled driver gets a real interop for builtin requires (#24775). +const MONGO_URL = 'mongodb://root:docker@127.0.0.1:27017/test?authSource=admin&authMechanism=SCRAM-SHA-1'; + +export default defineHandler(async () => { + if (mongoose.connection.readyState !== mongoose.ConnectionStates.connected) { + await mongoose.connect(MONGO_URL); + } + + await new BlogPost({ title: 'test-post' }).save(); + const found = await BlogPost.findOne({ title: 'test-post' }); + + return { title: found?.title }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts new file mode 100644 index 000000000000..e48c97ed1af6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/db-mongoose.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; + +// The Nitro bundle force-inlines the instrumented drivers while their CommonJS dependencies and +// Node builtins stay external, and every `require()` across that boundary needs working interop +// (#24775). mongoose covers both: `new mquery()`, and SCRAM-SHA-1 auth lazily requiring `crypto`. +async function collectRequestSpans() { + const spans = await collectStreamedSpansUntilSegment( + 'nuxt-5', + span => span.attributes['url.path']?.value === '/api/db-mongoose', + ); + const rootSpan = spans.find(span => span.is_segment && span.attributes['url.path']?.value === '/api/db-mongoose'); + + return spans.filter(span => span.trace_id === rootSpan?.trace_id); +} + +test('Instruments mongoose automatically', async ({ baseURL }) => { + const spansPromise = collectRequestSpans(); + + const response = await fetch(`${baseURL}/api/db-mongoose`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ title: 'test-post' }); + + const spans = await spansPromise; + + const mongooseSpans = spans.filter( + span => span.attributes['sentry.origin']?.value === 'auto.db.mongoose.diagnostic_channel', + ); + expect(mongooseSpans).toHaveLength(2); + + const saveSpan = mongooseSpans.find(span => span.name === 'save blogposts'); + expect(saveSpan?.status).toBe('ok'); + expect(saveSpan?.is_segment).toBe(false); + expect(saveSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(saveSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(saveSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(saveSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(saveSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'save' }); + + const findOneSpan = mongooseSpans.find(span => span.name === 'findOne blogposts'); + expect(findOneSpan?.status).toBe('ok'); + expect(findOneSpan?.is_segment).toBe(false); + expect(findOneSpan?.attributes['sentry.op']).toEqual({ type: 'string', value: 'db' }); + expect(findOneSpan?.attributes['db.system.name']).toEqual({ type: 'string', value: 'mongodb' }); + expect(findOneSpan?.attributes['db.namespace']).toEqual({ type: 'string', value: 'test' }); + expect(findOneSpan?.attributes['db.collection.name']).toEqual({ type: 'string', value: 'blogposts' }); + expect(findOneSpan?.attributes['db.operation.name']).toEqual({ type: 'string', value: 'findOne' }); + expect(findOneSpan?.attributes['db.query.text']).toEqual({ type: 'string', value: '{"title":"?"}' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs index c16240141b6d..c4bdeea732a4 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/instrument.mjs @@ -5,4 +5,6 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, tunnel: `http://localhost:3031/`, // proxy server + // Several tests trigger the same server error one after another, which Dedupe would drop. + integrations: integrations => integrations.filter(integration => integration.name !== 'Dedupe'), }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx index 738cd1515a4d..01682288ab71 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/app/entry.server.tsx @@ -1,5 +1,6 @@ import { createReadableStreamFromReadable } from '@react-router/node'; import * as Sentry from '@sentry/react-router'; +import { randomBytes } from 'node:crypto'; import { renderToPipeableStream } from 'react-dom/server'; import { ServerRouter } from 'react-router'; import { type HandleErrorFunction } from 'react-router'; @@ -11,6 +12,7 @@ const handleRequest = Sentry.createSentryHandleRequest({ ServerRouter, renderToPipeableStream, createReadableStreamFromReadable, + getNonce: () => randomBytes(16).toString('base64'), }); export default handleRequest; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/csp-nonce.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/csp-nonce.server.test.ts new file mode 100644 index 000000000000..1787bde746cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/csp-nonce.server.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; + +function getScriptNonces(html: string): string[] { + return Array.from(html.matchAll(/]*\snonce="([^"]+)"/g), match => match[1]!); +} + +test.describe('CSP nonce', () => { + test('adds the same per-request nonce to all inline scripts', async ({ request }) => { + const firstHtml = await (await request.get('/')).text(); + const secondHtml = await (await request.get('/')).text(); + + const firstNonces = getScriptNonces(firstHtml); + const secondNonces = getScriptNonces(secondHtml); + + expect(firstNonces.length).toBeGreaterThan(0); + expect(firstNonces.length).toBe((firstHtml.match(/` : ''; @@ -88,6 +88,19 @@ export function addSentryCodeToPage(options: { }; } +/** + * Prerendered pages would bake one trace id and sampling decision into the HTML for every visitor. + * + * `building` from `$app/environment` isn't available here (the SDK is loaded from node_modules at + * runtime), so we rely on `_SENTRY_SVELTEKIT_BUILDING`, which our Vite plugin sets during `vite build` + * and SvelteKit copies into the worker it prerenders in. + */ +function isSvelteKitBuilding(): boolean { + // `process` doesn't exist in every runtime (e.g. Cloudflare Workers without `nodejs_compat`) + const env = typeof process !== 'undefined' && typeof process.env === 'object' ? process.env : undefined; + return !!env?._SENTRY_SVELTEKIT_BUILDING; +} + /** * We only need to inject the fetch proxy script for SvelteKit versions < 2.16.0. * Exported only for testing. diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 0dc4dcc20c26..cff338b62ed0 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -60,6 +60,8 @@ export { isEnabled, knexIntegration, kafkaIntegration, + honoIntegration, + honoMiddleware, koaIntegration, lastEventId, linkedErrorsIntegration, diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index a390e3abf2ba..9052560af04b 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -78,6 +78,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} sentryOrchestrionPlugin({ buildTimeInstrumentation: mergedOptions.buildTimeInstrumentation, }), + makeBuildFlagPlugin(), ); const sentryVitePluginsOptions = generateVitePluginOptions(mergedOptions); @@ -108,6 +109,65 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} return sentryPlugins; } +/** + * Flags `vite build` so `sentryHandle` can skip trace meta tags on pages SvelteKit renders at build time + * (prerendered and adapter fallback pages). Kit renders them in workers that inherit `process.env`, so the + * flag must stay set until the adapter ran. Afterwards we remove it again, so it doesn't leak into e.g. a + * server started in the same process after a programmatic build. + * + * When that point is reached depends on the Kit version: + * - Kit 2 renders from the SSR build's `writeBundle` and `closeBundle` hooks. In between, it runs a nested + * client build, whose `closeBundle` fires before any page was rendered. + * - Kit 3 builds all environments via Vite's app builder and renders from `buildApp` hooks afterwards. + * Every `closeBundle` fires before that. + */ +function makeBuildFlagPlugin(): Plugin { + let usesAppBuilder = false; + let isServerBuild = false; + let isWatchMode = false; + + const clearFlag = (): void => { + delete process.env._SENTRY_SVELTEKIT_BUILDING; + }; + + // `buildApp` only exists as a plugin hook in Vite 7+ + const plugin: Plugin & { buildApp?: { order: 'post'; handler: () => void } } = { + name: 'sentry-sveltekit-build-flag', + apply: 'build', + // Together with `order: 'post'`, this makes our hooks run after Kit's (including its adapter's) + enforce: 'post', + config() { + process.env._SENTRY_SVELTEKIT_BUILDING = 'true'; + }, + configResolved(config) { + usesAppBuilder = !!config.builder; + isServerBuild = !!config.build.ssr; + // Kit re-renders pages on every rebuild in watch mode + isWatchMode = !!config.build.watch; + }, + closeBundle: { + order: 'post', + sequential: true, + handler() { + if (!usesAppBuilder && isServerBuild && !isWatchMode) { + clearFlag(); + } + }, + }, + buildApp: { + order: 'post', + handler: async () => { + // Without an app builder config, Vite 7+ calls `buildApp` hooks *before* the actual (legacy) build + if (usesAppBuilder && !isWatchMode) { + clearFlag(); + } + }, + }, + }; + + return plugin; +} + // A bare subpath (not a relative import) so this plugin's `resolveId` can intercept it. const BROWSER_TRACING_VARIANT_ID = '@sentry/sveltekit/browser-tracing-variant'; diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 0298d4f5d788..8843e63ed1e6 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -5,7 +5,7 @@ import * as SentryCore from '@sentry/core'; import { NodeClient, setCurrentClient } from '@sentry/node'; import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { addSentryCodeToPage, FETCH_PROXY_SCRIPT, @@ -514,6 +514,23 @@ describe('addSentryCodeToPage', () => { expect(transformed).toContain('${FETCH_PROXY_SCRIPT}`); }); + + describe('while SvelteKit builds the app', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("doesn't add meta tags but still adds the fetch proxy script if _SENTRY_SVELTEKIT_BUILDING is set", () => { + vi.stubEnv('_SENTRY_SVELTEKIT_BUILDING', 'true'); + + const transformPageChunk = addSentryCodeToPage({ injectFetchProxyScript: true }); + const transformed = transformPageChunk({ html, done: true }) as string; + + expect(transformed).not.toContain('${FETCH_PROXY_SCRIPT}`); + }); + }); }); describe('isFetchProxyRequired', () => { diff --git a/packages/sveltekit/test/server/integrations/http.test.ts b/packages/sveltekit/test/server/integrations/http.test.ts index 08a6fffcd06f..53e854d45441 100644 --- a/packages/sveltekit/test/server/integrations/http.test.ts +++ b/packages/sveltekit/test/server/integrations/http.test.ts @@ -1,8 +1,12 @@ import * as SentryNode from '@sentry/node'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { httpIntegration as svelteKitHttpIntegration } from '../../../src/server/integrations/http'; describe('httpIntegration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('calls the original httpIntegration with incoming request span recording disabled', () => { const sentryNodeHttpIntegration = vi.spyOn(SentryNode, 'httpIntegration'); svelteKitHttpIntegration({ breadcrumbs: false }); diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index 5221b323d056..c78099729998 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { Plugin } from 'vite'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as autoInstrument from '../../src/vite/autoInstrument'; import { generateVitePluginOptions, sentrySvelteKit } from '../../src/vite/sentryVitePlugins'; import * as sourceMaps from '../../src/vite/sourceMaps'; @@ -69,9 +69,9 @@ describe('sentrySvelteKit()', () => { expect(plugins).toBeInstanceOf(Array); // 1 kit config resolver + 1 browser-tracing variant resolver + 1 OpenTelemetry API resolver // + 1 auto instrument plugin - // + 1 orchestrion plugin + 1 global values injection plugin + 1 modified main plugin + // + 1 orchestrion plugin + 1 build flag plugin + 1 global values injection plugin + 1 modified main plugin // + 3 custom plugins - expect(plugins).toHaveLength(10); + expect(plugins).toHaveLength(11); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { @@ -88,6 +88,8 @@ describe('sentrySvelteKit()', () => { 'sentry-auto-instrumentation', // orchestrion build-time instrumentation plugin: 'sentry-orchestrion-vite', + // build flag plugin: + 'sentry-sveltekit-build-flag', // global values injection plugin: 'sentry-sveltekit-global-values-injection-plugin', // modified main plugin (writeBundle deferred to closeBundle): @@ -101,7 +103,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(5); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + expect(plugins).toHaveLength(6); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -111,7 +113,7 @@ describe('sentrySvelteKit()', () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); const instrumentPlugin = plugins[3]; - expect(plugins).toHaveLength(6); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + global values injection + expect(plugins).toHaveLength(7); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + auto instrument + orchestrion + build flag + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -120,7 +122,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(9); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + orchestrion + global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(10); // kit config resolver + browser-tracing variant resolver + OpenTelemetry API resolver + orchestrion + build flag + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -293,6 +295,94 @@ describe('OpenTelemetry API resolver plugin', () => { }); }); +describe('build flag plugin', () => { + type HookHandler = (...args: unknown[]) => void; + type BuildFlagPlugin = { + enforce: string; + config: HookHandler; + configResolved: HookHandler; + closeBundle: { order: string; sequential: boolean; handler: HookHandler }; + buildApp: { order: string; handler: HookHandler }; + }; + + beforeEach(() => { + vi.stubEnv('_SENTRY_SVELTEKIT_BUILDING', undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + async function startBuild(resolvedConfig: { builder?: object; build: { ssr?: boolean; watch?: object | null } }) { + const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); + const plugin = plugins.find(p => p.name === 'sentry-sveltekit-build-flag') as unknown as BuildFlagPlugin; + + plugin.config({}, { command: 'build', mode: 'production' }); + plugin.configResolved(resolvedConfig); + + return plugin; + } + + it('sets `_SENTRY_SVELTEKIT_BUILDING` so the prerender worker inherits it', async () => { + await startBuild({ build: { ssr: true } }); + + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + }); + + it("runs its hooks after SvelteKit's", async () => { + const plugin = await startBuild({ build: { ssr: true } }); + + expect(plugin.enforce).toBe('post'); + expect(plugin.closeBundle).toMatchObject({ order: 'post', sequential: true }); + expect(plugin.buildApp).toMatchObject({ order: 'post' }); + }); + + describe('Kit 2 (no app builder)', () => { + it('removes the flag after the SSR build', async () => { + const plugin = await startBuild({ build: { ssr: true } }); + + // Vite 7+ calls `buildApp` hooks before the legacy build even starts + plugin.buildApp.handler(); + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + + plugin.closeBundle.handler(); + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBeUndefined(); + }); + + it("keeps the flag after Kit's nested client build", async () => { + const plugin = await startBuild({ build: { ssr: false } }); + + plugin.closeBundle.handler(); + + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + }); + }); + + describe('Kit 3 (app builder)', () => { + it('removes the flag after all `buildApp` hooks ran', async () => { + const plugin = await startBuild({ builder: {}, build: { ssr: true } }); + + plugin.closeBundle.handler(); + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + + plugin.buildApp.handler(); + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBeUndefined(); + }); + }); + + it.each([ + ['Kit 2', {}], + ['Kit 3', { builder: {} }], + ])('keeps the flag in watch mode (%s)', async (_, config) => { + const plugin = await startBuild({ ...config, build: { ssr: true, watch: {} } }); + + plugin.closeBundle.handler(); + plugin.buildApp.handler(); + + expect(process.env._SENTRY_SVELTEKIT_BUILDING).toBe('true'); + }); +}); + describe('generateVitePluginOptions', () => { it('returns null if no relevant options are provided', () => { const options: SentrySvelteKitPluginOptions = {}; diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index b34dbae115dc..52ed9335a644 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -62,7 +62,7 @@ "@sentry/bundler-plugins": "11.1.0" }, "devDependencies": { - "vite": "^6.4.3" + "vite": "^8.3.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/vue/src/rootInstrumentation.ts b/packages/vue/src/rootInstrumentation.ts index 4f0e30dd2b2c..5b16a7346b4b 100644 --- a/packages/vue/src/rootInstrumentation.ts +++ b/packages/vue/src/rootInstrumentation.ts @@ -18,7 +18,8 @@ function createRootViewModel(): VueSentry { * builds where the Options API is compiled out and `app.mixin()` is a silent no-op (Nuxt 5 default). * * Vue runs all `mounted` hooks before `mount()` returns, so the wrap covers the same window as the - * mixin's root hooks. Late mounts extend neither path; the mixin's debounce timers are per component. + * mixin's root hooks. Late mounts extend the span only when mixins are used: they record render activity + * from every component, while the `app.mount()` wrap only observes the root component. */ export function instrumentAppMountWithoutMixin(app: Vue, mixins: Mixins): void { // A second wrap would duplicate the root spans (e.g. user and Nuxt SDK both add the integration). diff --git a/packages/vue/src/tracing.ts b/packages/vue/src/tracing.ts index bbcd1002a376..a6084cd58ff0 100644 --- a/packages/vue/src/tracing.ts +++ b/packages/vue/src/tracing.ts @@ -45,6 +45,7 @@ export interface VueSentry extends ViewModel { }; $_sentryRootComponentSpan?: Span; $_sentryRootComponentSpanTimer?: ReturnType; + $_sentryRootComponentSpanActivity?: number; } // Mappings from operation to corresponding lifecycle hook. @@ -59,18 +60,41 @@ const HOOKS: { [key in Operation]: Hook[] } = { update: ['beforeUpdate', 'updated'], }; -/** End the top-level component span and activity with a debounce configured using `timeout` option */ +/** + * End the root component span once no render activity happened for `timeout` (a debounce). + * + * All debounce state lives on `$root`, so hooks from every component share one timer. Each component + * only writes a timestamp (no new timer is created). So mounting any number of components uses a single timer. + */ function maybeEndRootComponentSpan(vm: VueSentry, timestamp: number, timeout: number): void { - if (vm.$_sentryRootComponentSpanTimer) { - clearTimeout(vm.$_sentryRootComponentSpanTimer); + const root = vm.$root; + root.$_sentryRootComponentSpanActivity = timestamp; + + if (!root.$_sentryRootComponentSpanTimer) { + scheduleRootComponentSpanEnd(root, timestamp, timeout); } +} + +/** + * Fires `delayMs` after the activity that scheduled it. Activity recorded in the meantime pushes + * the deadline out by the recorded gap, so the span always ends at the last activity timestamp. + */ +function scheduleRootComponentSpanEnd(root: VueSentry, activityWhenScheduled: number, delayMs: number): void { + root.$_sentryRootComponentSpanTimer = setTimeout(() => { + root.$_sentryRootComponentSpanTimer = undefined; + + const lastActivity = root.$_sentryRootComponentSpanActivity ?? activityWhenScheduled; + if (lastActivity > activityWhenScheduled) { + // activity happened after this timer was scheduled: push the deadline out + scheduleRootComponentSpanEnd(root, lastActivity, (lastActivity - activityWhenScheduled) * 1000); + return; + } - vm.$_sentryRootComponentSpanTimer = setTimeout(() => { - if (vm.$root?.$_sentryRootComponentSpan) { - vm.$root.$_sentryRootComponentSpan.end(timestamp); - vm.$root.$_sentryRootComponentSpan = undefined; + if (root.$_sentryRootComponentSpan) { + root.$_sentryRootComponentSpan.end(lastActivity); + root.$_sentryRootComponentSpan = undefined; } - }, timeout); + }, delayMs); } /** Find if the current component exists in the provided `TracingOptions.trackComponents` array option. */ diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 9f9a03aa71d4..8b5eb9de4048 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ +import type * as SentryCore from '@sentry/core'; import { spanToJSON } from '@sentry/core'; import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it as baseIt, vi } from 'vitest'; @@ -10,6 +11,13 @@ import { createApp, defineAsyncComponent, h, nextTick, ref } from 'vue'; import * as Sentry from '../../src'; import type { Options, TracingOptions } from '../../src/types'; +vi.mock('@sentry/core', async importOriginal => { + return { + ...(await importOriginal()), + timestampInSeconds: () => Date.now() / 1000, + }; +}); + const PUBLIC_DSN = 'https://username@domain/123'; const ROOT_SPAN_TIMEOUT_MS = 100; @@ -259,9 +267,8 @@ describe('tracing mixin span creation', () => { ]); }); - // `maybeEndRootComponentSpan` arms one debounce timer per component, so a late child never - // clears the root's earlier timer, and the root's timer ends the span first. The twin test in - // the disabled describe below proves the `app.mount()` wrap matches. + // The debounce only waits for recorded render activity, so a child that never mounts records + // none and cannot keep the root render span open. it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => { const { app } = createAppWithDeferredChild(); initSentry({ sdk: { app } }); @@ -274,6 +281,36 @@ describe('tracing mixin span creation', () => { ]); }); + it('extends the root render span while children keep mounting within the timeout', async ({ + uiSpans, + initSentry, + }) => { + const showChild = ref(false); + const child = { name: 'ChildComponent', render: () => h('p', 'child') }; + const app = createApp({ name: 'RootComponent', render: () => h('div', [showChild.value ? h(child) : null]) }); + initSentry({ sdk: { app } }); + const container = document.createElement('div'); + + await Sentry.startSpan({ name: 'pageload' }, async () => { + app.mount(container); + + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS - 1); + showChild.value = true; + await nextTick(); + + // The original deadline passes, but the child's mount pushed it out. + vi.advanceTimersByTime(2); + expect(uiSpans.map(span => span.op)).not.toContain(UI_RENDER_SPAN_OP); + + vi.advanceTimersByTime(ROOT_SPAN_TIMEOUT_MS + 1); + }); + + expect(uiSpans).toEqual([ + { name: 'Vue ', op: UI_MOUNT_SPAN_OP }, + { name: 'Application Render', op: UI_RENDER_SPAN_OP }, + ]); + }); + it('names UI spans after the component and preserves the original description when span streaming is enabled', ({ app, uiSpans, @@ -350,7 +387,8 @@ describe('tracing mixin span creation', () => { expect(rootInstance.$el).toBe(container.firstElementChild); }); - // Matches the mixin-path twin above: the mixin never waited for late children either. + // Unlike the mixin path, the `app.mount()` wrap only observes the root, so a late child can + // never extend the root render span here. it('ends the root render span before a deferred child mounts', ({ uiSpans, initSentry }) => { const { app } = createAppWithDeferredChild(); disableOptionsApi(app); diff --git a/packages/vue/test/tracing/tracingMixin.test.ts b/packages/vue/test/tracing/tracingMixin.test.ts index 72767c1005b6..5598c98f2f5c 100644 --- a/packages/vue/test/tracing/tracingMixin.test.ts +++ b/packages/vue/test/tracing/tracingMixin.test.ts @@ -1,9 +1,18 @@ import { getActiveSpan, startInactiveSpan } from '@sentry/browser'; +import type * as SentryCore from '@sentry/core'; import type { Mock } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_HOOKS } from '../../src/constants'; import { createTracingMixins } from '../../src/tracing'; +const clock = vi.hoisted(() => ({ now: 0 })); +vi.mock('@sentry/core', async importOriginal => { + return { + ...(await importOriginal()), + timestampInSeconds: () => clock.now, + }; +}); + vi.mock('@sentry/browser', () => { return { getActiveSpan: vi.fn(), @@ -42,6 +51,7 @@ describe('Vue Tracing Mixins', () => { beforeEach(() => { vi.clearAllMocks(); + clock.now = 0; mockRootInstance = { $root: null, @@ -148,6 +158,53 @@ describe('Vue Tracing Mixins', () => { ); }); + describe('Root Span Debounce', () => { + it('arms a single shared timer on the root for any number of untracked components', () => { + const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 }); + + mixins.beforeMount.call(mockRootInstance); + for (let i = 0; i < 100; i++) { + const child = { $root: mockRootInstance, componentName: `ChildComponent${i}` }; + mixins.beforeMount.call(child); + mixins.mounted.call(child); + } + + expect(vi.getTimerCount()).toBe(1); + }); + + it('ends the root span at the last recorded activity, pushing the deadline out for late activity', () => { + const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 }); + clock.now = 10; + mixins.beforeMount.call(mockRootInstance); + const rootSpan = mockRootInstance.$_sentryRootComponentSpan; + + clock.now = 10.8; + mixins.mounted.call(mockVueInstance); + + vi.advanceTimersByTime(1000); + expect(rootSpan.end).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(800); + expect(rootSpan.end).toHaveBeenCalledWith(10.8); + }); + + it('records no span when a component mounts after the root span already ended', () => { + const mixins = createTracingMixins({ trackComponents: false, timeout: 1000 }); + mixins.beforeMount.call(mockRootInstance); + const rootSpan = mockRootInstance.$_sentryRootComponentSpan; + vi.advanceTimersByTime(1001); + expect(rootSpan.end).toHaveBeenCalledTimes(1); + + clock.now = 5; + mixins.beforeMount.call(mockVueInstance); + mixins.mounted.call(mockVueInstance); + vi.advanceTimersByTime(1001); + + expect(rootSpan.end).toHaveBeenCalledTimes(1); + expect(mockRootInstance.$_sentryRootComponentSpan).toBeUndefined(); + }); + }); + describe('Component Span Lifecycle', () => { it('should create and end spans correctly through lifecycle hooks', () => { const mixins = createTracingMixins({ trackComponents: true }); diff --git a/vite/vite.config.ts b/vite/vite.config.ts index a22ca23e42f1..5c6e36c55c0d 100644 --- a/vite/vite.config.ts +++ b/vite/vite.config.ts @@ -5,8 +5,10 @@ const STRIP_ESM_MARKERS = /[ \t]*\/\*! rollup-include-esm-only(?:-end)? \*\/[ \t // Mirrors the ESM path of makeEsmCjsReplacePlugin from rollup-utils. Vitest // transforms TypeScript source to ESM so tests see only the ESM branch. +// Runs before TS transpilation, which can move the marker comments. const esmOnlyPlugin = { name: 'vitest-esm-only', + enforce: 'pre' as const, transform(code: string) { if (!code.includes('rollup-include-')) return null; return { code: code.replace(REMOVE_CJS_BLOCK, '').replace(STRIP_ESM_MARKERS, '') }; diff --git a/yarn.lock b/yarn.lock index 2796b4ef923e..004333c9dcd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -190,7 +190,7 @@ "@jridgewell/gen-mapping" "^0.1.0" "@jridgewell/trace-mapping" "^0.3.9" -"@ampproject/remapping@^2.1.0", "@ampproject/remapping@^2.2.1", "@ampproject/remapping@^2.3.0": +"@ampproject/remapping@^2.1.0", "@ampproject/remapping@^2.2.1": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== @@ -3549,14 +3549,6 @@ source-map-url "^0.4.1" terser "^5.7.0" -"@emnapi/core@1.11.1", "@emnapi/core@^1.1.0": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" - integrity sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== - dependencies: - "@emnapi/wasi-threads" "1.2.2" - tslib "^2.4.0" - "@emnapi/core@1.4.5": version "1.4.5" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.5.tgz#bfbb0cbbbb9f96ec4e2c4fd917b7bbe5495ceccb" @@ -3565,11 +3557,12 @@ "@emnapi/wasi-threads" "1.0.4" tslib "^2.4.0" -"@emnapi/runtime@1.11.1": +"@emnapi/core@^1.1.0": version "1.11.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.1.tgz#58f1f3d5d81a9b12f793ab688c96371901027c24" - integrity sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw== + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.1.tgz#b9e1064f3a6b1631e241e638eb48d736bfd372a6" + integrity sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== dependencies: + "@emnapi/wasi-threads" "1.2.2" tslib "^2.4.0" "@emnapi/runtime@1.4.5": @@ -3631,11 +3624,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== -"@esbuild/aix-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" - integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== - "@esbuild/aix-ppc64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" @@ -3666,11 +3654,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== -"@esbuild/android-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" - integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== - "@esbuild/android-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" @@ -3706,11 +3689,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== -"@esbuild/android-arm@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" - integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== - "@esbuild/android-arm@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" @@ -3741,11 +3719,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== -"@esbuild/android-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" - integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== - "@esbuild/android-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" @@ -3776,11 +3749,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== -"@esbuild/darwin-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" - integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== - "@esbuild/darwin-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" @@ -3811,11 +3779,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== -"@esbuild/darwin-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" - integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== - "@esbuild/darwin-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" @@ -3846,11 +3809,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== -"@esbuild/freebsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" - integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== - "@esbuild/freebsd-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" @@ -3881,11 +3839,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== -"@esbuild/freebsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" - integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== - "@esbuild/freebsd-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" @@ -3916,11 +3869,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== -"@esbuild/linux-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" - integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== - "@esbuild/linux-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" @@ -3951,11 +3899,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== -"@esbuild/linux-arm@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" - integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== - "@esbuild/linux-arm@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" @@ -3986,11 +3929,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== -"@esbuild/linux-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" - integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== - "@esbuild/linux-ia32@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" @@ -4031,11 +3969,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== -"@esbuild/linux-loong64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" - integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== - "@esbuild/linux-loong64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" @@ -4066,11 +3999,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== -"@esbuild/linux-mips64el@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" - integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== - "@esbuild/linux-mips64el@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" @@ -4101,11 +4029,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== -"@esbuild/linux-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" - integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== - "@esbuild/linux-ppc64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" @@ -4136,11 +4059,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== -"@esbuild/linux-riscv64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" - integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== - "@esbuild/linux-riscv64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" @@ -4171,11 +4089,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== -"@esbuild/linux-s390x@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" - integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== - "@esbuild/linux-s390x@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" @@ -4206,11 +4119,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== -"@esbuild/linux-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" - integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== - "@esbuild/linux-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" @@ -4236,11 +4144,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== -"@esbuild/netbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" - integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== - "@esbuild/netbsd-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" @@ -4271,11 +4174,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== -"@esbuild/netbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" - integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== - "@esbuild/netbsd-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" @@ -4301,11 +4199,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== -"@esbuild/openbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" - integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== - "@esbuild/openbsd-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" @@ -4336,11 +4229,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== -"@esbuild/openbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" - integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== - "@esbuild/openbsd-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" @@ -4366,11 +4254,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== -"@esbuild/openharmony-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" - integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== - "@esbuild/openharmony-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" @@ -4401,11 +4284,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== -"@esbuild/sunos-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" - integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== - "@esbuild/sunos-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" @@ -4436,11 +4314,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== -"@esbuild/win32-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" - integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== - "@esbuild/win32-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" @@ -4471,11 +4344,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== -"@esbuild/win32-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" - integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== - "@esbuild/win32-ia32@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" @@ -4506,11 +4374,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== -"@esbuild/win32-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" - integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== - "@esbuild/win32-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" @@ -5584,7 +5447,7 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28", "@jridgewell/trace-mapping@^0.3.31", "@jridgewell/trace-mapping@^0.3.9": +"@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28", "@jridgewell/trace-mapping@^0.3.31", "@jridgewell/trace-mapping@^0.3.9": version "0.3.31" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -5831,13 +5694,6 @@ "@emnapi/runtime" "^1.1.0" "@tybys/wasm-util" "^0.9.0" -"@napi-rs/wasm-runtime@^1.1.5": - version "1.1.5" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz#cccd6ebc40b991dea6936f9126b1b8155b6c4c95" - integrity sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q== - dependencies: - "@tybys/wasm-util" "^0.10.2" - "@nestjs/common@^10.0.0": version "10.4.15" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70" @@ -6706,106 +6562,206 @@ resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.143.0.tgz#de6161a53df0ec70bf78f7fd03a1bd363d4b05a1" integrity sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg== +"@oxc-parser/binding-android-arm-eabi@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.152.0.tgz#173f10d30a2d7108cbf7d80e14726f395ef3dd2b" + integrity sha512-393VtirINkae4XHBTkq/yQxhOwEr4V+r0et37P2fp+sYZSdYgZTRDPo5Um8vBh0QjMZ+RmaMy4PP0qwr4cTMwA== + "@oxc-parser/binding-android-arm64@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.143.0.tgz#26fbfda4dbf2456cb5bf5871119c509c434d1fce" integrity sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ== +"@oxc-parser/binding-android-arm64@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.152.0.tgz#408c709b8e9dd5a3d8ec968e0b87dddfb5eab193" + integrity sha512-iYFcExATJNZ0rZj0o02THbD1Nc9jAW0nVgIQPee6ejbSASpbZXlIMw8jxtpzaG14JQTWwGSZ6U5dEtRJXE6V3Q== + "@oxc-parser/binding-darwin-arm64@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.143.0.tgz#2e5006d4d002006ca1570be8ae24105066c603b8" integrity sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA== +"@oxc-parser/binding-darwin-arm64@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.152.0.tgz#f2dc1f27fbbf26eb55f8f48ca8dc8a41d07e0a14" + integrity sha512-fnU+DfUzi5/rFy0LzmipYRz06PAUUkOS8kCJQ3Eg6oqlunC1XZXoffFqxmdL51RmcbYtUG3P8DVG1U5fckoa2A== + "@oxc-parser/binding-darwin-x64@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.143.0.tgz#6876551067865e4cf5411a3c453b22e97c6fd860" integrity sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ== +"@oxc-parser/binding-darwin-x64@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.152.0.tgz#1f705820f0326a0b5416249a98b38ef4dbeef431" + integrity sha512-Y3U2okryWTs7hDld6UhjJwLNf8/do+x4g4C+1U5SiGwrTLwbO83WJYJA8+l9iFSilWBgnfpd/+U76tfeNxUHzA== + "@oxc-parser/binding-freebsd-x64@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.143.0.tgz#9c99437e08a15a6e8cca9e48dc0f667337d37fef" integrity sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw== +"@oxc-parser/binding-freebsd-x64@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.152.0.tgz#3b80d2e7cec517e9eb61050dd097033c73089ddc" + integrity sha512-XkhkRrrPffEw+ZLv/d/5pEni0buxinm/E/N6fLbJCunD1DBbeCTtVP23ESV+4CWCWdSz397asks1ViGslpmWiQ== + "@oxc-parser/binding-linux-arm-gnueabihf@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.143.0.tgz#c8803be9811eb1fca83a4bebbc9ff5d70244d250" integrity sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww== +"@oxc-parser/binding-linux-arm-gnueabihf@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.152.0.tgz#536c374489cdf6bbf09d83c278c2d685dfcf2c69" + integrity sha512-MLzsRRbdfmdzo6JEhbC1yKU9ItfvM8tEf18HM32jCiNYZEz6J3bRBRl3cTkKR0vBdrk9ZVlcARXtwHccs+epDA== + "@oxc-parser/binding-linux-arm-musleabihf@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.143.0.tgz#26dbad4cfa6bdc976204a025b663adafd4a22401" integrity sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w== +"@oxc-parser/binding-linux-arm-musleabihf@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.152.0.tgz#7d31cb38c61d75db6d2fd940b8d36a173b3f46ba" + integrity sha512-HHerTEJz7Iy10Ji6BGsk4gqG6PU0UzFuBu91jIEOX89FnrI5ECWSEr7BLpkYQhp7U0QFtMnBhNfQKI0hYZK78w== + "@oxc-parser/binding-linux-arm64-gnu@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.143.0.tgz#d66424ff4b7b51fb79cbb0a0bf92f1521399cce8" integrity sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA== +"@oxc-parser/binding-linux-arm64-gnu@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.152.0.tgz#dfa7bdc555f43729848ade572b9e2827adbbe720" + integrity sha512-9Bb8GyzZ2uSLzRDZ27usaKokr07mxiBe2dDF5b6bd2janmaJTT1gL51UfP7AU30yFiXrJvcRyFKREAgKwZYs0w== + "@oxc-parser/binding-linux-arm64-musl@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.143.0.tgz#003228caab0d5f68b13516af4f4cd1a2aa091b22" integrity sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ== +"@oxc-parser/binding-linux-arm64-musl@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.152.0.tgz#b0df90fce5c7350d18d1a26cd888e31e0ba5f0ee" + integrity sha512-om7iDMUroI74A/lfRRWY9/Ev3VWrVsrUZ4tyvyh8raBey/sdcfyAD7tYWc+iykAiotnmftyKTpUKFr1QnaBrAQ== + "@oxc-parser/binding-linux-ppc64-gnu@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.143.0.tgz#3066ccd33d2d67316a8672135f77a9e3b0e33fb1" integrity sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw== +"@oxc-parser/binding-linux-ppc64-gnu@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.152.0.tgz#ed5fcfec68a61e7205b57e84b30ff4d8a2812e07" + integrity sha512-Lf1OL2lMTqwDMFC6jnRb30B+WYHKV0XT+bPHcFM6xHtNVwxpobiTzUIvKH6a0LWpvfwyru8Yu6zCGHqqFHH+gg== + "@oxc-parser/binding-linux-riscv64-gnu@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.143.0.tgz#807265a203ed1a3db44d5ac261c5c33f5e32b536" integrity sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg== +"@oxc-parser/binding-linux-riscv64-gnu@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.152.0.tgz#dc95bfc76c0252630cb4266a25b72e74026786d2" + integrity sha512-BYWSMQK+YJ9ZILStPcmmiSVq8KEG9Qdq/c4BHPC3rUEq/fEDCVjUIwdj5ZzjgZ9P9is9PKu0tcAXzq1ZXg4urQ== + "@oxc-parser/binding-linux-riscv64-musl@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.143.0.tgz#c42d104ef4f5bafa6f3fb12e4363039040f233f1" integrity sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g== +"@oxc-parser/binding-linux-riscv64-musl@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.152.0.tgz#2846b546b430981fcda466fb793e4261f02fb6bd" + integrity sha512-d2LqHNNNqdH185sRK5dPKWZu201JrNu9/CRFuVEuT+ht7n/QaVblEMWT9MALQPrDWYvhG10P+NSKJFquDWZ09w== + "@oxc-parser/binding-linux-s390x-gnu@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.143.0.tgz#1fb99a080d36bb0e049eb04aac2b00d46e0b85e9" integrity sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ== +"@oxc-parser/binding-linux-s390x-gnu@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.152.0.tgz#6da6686b988b9e170ff0c8d4e3addbfff56cd599" + integrity sha512-8gBeHt5rA/8+pKhHl+zLggfa3L/V/L2at96p7ty6pBE7id6lFO19nFUc15u2PcjC4OYeYK465MKy1fhhbpFIGw== + "@oxc-parser/binding-linux-x64-gnu@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.143.0.tgz#6b3954ccda436ddd5ec6087d6fab3660462611ff" integrity sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw== +"@oxc-parser/binding-linux-x64-gnu@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.152.0.tgz#bb9d6140ae8f920a31f4295c3cc2e72616753dcf" + integrity sha512-ltgrSF6L+yPTVsIdaAyqKq3zTqzj/1VlNbxWZuxAe8flR3sWdyNsKvdXukoO8/ucJbLickhmieRBge+o/e8Ugw== + "@oxc-parser/binding-linux-x64-musl@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.143.0.tgz#8550941ea2a12406bf8da838f2abaf1a00701c73" integrity sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ== +"@oxc-parser/binding-linux-x64-musl@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.152.0.tgz#2c21fd8c901d881be84d7818c35cabf366b5a456" + integrity sha512-LcMp6BvrBnewSfdkLHoSqidhoAq4syKOTBV5TYHWd+2VnGkfD/nPRRVIzTBt/Lkbt7pfOFDGtkiTntFS0p6sQQ== + "@oxc-parser/binding-openharmony-arm64@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.143.0.tgz#07bdd1126b872a153bfd5e07925ea69eb5369ec4" integrity sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw== +"@oxc-parser/binding-openharmony-arm64@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.152.0.tgz#7ff80a0a0ff265eba35a8d58583ed7829ef61d9d" + integrity sha512-jbhoMCslVxzRCRNQ8TSHxjYdSKRvk8O6ciLVDkahJvmyHDzBqba/bMQHM6ULKj/rgHNNbSzlIiakdx8SEfkJzA== + "@oxc-parser/binding-win32-arm64-msvc@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.143.0.tgz#031921d8fd37d754dc1b5db61a2509a7dcd90b10" integrity sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ== +"@oxc-parser/binding-win32-arm64-msvc@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.152.0.tgz#5e29c076a46691a28862e4fdd0d86664f216cd42" + integrity sha512-JZYVlhv36mr/nertnRB7vchJECOBb1Lz6lltalILsB+o9M/is+H6BEDRLQKdqA4JKs6NjJszo46+iM5sfwj6jQ== + "@oxc-parser/binding-win32-ia32-msvc@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.143.0.tgz#61faaf9668c65ea615edcebabb78748591f40ac1" integrity sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA== +"@oxc-parser/binding-win32-ia32-msvc@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.152.0.tgz#455135640931fc0bb22e040073dab539a080ffe5" + integrity sha512-1X9oganvVsunRH0lH10UU05N+H9atpX6WsOybByUjwnfcd3h/0tYwvuv16XuHglrIUOnxV2TodbLVEwwafd64g== + "@oxc-parser/binding-win32-x64-msvc@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.143.0.tgz#b1a93532ab5a8fb1998012b74cd7efe7bfdb1b4d" integrity sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw== -"@oxc-project/types@=0.137.0": - version "0.137.0" - resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.137.0.tgz#56e77f8bb221fa05f18b1cd34d73f94f0954a773" - integrity sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA== +"@oxc-parser/binding-win32-x64-msvc@0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.152.0.tgz#c80fc3a2e18f88df2d375ebd9c666fa7d41b9cdb" + integrity sha512-sw0M80/dn9rcpu+Rqqph/C+MjvDDce6MlXmFjsNQtPEymha0eJy3ml6+foSUTqFfpDIDyHZZ4eoJ43IoMe8bJw== + +"@oxc-project/types@=0.151.0": + version "0.151.0" + resolved "https://sfw.security.sentry.io/npm/@oxc-project/types/-/types-0.151.0.tgz#37206ed3fdebb8c4c5fc49ae20d53d9b4eed2bfe" + integrity sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA== "@oxc-project/types@^0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.143.0.tgz#c3e4f3178b7b54e4dd194eac6d45258a60f0092b" integrity sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA== +"@oxc-project/types@^0.152.0": + version "0.152.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.152.0.tgz#643ff80ce7aacea025f859f13b60cbb42ae0a613" + integrity sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw== + "@oxc-transform/binding-android-arm-eabi@0.143.0": version "0.143.0" resolved "https://registry.yarnpkg.com/@oxc-transform/binding-android-arm-eabi/-/binding-android-arm-eabi-0.143.0.tgz#c41fa3a80da46fef5b78c5599d88dba7eaf47e27" @@ -7622,6 +7578,11 @@ react-router-dom "6.30.4" turbo-stream "2.4.1" +"@remix-run/route-pattern@^0.24.0": + version "0.24.1" + resolved "https://sfw.security.sentry.io/npm/@remix-run/route-pattern/-/route-pattern-0.24.1.tgz#cee709b2f946940777bb4775a8334598f3c4b909" + integrity sha512-HRee7tz2Ct7QdTQGDErHcVTAlXa5BBV6GOLEa/INw21ceTWTWlaU13FJupYVX0k1mh608rod880+SA5NlTPPfw== + "@remix-run/router@1.23.3": version "1.23.3" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.23.3.tgz#957c098d4393d301a8aa7dccf3ef28ea5430e36a" @@ -7688,84 +7649,80 @@ dependencies: web-streams-polyfill "^3.1.1" -"@rolldown/binding-android-arm64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz#88fd6b295a411e62b7926433a45eb5e17e68bba4" - integrity sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA== +"@rolldown/binding-android-arm-eabi@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz#898b1c0c0c4de1ffb5997050a705f04cc102b4eb" + integrity sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw== -"@rolldown/binding-darwin-arm64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.2.tgz#2f840f7e6501cb52370411c2fb008119f1fbf400" - integrity sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw== +"@rolldown/binding-android-arm64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz#bf8ee88baaecd1747326d6c1d05ebdad483edc34" + integrity sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA== -"@rolldown/binding-darwin-x64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.2.tgz#2ed3b66dded5140d22ca2ac58d4e1c1e3143f490" - integrity sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw== +"@rolldown/binding-darwin-arm64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz#4ebc103da1e0d6b24d815c1fb1d6af73245c1b1f" + integrity sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ== -"@rolldown/binding-freebsd-x64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.2.tgz#d3d8603ae480a505eb8c12643c901b2a3771b875" - integrity sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA== +"@rolldown/binding-darwin-x64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz#ab669b448eb2cd2366a7ff01a84579a0f854f5f0" + integrity sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w== -"@rolldown/binding-linux-arm-gnueabihf@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.2.tgz#bc36e0e33566bc80877fe4bca56fd32b241dbacb" - integrity sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw== +"@rolldown/binding-freebsd-x64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz#74c26ec877c293c47c8958ab58e07e57f9382070" + integrity sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow== -"@rolldown/binding-linux-arm64-gnu@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.2.tgz#6267a447e6bfc5a99eb030a6a99194ecc917a652" - integrity sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw== +"@rolldown/binding-linux-arm-gnueabihf@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz#cbfe844867d7dbce1655c9b593be05bcaffb501d" + integrity sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ== -"@rolldown/binding-linux-arm64-musl@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.2.tgz#68fc068f5ebc1d137eecdb651d90830f330aad48" - integrity sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ== +"@rolldown/binding-linux-arm64-gnu@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz#2028ebac4a3f56b26ecd02f8850c74447087cbb2" + integrity sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw== -"@rolldown/binding-linux-ppc64-gnu@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz#fe072f0bc3b713ae25b357651ced38d39e3d81e0" - integrity sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ== +"@rolldown/binding-linux-arm64-musl@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz#a143325f96d5d5040dff50e292374f7467e7c82c" + integrity sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg== -"@rolldown/binding-linux-s390x-gnu@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.2.tgz#9492609384775c6edec9bfbe5b1cbba9660dddcc" - integrity sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg== +"@rolldown/binding-linux-ppc64-gnu@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz#b38937ad8b4821682e3671ebfb609cb0e3ecb935" + integrity sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw== -"@rolldown/binding-linux-x64-gnu@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.2.tgz#74029d9f86d60fa9bcf2670b1480e22438203c98" - integrity sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw== +"@rolldown/binding-linux-s390x-gnu@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz#6be2645a1ad89185e8d03f69116798c94eae19ec" + integrity sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ== -"@rolldown/binding-linux-x64-musl@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.2.tgz#eb3004015027a7af12f9b0ac85ffa1634015c873" - integrity sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw== +"@rolldown/binding-linux-x64-gnu@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz#fbc9a6a6c3b3db00e35573c67894bfbaa3128040" + integrity sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A== -"@rolldown/binding-openharmony-arm64@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.2.tgz#6a02c49dc0f698614f97c68c6f7c21aadc7600b1" - integrity sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw== +"@rolldown/binding-linux-x64-musl@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz#f3f07a30c03d1d3a673b21fd4eaba84979aa4fd4" + integrity sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg== -"@rolldown/binding-wasm32-wasi@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.2.tgz#3f67c083e0762b8cd6c95e8edfe1a743d7ffdf78" - integrity sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg== - dependencies: - "@emnapi/core" "1.11.1" - "@emnapi/runtime" "1.11.1" - "@napi-rs/wasm-runtime" "^1.1.5" +"@rolldown/binding-openharmony-arm64@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz#c3848c61dd3b15edde9c98dfa8d50edb356ec71f" + integrity sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA== -"@rolldown/binding-win32-arm64-msvc@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.2.tgz#a69c5a03b3ba36cb0b1154709293e0cefc9dd69c" - integrity sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg== +"@rolldown/binding-win32-arm64-msvc@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz#99d9f7eb53b26f09212d646e9f50708a2f14414f" + integrity sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw== -"@rolldown/binding-win32-x64-msvc@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.2.tgz#e1d9a6ccd29de00378f8dd6e275adbde5731d30a" - integrity sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA== +"@rolldown/binding-win32-x64-msvc@1.2.11": + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz#c7bf663043c01021f63122a8499e9aca36408467" + integrity sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA== "@rolldown/pluginutils@^1.0.0", "@rolldown/pluginutils@^1.0.1": version "1.0.1" @@ -9154,13 +9111,6 @@ dependencies: tslib "^2.4.0" -"@tybys/wasm-util@^0.10.2": - version "0.10.2" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" - integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== - dependencies: - tslib "^2.4.0" - "@types/amqplib@^0.10.5": version "0.10.5" resolved "https://registry.yarnpkg.com/@types/amqplib/-/amqplib-0.10.5.tgz#fd883eddfbd669702a727fa10007b27c4c1e6ec7" @@ -9549,13 +9499,20 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b" integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ== -"@types/node@^18.19.1", "@types/node@^18.6.3": +"@types/node@^18.19.1": version "18.19.130" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== dependencies: undici-types "~5.26.4" +"@types/node@^20.19.0": + version "20.19.43" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.43.tgz#fcecf580ba42a0db55cf404c372c97973c376c97" + integrity sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA== + dependencies: + undici-types "~6.21.0" + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" @@ -10151,85 +10108,81 @@ dependencies: "@rolldown/pluginutils" "^1.0.1" -"@vitest/coverage-v8@^3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz#2e9ce1103445c237aaa420a7f0058125fe4a7854" - integrity sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg== +"@vitest/coverage-v8@^4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz#6f0636abe7e23e86dd35127244554be2c60bc2a5" + integrity sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw== dependencies: - "@ampproject/remapping" "^2.3.0" "@bcoe/v8-coverage" "^1.0.2" - ast-v8-to-istanbul "^0.3.3" - debug "^4.4.1" + "@vitest/utils" "4.1.11" + ast-v8-to-istanbul "^1.0.0" istanbul-lib-coverage "^3.2.2" istanbul-lib-report "^3.0.1" - istanbul-lib-source-maps "^5.0.6" - istanbul-reports "^3.1.7" - magic-string "^0.30.17" - magicast "^0.3.5" - std-env "^3.9.0" - test-exclude "^7.0.1" - tinyrainbow "^2.0.0" + istanbul-reports "^3.2.0" + magicast "^0.5.2" + obug "^2.1.1" + std-env "^4.0.0-rc.1" + tinyrainbow "^3.1.0" -"@vitest/expect@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.7.tgz#70a34158383d008c3bf5d802e2643317f09df6d8" - integrity sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w== +"@vitest/expect@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/expect/-/expect-4.1.11.tgz#5f580d1f9cdbba314dbf23b2d911f8eb23878f5f" + integrity sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw== dependencies: + "@standard-schema/spec" "^1.1.0" "@types/chai" "^5.2.2" - "@vitest/spy" "3.2.7" - "@vitest/utils" "3.2.7" - chai "^5.2.0" - tinyrainbow "^2.0.0" + "@vitest/spy" "4.1.11" + "@vitest/utils" "4.1.11" + chai "^6.2.2" + tinyrainbow "^3.1.0" -"@vitest/mocker@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.7.tgz#331be944cb783c642dd42bd743411aca24ea0466" - integrity sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA== +"@vitest/mocker@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/mocker/-/mocker-4.1.11.tgz#8e2906361bc5dfa271757a858ae80643118fcbb4" + integrity sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ== dependencies: - "@vitest/spy" "3.2.7" + "@vitest/spy" "4.1.11" estree-walker "^3.0.3" - magic-string "^0.30.17" + magic-string "^0.30.21" -"@vitest/pretty-format@3.2.7", "@vitest/pretty-format@^3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz#2a7b593f8e007e9d8ef7e7343aa30ec73fdeaf29" - integrity sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA== +"@vitest/pretty-format@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/pretty-format/-/pretty-format-4.1.11.tgz#8b28eb8240771d6ea970e33beaeb41384b51868e" + integrity sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw== dependencies: - tinyrainbow "^2.0.0" + tinyrainbow "^3.1.0" -"@vitest/runner@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.7.tgz#c0c080228189f1fa6cda40f59be09d746b0aca51" - integrity sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA== +"@vitest/runner@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/runner/-/runner-4.1.11.tgz#bfbad98c8d6c3f1fb4df12056ad569821ff77f21" + integrity sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw== dependencies: - "@vitest/utils" "3.2.7" + "@vitest/utils" "4.1.11" pathe "^2.0.3" - strip-literal "^3.0.0" -"@vitest/snapshot@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.7.tgz#a3a7e1950ce99ec4cf02395e20ddca403b6c818e" - integrity sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g== +"@vitest/snapshot@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/snapshot/-/snapshot-4.1.11.tgz#df461eb165924a3155986dde68e13360f53f3d4c" + integrity sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog== dependencies: - "@vitest/pretty-format" "3.2.7" - magic-string "^0.30.17" + "@vitest/pretty-format" "4.1.11" + "@vitest/utils" "4.1.11" + magic-string "^0.30.21" pathe "^2.0.3" -"@vitest/spy@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.7.tgz#ca7fbee44019523ca450395d9a2284ce9ece1f31" - integrity sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ== - dependencies: - tinyspy "^4.0.3" +"@vitest/spy@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/spy/-/spy-4.1.11.tgz#0add45cae953afed9c88f98e2f6fc9164558c32a" + integrity sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA== -"@vitest/utils@3.2.7": - version "3.2.7" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.7.tgz#302c8126211ac4dfea87b3b5085c098d6d22e89e" - integrity sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw== +"@vitest/utils@4.1.11": + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/@vitest/utils/-/utils-4.1.11.tgz#9b27a4293b827942b223539bfab1bd9f7eada31b" + integrity sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ== dependencies: - "@vitest/pretty-format" "3.2.7" - loupe "^3.1.4" - tinyrainbow "^2.0.0" + "@vitest/pretty-format" "4.1.11" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" "@volar/kit@~2.4.28": version "2.4.28" @@ -11391,11 +11344,6 @@ assert-never@^1.1.0, assert-never@^1.2.1: resolved "https://registry.yarnpkg.com/assert-never/-/assert-never-1.4.0.tgz#b0d4988628c87f35eb94716cc54422a63927e175" integrity sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA== -assertion-error@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" - integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== - ast-kit@^2.1.2, ast-kit@^2.1.3: version "2.2.0" resolved "https://registry.yarnpkg.com/ast-kit/-/ast-kit-2.2.0.tgz#6d9a298acefef5bdfc5a0fa51d94d1334ef2e671" @@ -11428,14 +11376,14 @@ ast-types@^0.16.1: dependencies: tslib "^2.0.1" -ast-v8-to-istanbul@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.3.tgz#697101c116cff6b51c0e668ba6352e7e41fe8dd5" - integrity sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw== +ast-v8-to-istanbul@^1.0.0: + version "1.0.7" + resolved "https://sfw.security.sentry.io/npm/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz#ead45f98ca59413f048f1b1466a78b6c8a1070bb" + integrity sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA== dependencies: - "@jridgewell/trace-mapping" "^0.3.25" + "@jridgewell/trace-mapping" "^0.3.31" estree-walker "^3.0.3" - js-tokens "^9.0.1" + js-tokens "^10.0.0" ast-walker-scope@^0.8.3: version "0.8.3" @@ -11637,13 +11585,13 @@ axios@1.16.0: form-data "^4.0.5" proxy-from-env "^2.1.0" -axios@1.18.0: - version "1.18.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.18.0.tgz#8a7f8854af280fcaae063272df2ed9f3837d2398" - integrity sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw== +axios@1.20.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.20.0.tgz#515513445aa60e71d04b6521ca6210829ccb4786" + integrity sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg== dependencies: follow-redirects "^1.16.0" - form-data "^4.0.5" + form-data "^4.0.6" https-proxy-agent "^5.0.1" proxy-from-env "^2.1.0" @@ -12092,7 +12040,7 @@ boxen@8.0.1, boxen@^8.0.1: widest-line "^5.0.0" wrap-ansi "^9.0.0" -brace-expansion@5.0.6, brace-expansion@^5.0.5: +brace-expansion@5.0.6: version "5.0.6" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.6.tgz#ec68fe0a641a29d8711579caf641d05bae1f2285" integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== @@ -12100,20 +12048,27 @@ brace-expansion@5.0.6, brace-expansion@^5.0.5: balanced-match "^4.0.2" brace-expansion@^1.1.7: - version "1.1.18" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.18.tgz#3ce74d89885136be1535341f8c3d4425c29a5cab" - integrity sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw== + version "1.1.21" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.21.tgz#edf4fab5c64d051aea5a8def49aba1c7522279f3" + integrity sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^2.0.1, brace-expansion@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" - integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + version "2.1.7" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.7.tgz#14ea3836731823bb24740225ea43b941b436f9d1" + integrity sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g== dependencies: balanced-match "^1.0.0" +brace-expansion@^5.0.5: + version "5.0.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec" + integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== + dependencies: + balanced-match "^4.0.2" + braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" @@ -12625,16 +12580,10 @@ ccount@^2.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== -chai@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/chai/-/chai-5.2.0.tgz#1358ee106763624114addf84ab02697e411c9c05" - integrity sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw== - dependencies: - assertion-error "^2.0.1" - check-error "^2.1.1" - deep-eql "^5.0.1" - loupe "^3.1.0" - pathval "^2.0.0" +chai@^6.2.2: + version "6.2.2" + resolved "https://sfw.security.sentry.io/npm/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== chainsaw@~0.1.0: version "0.1.0" @@ -12685,11 +12634,6 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -check-error@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" - integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== - "chokidar@>=3.0.0 <4.0.0", chokidar@^3.0.0, chokidar@^3.5.2, chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -13718,11 +13662,6 @@ dedent@^1.5.3: resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a" integrity sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ== -deep-eql@^5.0.1: - version "5.0.2" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" - integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== - deep-equal@^2.0.5: version "2.2.3" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1" @@ -15146,38 +15085,6 @@ esbuild@^0.25.0, esbuild@^0.25.3: "@esbuild/win32-ia32" "0.25.12" "@esbuild/win32-x64" "0.25.12" -esbuild@^0.27.0: - version "0.27.7" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" - integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== - optionalDependencies: - "@esbuild/aix-ppc64" "0.27.7" - "@esbuild/android-arm" "0.27.7" - "@esbuild/android-arm64" "0.27.7" - "@esbuild/android-x64" "0.27.7" - "@esbuild/darwin-arm64" "0.27.7" - "@esbuild/darwin-x64" "0.27.7" - "@esbuild/freebsd-arm64" "0.27.7" - "@esbuild/freebsd-x64" "0.27.7" - "@esbuild/linux-arm" "0.27.7" - "@esbuild/linux-arm64" "0.27.7" - "@esbuild/linux-ia32" "0.27.7" - "@esbuild/linux-loong64" "0.27.7" - "@esbuild/linux-mips64el" "0.27.7" - "@esbuild/linux-ppc64" "0.27.7" - "@esbuild/linux-riscv64" "0.27.7" - "@esbuild/linux-s390x" "0.27.7" - "@esbuild/linux-x64" "0.27.7" - "@esbuild/netbsd-arm64" "0.27.7" - "@esbuild/netbsd-x64" "0.27.7" - "@esbuild/openbsd-arm64" "0.27.7" - "@esbuild/openbsd-x64" "0.27.7" - "@esbuild/openharmony-arm64" "0.27.7" - "@esbuild/sunos-x64" "0.27.7" - "@esbuild/win32-arm64" "0.27.7" - "@esbuild/win32-ia32" "0.27.7" - "@esbuild/win32-x64" "0.27.7" - "esbuild@^0.27.0 || ^0.28.0", esbuild@^0.28.0, esbuild@^0.28.1, esbuild@~0.28.0: version "0.28.2" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" @@ -15598,10 +15505,10 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -expect-type@^1.2.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" - integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== +expect-type@^1.3.0: + version "1.4.0" + resolved "https://sfw.security.sentry.io/npm/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== express@5.2.1: version "5.2.1" @@ -15866,14 +15773,14 @@ fast-text-encoding@^1.0.0: integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== fast-uri@^3.0.0, fast-uri@^3.0.1: - version "3.1.7" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.7.tgz#743157d957f3cbb4c65310e033dc2ad4ad7dc60a" - integrity sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg== + version "3.1.8" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.8.tgz#f7db8d942e20eead3cfe93b0beeb4eb0169e938b" + integrity sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg== fast-uri@^4.0.0: - version "4.1.4" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-4.1.4.tgz#2076a9d7bce86a86aa31ac40650b935fd5b1cd60" - integrity sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ== + version "4.2.1" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-4.2.1.tgz#816fb0fd61f511adcc35aadbbc1bcbf656d9e72a" + integrity sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg== fast-wrap-ansi@^0.2.0: version "0.2.2" @@ -15907,10 +15814,10 @@ fast-xml-parser@^4.4.1: dependencies: strnum "^1.0.5" -fastify@^5.12.1: - version "5.12.1" - resolved "https://registry.yarnpkg.com/fastify/-/fastify-5.12.1.tgz#d3a736ed0d656d8ffeed909c7099809b24b9f008" - integrity sha512-FWi+tQvwxR/PeRX7Z2mhfEF5ozJ3jn9asiiclzKXNSzJRHAYcU924aIOKAdHFJ+YIKieh3cqr1IwCOvTr41B3Q== +fastify@^5.12.5: + version "5.12.5" + resolved "https://registry.yarnpkg.com/fastify/-/fastify-5.12.5.tgz#e3a6937e154a51332889a30ff3b8f97db52d7355" + integrity sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A== dependencies: "@fastify/ajv-compiler" "^4.0.5" "@fastify/error" "^4.0.0" @@ -16197,7 +16104,7 @@ foreground-child@^3.1.0: cross-spawn "^7.0.6" signal-exit "^4.0.1" -form-data@4.0.6, form-data@^4.0.0, form-data@^4.0.4, form-data@^4.0.5: +form-data@4.0.6, form-data@^4.0.0, form-data@^4.0.4, form-data@^4.0.5, form-data@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== @@ -16610,7 +16517,7 @@ glob@8.0.3: minimatch "^5.0.1" once "^1.3.0" -glob@^10.0.0, glob@^10.3.7, glob@^10.4.1: +glob@^10.0.0, glob@^10.3.7: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -17209,10 +17116,10 @@ homedir-polyfill@^1.0.1: dependencies: parse-passwd "^1.0.0" -hono@^4.13.5: - version "4.13.5" - resolved "https://registry.yarnpkg.com/hono/-/hono-4.13.5.tgz#3df9463c4668a0321c39515309f5891e388e9709" - integrity sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw== +hono@^4.13.7: + version "4.13.7" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.13.7.tgz#80fdab8326f03d02f049117fd968101933493600" + integrity sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ== hookable@^5.5.3: version "5.5.3" @@ -17757,9 +17664,9 @@ ioredis@5.10.1, ioredis@^5.10.1: standard-as-callback "^2.1.0" ip-address@^10.0.1: - version "10.4.0" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.4.0.tgz#c5910bc541b6eae287765d1e4846be0308a05d93" - integrity sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ== + version "10.7.2" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.7.2.tgz#5b3b2b7d46c6293861b82dfcd49cc203aa8f2c5c" + integrity sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w== ipaddr.js@1.9.1: version "1.9.1" @@ -18280,19 +18187,10 @@ istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: make-dir "^4.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^5.0.6: - version "5.0.6" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" - integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== - dependencies: - "@jridgewell/trace-mapping" "^0.3.23" - debug "^4.1.1" - istanbul-lib-coverage "^3.0.0" - -istanbul-reports@^3.1.7: - version "3.1.7" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" - integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== +istanbul-reports@^3.2.0: + version "3.2.0" + resolved "https://sfw.security.sentry.io/npm/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== dependencies: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" @@ -18396,11 +18294,6 @@ js-tokens@^10.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-tokens@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" - integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== - js-yaml@^3.13.0, js-yaml@^3.13.1: version "3.15.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.15.1.tgz#24bc95028f361cdaaa84745b06a109c4486773c0" @@ -18917,6 +18810,80 @@ light-my-request@^6.0.0: process-warning "^4.0.0" set-cookie-parser "^2.6.0" +lightningcss-android-arm64@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz#9a6841f88ae50fc83502903892b41af41bc2b907" + integrity sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== + +lightningcss-darwin-arm64@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz#c0f2c31c0bfd19fa4dd3f18e957a1f1a152097d6" + integrity sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== + +lightningcss-darwin-x64@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz#cb0705965acb538c6683949ce6925fb3cdf7c361" + integrity sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== + +lightningcss-freebsd-x64@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz#763538828b26bab2680dadafcc84ee78b0eb502b" + integrity sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== + +lightningcss-linux-arm-gnueabihf@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz#6862e3176a331aedbdec1ed352b4d7d0dd0784de" + integrity sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== + +lightningcss-linux-arm64-gnu@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz#c6a3a2ed15141daf6bdc2628930f8e39bdf473aa" + integrity sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== + +lightningcss-linux-arm64-musl@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz#7fa1334971fc82845f9827df6ef8a0b20914bac6" + integrity sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== + +lightningcss-linux-x64-gnu@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz#8b927862ea8c2bbc6831a46509244b50d9936e55" + integrity sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== + +lightningcss-linux-x64-musl@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz#0c525bb077dfd94404c059cfe42dad797e96aeaf" + integrity sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== + +lightningcss-win32-arm64-msvc@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz#850ee1103dac989cfab50e3ac22d1a69e394e63d" + integrity sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== + +lightningcss-win32-x64-msvc@1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz#e343ae152eed3609dc6e11949d1a3bf39a1c946f" + integrity sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== + +lightningcss@^1.33.0: + version "1.33.0" + resolved "https://sfw.security.sentry.io/npm/lightningcss/-/lightningcss-1.33.0.tgz#c08867d71a79385c6e190214fd72fef3e5f95f0b" + integrity sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.33.0" + lightningcss-darwin-arm64 "1.33.0" + lightningcss-darwin-x64 "1.33.0" + lightningcss-freebsd-x64 "1.33.0" + lightningcss-linux-arm-gnueabihf "1.33.0" + lightningcss-linux-arm64-gnu "1.33.0" + lightningcss-linux-arm64-musl "1.33.0" + lightningcss-linux-x64-gnu "1.33.0" + lightningcss-linux-x64-musl "1.33.0" + lightningcss-win32-arm64-msvc "1.33.0" + lightningcss-win32-x64-msvc "1.33.0" + lilconfig@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" @@ -19181,11 +19148,6 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0 || ^4.0.0" -loupe@^3.1.0, loupe@^3.1.4: - version "3.1.4" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.4.tgz#784a0060545cb38778ffb19ccde44d7870d5fdd9" - integrity sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg== - lower-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" @@ -19291,14 +19253,14 @@ magic-string@^0.26.0, magic-string@^0.26.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.14, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21, magic-string@~0.30.8: +magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.14, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" -magic-string@^1.1.0: +magic-string@^1.1.0, "magic-string@~0.30.8 || ^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-1.1.0.tgz#54a8470aabf2a04b970177f26c0308cabea74a3f" integrity sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g== @@ -20269,9 +20231,9 @@ module-lookup-amd@^9.0.3: requirejs-config-file "^4.0.0" moment@^2.30.1: - version "2.30.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" - integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + version "2.31.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.31.0.tgz#6e19184e8005a9dfc61c9351263d4d6ea2ace7aa" + integrity sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw== mongodb-connection-string-url@^2.6.0: version "2.6.0" @@ -20498,10 +20460,10 @@ named-placeholders@^1.1.6: dependencies: lru.min "^1.1.0" -nanoid@^3.3.17, nanoid@^3.3.6, nanoid@^3.3.8: - version "3.3.18" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" - integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== +nanoid@^3.3.18, nanoid@^3.3.6, nanoid@^3.3.8: + version "3.3.19" + resolved "https://sfw.security.sentry.io/npm/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a" + integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== nanoid@^5.1.7: version "5.1.9" @@ -21693,6 +21655,33 @@ oxc-parser@^0.143.0: "@oxc-parser/binding-win32-ia32-msvc" "0.143.0" "@oxc-parser/binding-win32-x64-msvc" "0.143.0" +oxc-parser@^0.152.0: + version "0.152.0" + resolved "https://registry.yarnpkg.com/oxc-parser/-/oxc-parser-0.152.0.tgz#1520dbf962f210d0d5dd4d6a32487f33afd08179" + integrity sha512-ZztsZgaCWmPeKYMdoTT5e0oV/oxD1NeLWNR3kAtr/G707cUPYaaozRlVDHLCGnszHJRszN9mJwmPNpqgwloC0A== + dependencies: + "@oxc-project/types" "^0.152.0" + optionalDependencies: + "@oxc-parser/binding-android-arm-eabi" "0.152.0" + "@oxc-parser/binding-android-arm64" "0.152.0" + "@oxc-parser/binding-darwin-arm64" "0.152.0" + "@oxc-parser/binding-darwin-x64" "0.152.0" + "@oxc-parser/binding-freebsd-x64" "0.152.0" + "@oxc-parser/binding-linux-arm-gnueabihf" "0.152.0" + "@oxc-parser/binding-linux-arm-musleabihf" "0.152.0" + "@oxc-parser/binding-linux-arm64-gnu" "0.152.0" + "@oxc-parser/binding-linux-arm64-musl" "0.152.0" + "@oxc-parser/binding-linux-ppc64-gnu" "0.152.0" + "@oxc-parser/binding-linux-riscv64-gnu" "0.152.0" + "@oxc-parser/binding-linux-riscv64-musl" "0.152.0" + "@oxc-parser/binding-linux-s390x-gnu" "0.152.0" + "@oxc-parser/binding-linux-x64-gnu" "0.152.0" + "@oxc-parser/binding-linux-x64-musl" "0.152.0" + "@oxc-parser/binding-openharmony-arm64" "0.152.0" + "@oxc-parser/binding-win32-arm64-msvc" "0.152.0" + "@oxc-parser/binding-win32-ia32-msvc" "0.152.0" + "@oxc-parser/binding-win32-x64-msvc" "0.152.0" + oxc-transform@^0.143.0: version "0.143.0" resolved "https://registry.yarnpkg.com/oxc-transform/-/oxc-transform-0.143.0.tgz#d5c029abdd1d7faac2d5901d6250535090f04b8c" @@ -22181,11 +22170,6 @@ pathe@^2.0.1, pathe@^2.0.3: resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== -pathval@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" - integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== - pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -22307,10 +22291,10 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== -picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.5.tgz#51ea57a17d86f605f81039595fbc40ed06a55fab" - integrity sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== +picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4, picomatch@^4.0.5, picomatch@^4.0.7: + version "4.0.7" + resolved "https://sfw.security.sentry.io/npm/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== pidtree@^0.6.0: version "0.6.0" @@ -23061,12 +23045,12 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.1.10, postcss@^8.2.14, postcss@^8.3.7, postcss@^8.4.43, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.19, postcss@^8.5.25, postcss@^8.5.3, postcss@^8.5.6: - version "8.5.26" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620" - integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ== +postcss@^8.1.10, postcss@^8.2.14, postcss@^8.3.7, postcss@^8.4.43, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.19, postcss@^8.5.25, postcss@^8.5.28, postcss@^8.5.3, postcss@^8.5.6: + version "8.5.28" + resolved "https://sfw.security.sentry.io/npm/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" + integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== dependencies: - nanoid "^3.3.17" + nanoid "^3.3.18" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -24366,29 +24350,29 @@ roarr@^7.0.4: safe-stable-stringify "^2.4.1" semver-compare "^1.0.0" -rolldown@^1.0.0, rolldown@^1.0.0-rc.15, rolldown@^1.0.0-rc.8: - version "1.1.2" - resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.1.2.tgz#accb41e26c872ad2c5198a39c1281c7b6b844097" - integrity sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ== +rolldown@^1.0.0, rolldown@^1.0.0-rc.15, rolldown@^1.0.0-rc.8, rolldown@~1.2.9: + version "1.2.11" + resolved "https://sfw.security.sentry.io/npm/rolldown/-/rolldown-1.2.11.tgz#89c4e07d9098b88fe0b023de59d63a6216e9285c" + integrity sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw== dependencies: - "@oxc-project/types" "=0.137.0" + "@oxc-project/types" "=0.151.0" "@rolldown/pluginutils" "^1.0.0" optionalDependencies: - "@rolldown/binding-android-arm64" "1.1.2" - "@rolldown/binding-darwin-arm64" "1.1.2" - "@rolldown/binding-darwin-x64" "1.1.2" - "@rolldown/binding-freebsd-x64" "1.1.2" - "@rolldown/binding-linux-arm-gnueabihf" "1.1.2" - "@rolldown/binding-linux-arm64-gnu" "1.1.2" - "@rolldown/binding-linux-arm64-musl" "1.1.2" - "@rolldown/binding-linux-ppc64-gnu" "1.1.2" - "@rolldown/binding-linux-s390x-gnu" "1.1.2" - "@rolldown/binding-linux-x64-gnu" "1.1.2" - "@rolldown/binding-linux-x64-musl" "1.1.2" - "@rolldown/binding-openharmony-arm64" "1.1.2" - "@rolldown/binding-wasm32-wasi" "1.1.2" - "@rolldown/binding-win32-arm64-msvc" "1.1.2" - "@rolldown/binding-win32-x64-msvc" "1.1.2" + "@rolldown/binding-android-arm-eabi" "1.2.11" + "@rolldown/binding-android-arm64" "1.2.11" + "@rolldown/binding-darwin-arm64" "1.2.11" + "@rolldown/binding-darwin-x64" "1.2.11" + "@rolldown/binding-freebsd-x64" "1.2.11" + "@rolldown/binding-linux-arm-gnueabihf" "1.2.11" + "@rolldown/binding-linux-arm64-gnu" "1.2.11" + "@rolldown/binding-linux-arm64-musl" "1.2.11" + "@rolldown/binding-linux-ppc64-gnu" "1.2.11" + "@rolldown/binding-linux-s390x-gnu" "1.2.11" + "@rolldown/binding-linux-x64-gnu" "1.2.11" + "@rolldown/binding-linux-x64-musl" "1.2.11" + "@rolldown/binding-openharmony-arm64" "1.2.11" + "@rolldown/binding-win32-arm64-msvc" "1.2.11" + "@rolldown/binding-win32-x64-msvc" "1.2.11" rollup-plugin-copy-assets@^2.0.3: version "2.0.3" @@ -24860,6 +24844,11 @@ sentry@^0.44.0: resolved "https://sfw.security.sentry.io/npm/sentry/-/sentry-0.44.0.tgz#94d53f6cfd8df6ce1186d7a222ec38c461c1b83c" integrity sha512-z9hagaB24WTZLKOuIdoi+XIJb2Db1k/TxPSmASvBD86H5/tqURxguLWecnraqWtDuf7ByEkUkMP7JP/mq8v89w== +sentry@^0.45.0: + version "0.45.0" + resolved "https://registry.yarnpkg.com/sentry/-/sentry-0.45.0.tgz#0803c63b7337076f80ea384c910f9df17247dc1c" + integrity sha512-kgmEbLlC22lL7uneeUm9Sjz65rp5cvwYOMTxXbZCmBum4sbVre/lLT3zTV3kwXHJ9/BM8ZS0/GhZe9Cl1t6WyA== + serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" @@ -25605,14 +25594,14 @@ statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.1, statuses@~2.0.2: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== -std-env@^3.7.0, std-env@^3.9.0: +std-env@^3.7.0: version "3.10.0" resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== -std-env@^4.0.0, std-env@^4.1.0, std-env@^4.2.0: +std-env@^4.0.0, std-env@^4.0.0-rc.1, std-env@^4.1.0, std-env@^4.2.0: version "4.2.0" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" + resolved "https://sfw.security.sentry.io/npm/std-env/-/std-env-4.2.0.tgz#8ebe0ec60485668ab47227b312f4254cdf80c9d3" integrity sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== stdin-discarder@^0.2.2: @@ -25871,13 +25860,6 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= -strip-literal@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" - integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== - dependencies: - js-tokens "^9.0.1" - strip-literal@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-4.0.0.tgz#01bb0daf7af141ce8d2328bdeecc6b1597aebc0c" @@ -26207,15 +26189,6 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -test-exclude@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" - integrity sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg== - dependencies: - "@istanbuljs/schema" "^0.1.2" - glob "^10.4.1" - minimatch "^9.0.4" - text-decoder@^1.1.0: version "1.2.3" resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.3.tgz#b19da364d981b2326d5f43099c310cc80d770c65" @@ -26308,15 +26281,15 @@ tinyclip@^0.1.15: resolved "https://registry.yarnpkg.com/tinyclip/-/tinyclip-0.1.15.tgz#db35eaa2bd5fe627b3ef2ea38d8b0407f2bc2def" integrity sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A== -tinyexec@^0.3.1, tinyexec@^0.3.2: +tinyexec@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== -tinyexec@^1.2.4: - version "1.3.0" - resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.3.0.tgz#aacc1dbb1d4e93e6ad8dd64944e09f9ad147a474" - integrity sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ== +tinyexec@^1.0.2, tinyexec@^1.2.4: + version "1.3.1" + resolved "https://sfw.security.sentry.io/npm/tinyexec/-/tinyexec-1.3.1.tgz#16a2e3c6e23fafce72640e678e651db36145b9c6" + integrity sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA== tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15, tinyglobby@^0.2.16, tinyglobby@^0.2.17, tinyglobby@^0.2.2: version "0.2.17" @@ -26331,20 +26304,10 @@ tinypool@2.1.2: resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-2.1.2.tgz#fcb3df44405c2530259e4bef5928a90bf46134dd" integrity sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww== -tinypool@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" - integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== - -tinyrainbow@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" - integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== - -tinyspy@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.3.tgz#d1d0f0602f4c15f1aae083a34d6d0df3363b1b52" - integrity sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A== +tinyrainbow@^3.1.0: + version "3.1.1" + resolved "https://sfw.security.sentry.io/npm/tinyrainbow/-/tinyrainbow-3.1.1.tgz#c0168387d3d8d70b6b3c2c0936de5fee738cea20" + integrity sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw== tldts-core@^6.1.86: version "6.1.86" @@ -26890,6 +26853,11 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + undici-types@~7.18.0: version "7.18.2" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" @@ -27479,7 +27447,7 @@ vite-hot-client@^2.2.0: resolved "https://registry.yarnpkg.com/vite-hot-client/-/vite-hot-client-2.2.0.tgz#284fa1afa63cc8781d079515884eb49d2890ac2f" integrity sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w== -vite-node@3.2.4, vite-node@^3.2.2: +vite-node@^3.2.2: version "3.2.4" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== @@ -27552,20 +27520,6 @@ vite-plugin-vue-tracer@^1.4.0: pathe "^2.0.3" source-map-js "^1.2.1" -vite@7.3.5: - version "7.3.5" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.5.tgz#90c2d0b7b94a224e7e7dcf22d2912ff0b5291165" - integrity sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww== - dependencies: - esbuild "^0.27.0" - fdir "^6.5.0" - picomatch "^4.0.3" - postcss "^8.5.6" - rollup "^4.43.0" - tinyglobby "^0.2.15" - optionalDependencies: - fsevents "~2.3.3" - "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^7.3.1, vite@^7.3.6: version "7.3.6" resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.6.tgz#0547a395e68d3746e9a505f1fd4469fe09b49cc4" @@ -27591,7 +27545,20 @@ vite@^5.4.11: optionalDependencies: fsevents "~2.3.3" -vite@^6.4.1, vite@^6.4.3: +"vite@^6.0.0 || ^7.0.0 || ^8.0.0", vite@^8.3.1: + version "8.3.1" + resolved "https://sfw.security.sentry.io/npm/vite/-/vite-8.3.1.tgz#aa1099a4ccaf104a8c32492951497fa1781d9fce" + integrity sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA== + dependencies: + lightningcss "^1.33.0" + picomatch "^4.0.7" + postcss "^8.5.28" + rolldown "~1.2.9" + tinyglobby "^0.2.17" + optionalDependencies: + fsevents "~2.3.3" + +vite@^6.4.1: version "6.4.3" resolved "https://registry.yarnpkg.com/vite/-/vite-6.4.3.tgz#85a164db7ce706f2a776812efa2b340f1721858e" integrity sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A== @@ -27615,33 +27582,30 @@ vitefu@^1.0.4: resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-1.0.7.tgz#430a6b178450ee90c1ea448cf3739ce100e9c54c" integrity sha512-eRWXLBbJjW3X5z5P5IHcSm2yYbYRPb2kQuc+oqsbAl99WB5kVsPbiiox+cymo8twTzifA6itvhr2CmjnaZZp0Q== -vitest@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.7.tgz#1944b6ed013a25fd26a73d18e1af92c10a57af6c" - integrity sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg== - dependencies: - "@types/chai" "^5.2.2" - "@vitest/expect" "3.2.7" - "@vitest/mocker" "3.2.7" - "@vitest/pretty-format" "^3.2.7" - "@vitest/runner" "3.2.7" - "@vitest/snapshot" "3.2.7" - "@vitest/spy" "3.2.7" - "@vitest/utils" "3.2.7" - chai "^5.2.0" - debug "^4.4.1" - expect-type "^1.2.1" - magic-string "^0.30.17" +vitest@^4.1.11: + version "4.1.11" + resolved "https://sfw.security.sentry.io/npm/vitest/-/vitest-4.1.11.tgz#1653c1521ae917f960d9b21877797c47dfd8bf21" + integrity sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw== + dependencies: + "@vitest/expect" "4.1.11" + "@vitest/mocker" "4.1.11" + "@vitest/pretty-format" "4.1.11" + "@vitest/runner" "4.1.11" + "@vitest/snapshot" "4.1.11" + "@vitest/spy" "4.1.11" + "@vitest/utils" "4.1.11" + es-module-lexer "^2.0.0" + expect-type "^1.3.0" + magic-string "^0.30.21" + obug "^2.1.1" pathe "^2.0.3" - picomatch "^4.0.2" - std-env "^3.9.0" + picomatch "^4.0.3" + std-env "^4.0.0-rc.1" tinybench "^2.9.0" - tinyexec "^0.3.2" - tinyglobby "^0.2.14" - tinypool "^1.1.1" - tinyrainbow "^2.0.0" - vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" - vite-node "3.2.4" + tinyexec "^1.0.2" + tinyglobby "^0.2.15" + tinyrainbow "^3.1.0" + vite "^6.0.0 || ^7.0.0 || ^8.0.0" why-is-node-running "^2.3.0" volar-service-html@~0.0.71: