From 813551a86ba3541a215d95474f75a56458193f9c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 26 Feb 2026 09:51:56 -0500 Subject: [PATCH 1/9] feat(vinext): first sdk draft implementation Co-Authored-By: Claude Opus 4.6 --- .../test-applications/vinext-app/.npmrc | 2 + .../vinext-app/instrumentation-client.ts | 11 + .../vinext-app/instrumentation.ts | 15 ++ .../test-applications/vinext-app/package.json | 25 +++ .../vinext-app/playwright.config.mjs | 8 + .../vinext-app/src/app/api/test/route.ts | 9 + .../vinext-app/src/app/error-page/page.tsx | 17 ++ .../vinext-app/src/app/layout.tsx | 7 + .../vinext-app/src/app/page.tsx | 8 + .../vinext-app/start-event-proxy.mjs | 6 + .../vinext-app/tests/errors.test.ts | 33 +++ .../vinext-app/tests/transactions.test.ts | 29 +++ .../vinext-app/tsconfig.json | 12 ++ package.json | 1 + packages/vinext/.eslintrc.js | 22 ++ packages/vinext/package.json | 88 ++++++++ packages/vinext/rollup.npm.config.mjs | 22 ++ .../src/client/browserTracingIntegration.ts | 110 ++++++++++ packages/vinext/src/client/index.ts | 62 ++++++ packages/vinext/src/common/debug-build.ts | 8 + packages/vinext/src/common/index.ts | 7 + packages/vinext/src/common/types.ts | 12 ++ packages/vinext/src/common/wrappers.ts | 189 ++++++++++++++++++ packages/vinext/src/index.client.ts | 1 + packages/vinext/src/index.server.ts | 1 + packages/vinext/src/index.types.ts | 38 ++++ packages/vinext/src/index.worker.ts | 5 + .../vinext/src/server/captureRequestError.ts | 49 +++++ packages/vinext/src/server/index.ts | 97 +++++++++ packages/vinext/src/server/worker.ts | 97 +++++++++ .../vinext/src/vite/autoInstrumentation.ts | 156 +++++++++++++++ packages/vinext/src/vite/index.ts | 2 + .../vinext/src/vite/sentryVinextPlugin.ts | 116 +++++++++++ packages/vinext/src/vite/types.ts | 54 +++++ packages/vinext/test/common/wrappers.test.ts | 168 ++++++++++++++++ .../test/server/captureRequestError.test.ts | 90 +++++++++ packages/vinext/test/server/sdk.test.ts | 141 +++++++++++++ .../test/vite/autoInstrumentation.test.ts | 112 +++++++++++ .../test/vite/sentryVinextPlugin.test.ts | 67 +++++++ packages/vinext/tsconfig.json | 9 + packages/vinext/tsconfig.test.json | 9 + packages/vinext/tsconfig.types.json | 10 + packages/vinext/vite.config.ts | 9 + yarn.lock | 17 ++ 44 files changed, 1951 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/package.json create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/src/app/api/test/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/src/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/src/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/tsconfig.json create mode 100644 packages/vinext/.eslintrc.js create mode 100644 packages/vinext/package.json create mode 100644 packages/vinext/rollup.npm.config.mjs create mode 100644 packages/vinext/src/client/browserTracingIntegration.ts create mode 100644 packages/vinext/src/client/index.ts create mode 100644 packages/vinext/src/common/debug-build.ts create mode 100644 packages/vinext/src/common/index.ts create mode 100644 packages/vinext/src/common/types.ts create mode 100644 packages/vinext/src/common/wrappers.ts create mode 100644 packages/vinext/src/index.client.ts create mode 100644 packages/vinext/src/index.server.ts create mode 100644 packages/vinext/src/index.types.ts create mode 100644 packages/vinext/src/index.worker.ts create mode 100644 packages/vinext/src/server/captureRequestError.ts create mode 100644 packages/vinext/src/server/index.ts create mode 100644 packages/vinext/src/server/worker.ts create mode 100644 packages/vinext/src/vite/autoInstrumentation.ts create mode 100644 packages/vinext/src/vite/index.ts create mode 100644 packages/vinext/src/vite/sentryVinextPlugin.ts create mode 100644 packages/vinext/src/vite/types.ts create mode 100644 packages/vinext/test/common/wrappers.test.ts create mode 100644 packages/vinext/test/server/captureRequestError.test.ts create mode 100644 packages/vinext/test/server/sdk.test.ts create mode 100644 packages/vinext/test/vite/autoInstrumentation.test.ts create mode 100644 packages/vinext/test/vite/sentryVinextPlugin.test.ts create mode 100644 packages/vinext/tsconfig.json create mode 100644 packages/vinext/tsconfig.test.json create mode 100644 packages/vinext/tsconfig.types.json create mode 100644 packages/vinext/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/.npmrc b/dev-packages/e2e-tests/test-applications/vinext-app/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/vinext-app/instrumentation-client.ts new file mode 100644 index 000000000000..aff30b427ba6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/instrumentation-client.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/vinext'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1, + transportOptions: { + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/instrumentation.ts b/dev-packages/e2e-tests/test-applications/vinext-app/instrumentation.ts new file mode 100644 index 000000000000..1d9388f6cedc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/instrumentation.ts @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/vinext'; + +export async function register() { + Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1, + transportOptions: { + bufferSize: 1000, + }, + }); +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/package.json b/dev-packages/e2e-tests/test-applications/vinext-app/package.json new file mode 100644 index 000000000000..19e235301293 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/package.json @@ -0,0 +1,25 @@ +{ + "name": "vinext-app-e2e-test", + "private": true, + "type": "module", + "scripts": { + "dev": "vinext dev", + "build": "vinext build", + "start": "vinext start --port 3000", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/vinext": "latest || *", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vinext": "latest" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "typescript": "~5.5.0", + "vite": "^7.0.0" + } +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/vinext-app/playwright.config.mjs new file mode 100644 index 000000000000..4ca3c24e7fda --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, + port: 3000, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/src/app/api/test/route.ts b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/api/test/route.ts new file mode 100644 index 000000000000..331e7c9a7bfd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/api/test/route.ts @@ -0,0 +1,9 @@ +export async function GET() { + return new Response(JSON.stringify({ message: 'Hello from vinext API!' }), { + headers: { 'content-type': 'application/json' }, + }); +} + +export async function POST() { + throw new Error('API Route Error'); +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/page.tsx b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/page.tsx new file mode 100644 index 000000000000..56e8d3d42aae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +export default function ErrorPage() { + return ( +
+

Error Test Page

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/src/app/layout.tsx b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/layout.tsx new file mode 100644 index 000000000000..f3ef34cd8b91 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/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-app/src/app/page.tsx b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/page.tsx new file mode 100644 index 000000000000..52bd6ada7b57 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/page.tsx @@ -0,0 +1,8 @@ +export default function Home() { + return ( +
+

Vinext E2E Test App

+ Error Page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/vinext-app/start-event-proxy.mjs new file mode 100644 index 000000000000..a7afc0e656c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'vinext-app', +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/vinext-app/tests/errors.test.ts new file mode 100644 index 000000000000..b8f1aec489ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/tests/errors.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('captures a client-side error', async ({ page }) => { + const errorEventPromise = waitForError('vinext-app', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'E2E Test Error'; + }); + + await page.goto('/error-page'); + await page.locator('#error-button').click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'E2E Test Error', + }); +}); + +test('captures a server-side API route error', async ({ baseURL }) => { + const errorEventPromise = waitForError('vinext-app', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'API Route Error'; + }); + + await fetch(`${baseURL}/api/test`, { method: 'POST' }); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values?.[0]).toMatchObject({ + type: 'Error', + value: 'API Route Error', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/vinext-app/tests/transactions.test.ts new file mode 100644 index 000000000000..8d1a05c0f959 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/tests/transactions.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('creates a pageload transaction', async ({ page }) => { + const transactionPromise = waitForTransaction('vinext-app', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + + const transaction = await transactionPromise; + + expect(transaction.contexts?.trace).toMatchObject({ + op: 'pageload', + }); +}); + +test('creates a transaction for API routes', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('vinext-app', transactionEvent => { + return transactionEvent?.transaction === 'GET /api/test'; + }); + + await fetch(`${baseURL}/api/test`); + + const transaction = await transactionPromise; + + expect(transaction.transaction).toBe('GET /api/test'); + expect(transaction.contexts?.trace?.op).toBe('http.server'); +}); diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/tsconfig.json b/dev-packages/e2e-tests/test-applications/vinext-app/tsconfig.json new file mode 100644 index 000000000000..e7b39578801c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/package.json b/package.json index 91bc549e4527..4683c3bb7e59 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "packages/types", "packages/typescript", "packages/vercel-edge", + "packages/vinext", "packages/vue", "packages/wasm", "dev-packages/browser-integration-tests", diff --git a/packages/vinext/.eslintrc.js b/packages/vinext/.eslintrc.js new file mode 100644 index 000000000000..d567b12530d0 --- /dev/null +++ b/packages/vinext/.eslintrc.js @@ -0,0 +1,22 @@ +module.exports = { + env: { + browser: true, + node: true, + }, + overrides: [ + { + files: ['vite.config.ts'], + parserOptions: { + project: ['tsconfig.test.json'], + }, + }, + { + files: ['src/vite/**', 'src/server/**'], + rules: { + '@sentry-internal/sdk/no-optional-chaining': 'off', + '@sentry-internal/sdk/no-nullish-coalescing': 'off', + }, + }, + ], + extends: ['../../.eslintrc.js'], +}; diff --git a/packages/vinext/package.json b/packages/vinext/package.json new file mode 100644 index 000000000000..52521d8ac8ee --- /dev/null +++ b/packages/vinext/package.json @@ -0,0 +1,88 @@ +{ + "name": "@sentry/vinext", + "version": "10.40.0", + "description": "Official Sentry SDK for vinext", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vinext", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.server.js", + "module": "build/esm/index.server.js", + "browser": "build/esm/index.client.js", + "types": "build/types/index.types.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.types.d.ts", + "worker": { + "import": "./build/esm/index.worker.js", + "require": "./build/cjs/index.worker.js" + }, + "workerd": { + "import": "./build/esm/index.worker.js", + "require": "./build/cjs/index.worker.js" + }, + "browser": { + "import": "./build/esm/index.client.js", + "require": "./build/cjs/index.client.js" + }, + "node": { + "import": "./build/esm/index.server.js", + "require": "./build/cjs/index.server.js" + } + } + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/cloudflare": "10.40.0", + "@sentry/core": "10.40.0", + "@sentry/node": "10.40.0", + "@sentry/react": "10.40.0", + "@sentry/vite-plugin": "^5.1.0" + }, + "devDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^5.4.11" + }, + "peerDependencies": { + "vinext": ">=0.1.0", + "react": "^19.0.0" + }, + "peerDependenciesMeta": { + "vinext": { + "optional": true + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "circularDepCheck": "madge --circular src/index.client.ts && madge --circular src/index.server.ts && madge --circular src/index.worker.ts && madge --circular src/index.types.ts", + "clean": "rimraf build coverage sentry-vinext-*.tgz", + "fix": "eslint . --format stylish --fix", + "lint": "eslint . --format stylish", + "lint:es-compatibility": "es-check es2020 ./build/cjs/*.js && es-check es2020 ./build/esm/*.js --module", + "test": "yarn test:unit", + "test:unit": "vitest run", + "test:watch": "vitest --watch", + "yalc:publish": "yalc publish --push --sig" + }, + "volta": { + "extends": "../../package.json" + }, + "sideEffects": false +} diff --git a/packages/vinext/rollup.npm.config.mjs b/packages/vinext/rollup.npm.config.mjs new file mode 100644 index 000000000000..453baf4b5567 --- /dev/null +++ b/packages/vinext/rollup.npm.config.mjs @@ -0,0 +1,22 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default [ + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: [ + 'src/index.client.ts', + 'src/index.server.ts', + 'src/index.worker.ts', + 'src/client/index.ts', + 'src/server/index.ts', + 'src/vite/index.ts', + ], + packageSpecificConfig: { + external: ['vite', 'vinext'], + output: { + dynamicImportInCjs: true, + }, + }, + }), + ), +]; diff --git a/packages/vinext/src/client/browserTracingIntegration.ts b/packages/vinext/src/client/browserTracingIntegration.ts new file mode 100644 index 000000000000..c47ff25094ee --- /dev/null +++ b/packages/vinext/src/client/browserTracingIntegration.ts @@ -0,0 +1,110 @@ +import type { Client, Integration } from '@sentry/core'; +import { + GLOBAL_OBJ, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import { + browserTracingIntegration as originalBrowserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from '@sentry/react'; + +const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; + +/** + * A custom browser tracing integration for vinext. + * + * This wraps the standard browser tracing integration with vinext-specific + * page load and navigation instrumentation for both the Pages Router and App Router. + */ +export function browserTracingIntegration( + options: Parameters[0] = {}, +): Integration { + const browserTracingIntegrationInstance = originalBrowserTracingIntegration({ + ...options, + instrumentNavigation: false, + instrumentPageLoad: false, + }); + + const { instrumentPageLoad = true, instrumentNavigation = true } = options; + + return { + ...browserTracingIntegrationInstance, + afterAllSetup(client) { + browserTracingIntegrationInstance.afterAllSetup(client); + + if (instrumentPageLoad) { + instrumentVinextPageLoad(client); + } + + if (instrumentNavigation) { + instrumentVinextNavigation(client); + } + }, + }; +} + +function instrumentVinextPageLoad(client: Client): void { + const route = getRouteFromWindow(); + startBrowserTracingPageLoadSpan(client, { + name: route || WINDOW.location.pathname, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vinext', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: route ? 'route' : 'url', + }, + }); +} + +function instrumentVinextNavigation(client: Client): void { + const originalPushState = history.pushState.bind(history); + const originalReplaceState = history.replaceState.bind(history); + + history.pushState = function (...args) { + const result = originalPushState(...args); + startNavigationSpan(client, 'pushState'); + return result; + }; + + history.replaceState = function (...args) { + const result = originalReplaceState(...args); + startNavigationSpan(client, 'replaceState'); + return result; + }; + + WINDOW.addEventListener('popstate', () => { + startNavigationSpan(client, 'popstate'); + }); +} + +function startNavigationSpan(client: Client, trigger: string): void { + startBrowserTracingNavigationSpan(client, { + name: WINDOW.location.pathname, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vinext', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + 'vinext.navigation.trigger': trigger, + }, + }); +} + +/** + * Attempts to extract a parameterized route from the vinext __NEXT_DATA__ object + * (which vinext injects for Pages Router compatibility). + */ +function getRouteFromWindow(): string | undefined { + try { + const nextData = (GLOBAL_OBJ as unknown as Record).__NEXT_DATA__ as + | { page?: string } + | undefined; + if (nextData?.page && nextData.page !== '/') { + return nextData.page; + } + } catch { + // noop + } + return undefined; +} diff --git a/packages/vinext/src/client/index.ts b/packages/vinext/src/client/index.ts new file mode 100644 index 000000000000..ac3cfbc488ba --- /dev/null +++ b/packages/vinext/src/client/index.ts @@ -0,0 +1,62 @@ +/* eslint-disable import/export */ +import type { Client, EventProcessor, Integration } from '@sentry/core'; +import { addEventProcessor, applySdkMetadata } from '@sentry/core'; +import type { BrowserOptions } from '@sentry/react'; +import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; +import { browserTracingIntegration } from './browserTracingIntegration'; + +export type { BrowserOptions }; +export { browserTracingIntegration } from './browserTracingIntegration'; + +// Treeshakable guard to remove all code related to tracing +declare const __SENTRY_TRACING__: boolean; + +/** Inits the Sentry vinext SDK on the browser. */ +export function init(options: BrowserOptions): Client | undefined { + const opts = { + environment: options.environment || process.env.NODE_ENV, + defaultIntegrations: getDefaultIntegrations(options), + ...options, + } satisfies BrowserOptions; + + applySdkMetadata(opts, 'vinext', ['vinext', 'react']); + + const client = reactInit(opts); + + const filter404Transactions: EventProcessor = event => + event.type === 'transaction' && event.transaction === '/404' ? null : event; + filter404Transactions.id = 'VinextClient404Filter'; + addEventProcessor(filter404Transactions); + + const filterRedirectErrors: EventProcessor = (_event, hint) => { + const originalException = hint?.originalException; + if ( + typeof originalException === 'object' && + originalException !== null && + 'digest' in originalException && + typeof (originalException as { digest: unknown }).digest === 'string' && + (originalException as { digest: string }).digest.startsWith('NEXT_REDIRECT') + ) { + return null; + } + return _event; + }; + filterRedirectErrors.id = 'VinextRedirectErrorFilter'; + addEventProcessor(filterRedirectErrors); + + return client; +} + +function getDefaultIntegrations(options: BrowserOptions): Integration[] { + const customDefaultIntegrations = getReactDefaultIntegrations(options); + + if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { + customDefaultIntegrations.push(browserTracingIntegration()); + } + + return customDefaultIntegrations; +} + +export * from '../common'; + +export * from '@sentry/react'; diff --git a/packages/vinext/src/common/debug-build.ts b/packages/vinext/src/common/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/vinext/src/common/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/vinext/src/common/index.ts b/packages/vinext/src/common/index.ts new file mode 100644 index 000000000000..301ea7af422b --- /dev/null +++ b/packages/vinext/src/common/index.ts @@ -0,0 +1,7 @@ +export type { ErrorContext, RequestInfo } from './types'; +export { + wrapRouteHandlerWithSentry, + wrapServerComponentWithSentry, + wrapMiddlewareWithSentry, + wrapApiHandlerWithSentry, +} from './wrappers'; diff --git a/packages/vinext/src/common/types.ts b/packages/vinext/src/common/types.ts new file mode 100644 index 000000000000..e48688a87c70 --- /dev/null +++ b/packages/vinext/src/common/types.ts @@ -0,0 +1,12 @@ +export type RequestInfo = { + path: string; + method: string; + headers: Record; +}; + +export type ErrorContext = { + routerKind: string; + routePath: string; + routeType: string; + revalidateReason?: 'on-demand' | 'stale' | undefined; +}; diff --git a/packages/vinext/src/common/wrappers.ts b/packages/vinext/src/common/wrappers.ts new file mode 100644 index 000000000000..7e5315a5f4d7 --- /dev/null +++ b/packages/vinext/src/common/wrappers.ts @@ -0,0 +1,189 @@ +import { + captureException, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + startSpan, + withIsolationScope, +} from '@sentry/core'; + +type ServerComponentContext = { + componentRoute: string; + componentType: string; +}; + +/** + * Wraps a vinext App Router route handler with Sentry instrumentation. + */ +export function wrapRouteHandlerWithSentry unknown>( + handler: T, + method: string, + parameterizedRoute: string, +): T { + return (async function sentryWrappedRouteHandler(this: unknown, ...args: unknown[]) { + return withIsolationScope(async isolationScope => { + isolationScope.setTransactionName(`${method} ${parameterizedRoute}`); + + return startSpan( + { + name: `${method} ${parameterizedRoute}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.vinext', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.method': method, + }, + }, + async () => { + try { + return await handler.apply(this, args); + } catch (error) { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.function.vinext.route_handler', + }, + }); + throw error; + } + }, + ); + }); + }) as unknown as T; +} + +/** + * Wraps a vinext App Router server component with Sentry instrumentation. + */ +export function wrapServerComponentWithSentry unknown>( + component: T, + context: ServerComponentContext, +): T { + const { componentRoute, componentType } = context; + + return (async function sentryWrappedServerComponent(this: unknown, ...args: unknown[]) { + return withIsolationScope(async isolationScope => { + isolationScope.setTransactionName(componentRoute); + + return startSpan( + { + name: `${componentType} ${componentRoute}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.${componentType}.render`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.vinext', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'vinext.component_type': componentType, + }, + }, + async () => { + try { + return await component.apply(this, args); + } catch (error) { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.function.vinext.server_component', + }, + }); + throw error; + } + }, + ); + }); + }) as unknown as T; +} + +/** + * Wraps a vinext middleware function with Sentry instrumentation. + */ +export function wrapMiddlewareWithSentry unknown>(middleware: T): T { + return (async function sentryWrappedMiddleware(this: unknown, ...args: unknown[]) { + return withIsolationScope(async isolationScope => { + // Try to extract the path from the first argument (Request object) + const request = args[0] as { url?: string; method?: string } | undefined; + let requestPath = '/'; + let method = 'GET'; + + if (request?.url) { + try { + const url = new URL(request.url); + requestPath = url.pathname; + } catch { + // noop + } + } + if (request?.method) { + method = request.method; + } + + isolationScope.setTransactionName(`middleware ${method} ${requestPath}`); + + return startSpan( + { + name: `middleware ${method} ${requestPath}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server.middleware', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.vinext.middleware', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }, + async () => { + try { + return await middleware.apply(this, args); + } catch (error) { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.function.vinext.middleware', + }, + }); + throw error; + } + }, + ); + }); + }) as unknown as T; +} + +/** + * Wraps a vinext Pages Router API handler with Sentry instrumentation. + */ +export function wrapApiHandlerWithSentry unknown>( + handler: T, + parameterizedRoute: string, +): T { + return (async function sentryWrappedApiHandler(this: unknown, ...args: unknown[]) { + return withIsolationScope(async isolationScope => { + // Try to extract the method from the first argument (IncomingMessage) + const req = args[0] as { method?: string } | undefined; + const method = req?.method || 'GET'; + + isolationScope.setTransactionName(`${method} ${parameterizedRoute}`); + + return startSpan( + { + name: `${method} ${parameterizedRoute}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.vinext.api_route', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.method': method, + }, + }, + async () => { + try { + return await handler.apply(this, args); + } catch (error) { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.function.vinext.api_route', + }, + }); + throw error; + } + }, + ); + }); + }) as unknown as T; +} diff --git a/packages/vinext/src/index.client.ts b/packages/vinext/src/index.client.ts new file mode 100644 index 000000000000..4f1cce44fa36 --- /dev/null +++ b/packages/vinext/src/index.client.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/packages/vinext/src/index.server.ts b/packages/vinext/src/index.server.ts new file mode 100644 index 000000000000..0ce5251aa327 --- /dev/null +++ b/packages/vinext/src/index.server.ts @@ -0,0 +1 @@ +export * from './server'; diff --git a/packages/vinext/src/index.types.ts b/packages/vinext/src/index.types.ts new file mode 100644 index 000000000000..92f8bd3089fb --- /dev/null +++ b/packages/vinext/src/index.types.ts @@ -0,0 +1,38 @@ +import type { Client, Integration, Options, StackParser } from '@sentry/core'; +import type * as clientSdk from './client'; +import type * as serverSdk from './server'; + +/** Initializes Sentry vinext SDK */ +export declare function init(options: Options | clientSdk.BrowserOptions | serverSdk.NodeOptions): Client | undefined; + +export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; +export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; + +export declare const getDefaultIntegrations: (options: Options) => Integration[]; +export declare const defaultStackParser: StackParser; + +export declare function getSentryRelease(fallback?: string): string | undefined; + +export declare const ErrorBoundary: typeof clientSdk.ErrorBoundary; +export declare const showReportDialog: typeof clientSdk.showReportDialog; +export declare const withErrorBoundary: typeof clientSdk.withErrorBoundary; + +export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; + +export { captureRequestError } from './server/captureRequestError'; +export { sentryVinext, type SentryVinextPluginOptions } from './vite'; + +export { + wrapRouteHandlerWithSentry, + wrapServerComponentWithSentry, + wrapMiddlewareWithSentry, + wrapApiHandlerWithSentry, +} from './common'; + +export type { ErrorContext, RequestInfo } from './common'; + +// Re-export from client +export { browserTracingIntegration } from './client'; + +// Re-export from server +export { init as serverInit } from './server'; diff --git a/packages/vinext/src/index.worker.ts b/packages/vinext/src/index.worker.ts new file mode 100644 index 000000000000..0eef2419b358 --- /dev/null +++ b/packages/vinext/src/index.worker.ts @@ -0,0 +1,5 @@ +/* eslint-disable import/export */ +export * from '@sentry/cloudflare'; +export { applyVinextEventProcessors, withSentry, wrapRequestHandler } from './server/worker'; +export { captureRequestError } from './server/captureRequestError'; +export * from './common'; diff --git a/packages/vinext/src/server/captureRequestError.ts b/packages/vinext/src/server/captureRequestError.ts new file mode 100644 index 000000000000..2adbece2bda2 --- /dev/null +++ b/packages/vinext/src/server/captureRequestError.ts @@ -0,0 +1,49 @@ +import type { RequestEventData } from '@sentry/core'; +import { captureException, flush, headersToDict, vercelWaitUntil, withScope } from '@sentry/core'; +import type { ErrorContext, RequestInfo } from '../common/types'; + +/** + * Reports errors passed to vinext's `onRequestError` instrumentation hook. + * + * Usage in `instrumentation.ts`: + * ```ts + * import * as Sentry from '@sentry/vinext'; + * export const onRequestError = Sentry.captureRequestError; + * ``` + */ +export function captureRequestError(error: unknown, request: RequestInfo, errorContext: ErrorContext): void { + withScope(scope => { + scope.setSDKProcessingMetadata({ + normalizedRequest: { + headers: headersToDict(request.headers), + method: request.method, + } satisfies RequestEventData, + }); + + scope.setContext('vinext', { + request_path: request.path, + router_kind: errorContext.routerKind, + router_path: errorContext.routePath, + route_type: errorContext.routeType, + }); + + scope.setTransactionName(`${request.method} ${errorContext.routePath}`); + + captureException(error, { + mechanism: { + handled: false, + type: 'auto.function.vinext.on_request_error', + }, + }); + + vercelWaitUntil(flushSafelyWithTimeout()); + }); +} + +async function flushSafelyWithTimeout(): Promise { + try { + await flush(2000); + } catch { + // noop + } +} diff --git a/packages/vinext/src/server/index.ts b/packages/vinext/src/server/index.ts new file mode 100644 index 000000000000..8da2b22ef3ae --- /dev/null +++ b/packages/vinext/src/server/index.ts @@ -0,0 +1,97 @@ +/* eslint-disable import/export */ +import type { EventProcessor } from '@sentry/core'; +import { applySdkMetadata, consoleSandbox, getClient, getGlobalScope } from '@sentry/core'; +import type { NodeClient, NodeOptions } from '@sentry/node'; +import { getDefaultIntegrations, init as nodeInit } from '@sentry/node'; +import { DEBUG_BUILD } from '../common/debug-build'; + +export type { NodeOptions }; + +/** Inits the Sentry vinext SDK on the Node.js server. */ +export function init(options: NodeOptions): NodeClient | undefined { + if (sdkAlreadyInitialized()) { + DEBUG_BUILD && + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('[@sentry/vinext] SDK already initialized on the server.'); + }); + return; + } + + const opts: NodeOptions = { + environment: options.environment || process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV, + defaultIntegrations: getDefaultIntegrations(options), + ...options, + }; + + applySdkMetadata(opts, 'vinext', ['vinext', 'node']); + + const client = nodeInit(opts); + + getGlobalScope().addEventProcessor( + Object.assign( + (event => { + if (event.type === 'transaction') { + if (event.transaction?.match(/\/__vinext\//)) { + return null; + } + + if ( + event.transaction === '/404' || + event.transaction?.match(/^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \/(404|_not-found)$/) + ) { + return null; + } + } + + return event; + }) satisfies EventProcessor, + { id: 'VinextLowQualityTransactionsFilter' }, + ), + ); + + getGlobalScope().addEventProcessor( + Object.assign( + ((event, hint) => { + if (event.type !== undefined) { + return event; + } + + const originalException = hint.originalException; + + const isPostponeError = + typeof originalException === 'object' && + originalException !== null && + '$$typeof' in originalException && + originalException.$$typeof === Symbol.for('react.postpone'); + + if (isPostponeError) { + return null; + } + + const exceptionMessage = event.exception?.values?.[0]?.value; + if ( + exceptionMessage?.includes('Suspense Exception: This is not a real error!') || + exceptionMessage?.includes('Suspense Exception: This is not a real error, and should not leak') + ) { + return null; + } + + return event; + }) satisfies EventProcessor, + { id: 'VinextDropReactControlFlowErrors' }, + ), + ); + + return client; +} + +function sdkAlreadyInitialized(): boolean { + return !!getClient(); +} + +export { captureRequestError } from './captureRequestError'; + +export * from '../common'; + +export * from '@sentry/node'; diff --git a/packages/vinext/src/server/worker.ts b/packages/vinext/src/server/worker.ts new file mode 100644 index 000000000000..9b9bc460e938 --- /dev/null +++ b/packages/vinext/src/server/worker.ts @@ -0,0 +1,97 @@ +import type { EventProcessor } from '@sentry/core'; +import { applySdkMetadata, getClient, getGlobalScope } from '@sentry/core'; + +/** + * Inits the Sentry vinext SDK on Cloudflare Workers. + * + * On Cloudflare Workers, users should use the `withSentry` wrapper from `@sentry/cloudflare` + * to initialize the SDK within the Worker's fetch handler. This function applies vinext-specific + * event processors after the Cloudflare SDK has been initialized. + * + * @example + * ```ts + * // worker/index.ts (Cloudflare Workers entry) + * import { withSentry } from '@sentry/cloudflare'; + * + * export default withSentry( + * (env) => ({ dsn: env.SENTRY_DSN }), + * { fetch(request, env, ctx) { ... } } + * ); + * ``` + * + * Then call `applyVinextEventProcessors()` in the `register()` callback + * of your `instrumentation.ts`. + */ +export function applyVinextEventProcessors(): void { + if (sdkAlreadyInitialized()) { + applyProcessors(); + } +} + +function applyProcessors(): void { + applySdkMetadata({}, 'vinext', ['vinext', 'cloudflare']); + + getGlobalScope().addEventProcessor( + Object.assign( + (event => { + if (event.type === 'transaction') { + if (event.transaction?.match(/\/__vinext\//)) { + return null; + } + + if ( + event.transaction === '/404' || + event.transaction?.match(/^(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH) \/(404|_not-found)$/) + ) { + return null; + } + } + + return event; + }) satisfies EventProcessor, + { id: 'VinextWorkerLowQualityTransactionsFilter' }, + ), + ); + + getGlobalScope().addEventProcessor( + Object.assign( + ((event, hint) => { + if (event.type !== undefined) { + return event; + } + + const originalException = hint.originalException; + + const isPostponeError = + typeof originalException === 'object' && + originalException !== null && + '$$typeof' in originalException && + originalException.$$typeof === Symbol.for('react.postpone'); + + if (isPostponeError) { + return null; + } + + const exceptionMessage = event.exception?.values?.[0]?.value; + if ( + exceptionMessage?.includes('Suspense Exception: This is not a real error!') || + exceptionMessage?.includes('Suspense Exception: This is not a real error, and should not leak') + ) { + return null; + } + + return event; + }) satisfies EventProcessor, + { id: 'VinextWorkerDropReactControlFlowErrors' }, + ), + ); +} + +function sdkAlreadyInitialized(): boolean { + return !!getClient(); +} + +// We don't provide a standalone `init()` for Workers because +// `@sentry/cloudflare` provides `withSentry()` which handles initialization. +// Re-export cloudflare utilities for convenience. +export { withSentry, wrapRequestHandler } from '@sentry/cloudflare'; diff --git a/packages/vinext/src/vite/autoInstrumentation.ts b/packages/vinext/src/vite/autoInstrumentation.ts new file mode 100644 index 000000000000..05061503230b --- /dev/null +++ b/packages/vinext/src/vite/autoInstrumentation.ts @@ -0,0 +1,156 @@ +import * as path from 'path'; +import type { Plugin } from 'vite'; +import type { AutoInstrumentOptions } from './types'; + +const WRAPPED_MODULE_SUFFIX = '?sentry-auto-wrap'; + +/** + * Creates a Vite plugin that automatically instruments vinext application code. + * Wraps App Router server components, route handlers, middleware, and Pages Router API routes. + */ +export function makeAutoInstrumentationPlugin(options: boolean | AutoInstrumentOptions): Plugin { + const resolvedOptions: Required = { + serverComponents: true, + routeHandlers: true, + middleware: true, + apiRoutes: true, + ...(typeof options === 'object' ? options : {}), + }; + + return { + name: 'sentry-vinext-auto-instrumentation', + enforce: 'pre', + + load(id) { + const filename = path.basename(id); + const normalizedId = id.replace(/\\/g, '/'); + + // Skip already-wrapped modules to avoid infinite recursion + if (id.includes(WRAPPED_MODULE_SUFFIX)) { + return null; + } + + // App Router route handlers: app/**/route.(ts|js|tsx|jsx) + if (resolvedOptions.routeHandlers && isRouteHandler(normalizedId, filename)) { + const parameterizedRoute = extractAppRouterRoute(normalizedId); + return getRouteHandlerWrapperCode(id, parameterizedRoute); + } + + // App Router server components: app/**/page.(ts|js|tsx|jsx) and app/**/layout.(ts|js|tsx|jsx) + if (resolvedOptions.serverComponents && isServerComponent(normalizedId, filename)) { + const parameterizedRoute = extractAppRouterRoute(normalizedId); + const componentType = filename.startsWith('page') ? 'page' : 'layout'; + return getServerComponentWrapperCode(id, parameterizedRoute, componentType); + } + + // Middleware: middleware.(ts|js) + if (resolvedOptions.middleware && isMiddleware(normalizedId, filename)) { + return getMiddlewareWrapperCode(id); + } + + // Pages Router API routes: pages/api/** + if (resolvedOptions.apiRoutes && isApiRoute(normalizedId, filename)) { + const parameterizedRoute = extractPagesRouterRoute(normalizedId); + return getApiRouteWrapperCode(id, parameterizedRoute); + } + + return null; + }, + }; +} + +function isRouteHandler(normalizedId: string, filename: string): boolean { + return /\/app\//.test(normalizedId) && /^route\.(ts|js|tsx|jsx|mts|mjs)$/.test(filename); +} + +function isServerComponent(normalizedId: string, filename: string): boolean { + return /\/app\//.test(normalizedId) && /^(page|layout)\.(ts|js|tsx|jsx|mts|mjs)$/.test(filename); +} + +function isMiddleware(normalizedId: string, filename: string): boolean { + return ( + /^middleware\.(ts|js|mts|mjs)$/.test(filename) && + // Ensure it's at the project root or src/ level, not nested in app/ + !normalizedId.includes('/app/') && + !normalizedId.includes('/pages/') + ); +} + +function isApiRoute(normalizedId: string, filename: string): boolean { + return /\/pages\/api\//.test(normalizedId) && /\.(ts|js|tsx|jsx|mts|mjs)$/.test(filename); +} + +/** + * Extracts a parameterized route from an App Router file path. + * e.g. `/path/to/app/blog/[slug]/page.tsx` -> `/blog/[slug]` + */ +function extractAppRouterRoute(normalizedId: string): string { + const appMatch = normalizedId.match(/\/app(\/.*?)\/(page|layout|route)\.\w+$/); + if (appMatch) { + return appMatch[1] || '/'; + } + // Root level: app/page.tsx -> / + if (/\/app\/(page|layout|route)\.\w+$/.test(normalizedId)) { + return '/'; + } + return '/'; +} + +/** + * Extracts a parameterized route from a Pages Router file path. + * e.g. `/path/to/pages/api/users/[id].ts` -> `/api/users/[id]` + */ +function extractPagesRouterRoute(normalizedId: string): string { + const pagesMatch = normalizedId.match(/\/pages(\/.*?)\.\w+$/); + if (pagesMatch) { + // Remove /index suffix since /pages/api/index.ts -> /api + return pagesMatch[1]?.replace(/\/index$/, '') || '/'; + } + return '/'; +} + +function getRouteHandlerWrapperCode(id: string, route: string): string { + const wrappedId = `${id}${WRAPPED_MODULE_SUFFIX}`; + return [ + 'import { wrapRouteHandlerWithSentry as _sentry_wrapRouteHandler } from "@sentry/vinext";', + `import * as _sentry_routeModule from ${JSON.stringify(wrappedId)};`, + `const _sentry_route = ${JSON.stringify(route)};`, + 'export const GET = _sentry_routeModule.GET ? _sentry_wrapRouteHandler(_sentry_routeModule.GET, "GET", _sentry_route) : undefined;', + 'export const POST = _sentry_routeModule.POST ? _sentry_wrapRouteHandler(_sentry_routeModule.POST, "POST", _sentry_route) : undefined;', + 'export const PUT = _sentry_routeModule.PUT ? _sentry_wrapRouteHandler(_sentry_routeModule.PUT, "PUT", _sentry_route) : undefined;', + 'export const PATCH = _sentry_routeModule.PATCH ? _sentry_wrapRouteHandler(_sentry_routeModule.PATCH, "PATCH", _sentry_route) : undefined;', + 'export const DELETE = _sentry_routeModule.DELETE ? _sentry_wrapRouteHandler(_sentry_routeModule.DELETE, "DELETE", _sentry_route) : undefined;', + 'export const HEAD = _sentry_routeModule.HEAD ? _sentry_wrapRouteHandler(_sentry_routeModule.HEAD, "HEAD", _sentry_route) : undefined;', + 'export const OPTIONS = _sentry_routeModule.OPTIONS ? _sentry_wrapRouteHandler(_sentry_routeModule.OPTIONS, "OPTIONS", _sentry_route) : undefined;', + ].join('\n'); +} + +function getServerComponentWrapperCode(id: string, route: string, componentType: string): string { + const wrappedId = `${id}${WRAPPED_MODULE_SUFFIX}`; + return [ + 'import { wrapServerComponentWithSentry as _sentry_wrapServerComponent } from "@sentry/vinext";', + `import * as _sentry_componentModule from ${JSON.stringify(wrappedId)};`, + `export default _sentry_componentModule.default ? _sentry_wrapServerComponent(_sentry_componentModule.default, { componentRoute: ${JSON.stringify(route)}, componentType: ${JSON.stringify(componentType)} }) : undefined;`, + `export * from ${JSON.stringify(wrappedId)};`, + ].join('\n'); +} + +function getMiddlewareWrapperCode(id: string): string { + const wrappedId = `${id}${WRAPPED_MODULE_SUFFIX}`; + return [ + 'import { wrapMiddlewareWithSentry as _sentry_wrapMiddleware } from "@sentry/vinext";', + `import * as _sentry_middlewareModule from ${JSON.stringify(wrappedId)};`, + 'export default _sentry_middlewareModule.default ? _sentry_wrapMiddleware(_sentry_middlewareModule.default) : undefined;', + 'export const config = _sentry_middlewareModule.config;', + ].join('\n'); +} + +function getApiRouteWrapperCode(id: string, route: string): string { + const wrappedId = `${id}${WRAPPED_MODULE_SUFFIX}`; + return [ + 'import { wrapApiHandlerWithSentry as _sentry_wrapApiHandler } from "@sentry/vinext";', + `import * as _sentry_apiModule from ${JSON.stringify(wrappedId)};`, + `export default _sentry_apiModule.default ? _sentry_wrapApiHandler(_sentry_apiModule.default, ${JSON.stringify(route)}) : _sentry_apiModule.default;`, + `export * from ${JSON.stringify(wrappedId)};`, + ].join('\n'); +} diff --git a/packages/vinext/src/vite/index.ts b/packages/vinext/src/vite/index.ts new file mode 100644 index 000000000000..606920fce6bb --- /dev/null +++ b/packages/vinext/src/vite/index.ts @@ -0,0 +1,2 @@ +export { sentryVinext } from './sentryVinextPlugin'; +export type { SentryVinextPluginOptions } from './types'; diff --git a/packages/vinext/src/vite/sentryVinextPlugin.ts b/packages/vinext/src/vite/sentryVinextPlugin.ts new file mode 100644 index 000000000000..0c3bfcd8c64a --- /dev/null +++ b/packages/vinext/src/vite/sentryVinextPlugin.ts @@ -0,0 +1,116 @@ +import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; +import type { Plugin, UserConfig } from 'vite'; +import { makeAutoInstrumentationPlugin } from './autoInstrumentation'; +import type { SentryVinextPluginOptions } from './types'; + +const DEFAULT_OPTIONS: SentryVinextPluginOptions = { + autoUploadSourceMaps: true, + autoInstrument: true, +}; + +/** + * Sentry Vite plugin for vinext applications. + * + * Handles source map upload and auto-instrumentation of server components, + * route handlers, and middleware. + * + * @example + * ```ts + * // vite.config.ts + * import { sentryVinext } from '@sentry/vinext'; + * import vinext from 'vinext'; + * + * export default defineConfig({ + * plugins: [ + * vinext(), + * sentryVinext({ + * org: 'my-org', + * project: 'my-project', + * authToken: process.env.SENTRY_AUTH_TOKEN, + * }), + * ], + * }); + * ``` + */ +export async function sentryVinext(options: SentryVinextPluginOptions = {}): Promise { + const mergedOptions = { + ...DEFAULT_OPTIONS, + ...options, + }; + + const sentryPlugins: Plugin[] = []; + + if (mergedOptions.autoInstrument) { + sentryPlugins.push(makeAutoInstrumentationPlugin(mergedOptions.autoInstrument)); + } + + sentryPlugins.push(makeSourceMapSettingsPlugin(mergedOptions)); + + if (mergedOptions.autoUploadSourceMaps && process.env.NODE_ENV !== 'development') { + const vitePluginOptions = buildSentryVitePluginOptions(mergedOptions); + if (vitePluginOptions) { + const uploadPlugins = await sentryVitePlugin(vitePluginOptions); + sentryPlugins.push(...uploadPlugins); + } + } + + return sentryPlugins; +} + +function makeSourceMapSettingsPlugin(options: SentryVinextPluginOptions): Plugin { + return { + name: 'sentry-vinext-source-map-settings', + apply: 'build', + config(config: UserConfig) { + const currentSourceMap = config.build?.sourcemap; + + if (currentSourceMap === false) { + if (options.debug) { + // eslint-disable-next-line no-console + console.warn( + '[Sentry] Source map generation is disabled in your Vite config. Sentry will not override this. Without source maps, code snippets on the Sentry Issues page will remain minified.', + ); + } + return config; + } + + if (currentSourceMap && ['hidden', 'inline', true].includes(currentSourceMap as string | boolean)) { + return config; + } + + return { + ...config, + build: { + ...config.build, + sourcemap: 'hidden', + }, + }; + }, + }; +} + +function buildSentryVitePluginOptions(options: SentryVinextPluginOptions): SentryVitePluginOptions | null { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + autoInstrument: _ai, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + autoUploadSourceMaps: _au, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + bundleSizeOptimizations: _bso, + ...vitePluginOptions + } = options; + + if (!vitePluginOptions.org && !process.env.SENTRY_ORG) { + return null; + } + + return { + ...vitePluginOptions, + _metaOptions: { + telemetry: { + metaFramework: 'vinext', + }, + }, + }; +} diff --git a/packages/vinext/src/vite/types.ts b/packages/vinext/src/vite/types.ts new file mode 100644 index 000000000000..49b26625c6f1 --- /dev/null +++ b/packages/vinext/src/vite/types.ts @@ -0,0 +1,54 @@ +import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; + +export interface SentryVinextPluginOptions extends Partial { + /** + * If enabled, the plugin will automatically wrap route handlers, server components, + * and middleware with Sentry instrumentation. + * + * @default true + */ + autoInstrument?: boolean | AutoInstrumentOptions; + + /** + * If enabled, source maps will be automatically uploaded to Sentry during production builds. + * + * @default true + */ + autoUploadSourceMaps?: boolean; + + /** + * Options for bundle size optimizations. + */ + bundleSizeOptimizations?: { + excludeDebugStatements?: boolean; + excludeReplayIframe?: boolean; + excludeReplayShadowDom?: boolean; + excludeReplayWorker?: boolean; + }; +} + +export interface AutoInstrumentOptions { + /** + * Whether to auto-wrap App Router server components (page.tsx, layout.tsx). + * @default true + */ + serverComponents?: boolean; + + /** + * Whether to auto-wrap App Router route handlers (route.ts). + * @default true + */ + routeHandlers?: boolean; + + /** + * Whether to auto-wrap middleware (middleware.ts). + * @default true + */ + middleware?: boolean; + + /** + * Whether to auto-wrap Pages Router API routes. + * @default true + */ + apiRoutes?: boolean; +} diff --git a/packages/vinext/test/common/wrappers.test.ts b/packages/vinext/test/common/wrappers.test.ts new file mode 100644 index 000000000000..f4dd2ad0797e --- /dev/null +++ b/packages/vinext/test/common/wrappers.test.ts @@ -0,0 +1,168 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + wrapApiHandlerWithSentry, + wrapMiddlewareWithSentry, + wrapRouteHandlerWithSentry, + wrapServerComponentWithSentry, +} from '../../src/common/wrappers'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + startSpan: vi.fn((_options, callback) => callback()), + withIsolationScope: vi.fn(callback => + callback({ + setTransactionName: vi.fn(), + }), + ), + captureException: vi.fn(), + }; +}); + +describe('wrapRouteHandlerWithSentry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls the original handler and returns its result', async () => { + const handler = vi.fn().mockResolvedValue(new Response('OK')); + const wrapped = wrapRouteHandlerWithSentry(handler, 'GET', '/api/users'); + + const result = await wrapped(); + + expect(handler).toHaveBeenCalledTimes(1); + expect(result).toBeInstanceOf(Response); + }); + + it('creates a span with the correct attributes', async () => { + const handler = vi.fn().mockResolvedValue(new Response('OK')); + const wrapped = wrapRouteHandlerWithSentry(handler, 'POST', '/api/data'); + + await wrapped(); + + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'POST /api/data', + attributes: expect.objectContaining({ + 'http.method': 'POST', + }), + }), + expect.any(Function), + ); + }); + + it('captures errors and re-throws', async () => { + const error = new Error('handler failed'); + const handler = vi.fn().mockRejectedValue(error); + const wrapped = wrapRouteHandlerWithSentry(handler, 'GET', '/api/test'); + + await expect(wrapped()).rejects.toThrow('handler failed'); + expect(SentryCore.captureException).toHaveBeenCalledWith(error, { + mechanism: { + handled: false, + type: 'auto.function.vinext.route_handler', + }, + }); + }); +}); + +describe('wrapServerComponentWithSentry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls the original component', async () => { + const component = vi.fn().mockResolvedValue('
Hello
'); + const wrapped = wrapServerComponentWithSentry(component, { + componentRoute: '/blog/[slug]', + componentType: 'page', + }); + + await wrapped({ slug: 'test' }); + + expect(component).toHaveBeenCalledWith({ slug: 'test' }); + }); + + it('creates a span with correct attributes', async () => { + const component = vi.fn().mockResolvedValue(null); + const wrapped = wrapServerComponentWithSentry(component, { + componentRoute: '/about', + componentType: 'layout', + }); + + await wrapped(); + + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'layout /about', + attributes: expect.objectContaining({ + 'vinext.component_type': 'layout', + }), + }), + expect.any(Function), + ); + }); +}); + +describe('wrapMiddlewareWithSentry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls the original middleware', async () => { + const middleware = vi.fn().mockResolvedValue(new Response()); + const wrapped = wrapMiddlewareWithSentry(middleware); + + const request = { url: 'http://localhost:3000/api/test', method: 'GET' }; + await wrapped(request); + + expect(middleware).toHaveBeenCalledWith(request); + }); + + it('extracts path from request URL', async () => { + const middleware = vi.fn().mockResolvedValue(new Response()); + const wrapped = wrapMiddlewareWithSentry(middleware); + + await wrapped({ url: 'http://localhost:3000/protected', method: 'POST' }); + + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'middleware POST /protected', + }), + expect.any(Function), + ); + }); +}); + +describe('wrapApiHandlerWithSentry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls the original handler', async () => { + const handler = vi.fn(); + const wrapped = wrapApiHandlerWithSentry(handler, '/api/users'); + + const req = { method: 'GET' }; + const res = {}; + await wrapped(req, res); + + expect(handler).toHaveBeenCalledWith(req, res); + }); + + it('creates a span with the route', async () => { + const handler = vi.fn(); + const wrapped = wrapApiHandlerWithSentry(handler, '/api/users/[id]'); + + await wrapped({ method: 'PUT' }, {}); + + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'PUT /api/users/[id]', + }), + expect.any(Function), + ); + }); +}); diff --git a/packages/vinext/test/server/captureRequestError.test.ts b/packages/vinext/test/server/captureRequestError.test.ts new file mode 100644 index 000000000000..8f101fc1a1be --- /dev/null +++ b/packages/vinext/test/server/captureRequestError.test.ts @@ -0,0 +1,90 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { captureRequestError } from '../../src/server/captureRequestError'; + +describe('captureRequestError', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('captures the error with the correct context', () => { + const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => ''); + const withScopeSpy = vi.spyOn(SentryCore, 'withScope').mockImplementation(fn => { + const mockScope = { + setSDKProcessingMetadata: vi.fn(), + setContext: vi.fn(), + setTransactionName: vi.fn(), + }; + return fn(mockScope as any); + }); + + const error = new Error('test error'); + const request = { + path: '/api/users', + method: 'POST', + headers: { 'content-type': 'application/json' }, + }; + const errorContext = { + routerKind: 'App Router', + routePath: '/api/users', + routeType: 'route', + }; + + captureRequestError(error, request, errorContext); + + expect(withScopeSpy).toHaveBeenCalledTimes(1); + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { + handled: false, + type: 'auto.function.vinext.on_request_error', + }, + }); + }); + + it('sets the vinext context on the scope', () => { + vi.spyOn(SentryCore, 'captureException').mockImplementation(() => ''); + const setContextFn = vi.fn(); + vi.spyOn(SentryCore, 'withScope').mockImplementation(fn => { + const mockScope = { + setSDKProcessingMetadata: vi.fn(), + setContext: setContextFn, + setTransactionName: vi.fn(), + }; + return fn(mockScope as any); + }); + + captureRequestError( + new Error('test'), + { path: '/blog/[slug]', method: 'GET', headers: {} }, + { routerKind: 'App Router', routePath: '/blog/[slug]', routeType: 'render' }, + ); + + expect(setContextFn).toHaveBeenCalledWith('vinext', { + request_path: '/blog/[slug]', + router_kind: 'App Router', + router_path: '/blog/[slug]', + route_type: 'render', + }); + }); + + it('sets the transaction name from request method and route', () => { + vi.spyOn(SentryCore, 'captureException').mockImplementation(() => ''); + const setTransactionNameFn = vi.fn(); + vi.spyOn(SentryCore, 'withScope').mockImplementation(fn => { + const mockScope = { + setSDKProcessingMetadata: vi.fn(), + setContext: vi.fn(), + setTransactionName: setTransactionNameFn, + }; + return fn(mockScope as any); + }); + + captureRequestError( + new Error('test'), + { path: '/api/data', method: 'DELETE', headers: {} }, + { routerKind: 'Pages Router', routePath: '/api/data', routeType: 'route' }, + ); + + expect(setTransactionNameFn).toHaveBeenCalledWith('DELETE /api/data'); + }); +}); diff --git a/packages/vinext/test/server/sdk.test.ts b/packages/vinext/test/server/sdk.test.ts new file mode 100644 index 000000000000..a886b1a1639d --- /dev/null +++ b/packages/vinext/test/server/sdk.test.ts @@ -0,0 +1,141 @@ +import type { EventProcessor } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { SDK_VERSION } from '@sentry/node'; +import * as SentryNode from '@sentry/node'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { init as vinextInit } from '../../src/server'; + +const nodeInit = vi.spyOn(SentryNode, 'init'); + +describe('Vinext Server SDK init', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Ensure no client is initialized for the next test + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('has the correct metadata', () => { + const client = vinextInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + const expectedMetadata = { + _metadata: { + sdk: { + name: 'sentry.javascript.vinext', + packages: [ + { name: 'npm:@sentry/vinext', version: SDK_VERSION }, + { name: 'npm:@sentry/node', version: SDK_VERSION }, + ], + version: SDK_VERSION, + }, + }, + }; + + expect(client).not.toBeUndefined(); + expect(nodeInit).toHaveBeenCalledTimes(1); + expect(nodeInit).toHaveBeenLastCalledWith(expect.objectContaining(expectedMetadata)); + }); + + it('registers event processors', () => { + const passedEventProcessors: EventProcessor[] = []; + vi.spyOn(SentryCore, 'getGlobalScope').mockReturnValue({ + addEventProcessor: (ep: EventProcessor) => { + passedEventProcessors.push(ep); + return {} as any; + }, + } as any); + + vinextInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); + + expect(passedEventProcessors.length).toBe(2); + expect(passedEventProcessors[0]?.id).toEqual('VinextLowQualityTransactionsFilter'); + expect(passedEventProcessors[1]?.id).toEqual('VinextDropReactControlFlowErrors'); + }); + + describe('VinextLowQualityTransactionsFilter', () => { + function getFilter(): EventProcessor { + const passedEventProcessors: EventProcessor[] = []; + vi.spyOn(SentryCore, 'getGlobalScope').mockReturnValue({ + addEventProcessor: (ep: EventProcessor) => { + passedEventProcessors.push(ep); + return {} as any; + }, + } as any); + + vinextInit({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + const filter = passedEventProcessors.find(ep => ep.id === 'VinextLowQualityTransactionsFilter'); + expect(filter).toBeDefined(); + return filter!; + } + + it('filters __vinext internal transactions', () => { + const filter = getFilter(); + expect(filter({ type: 'transaction', transaction: 'GET /__vinext/image' }, {})).toBeNull(); + }); + + it('filters 404 transactions', () => { + const filter = getFilter(); + expect(filter({ type: 'transaction', transaction: '/404' }, {})).toBeNull(); + expect(filter({ type: 'transaction', transaction: 'GET /404' }, {})).toBeNull(); + expect(filter({ type: 'transaction', transaction: 'GET /_not-found' }, {})).toBeNull(); + }); + + it('keeps valid transactions', () => { + const filter = getFilter(); + const event = { type: 'transaction' as const, transaction: 'GET /api/users' }; + expect(filter(event, {})).toEqual(event); + }); + }); + + describe('VinextDropReactControlFlowErrors', () => { + function getFilter(): EventProcessor { + const passedEventProcessors: EventProcessor[] = []; + vi.spyOn(SentryCore, 'getGlobalScope').mockReturnValue({ + addEventProcessor: (ep: EventProcessor) => { + passedEventProcessors.push(ep); + return {} as any; + }, + } as any); + + vinextInit({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + + const filter = passedEventProcessors.find(ep => ep.id === 'VinextDropReactControlFlowErrors'); + expect(filter).toBeDefined(); + return filter!; + } + + it('filters React Suspense errors', () => { + const filter = getFilter(); + const event = { + exception: { + values: [{ value: 'Suspense Exception: This is not a real error!' }], + }, + }; + expect(filter(event, {})).toBeNull(); + }); + + it('filters React postpone errors', () => { + const filter = getFilter(); + const postponeError = { $$typeof: Symbol.for('react.postpone') }; + expect(filter({}, { originalException: postponeError })).toBeNull(); + }); + + it('keeps real errors', () => { + const filter = getFilter(); + const event = { + exception: { + values: [{ value: 'TypeError: Cannot read property of undefined' }], + }, + }; + expect(filter(event, { originalException: new Error('real error') })).toEqual(event); + }); + }); +}); diff --git a/packages/vinext/test/vite/autoInstrumentation.test.ts b/packages/vinext/test/vite/autoInstrumentation.test.ts new file mode 100644 index 000000000000..da8678882f6b --- /dev/null +++ b/packages/vinext/test/vite/autoInstrumentation.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { makeAutoInstrumentationPlugin } from '../../src/vite/autoInstrumentation'; + +describe('makeAutoInstrumentationPlugin', () => { + it('creates a plugin with the correct name', () => { + const plugin = makeAutoInstrumentationPlugin(true); + expect(plugin.name).toBe('sentry-vinext-auto-instrumentation'); + }); + + it('sets enforce to pre', () => { + const plugin = makeAutoInstrumentationPlugin(true); + expect(plugin.enforce).toBe('pre'); + }); + + it('returns null for non-matching files', () => { + const plugin = makeAutoInstrumentationPlugin(true); + const load = plugin.load as (id: string) => string | null; + expect(load('/Users/project/src/utils/helper.ts')).toBeNull(); + }); + + it('returns null for already-wrapped modules', () => { + const plugin = makeAutoInstrumentationPlugin(true); + const load = plugin.load as (id: string) => string | null; + expect(load('/Users/project/app/page.tsx?sentry-auto-wrap')).toBeNull(); + }); + + it('wraps App Router route handlers', () => { + const plugin = makeAutoInstrumentationPlugin(true); + const load = plugin.load as (id: string) => string | null; + const result = load('/Users/project/app/api/users/route.ts'); + + expect(result).not.toBeNull(); + expect(result).toContain('wrapRouteHandlerWithSentry'); + expect(result).toContain('sentry-auto-wrap'); + expect(result).toContain('/api/users'); + }); + + it('wraps App Router page components', () => { + const plugin = makeAutoInstrumentationPlugin(true); + const load = plugin.load as (id: string) => string | null; + const result = load('/Users/project/app/blog/[slug]/page.tsx'); + + expect(result).not.toBeNull(); + expect(result).toContain('wrapServerComponentWithSentry'); + expect(result).toContain('/blog/[slug]'); + expect(result).toContain('"page"'); + }); + + it('wraps App Router layout components', () => { + const plugin = makeAutoInstrumentationPlugin(true); + const load = plugin.load as (id: string) => string | null; + const result = load('/Users/project/app/layout.tsx'); + + expect(result).not.toBeNull(); + expect(result).toContain('wrapServerComponentWithSentry'); + expect(result).toContain('"layout"'); + }); + + it('wraps middleware files', () => { + const plugin = makeAutoInstrumentationPlugin(true); + const load = plugin.load as (id: string) => string | null; + const result = load('/Users/project/middleware.ts'); + + expect(result).not.toBeNull(); + expect(result).toContain('wrapMiddlewareWithSentry'); + }); + + it('wraps Pages Router API routes', () => { + const plugin = makeAutoInstrumentationPlugin(true); + const load = plugin.load as (id: string) => string | null; + const result = load('/Users/project/pages/api/users/[id].ts'); + + expect(result).not.toBeNull(); + expect(result).toContain('wrapApiHandlerWithSentry'); + expect(result).toContain('/api/users/[id]'); + }); + + it('does not wrap middleware inside app/ directory', () => { + const plugin = makeAutoInstrumentationPlugin(true); + const load = plugin.load as (id: string) => string | null; + expect(load('/Users/project/app/middleware.ts')).toBeNull(); + }); + + it('respects disabled options', () => { + const plugin = makeAutoInstrumentationPlugin({ + serverComponents: false, + routeHandlers: false, + middleware: false, + apiRoutes: false, + }); + const load = plugin.load as (id: string) => string | null; + + expect(load('/Users/project/app/page.tsx')).toBeNull(); + expect(load('/Users/project/app/api/route.ts')).toBeNull(); + expect(load('/Users/project/middleware.ts')).toBeNull(); + expect(load('/Users/project/pages/api/test.ts')).toBeNull(); + }); + + it('allows selective enabling', () => { + const plugin = makeAutoInstrumentationPlugin({ + serverComponents: false, + routeHandlers: true, + middleware: false, + apiRoutes: false, + }); + const load = plugin.load as (id: string) => string | null; + + expect(load('/Users/project/app/page.tsx')).toBeNull(); + expect(load('/Users/project/app/api/route.ts')).not.toBeNull(); + expect(load('/Users/project/middleware.ts')).toBeNull(); + }); +}); diff --git a/packages/vinext/test/vite/sentryVinextPlugin.test.ts b/packages/vinext/test/vite/sentryVinextPlugin.test.ts new file mode 100644 index 000000000000..27dd96a954bc --- /dev/null +++ b/packages/vinext/test/vite/sentryVinextPlugin.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; +import { sentryVinext } from '../../src/vite/sentryVinextPlugin'; + +// Mock the sentry vite plugin +vi.mock('@sentry/vite-plugin', () => ({ + sentryVitePlugin: vi.fn().mockResolvedValue([{ name: 'sentry-vite-plugin' }]), +})); + +describe('sentryVinext', () => { + it('returns an array of plugins', async () => { + const plugins = await sentryVinext(); + expect(Array.isArray(plugins)).toBe(true); + expect(plugins.length).toBeGreaterThan(0); + }); + + it('includes auto-instrumentation plugin by default', async () => { + const plugins = await sentryVinext(); + const autoPlugin = plugins.find(p => p.name === 'sentry-vinext-auto-instrumentation'); + expect(autoPlugin).toBeDefined(); + }); + + it('includes source map settings plugin', async () => { + const plugins = await sentryVinext(); + const sourceMapPlugin = plugins.find(p => p.name === 'sentry-vinext-source-map-settings'); + expect(sourceMapPlugin).toBeDefined(); + }); + + it('can disable auto-instrumentation', async () => { + const plugins = await sentryVinext({ autoInstrument: false }); + const autoPlugin = plugins.find(p => p.name === 'sentry-vinext-auto-instrumentation'); + expect(autoPlugin).toBeUndefined(); + }); + + it('source map settings plugin enables hidden source maps when not set', async () => { + const plugins = await sentryVinext(); + const sourceMapPlugin = plugins.find(p => p.name === 'sentry-vinext-source-map-settings')!; + + const configHook = (sourceMapPlugin as any).config; + const result = configHook({ build: {} }); + + expect(result.build.sourcemap).toBe('hidden'); + }); + + it('source map settings plugin preserves existing source map setting', async () => { + const plugins = await sentryVinext(); + const sourceMapPlugin = plugins.find(p => p.name === 'sentry-vinext-source-map-settings')!; + + const configHook = (sourceMapPlugin as any).config; + const inputConfig = { build: { sourcemap: true } }; + const result = configHook(inputConfig); + + // When source maps are already configured, the plugin returns the original config unchanged + expect(result).toBe(inputConfig); + }); + + it('source map settings plugin preserves disabled source maps', async () => { + const plugins = await sentryVinext(); + const sourceMapPlugin = plugins.find(p => p.name === 'sentry-vinext-source-map-settings')!; + + const configHook = (sourceMapPlugin as any).config; + const inputConfig = { build: { sourcemap: false } }; + const result = configHook(inputConfig); + + // When source maps are explicitly disabled, the plugin returns the original config unchanged + expect(result).toBe(inputConfig); + }); +}); diff --git a/packages/vinext/tsconfig.json b/packages/vinext/tsconfig.json new file mode 100644 index 000000000000..f90136f6d07b --- /dev/null +++ b/packages/vinext/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + "jsx": "react-jsx" + } +} diff --git a/packages/vinext/tsconfig.test.json b/packages/vinext/tsconfig.test.json new file mode 100644 index 000000000000..508cf3ea381b --- /dev/null +++ b/packages/vinext/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*", "vite.config.ts"], + + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/vinext/tsconfig.types.json b/packages/vinext/tsconfig.types.json new file mode 100644 index 000000000000..65455f66bd75 --- /dev/null +++ b/packages/vinext/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/vinext/vite.config.ts b/packages/vinext/vite.config.ts new file mode 100644 index 000000000000..1094fe0d79da --- /dev/null +++ b/packages/vinext/vite.config.ts @@ -0,0 +1,9 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + environment: 'jsdom', + }, +}; diff --git a/yarn.lock b/yarn.lock index ac89a4468d6a..b8f4ff6db1a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25411,6 +25411,13 @@ react-dom@^18.3.1: loose-envify "^1.1.0" scheduler "^0.23.2" +react-dom@^19.0.0: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591" + integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== + dependencies: + scheduler "^0.27.0" + react-error-boundary@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.1.tgz#932c5ca5cbab8ec4fe37fd7b415aa5c3a47597e7" @@ -25516,6 +25523,11 @@ react@^18.3.1: dependencies: loose-envify "^1.1.0" +react@^19.0.0: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" + integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== + read-cache@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" @@ -26653,6 +26665,11 @@ scheduler@^0.23.2: dependencies: loose-envify "^1.1.0" +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== + schema-utils@^2.6.5: version "2.7.1" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" From 2621a784d48a1769e33f5fa7183595dae50b432e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 3 Mar 2026 14:27:06 -0500 Subject: [PATCH 2/9] fix: export vite subexport Co-Authored-By: Claude Opus 4.6 --- packages/vinext/package.json | 5 +++++ packages/vinext/src/server/index.ts | 3 +++ 2 files changed, 8 insertions(+) diff --git a/packages/vinext/package.json b/packages/vinext/package.json index 52521d8ac8ee..d05336a44b3a 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -36,6 +36,11 @@ "import": "./build/esm/index.server.js", "require": "./build/cjs/index.server.js" } + }, + "./vite": { + "types": "./build/types/vite/index.d.ts", + "import": "./build/esm/vite/index.js", + "require": "./build/cjs/vite/index.js" } }, "publishConfig": { diff --git a/packages/vinext/src/server/index.ts b/packages/vinext/src/server/index.ts index 8da2b22ef3ae..ffdfea0086ea 100644 --- a/packages/vinext/src/server/index.ts +++ b/packages/vinext/src/server/index.ts @@ -92,6 +92,9 @@ function sdkAlreadyInitialized(): boolean { export { captureRequestError } from './captureRequestError'; +export { sentryVinext } from '../vite'; +export type { SentryVinextPluginOptions } from '../vite'; + export * from '../common'; export * from '@sentry/node'; From 1501bed7ab4dcf44b43a070810d11c69d7b30ea8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 3 Mar 2026 14:29:34 -0500 Subject: [PATCH 3/9] chore: bump release Co-Authored-By: Claude Opus 4.6 --- packages/vinext/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/vinext/package.json b/packages/vinext/package.json index d05336a44b3a..b5ea9c739564 100644 --- a/packages/vinext/package.json +++ b/packages/vinext/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vinext", - "version": "10.40.0", + "version": "10.42.0", "description": "Official Sentry SDK for vinext", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vinext", @@ -47,10 +47,10 @@ "access": "public" }, "dependencies": { - "@sentry/cloudflare": "10.40.0", - "@sentry/core": "10.40.0", - "@sentry/node": "10.40.0", - "@sentry/react": "10.40.0", + "@sentry/cloudflare": "10.42.0", + "@sentry/core": "10.42.0", + "@sentry/node": "10.42.0", + "@sentry/react": "10.42.0", "@sentry/vite-plugin": "^5.1.0" }, "devDependencies": { From 9340f176150dc75da2377a8194c83051bc503bff Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 3 Mar 2026 15:19:25 -0500 Subject: [PATCH 4/9] fix: format --- .../src/client/browserTracingIntegration.ts | 4 +--- packages/vinext/src/common/wrappers.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/client/browserTracingIntegration.ts b/packages/vinext/src/client/browserTracingIntegration.ts index c47ff25094ee..c06f35332340 100644 --- a/packages/vinext/src/client/browserTracingIntegration.ts +++ b/packages/vinext/src/client/browserTracingIntegration.ts @@ -97,9 +97,7 @@ function startNavigationSpan(client: Client, trigger: string): void { */ function getRouteFromWindow(): string | undefined { try { - const nextData = (GLOBAL_OBJ as unknown as Record).__NEXT_DATA__ as - | { page?: string } - | undefined; + const nextData = (GLOBAL_OBJ as unknown as Record).__NEXT_DATA__ as { page?: string } | undefined; if (nextData?.page && nextData.page !== '/') { return nextData.page; } diff --git a/packages/vinext/src/common/wrappers.ts b/packages/vinext/src/common/wrappers.ts index 7e5315a5f4d7..cab75bced525 100644 --- a/packages/vinext/src/common/wrappers.ts +++ b/packages/vinext/src/common/wrappers.ts @@ -20,7 +20,7 @@ export function wrapRouteHandlerWithSentry unk method: string, parameterizedRoute: string, ): T { - return (async function sentryWrappedRouteHandler(this: unknown, ...args: unknown[]) { + return async function sentryWrappedRouteHandler(this: unknown, ...args: unknown[]) { return withIsolationScope(async isolationScope => { isolationScope.setTransactionName(`${method} ${parameterizedRoute}`); @@ -49,7 +49,7 @@ export function wrapRouteHandlerWithSentry unk }, ); }); - }) as unknown as T; + } as unknown as T; } /** @@ -61,7 +61,7 @@ export function wrapServerComponentWithSentry ): T { const { componentRoute, componentType } = context; - return (async function sentryWrappedServerComponent(this: unknown, ...args: unknown[]) { + return async function sentryWrappedServerComponent(this: unknown, ...args: unknown[]) { return withIsolationScope(async isolationScope => { isolationScope.setTransactionName(componentRoute); @@ -90,14 +90,14 @@ export function wrapServerComponentWithSentry }, ); }); - }) as unknown as T; + } as unknown as T; } /** * Wraps a vinext middleware function with Sentry instrumentation. */ export function wrapMiddlewareWithSentry unknown>(middleware: T): T { - return (async function sentryWrappedMiddleware(this: unknown, ...args: unknown[]) { + return async function sentryWrappedMiddleware(this: unknown, ...args: unknown[]) { return withIsolationScope(async isolationScope => { // Try to extract the path from the first argument (Request object) const request = args[0] as { url?: string; method?: string } | undefined; @@ -142,7 +142,7 @@ export function wrapMiddlewareWithSentry unkno }, ); }); - }) as unknown as T; + } as unknown as T; } /** @@ -152,7 +152,7 @@ export function wrapApiHandlerWithSentry unkno handler: T, parameterizedRoute: string, ): T { - return (async function sentryWrappedApiHandler(this: unknown, ...args: unknown[]) { + return async function sentryWrappedApiHandler(this: unknown, ...args: unknown[]) { return withIsolationScope(async isolationScope => { // Try to extract the method from the first argument (IncomingMessage) const req = args[0] as { method?: string } | undefined; @@ -185,5 +185,5 @@ export function wrapApiHandlerWithSentry unkno }, ); }); - }) as unknown as T; + } as unknown as T; } From 4fb5c44eca44fe3cd60aed63376ce16498a3e647 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 3 Mar 2026 15:40:14 -0500 Subject: [PATCH 5/9] chore(vinext): add verdaccio config, craft target, LICENSE, and README Co-Authored-By: Claude Opus 4.6 --- .craft.yml | 3 +++ .../e2e-tests/verdaccio-config/config.yaml | 6 ++++++ packages/vinext/LICENSE | 21 +++++++++++++++++++ packages/vinext/README.md | 18 ++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 packages/vinext/LICENSE create mode 100644 packages/vinext/README.md diff --git a/.craft.yml b/.craft.yml index 331d065a2ff9..e23d766b103a 100644 --- a/.craft.yml +++ b/.craft.yml @@ -120,6 +120,9 @@ targets: - name: npm id: '@sentry/tanstackstart-react' includeNames: /^sentry-tanstackstart-react-\d.*\.tgz$/ + - name: npm + id: '@sentry/vinext' + includeNames: /^sentry-vinext-\d.*\.tgz$/ - name: npm id: '@sentry/gatsby' includeNames: /^sentry-gatsby-\d.*\.tgz$/ diff --git a/dev-packages/e2e-tests/verdaccio-config/config.yaml b/dev-packages/e2e-tests/verdaccio-config/config.yaml index 6e57ee2ea812..42354ed2b5b6 100644 --- a/dev-packages/e2e-tests/verdaccio-config/config.yaml +++ b/dev-packages/e2e-tests/verdaccio-config/config.yaml @@ -224,6 +224,12 @@ packages: unpublish: $all # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/vinext': + access: $all + publish: $all + unpublish: $all + # proxy: npmjs # Don't proxy for E2E tests! + '@sentry/wasm': access: $all publish: $all diff --git a/packages/vinext/LICENSE b/packages/vinext/LICENSE new file mode 100644 index 000000000000..b3c4b18a6317 --- /dev/null +++ b/packages/vinext/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/vinext/README.md b/packages/vinext/README.md new file mode 100644 index 000000000000..fc2e548afabc --- /dev/null +++ b/packages/vinext/README.md @@ -0,0 +1,18 @@ +

+ + Sentry + +

+ +# Official Sentry SDK for Vinext (Experimental) + +[![npm version](https://img.shields.io/npm/v/@sentry/vinext.svg)](https://www.npmjs.com/package/@sentry/vinext) +[![npm dm](https://img.shields.io/npm/dm/@sentry/vinext.svg)](https://www.npmjs.com/package/@sentry/vinext) +[![npm dt](https://img.shields.io/npm/dt/@sentry/vinext.svg)](https://www.npmjs.com/package/@sentry/vinext) + +> **Warning:** This SDK is experimental and under active development. Breaking changes may occur. + +## General + +This package is a wrapper around `@sentry/node` for the server and `@sentry/react` for the client side, with added +functionality related to Vinext. From 1bfdf8b02a260a82142ad3eb901c18fb68a70a90 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 4 Mar 2026 10:10:55 -0500 Subject: [PATCH 6/9] fix: added volta config --- .../e2e-tests/test-applications/vinext-app/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/package.json b/dev-packages/e2e-tests/test-applications/vinext-app/package.json index 19e235301293..6e572068709e 100644 --- a/dev-packages/e2e-tests/test-applications/vinext-app/package.json +++ b/dev-packages/e2e-tests/test-applications/vinext-app/package.json @@ -21,5 +21,8 @@ "@sentry-internal/test-utils": "link:../../../test-utils", "typescript": "~5.5.0", "vite": "^7.0.0" + }, + "volta": { + "extends": "../../package.json" } } From bccdfc053aee59912d8fe0743f9e8c44a3bb392f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 4 Mar 2026 11:15:09 -0500 Subject: [PATCH 7/9] fix: pin node 22 to vinext app --- .../e2e-tests/test-applications/vinext-app/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/package.json b/dev-packages/e2e-tests/test-applications/vinext-app/package.json index 6e572068709e..8ec7e200897e 100644 --- a/dev-packages/e2e-tests/test-applications/vinext-app/package.json +++ b/dev-packages/e2e-tests/test-applications/vinext-app/package.json @@ -23,6 +23,7 @@ "vite": "^7.0.0" }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.18.0" } } From 19c20eb9f75307b3155a55df133af2f50a9d8b94 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 4 Mar 2026 11:42:11 -0500 Subject: [PATCH 8/9] fix: build and start scripts --- .../test-applications/vinext-app/package.json | 11 ++++++----- .../vinext-app/playwright.config.mjs | 2 +- .../test-applications/vinext-app/tsconfig.json | 2 +- .../test-applications/vinext-app/vite.config.ts | 7 +++++++ 4 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/package.json b/dev-packages/e2e-tests/test-applications/vinext-app/package.json index 8ec7e200897e..0b5cba962836 100644 --- a/dev-packages/e2e-tests/test-applications/vinext-app/package.json +++ b/dev-packages/e2e-tests/test-applications/vinext-app/package.json @@ -5,22 +5,23 @@ "scripts": { "dev": "vinext dev", "build": "vinext build", - "start": "vinext start --port 3000", + "start": "vinext start --port 3030", "test": "playwright test", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test" }, "dependencies": { "@sentry/vinext": "latest || *", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "vinext": "latest" + "react": "^19.2.4", + "react-dom": "^19.2.4" }, "devDependencies": { "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", + "@vitejs/plugin-rsc": "^0.5.20", "typescript": "~5.5.0", - "vite": "^7.0.0" + "vinext": "^0.0.19", + "vite": "^7.3.1" }, "volta": { "extends": "../../package.json", diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/vinext-app/playwright.config.mjs index 4ca3c24e7fda..3d3ab7d8df02 100644 --- a/dev-packages/e2e-tests/test-applications/vinext-app/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/vinext-app/playwright.config.mjs @@ -2,7 +2,7 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; const config = getPlaywrightConfig({ startCommand: `pnpm start`, - port: 3000, + port: 3030, }); export default config; diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/tsconfig.json b/dev-packages/e2e-tests/test-applications/vinext-app/tsconfig.json index e7b39578801c..8420aab9d526 100644 --- a/dev-packages/e2e-tests/test-applications/vinext-app/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/vinext-app/tsconfig.json @@ -8,5 +8,5 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["src/**/*"] + "include": ["src/**/*", "*.ts", "*.mts"] } diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/vite.config.ts b/dev-packages/e2e-tests/test-applications/vinext-app/vite.config.ts new file mode 100644 index 000000000000..46ca32d96a43 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import vinext from 'vinext'; +import { sentryVinext } from '@sentry/vinext/vite'; + +export default defineConfig({ + plugins: [vinext(), sentryVinext()], +}); From 9d98c39e51347bdd766d85254a6d84cd7b221baf Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 4 Mar 2026 16:57:20 -0500 Subject: [PATCH 9/9] fix: split client component --- .../vinext-app/src/app/error-page/ErrorButton.tsx | 14 ++++++++++++++ .../vinext-app/src/app/error-page/page.tsx | 11 ++--------- 2 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/ErrorButton.tsx diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/ErrorButton.tsx b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/ErrorButton.tsx new file mode 100644 index 000000000000..205017205f57 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/ErrorButton.tsx @@ -0,0 +1,14 @@ +'use client'; + +export default function ErrorButton() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/page.tsx b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/page.tsx index 56e8d3d42aae..2a836e9dde4e 100644 --- a/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/page.tsx +++ b/dev-packages/e2e-tests/test-applications/vinext-app/src/app/error-page/page.tsx @@ -1,17 +1,10 @@ -'use client'; +import ErrorButton from './ErrorButton'; export default function ErrorPage() { return (

Error Test Page

- +
); }