From e4ac7e8fe836333752a294f7fa798c55ccecbe37 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 1 Oct 2026 15:26:16 +0200 Subject: [PATCH] test(nextjs): Add vinext e2e tests vinext builds a Next.js app with Vite. Its e2e tests run in two places: - nextjs-16 gets the optional variants `nextjs-16 (vinext)` and `nextjs-16 (vinext, bun)`. vinext builds the same app, and the same tests run as for Next.js. They skip the checks of the Next.js build output, and two SDK gaps on vinext: `openai` and `ioredis` get their diagnostics channels from the build-time loader of webpack or Turbopack, which vinext does not run. The OpenNext wrangler config is now `wrangler.opennext.jsonc`, because vinext refuses to build without the Cloudflare plugin when the project root has a wrangler config. - `vinext-cf-workers` covers vinext on Workers. Its Worker entry has no Sentry calls, so `sentryCloudflareVitePlugin` must wrap it with `withSentry` from `@sentry/nextjs/cloudflare`. The app also has the server config of a Next.js app, whose `init` must create no second client in the Worker. The tests check that each request has one `http.server` span with the route of Next.js and the Next.js spans in its segment, that an incoming trace is continued in that segment, and that an error from `onRequestError` is in the trace of the request. There is no Deno variant. On Deno, the `node:http` server of vinext starts each request after the first one in the AsyncLocalStorage store of an earlier request, also without Sentry, so vinext creates no request span. Co-Authored-By: Claude Opus 5.5 --- .../test-applications/nextjs-16/package.json | 24 +++++- .../nextjs-16/playwright.config.mjs | 8 +- .../nextjs-16/tests/build-output.test.ts | 3 + .../nextjs-16/tests/db-page.test.ts | 56 +++++++------ .../nextjs-16/tests/isVinext.ts | 1 + .../nextjs-16/tests/middleware.test.ts | 5 ++ .../nextjs-16/tests/openai.test.ts | 3 + .../nextjs-16/tests/propagation.test.ts | 8 +- .../tests/server-action-redirect.test.ts | 8 +- .../nextjs-16/vite.config.mts | 7 ++ ...wrangler.jsonc => wrangler.opennext.jsonc} | 2 + .../vinext-cf-workers/.gitignore | 4 + .../vinext-cf-workers/next.config.ts | 5 ++ .../vinext-cf-workers/package.json | 38 +++++++++ .../vinext-cf-workers/playwright.config.mjs | 6 ++ .../vinext-cf-workers/sentry.server.config.ts | 8 ++ .../src/app/api/hello/route.ts | 5 ++ .../src/app/api/throw/route.ts | 5 ++ .../vinext-cf-workers/src/app/layout.tsx | 7 ++ .../vinext-cf-workers/src/app/page.tsx | 3 + .../src/app/parameterized/[id]/page.tsx | 6 ++ .../vinext-cf-workers/src/instrumentation.ts | 9 ++ .../vinext-cf-workers/start-event-proxy.mjs | 6 ++ .../vinext-cf-workers/tests/errors.test.ts | 26 ++++++ .../tests/server-spans.test.ts | 82 +++++++++++++++++++ .../vinext-cf-workers/tsconfig.json | 15 ++++ .../vinext-cf-workers/vite.config.ts | 14 ++++ .../vinext-cf-workers/worker/index.ts | 3 + .../worker/instrument.server.ts | 7 ++ .../vinext-cf-workers/wrangler.jsonc | 12 +++ 30 files changed, 355 insertions(+), 31 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/isVinext.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/vite.config.mts rename dev-packages/e2e-tests/test-applications/nextjs-16/{wrangler.jsonc => wrangler.opennext.jsonc} (84%) create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/next.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/api/hello/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/api/throw/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/parameterized/[id]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/tests/server-spans.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/worker/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/worker/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-cf-workers/wrangler.jsonc diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index fcc8aa64f9d8..f19d1697c36f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -23,10 +23,13 @@ "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", "test:assert:bun": "RUNTIME=bun pnpm test:prod", "test:assert:deno": "RUNTIME=deno pnpm test:prod", - "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build", - "test:build-latest:cloudflare": "pnpm install && pnpm add next@latest && opennextjs-cloudflare build", + "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build --config wrangler.opennext.jsonc", + "test:build-latest:cloudflare": "pnpm install && pnpm add next@latest && opennextjs-cloudflare build --config wrangler.opennext.jsonc", "test:assert:cloudflare": "RUNTIME=cloudflare pnpm test:prod", - "start:cloudflare": "opennextjs-cloudflare preview --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"" + "start:cloudflare": "opennextjs-cloudflare preview --config wrangler.opennext.jsonc --var \"E2E_TEST_DSN:$E2E_TEST_DSN\"", + "test:build:vinext": "pnpm install && pnpm add react@19.3.0 react-dom@19.3.0 && npm pkg set type=module && vinext build", + "test:assert:vinext": "VINEXT=1 pnpm test:prod", + "test:assert:vinext:bun": "VINEXT=1 RUNTIME=bun pnpm test:prod" }, "//": "Pin `ioredis` to 5.10.1: it's the last version before the driver publishes its own native diagnostics channels; orchestrion's config covers `ioredis <5.11.0`.", "dependencies": { @@ -56,7 +59,12 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^16", + "@vitejs/plugin-react": "^6.1.1", + "@vitejs/plugin-rsc": "^0.5.35", + "react-server-dom-webpack": "^19.3.0", "typescript": "^5", + "vinext": "1.0.0-beta.13", + "vite": "^8.3.0", "wrangler": "^4.61.0" }, "volta": { @@ -108,6 +116,16 @@ "assert-command": "pnpm test:assert:deno", "deno-version": "v2.9.0", "label": "nextjs-16 (deno)" + }, + { + "build-command": "pnpm test:build:vinext", + "assert-command": "pnpm test:assert:vinext", + "label": "nextjs-16 (vinext)" + }, + { + "build-command": "pnpm test:build:vinext", + "assert-command": "pnpm test:assert:vinext:bun", + "label": "nextjs-16 (vinext, bun)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs index 7e356d81488b..0d18ae12efc4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs @@ -12,6 +12,12 @@ const productionStartCommands = { cloudflare: 'pnpm start:cloudflare --port 3030', }; +// The `nextjs-16 (vinext)` variants build the app with vinext instead of Next.js. +const vinextStartCommands = { + node: 'node ./node_modules/vinext/dist/cli.js start --port 3030', + bun: 'bun --bun ./node_modules/vinext/dist/cli.js start --port 3030', +}; + const getStartCommand = () => { if (testEnv === 'development-webpack') { return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs'; @@ -22,7 +28,7 @@ const getStartCommand = () => { } if (testEnv === 'production') { - return productionStartCommands[getRuntime()]; + return (process.env.VINEXT ? vinextStartCommands : productionStartCommands)[getRuntime()]; } throw new Error(`Unknown test env: ${testEnv}`); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/build-output.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/build-output.test.ts index 8c8ed57fc7f9..cc3dfde118af 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/build-output.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/build-output.test.ts @@ -3,6 +3,9 @@ import { findAbsolutePathImports } from '@sentry-internal/test-utils'; import * as fs from 'fs'; import * as path from 'path'; import { isDevMode } from './isDevMode'; +import { isVinext } from './isVinext'; + +test.skip(isVinext, 'Checks the build output of Next.js'); test('emits no absolute-path imports into the server output', () => { const leaks = findAbsolutePathImports({ outputDir: path.join(process.cwd(), '.next', 'server') }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts index eb564116a132..59ad999e25b5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/db-page.test.ts @@ -1,7 +1,10 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpansUntilSegment, getRuntime } from '@sentry-internal/test-utils'; +import { isVinext } from './isVinext'; test('Instruments DB calls made during server-side rendering of a page', async ({ page }) => { + test.skip(isVinext && getRuntime() === 'bun', 'Bun has no runtime module hook and vinext no build-time loader'); + // The db spans are children of the segment span, which ends last. const spansPromise = collectStreamedSpansUntilSegment('nextjs-16', 'GET /db-page'); @@ -13,7 +16,8 @@ test('Instruments DB calls made during server-side rendering of a page', async ( // One page render produces spans from both injection paths: pg (externalized → runtime module // hook) and ioredis (bundle-safe allowlisted → build-time loader). Bun and Workers have no runtime module - // hook, so pg creates no spans there. + // hook, so pg creates no spans there. vinext does not run the build-time loader of webpack or Turbopack, so ioredis + // creates no spans there. if (getRuntime() !== 'bun' && getRuntime() !== 'cloudflare') { expect(spans).toContainEqual( expect.objectContaining({ @@ -28,30 +32,32 @@ test('Instruments DB calls made during server-side rendering of a page', async ( }), ); } - expect(spans).toContainEqual( - expect.objectContaining({ - name: 'set localhost:6379', - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { value: 'db.query', type: 'string' }, - 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, - 'db.system.name': { value: 'redis', type: 'string' }, - 'db.operation.name': { value: 'set', type: 'string' }, - 'db.query.text': { value: 'set page-key [1 other arguments]', type: 'string' }, + if (!isVinext) { + expect(spans).toContainEqual( + expect.objectContaining({ + name: 'set localhost:6379', + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'db.query', type: 'string' }, + 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, + 'db.system.name': { value: 'redis', type: 'string' }, + 'db.operation.name': { value: 'set', type: 'string' }, + 'db.query.text': { value: 'set page-key [1 other arguments]', type: 'string' }, + }), }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - name: 'get localhost:6379', - status: 'ok', - attributes: expect.objectContaining({ - 'sentry.op': { value: 'db.query', type: 'string' }, - 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, - 'db.system.name': { value: 'redis', type: 'string' }, - 'db.operation.name': { value: 'get', type: 'string' }, - 'db.query.text': { value: 'get page-key', type: 'string' }, + ); + expect(spans).toContainEqual( + expect.objectContaining({ + name: 'get localhost:6379', + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'db.query', type: 'string' }, + 'sentry.origin': { value: 'auto.db.redis', type: 'string' }, + 'db.system.name': { value: 'redis', type: 'string' }, + 'db.operation.name': { value: 'get', type: 'string' }, + 'db.query.text': { value: 'get page-key', type: 'string' }, + }), }), - }), - ); + ); + } }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isVinext.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isVinext.ts new file mode 100644 index 000000000000..5ae7f7a2fa79 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isVinext.ts @@ -0,0 +1 @@ +export const isVinext = !!process.env.VINEXT; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 9359c0a96c7c..0dfb2ae19c43 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -7,6 +7,7 @@ import { waitForStreamedSpan, } from '@sentry-internal/test-utils'; import { isDevMode } from './isDevMode'; +import { isVinext } from './isVinext'; test('Should create a span for middleware', async ({ request }) => { test.skip(getRuntime() === 'cloudflare', 'On Workers the middleware span is a child of the request span'); @@ -109,6 +110,10 @@ test('Faulty middlewares inside the request segment of the Worker', async ({ req test('Should trace outgoing fetch requests inside middleware', async ({ request }) => { test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm'); + test.skip( + isVinext && getRuntime() === 'bun', + 'vinext replaces `globalThis.fetch` with a wrapper of the `fetch` from before `Sentry.init`, so `@sentry/bun` creates no fetch spans', + ); // In some builds (especially webpack) the fetch span is not a child of the middleware segment but a // segment of its own, so this waits for either. `http.client` span names are low cardinality under diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/openai.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/openai.test.ts index 1ebc4f6db104..0ea958dfa173 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/openai.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/openai.test.ts @@ -1,5 +1,8 @@ import { expect, test } from '@playwright/test'; import { waitForStreamedSpans } from '@sentry-internal/test-utils'; +import { isVinext } from './isVinext'; + +test.skip(isVinext, 'vinext does not run the webpack or Turbopack loader that injects the OpenAI channel'); // gen_ai spans are emitted as a separate span-v2 envelope item (not inline on the transaction), so we // assert on the streamed spans. Attribute values are wrapped as `{ value, type }` in the v2 format. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/propagation.test.ts index 72da87388bfc..c19955031c9f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/propagation.test.ts @@ -1,7 +1,13 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getRuntime, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isVinext } from './isVinext'; test('Propagates trace for outgoing fetch requests', async ({ baseURL, request }) => { + test.skip( + isVinext && getRuntime() === 'bun', + 'vinext replaces `globalThis.fetch` with a wrapper of the `fetch` from before `Sentry.init`, so `@sentry/bun` creates no fetch spans', + ); + // Inbound span, outbound span and the http.client span in between all share one trace, and // `collectStreamedSpans` evaluates a single trace at a time, so requiring all three together // keeps them paired. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts index 27b82ae35c61..9d47aca31ecd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts @@ -1,7 +1,13 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getRuntime, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { isVinext } from './isVinext'; test('Should handle server action redirect without capturing errors', async ({ page }) => { + test.skip( + isVinext && getRuntime() === 'bun', + 'Server actions of vinext fail on Bun 1.3.14 with `ReadableStream is locked`, also without Sentry', + ); + // Wait for the initial pageload span const pageLoadSpanPromise = waitForStreamedSpan('nextjs-16', span => { return span.name === '/redirect/origin' && span.is_segment; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/vite.config.mts b/dev-packages/e2e-tests/test-applications/nextjs-16/vite.config.mts new file mode 100644 index 000000000000..6f2f0b6ebf01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/vite.config.mts @@ -0,0 +1,7 @@ +import vinext from 'vinext'; +import { defineConfig } from 'vite'; + +// Only for the `nextjs-16 (vinext)` variants, which build this app with vinext instead of Next.js. Their build command +// sets `"type": "module"`, because `vinext start` does not find the `.mjs` server files that Vite writes otherwise, and +// React 19.3, because vinext needs React 19.2.6 or newer. +export default defineConfig({ plugins: [vinext()] }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nextjs-16/wrangler.opennext.jsonc similarity index 84% rename from dev-packages/e2e-tests/test-applications/nextjs-16/wrangler.jsonc rename to dev-packages/e2e-tests/test-applications/nextjs-16/wrangler.opennext.jsonc index f6bfe6a4ec38..1ecc0cf8aa24 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/wrangler.jsonc +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/wrangler.opennext.jsonc @@ -1,3 +1,5 @@ +// Not `wrangler.jsonc`: vinext refuses to build without the Cloudflare plugin when the project root has a wrangler +// config, and the `nextjs-16 (vinext)` variants build this app for Node.js and Bun. { "$schema": "node_modules/wrangler/config-schema.json", "name": "nextjs-16", diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/.gitignore b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/.gitignore new file mode 100644 index 000000000000..3f64191fe034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/.gitignore @@ -0,0 +1,4 @@ +dist +.wrangler +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/next.config.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/next.config.ts new file mode 100644 index 000000000000..b22af960af49 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json new file mode 100644 index 000000000000..4d495c9b768e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/package.json @@ -0,0 +1,38 @@ +{ + "name": "vinext-cf-workers", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vinext build", + "preview": "wrangler dev --config dist/server/wrangler.json --port 3030 --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "next": "16.2.2", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.56.0", + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^26.1.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "@vitejs/plugin-rsc": "^0.5.35", + "react-server-dom-webpack": "^19.3.0", + "typescript": "~6.0.3", + "vinext": "1.0.0-beta.13", + "vite": "^8.3.0", + "wrangler": "^4.135.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/playwright.config.mjs new file mode 100644 index 000000000000..3dfd688a7130 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/playwright.config.mjs @@ -0,0 +1,6 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig({ + startCommand: 'pnpm preview', + port: 3030, +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/sentry.server.config.ts new file mode 100644 index 000000000000..ff0507e78d27 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/sentry.server.config.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nextjs'; + +// The server config of a Next.js app. vinext runs it in the Worker too, where this `init` creates no client, because +// `withSentry` from `@sentry/nextjs/cloudflare` (added by the Vite plugin) already handles the request. +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/api/hello/route.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/api/hello/route.ts new file mode 100644 index 000000000000..ba30786d3857 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/api/hello/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export async function GET() { + return Response.json({ message: 'Hello from vinext' }); +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/api/throw/route.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/api/throw/route.ts new file mode 100644 index 000000000000..7ffd4bcd0bf1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/api/throw/route.ts @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export async function GET() { + throw new Error('Route handler error from vinext'); +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/layout.tsx b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/layout.tsx new file mode 100644 index 000000000000..f3ef34cd8b91 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/layout.tsx @@ -0,0 +1,7 @@ +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/page.tsx b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/page.tsx new file mode 100644 index 000000000000..6c121ed6c135 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

vinext on Cloudflare Workers

; +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/parameterized/[id]/page.tsx b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/parameterized/[id]/page.tsx new file mode 100644 index 000000000000..52149eaba2ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/app/parameterized/[id]/page.tsx @@ -0,0 +1,6 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return

Parameterized page {id}

; +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/instrumentation.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/instrumentation.ts new file mode 100644 index 000000000000..39f8e4ef935c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/src/instrumentation.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('../sentry.server.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/start-event-proxy.mjs new file mode 100644 index 000000000000..1c2735871430 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'vinext-cf-workers', +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/tests/errors.test.ts new file mode 100644 index 000000000000..5f43d1cbee9f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/tests/errors.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpansUntilSegment, waitForError } from '@sentry-internal/test-utils'; + +test('Captures a route handler error in the trace of the request', async ({ request }) => { + const errorPromise = waitForError('vinext-cf-workers', event => { + return event.exception?.values?.[0]?.value === 'Route handler error from vinext'; + }); + const spansPromise = collectStreamedSpansUntilSegment('vinext-cf-workers', 'GET /api/throw'); + + const response = await request.get('/api/throw'); + expect(response.status()).toBe(500); + + const errorEvent = await errorPromise; + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; + + expect(errorEvent.sdk?.name).toBe('sentry.javascript.nextjs'); + // Only the options of `withSentry` set the environment, so no client of the `init` in `sentry.server.config.ts` sent it. + expect(errorEvent.environment).toBe('qa'); + expect(errorEvent.exception?.values?.[0]?.mechanism).toMatchObject({ + type: 'auto.function.nextjs.on_request_error', + handled: false, + }); + expect(errorEvent.contexts?.trace?.trace_id).toBe(segmentSpan.trace_id); + expect(segmentSpan.status).toBe('error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/tests/server-spans.test.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/tests/server-spans.test.ts new file mode 100644 index 000000000000..7efdd16f9e6f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/tests/server-spans.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +test('Sends the Next.js spans of a page in the request span of the Worker', async ({ request }) => { + const spansPromise = collectStreamedSpans( + 'vinext-cf-workers', + spans => + spans.some(span => span.is_segment && span.name === 'GET /parameterized/[id]') && + spans.some(span => span.name === 'render route (app) /parameterized/[id]'), + ); + + await request.get('/parameterized/42'); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; + const renderSpan = spans.find(span => span.name === 'render route (app) /parameterized/[id]')!; + + expect(segmentSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'http.server', type: 'string' }, + 'sentry.origin': { value: 'auto.http.cloudflare', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'http.route': { value: '/parameterized/[id]', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + }); + expect(spans.filter(span => getSpanOp(span) === 'http.server')).toEqual([segmentSpan]); + expect(renderSpan.attributes['sentry.segment.id']?.value).toBe(segmentSpan.span_id); +}); + +test('Sends the Next.js spans of a route handler in the request span of the Worker', async ({ request }) => { + const spansPromise = collectStreamedSpans( + 'vinext-cf-workers', + spans => + spans.some(span => span.is_segment && span.name === 'GET /api/hello') && + spans.some(span => span.name === 'executing api route (app) /api/hello'), + ); + + await request.get('/api/hello'); + + const spans = await spansPromise; + const segmentSpan = spans.find(span => span.is_segment)!; + const handlerSpan = spans.find(span => span.name === 'executing api route (app) /api/hello')!; + + expect(segmentSpan.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.http.cloudflare', type: 'string' }, + 'http.route': { value: '/api/hello', type: 'string' }, + 'http.response.status_code': { value: 200, type: 'integer' }, + }); + expect(spans.filter(span => getSpanOp(span) === 'http.server')).toEqual([segmentSpan]); + expect(handlerSpan.attributes['sentry.segment.id']?.value).toBe(segmentSpan.span_id); +}); + +test('Continues an incoming trace in the request span of the Worker', async ({ request }) => { + const traceId = crypto.randomUUID().replace(/-/g, ''); + const parentSpanId = traceId.slice(0, 16); + + const spansPromise = collectStreamedSpans( + 'vinext-cf-workers', + spans => + spans.some( + span => + span.trace_id === traceId && + span.is_segment && + span.attributes['sentry.origin']?.value === 'auto.http.cloudflare', + ) && spans.some(span => span.trace_id === traceId && span.name === 'render route (app) /parameterized/[id]'), + ); + + await request.get('/parameterized/42', { + headers: { + 'sentry-trace': `${traceId}-${parentSpanId}-1`, + baggage: `sentry-trace_id=${traceId},sentry-sample_rate=1,sentry-sampled=true`, + }, + }); + + const spans = await spansPromise; + const segmentSpan = spans.find( + span => span.is_segment && span.attributes['sentry.origin']?.value === 'auto.http.cloudflare', + )!; + const renderSpan = spans.find(span => span.name === 'render route (app) /parameterized/[id]')!; + + expect(segmentSpan.parent_span_id).toBe(parentSpanId); + expect(renderSpan.attributes['sentry.segment.id']?.value).toBe(segmentSpan.span_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/tsconfig.json b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/tsconfig.json new file mode 100644 index 000000000000..dfca71828f01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true + }, + "include": ["src/**/*", "worker/**/*", "vite.config.ts", "next.config.ts", "sentry.server.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/vite.config.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/vite.config.ts new file mode 100644 index 000000000000..6bab8f3b5d74 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/vite.config.ts @@ -0,0 +1,14 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import vinext from 'vinext'; +import { defineConfig } from 'vite'; + +// `worker/index.ts` contains no Sentry calls: in a vinext build the plugin wraps it with `withSentry` from +// `@sentry/nextjs/cloudflare`. +export default defineConfig({ + plugins: [ + vinext(), + cloudflare({ viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] } }), + sentryCloudflareVitePlugin(), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/worker/index.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/worker/index.ts new file mode 100644 index 000000000000..7b164a4bf115 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/worker/index.ts @@ -0,0 +1,3 @@ +import handler from 'vinext/server/fetch-handler'; + +export default handler; diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/worker/instrument.server.ts b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/worker/instrument.server.ts new file mode 100644 index 000000000000..dbae8f3fa0b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/worker/instrument.server.ts @@ -0,0 +1,7 @@ +// The Vite plugin uses the default export of this file, next to the Worker entry, as the options callback. +export default (env: { E2E_TEST_DSN: string }) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-cf-workers/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/wrangler.jsonc new file mode 100644 index 000000000000..6e2a5b691e8d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-cf-workers/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "vinext-cf-workers", + "main": "./worker/index.ts", + "compatibility_date": "2026-09-19", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "directory": "dist/client", + "not_found_handling": "none", + "binding": "ASSETS", + }, +}