Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions dev-packages/e2e-tests/test-applications/nextjs-16/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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)"
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') });
Expand Down
Original file line number Diff line number Diff line change
@@ -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');

Expand All @@ -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({
Expand All @@ -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' },
}),
}),
}),
);
);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const isVinext = !!process.env.VINEXT;
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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()] });
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {};

export default nextConfig;
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

export default getPlaywrightConfig({
startCommand: 'pnpm preview',
port: 3030,
});
Original file line number Diff line number Diff line change
@@ -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,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export async function GET() {
return Response.json({ message: 'Hello from vinext' });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export async function GET() {
throw new Error('Route handler error from vinext');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <p>vinext on Cloudflare Workers</p>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const dynamic = 'force-dynamic';

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <p>Parameterized page {id}</p>;
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'vinext-cf-workers',
});
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading