diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5047e7acd0a..7c05e0644b09 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -994,6 +994,7 @@ jobs: 'ember-classic', 'ember-embroider', 'nextjs-app-dir', + 'nextjs-13', 'nextjs-14', 'nextjs-15', 'react-17', diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 42c1594ebc5b..1f584d2a921c 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -78,6 +78,12 @@ jobs: - test-application: 'nextjs-app-dir' build-command: 'test:build-latest' label: 'nextjs-app-dir (latest)' + - test-application: 'nextjs-13' + build-command: 'test:build-canary' + label: 'nextjs-13 (canary)' + - test-application: 'nextjs-13' + build-command: 'test:build-latest' + label: 'nextjs-13 (latest)' - test-application: 'nextjs-14' build-command: 'test:build-canary' label: 'nextjs-14 (canary)' diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-13/.gitignore new file mode 100644 index 000000000000..b7a8bf3b3701 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +!*.d.ts + +test-results + +.vscode diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-13/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/pageload-transaction/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/pageload-transaction/page.tsx new file mode 100644 index 000000000000..b8109689f986 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/pageload-transaction/page.tsx @@ -0,0 +1,3 @@ +export default function PageloadTransactionPage() { + return

Pageload Transaction Page

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/rsc-error/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/rsc-error/page.tsx new file mode 100644 index 000000000000..9328f85142a8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/rsc-error/page.tsx @@ -0,0 +1,6 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + throw new Error('RSC error'); + return

Hello World

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/globals.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/globals.d.ts new file mode 100644 index 000000000000..109dbcd55648 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/globals.d.ts @@ -0,0 +1,4 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/instrumentation.ts new file mode 100644 index 000000000000..1f889238427c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/instrumentation.ts @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/nextjs'; + +export function register() { + if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') { + Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + sendDefaultPii: true, + transportOptions: { + // We are doing a lot of events at once in this test app + bufferSize: 1000, + }, + }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/next-env.d.ts new file mode 100644 index 000000000000..fd36f9494e2c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-13/next.config.js new file mode 100644 index 000000000000..a08502723262 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/next.config.js @@ -0,0 +1,17 @@ +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const moduleExports = { + typescript: { + ignoreBuildErrors: true, // TODO: Remove this + }, + experimental: { + appDir: true, + }, + pageExtensions: ['jsx', 'js', 'tsx', 'ts', 'page.tsx'], +}; + +module.exports = withSentryConfig(moduleExports, { + silent: true, + excludeServerRoutes: ['/api/endpoint-excluded-with-string', /\/api\/endpoint-excluded-with-regex/], +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/package.json b/dev-packages/e2e-tests/test-applications/nextjs-13/package.json new file mode 100644 index 000000000000..3f1219f210a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/package.json @@ -0,0 +1,45 @@ +{ + "name": "create-next-app", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .next", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@beta && pnpm add react-dom@beta && npx playwright install && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@latest && npx playwright install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@types/node": "18.11.17", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "13.2.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "typescript": "4.9.5" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry-internal/feedback": "latest || *", + "@sentry-internal/replay-canvas": "latest || *", + "@sentry-internal/browser-utils": "latest || *", + "@sentry/browser": "latest || *", + "@sentry/core": "latest || *", + "@sentry/nextjs": "latest || *", + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@sentry/react": "latest || *", + "@sentry-internal/replay": "latest || *", + "@sentry/types": "latest || *", + "@sentry/utils": "latest || *", + "@sentry/vercel-edge": "latest || *" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/click-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/click-error.tsx new file mode 100644 index 000000000000..c0b3dc70edec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/click-error.tsx @@ -0,0 +1,12 @@ +export default function ClickErrorPage() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/navigation-start-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/navigation-start-page.tsx new file mode 100644 index 000000000000..4a344176db31 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/navigation-start-page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link'; + +export default function Page() { + return ( + + Navigate + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/navigation-target-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/navigation-target-page.tsx new file mode 100644 index 000000000000..c49ff17fd490 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/navigation-target-page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

arrived

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/pages-pageload.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/pages-pageload.tsx new file mode 100644 index 000000000000..5b0847bb89fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/pages-pageload.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

pageload test page

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/withInitialProps.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/withInitialProps.tsx new file mode 100644 index 000000000000..01b557bdd09f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/withInitialProps.tsx @@ -0,0 +1,7 @@ +const WithInitialPropsPage = ({ data }: { data: string }) =>

WithInitialPropsPage {data}

; + +WithInitialPropsPage.getInitialProps = () => { + return { data: '[some getInitialProps data]' }; +}; + +export default WithInitialPropsPage; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/withServerSideProps.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/withServerSideProps.tsx new file mode 100644 index 000000000000..0379cc202436 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/[param]/withServerSideProps.tsx @@ -0,0 +1,7 @@ +export default function WithServerSidePropsPage({ data }: { data: string }) { + return

WithServerSidePropsPage {data}

; +} + +export async function getServerSideProps() { + return { props: { data: '[some getServerSideProps data]' } }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/_app.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/_app.tsx new file mode 100644 index 000000000000..d6dfa41828d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/_app.tsx @@ -0,0 +1,19 @@ +import App, { AppContext, AppProps } from 'next/app'; + +const MyApp = ({ Component, pageProps }: AppProps) => { + // @ts-ignore I don't know why TS complains here + return ; +}; + +MyApp.getInitialProps = async (appContext: AppContext) => { + // This simulates user misconfiguration. Users should always call `App.getInitialProps(appContext)`, but they don't, + // so we have a test for this so we don't break their apps. + if (appContext.ctx.pathname === '/misconfigured-_app-getInitialProps') { + return {}; + } + + const appProps = await App.getInitialProps(appContext); + return { ...appProps }; +}; + +export default MyApp; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/[param]/failure-api-route.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/[param]/failure-api-route.ts new file mode 100644 index 000000000000..8a0d5f537aa6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/[param]/failure-api-route.ts @@ -0,0 +1,5 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +export default async (_req: NextApiRequest, res: NextApiResponse) => { + throw new Error('api route error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/[param]/index.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/[param]/index.ts new file mode 100644 index 000000000000..faa9a571ca10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/[param]/index.ts @@ -0,0 +1,5 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +export default async (_req: NextApiRequest, res: NextApiResponse) => { + res.status(200).json({ success: true }); +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/[param]/success-api-route.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/[param]/success-api-route.ts new file mode 100644 index 000000000000..faa9a571ca10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/[param]/success-api-route.ts @@ -0,0 +1,5 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +export default async (_req: NextApiRequest, res: NextApiResponse) => { + res.status(200).json({ success: true }); +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/cjs-api-endpoint-with-require.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/cjs-api-endpoint-with-require.ts new file mode 100644 index 000000000000..63a5176101ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/cjs-api-endpoint-with-require.ts @@ -0,0 +1,14 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +if (process.env.NEXT_PUBLIC_SOME_FALSE_ENV_VAR === 'enabled') { + require('../../tests/server/utils/throw'); // Should not throw unless the hoisting in the wrapping loader is messed up! +} + +const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise => { + require('@sentry/nextjs').captureException; // Should not throw unless the wrapping loader messes up cjs imports + // @ts-expect-error + require.context('.'); // This is a webpack utility call. Should not throw unless the wrapping loader messes it up by mangling. + res.status(200).json({ success: true }); +}; + +module.exports = handler; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/cjs-api-endpoint.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/cjs-api-endpoint.ts new file mode 100644 index 000000000000..6ae521fa5cb4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/cjs-api-endpoint.ts @@ -0,0 +1,7 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise => { + res.status(200).json({ success: true }); +}; + +module.exports = handler; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/endpoint-excluded-with-regex.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/endpoint-excluded-with-regex.ts new file mode 100644 index 000000000000..6ae521fa5cb4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/endpoint-excluded-with-regex.ts @@ -0,0 +1,7 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise => { + res.status(200).json({ success: true }); +}; + +module.exports = handler; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/endpoint-excluded-with-string.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/endpoint-excluded-with-string.ts new file mode 100644 index 000000000000..6ae521fa5cb4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/endpoint-excluded-with-string.ts @@ -0,0 +1,7 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +const handler = async (_req: NextApiRequest, res: NextApiResponse): Promise => { + res.status(200).json({ success: true }); +}; + +module.exports = handler; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/no-params.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/no-params.ts new file mode 100644 index 000000000000..faa9a571ca10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/no-params.ts @@ -0,0 +1,5 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +export default async (_req: NextApiRequest, res: NextApiResponse) => { + res.status(200).json({ success: true }); +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/params/[...pathParts].ts b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/params/[...pathParts].ts new file mode 100644 index 000000000000..faa9a571ca10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/api/params/[...pathParts].ts @@ -0,0 +1,5 @@ +import { NextApiRequest, NextApiResponse } from 'next'; + +export default async (_req: NextApiRequest, res: NextApiResponse) => { + res.status(200).json({ success: true }); +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/crashed-session-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/crashed-session-page.tsx new file mode 100644 index 000000000000..277293e77aed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/crashed-session-page.tsx @@ -0,0 +1,13 @@ +export default function CrashedPage() { + // Magic to naively trigger onerror to make session crashed and allow for SSR + try { + if (typeof window !== 'undefined' && typeof window.onerror === 'function') { + // Lovely oldschool browsers syntax with 5 arguments <3 + // @ts-expect-error + window.onerror(null, null, null, null, new Error('Crashed')); + } + } catch (_e) { + // no-empty + } + return

Crashed

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/customPageExtension.page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/customPageExtension.page.tsx new file mode 100644 index 000000000000..5f25223a9b4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/customPageExtension.page.tsx @@ -0,0 +1,12 @@ +export default function BasicPage() { + return ( +

+ This page simply exists to test the compatibility of Next.js' `pageExtensions` option with our auto wrapping + process. This file should be turned into a page by Next.js and our webpack loader should process it. +

+ ); +} + +export async function getServerSideProps() { + throw new Error('custom page extension error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/error-getServerSideProps.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/error-getServerSideProps.tsx new file mode 100644 index 000000000000..9bc737cf7a7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/error-getServerSideProps.tsx @@ -0,0 +1,7 @@ +export default function WithServerSidePropsPage({ data }: { data: string }) { + return

WithServerSidePropsPage {data}

; +} + +export async function getServerSideProps() { + throw new Error('getServerSideProps Error'); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/fetch.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/fetch.tsx new file mode 100644 index 000000000000..0493d4e508f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/fetch.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; + +export default function FetchPage() { + useEffect(() => { + // test that a span is created in the pageload transaction for this fetch request + fetch('http://example.com').catch(() => { + // no-empty + }); + }, []); + + return

Hello world!

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/healthy-session-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/healthy-session-page.tsx new file mode 100644 index 000000000000..6a30e4f8b3a8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/healthy-session-page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

healthy page

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/misconfigured-_app-getInitialProps.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/misconfigured-_app-getInitialProps.tsx new file mode 100644 index 000000000000..3627c5088af8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/misconfigured-_app-getInitialProps.tsx @@ -0,0 +1,5 @@ +// See _app.tsx for more information why this file exists. + +export default function Page() { + return

faulty _app getInitialProps

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/reportDialog.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/reportDialog.tsx new file mode 100644 index 000000000000..a8e097c769a9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/reportDialog.tsx @@ -0,0 +1,15 @@ +import { captureException, showReportDialog } from '@sentry/nextjs'; + +export default function ReportDialogPage() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/pages/unmatchedCustomPageExtension.someExtension b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/unmatchedCustomPageExtension.someExtension new file mode 100644 index 000000000000..e8d58e47f18e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/pages/unmatchedCustomPageExtension.someExtension @@ -0,0 +1,3 @@ +This page simply exists to test the compatibility of Next.js' `pageExtensions` option with our auto wrapping +process. This file should not be turned into a page by Next.js and our webpack loader also shouldn't process it. +This page should not contain valid JavaScript. diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/playwright.config.ts new file mode 100644 index 000000000000..8448829443d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/playwright.config.ts @@ -0,0 +1,19 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const config = getPlaywrightConfig( + { + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + port: 3030, + }, + { + // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize + workers: '100%', + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/sentry.client.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/sentry.client.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-13/start-event-proxy.mjs new file mode 100644 index 000000000000..9983d484bcbc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-13', +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/app-dir-pageloads.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/app-dir-pageloads.test.ts new file mode 100644 index 000000000000..d7d08eb22773 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/app-dir-pageloads.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a pageload transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/pageload-transaction' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/pageload-transaction`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/pageload-transaction', to: '/pageload-transaction' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/pageload-transaction$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/pageload-transaction', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts new file mode 100644 index 000000000000..a9fbcdb69a45 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should send error for faulty click handlers', async ({ page }) => { + const errorPromise = waitForError('nextjs-13', async errorEvent => { + return errorEvent.exception?.values?.[0].value === 'click error'; + }); + + await page.goto('/42/click-error'); + await page.click('#error-button'); + + const errorEvent = await errorPromise; + + expect(errorEvent).toBeDefined(); + + const frames = errorEvent?.exception?.values?.[0]?.stacktrace?.frames; + + await test.step('error should have a non-url-encoded top frame in route with parameter', () => { + if (process.env.TEST_ENV === 'development') { + // In dev mode we want to check local source mapping + expect(frames?.[frames.length - 1].filename).toMatch(/\/\[param\]\/click-error.tsx$/); + } else { + expect(frames?.[frames.length - 1].filename).toMatch(/\/\[param\]\/click-error-[a-f0-9]+\.js$/); + } + }); + + await test.step('error should have `in_app`: false for nextjs internal frames', () => { + if (process.env.TEST_ENV !== 'development') { + expect(frames).toContainEqual( + expect.objectContaining({ + filename: expect.stringMatching( + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, + ), + in_app: false, + }), + ); + + expect(frames).not.toContainEqual( + expect.objectContaining({ + filename: expect.stringMatching( + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, + ), + in_app: true, + }), + ); + } + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/faultyAppGetInitialPropsConfiguration.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/faultyAppGetInitialPropsConfiguration.test.ts new file mode 100644 index 000000000000..68336c3e5c4e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/faultyAppGetInitialPropsConfiguration.test.ts @@ -0,0 +1,13 @@ +import { expect, test } from '@playwright/test'; + +// This test verifies that a faulty configuration of `getInitialProps` in `_app` will not cause our +// auto - wrapping / instrumentation to throw an error. +// See `_app.tsx` for more information. + +test('should not fail auto-wrapping when `getInitialProps` configuration is faulty.', async ({ page }) => { + await page.goto('/misconfigured-_app-getInitialProps'); + + const serverErrorText = await page.$('//*[contains(text(), "Internal Server Error")]'); + + expect(serverErrorText).toBeFalsy(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/fetch.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/fetch.test.ts new file mode 100644 index 000000000000..5b42e14269bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/fetch.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should correctly instrument `fetch` for performance tracing', async ({ page }) => { + await page.route('http://example.com/**/*', route => { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + foo: 'bar', + }), + }); + }); + + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return transactionEvent.transaction === '/fetch' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/fetch`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/fetch', + type: 'transaction', + contexts: { + trace: { + op: 'pageload', + }, + }, + }); + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { + 'http.method': 'GET', + url: 'http://example.com', + 'http.url': 'http://example.com/', + 'server.address': 'example.com', + type: 'fetch', + 'http.response_content_length': expect.any(Number), + 'http.response.status_code': 200, + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.browser', + }, + description: 'GET http://example.com', + op: 'http.client', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + status: expect.any(String), + origin: 'auto.http.browser', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-navigation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-navigation.test.ts new file mode 100644 index 000000000000..8bd128896b6c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-navigation.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should report a navigation transaction for pages router navigations', async ({ page }) => { + test.skip(process.env.TEST_ENV === 'development', 'Test is flakey in dev mode'); + const navigationTransactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/[param]/navigation-target-page' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.goto('/foo/navigation-start-page'); + await page.click('#navigation-link'); + + expect(await navigationTransactionPromise).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/foo/navigation-start-page', to: '/foo/navigation-start-page' }, + timestamp: expect.any(Number), + }, + { category: 'ui.click', message: 'body > div#__next > a#navigation-link', timestamp: expect.any(Number) }, + { + category: 'navigation', + data: { from: '/foo/navigation-start-page', to: '/foo/navigation-target-page' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + trace: { + data: { + 'sentry.idle_span_finish_reason': 'idleTimeout', + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.nextjs.pages_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'navigation', + origin: 'auto.navigation.nextjs.pages_router_instrumentation', + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + platform: 'javascript', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/foo\/navigation-target-page$/), + }, + spans: expect.arrayContaining([]), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/[param]/navigation-target-page', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-pageload.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-pageload.test.ts new file mode 100644 index 000000000000..8c74b2c99427 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/pages-dir-pageload.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a pageload transaction when the `pages` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/[param]/pages-pageload' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/foo/pages-pageload`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/foo/pages-pageload', to: '/foo/pages-pageload' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.pages_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.pages_router_instrumentation', + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/foo\/pages-pageload$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/[param]/pages-pageload', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/reportDialog.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/reportDialog.test.ts new file mode 100644 index 000000000000..386d228ebf0c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/reportDialog.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test'; + +test('should show a dialog', async ({ page }) => { + // *= means "containing" + const dialogScriptSelector = 'head > script[src*="/api/embed/error-page"]'; + + await page.goto('/reportDialog'); + + expect(await page.locator(dialogScriptSelector).count()).toEqual(0); + + await page.click('#open-report-dialog'); + + const dialogScript = await page.waitForSelector(dialogScriptSelector, { state: 'attached' }); + const dialogScriptSrc = await (await dialogScript.getProperty('src')).jsonValue(); + + expect(dialogScriptSrc).toMatch(/^http.*\/api\/embed\/error-page\/\?.*/); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/sessions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/sessions.test.ts new file mode 100644 index 000000000000..8fbe8ac8b7b5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/sessions.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; +import { waitForSession } from '@sentry-internal/test-utils'; + +test('should report healthy sessions', async ({ page }) => { + test.skip(process.env.TEST_ENV === 'development', 'test is flakey in dev mode'); + + const sessionPromise = waitForSession('nextjs-13', session => { + return session.init === true && session.status === 'ok' && session.errors === 0; + }); + + await page.goto('/healthy-session-page'); + + expect(await sessionPromise).toBeDefined(); +}); + +test('should report crashed sessions', async ({ page }) => { + test.skip(process.env.TEST_ENV === 'development', 'test is flakey in dev mode'); + + const sessionPromise = waitForSession('nextjs-13', session => { + return session.init === false && session.status === 'crashed' && session.errors === 1; + }); + + await page.goto('/crashed-session-page'); + + expect(await sessionPromise).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getInitialProps.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getInitialProps.test.ts new file mode 100644 index 000000000000..22da2071d533 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getInitialProps.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should propagate serverside `getInitialProps` trace to client', async ({ page }) => { + const pageloadTransactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/[param]/withInitialProps' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + const serverTransactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/[param]/withInitialProps' && + transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + await page.goto(`/42/withInitialProps`); + + const pageloadTransaction = await pageloadTransactionPromise; + + expect(pageloadTransaction).toBeDefined(); + + await test.step('should propagate tracing data from server to client', async () => { + const nextDataTag = await page.waitForSelector('#__NEXT_DATA__', { state: 'attached' }); + const nextDataTagValue = JSON.parse(await nextDataTag.evaluate(tag => (tag as HTMLElement).innerText)); + + const traceId = pageloadTransaction?.contexts?.trace?.trace_id; + + expect(traceId).toBeDefined(); + + expect(nextDataTagValue.props.pageProps.data).toBe('[some getInitialProps data]'); + expect(nextDataTagValue.props.pageProps._sentryTraceData).toBeTruthy(); + expect(nextDataTagValue.props.pageProps._sentryBaggage).toBeTruthy(); + + expect(nextDataTagValue.props.pageProps._sentryTraceData.split('-')[0]).toBe(traceId); + + expect(nextDataTagValue.props.pageProps._sentryBaggage.match(/sentry-trace_id=([a-f0-9]*),/)[1]).toBe(traceId); + }); + + await test.step('should record serverside performance', async () => { + expect(await serverTransactionPromise).toMatchObject({ + contexts: { + trace: { + op: 'http.server', + status: 'ok', + }, + }, + transaction: '/[param]/withInitialProps', + transaction_info: { + source: 'route', + }, + type: 'transaction', + request: { + url: expect.stringMatching(/http.*\/42\/withInitialProps$/), + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getServerSideProps.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getServerSideProps.test.ts new file mode 100644 index 000000000000..20bbbc9437f6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/isomorphic/getServerSideProps.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should record performance for getServerSideProps', async ({ page }) => { + const pageloadTransactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/[param]/withServerSideProps' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + const serverTransactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/[param]/withServerSideProps' && + transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + await page.goto(`/1337/withServerSideProps`); + + const pageloadTransaction = await pageloadTransactionPromise; + + expect(pageloadTransaction).toBeDefined(); + + await test.step('should propagate tracing data from server to client', async () => { + const nextDataTag = await page.waitForSelector('#__NEXT_DATA__', { state: 'attached' }); + const nextDataTagValue = JSON.parse(await nextDataTag.evaluate(tag => (tag as HTMLElement).innerText)); + + const traceId = pageloadTransaction?.contexts?.trace?.trace_id; + + expect(traceId).toBeDefined(); + + expect(nextDataTagValue.props.pageProps.data).toBe('[some getServerSideProps data]'); + expect(nextDataTagValue.props.pageProps._sentryTraceData).toBeTruthy(); + expect(nextDataTagValue.props.pageProps._sentryBaggage).toBeTruthy(); + + expect(nextDataTagValue.props.pageProps._sentryTraceData.split('-')[0]).toBe(traceId); + + expect(nextDataTagValue.props.pageProps._sentryBaggage.match(/sentry-trace_id=([a-f0-9]*),/)[1]).toBe(traceId); + }); + + await test.step('should record serverside performance', async () => { + expect(await serverTransactionPromise).toMatchObject({ + contexts: { + trace: { + op: 'http.server', + status: 'ok', + }, + }, + transaction: '/[param]/withServerSideProps', + transaction_info: { + source: 'route', + }, + type: 'transaction', + request: { + url: expect.stringMatching(/http.*\/1337\/withServerSideProps$/), + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/cjs-api-endpoints.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/cjs-api-endpoints.test.ts new file mode 100644 index 000000000000..8a0ed1176142 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/cjs-api-endpoints.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a transaction for a CJS pages router API endpoint', async ({ request }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/cjs-api-endpoint' && + transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + const result = (await request.get(`/api/cjs-api-endpoint`)).json(); + + expect(await result).toMatchObject({ success: true }); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + otel: { + resource: { + 'service.name': 'node', + 'service.namespace': 'sentry', + 'service.version': expect.any(String), + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + }, + }, + runtime: { name: 'node', version: expect.any(String) }, + trace: { + data: { + 'http.response.status_code': 200, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.nextjs', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.nextjs', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }, + }, + environment: 'qa', + event_id: expect.any(String), + platform: 'node', + request: { + cookies: expect.any(Object), + headers: expect.any(Object), + method: 'GET', + url: expect.stringMatching(/^http.*\/api\/cjs-api-endpoint$/), + }, + spans: expect.arrayContaining([]), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /api/cjs-api-endpoint', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should not mess up require statements in CJS API endpoints', async ({ request }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/cjs-api-endpoint-with-require' && + transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + const result = (await request.get(`/api/cjs-api-endpoint-with-require`)).json(); + + expect(await result).toMatchObject({ success: true }); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + otel: { + resource: { + 'service.name': 'node', + 'service.namespace': 'sentry', + 'service.version': expect.any(String), + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + }, + }, + runtime: { name: 'node', version: expect.any(String) }, + trace: { + data: { + 'http.response.status_code': 200, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.nextjs', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.nextjs', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }, + }, + environment: 'qa', + event_id: expect.any(String), + platform: 'node', + request: { + cookies: expect.any(Object), + headers: expect.any(Object), + method: 'GET', + url: expect.stringMatching(/^http.*\/api\/cjs-api-endpoint-with-require$/), + }, + spans: expect.arrayContaining([]), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /api/cjs-api-endpoint-with-require', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/excluded-api-endpoints.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/excluded-api-endpoints.test.ts new file mode 100644 index 000000000000..b148accf1450 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/excluded-api-endpoints.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should not automatically create transactions for routes that were excluded from auto wrapping (string)', async ({ + request, +}) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/endpoint-excluded-with-string' && + transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + await (await request.get(`/api/endpoint-excluded-with-string`)).json(); + + let transactionPromiseReceived = false; + transactionPromise.then(() => { + transactionPromiseReceived = true; + }); + + await new Promise(resolve => setTimeout(resolve, 5_000)); + + expect(transactionPromiseReceived).toBe(false); +}); + +test('should not automatically create transactions for routes that were excluded from auto wrapping (regex)', async ({ + request, +}) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/endpoint-excluded-with-regex' && + transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + await (await request.get(`/api/endpoint-excluded-with-regex`)).json(); + + let transactionPromiseReceived = false; + transactionPromise.then(() => { + transactionPromiseReceived = true; + }); + + await new Promise(resolve => setTimeout(resolve, 5_000)); + + expect(transactionPromiseReceived).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/getServerSideProps.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/getServerSideProps.test.ts new file mode 100644 index 000000000000..0c99ba302dfa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/getServerSideProps.test.ts @@ -0,0 +1,173 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should report an error event for errors thrown in getServerSideProps', async ({ page }) => { + const errorEventPromise = waitForError('nextjs-13', errorEvent => { + return errorEvent.exception?.values?.[0].value === 'getServerSideProps Error'; + }); + + const transactionEventPromise = waitForTransaction('nextjs-13', transactionEvent => { + return ( + transactionEvent.transaction === '/error-getServerSideProps' && + transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + await page.goto('/error-getServerSideProps'); + + expect(await errorEventPromise).toMatchObject({ + contexts: { + trace: { span_id: expect.any(String), trace_id: expect.any(String) }, + }, + event_id: expect.any(String), + exception: { + values: [ + { + mechanism: { handled: false, type: 'generic' }, + type: 'Error', + value: 'getServerSideProps Error', + stacktrace: { + frames: expect.arrayContaining([]), + }, + }, + ], + }, + platform: 'node', + request: { + cookies: expect.any(Object), + headers: expect.any(Object), + method: 'GET', + url: expect.stringMatching(/^http.*\/error-getServerSideProps/), + }, + timestamp: expect.any(Number), + transaction: 'getServerSideProps (/error-getServerSideProps)', + }); + + expect(await transactionEventPromise).toMatchObject({ + contexts: { + otel: { + resource: { + 'service.name': 'node', + 'service.namespace': 'sentry', + 'service.version': expect.any(String), + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + }, + }, + runtime: { name: 'node', version: expect.any(String) }, + trace: { + data: { + 'http.response.status_code': 500, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.function.nextjs', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.function.nextjs', + span_id: expect.any(String), + status: 'internal_error', + trace_id: expect.any(String), + }, + }, + event_id: expect.any(String), + platform: 'node', + request: { + cookies: expect.any(Object), + headers: expect.any(Object), + method: 'GET', + url: expect.stringMatching(/^http.*\/error-getServerSideProps/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/error-getServerSideProps', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('Should report an error event for errors thrown in getServerSideProps in pages with custom page extensions', async ({ + page, +}) => { + const errorEventPromise = waitForError('nextjs-13', errorEvent => { + return errorEvent.exception?.values?.[0].value === 'custom page extension error'; + }); + + const transactionEventPromise = waitForTransaction('nextjs-13', transactionEvent => { + return ( + transactionEvent.transaction === '/customPageExtension' && transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + await page.goto('/customPageExtension'); + + expect(await errorEventPromise).toMatchObject({ + contexts: { + trace: { span_id: expect.any(String), trace_id: expect.any(String) }, + }, + event_id: expect.any(String), + exception: { + values: [ + { + mechanism: { handled: false, type: 'generic' }, + type: 'Error', + value: 'custom page extension error', + stacktrace: { + frames: expect.arrayContaining([]), + }, + }, + ], + }, + platform: 'node', + request: { + cookies: expect.any(Object), + headers: expect.any(Object), + method: 'GET', + url: expect.stringMatching(/^http.*\/customPageExtension/), + }, + timestamp: expect.any(Number), + transaction: 'getServerSideProps (/customPageExtension)', + }); + + expect(await transactionEventPromise).toMatchObject({ + contexts: { + otel: { + resource: { + 'service.name': 'node', + 'service.namespace': 'sentry', + 'service.version': expect.any(String), + 'telemetry.sdk.language': 'nodejs', + 'telemetry.sdk.name': 'opentelemetry', + 'telemetry.sdk.version': expect.any(String), + }, + }, + runtime: { name: 'node', version: expect.any(String) }, + trace: { + data: { + 'http.response.status_code': 500, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.function.nextjs', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.function.nextjs', + span_id: expect.any(String), + status: 'internal_error', + trace_id: expect.any(String), + }, + }, + event_id: expect.any(String), + platform: 'node', + request: { + cookies: expect.any(Object), + headers: expect.any(Object), + method: 'GET', + url: expect.stringMatching(/^http.*\/customPageExtension/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/customPageExtension', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/pages-router-api-endpoints.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/pages-router-api-endpoints.test.ts new file mode 100644 index 000000000000..12196c08fcc1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/pages-router-api-endpoints.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should report an error event for errors thrown in pages router api routes', async ({ request }) => { + const errorEventPromise = waitForError('nextjs-13', errorEvent => { + return errorEvent.exception?.values?.[0].value === 'api route error'; + }); + + const transactionEventPromise = waitForTransaction('nextjs-13', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/[param]/failure-api-route' && + transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + request.get('/api/foo/failure-api-route').catch(e => { + // expected to crash + }); + + expect(await errorEventPromise).toMatchObject({ + contexts: { + runtime: { name: 'node', version: expect.any(String) }, + trace: { span_id: expect.any(String), trace_id: expect.any(String) }, + }, + exception: { + values: [ + { + mechanism: { + data: { + function: 'withSentry', + }, + handled: false, + type: 'instrument', + }, + stacktrace: { frames: expect.arrayContaining([]) }, + type: 'Error', + value: 'api route error', + }, + ], + }, + platform: 'node', + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.stringMatching(/^http.*\/api\/foo\/failure-api-route$/), + }, + timestamp: expect.any(Number), + transaction: 'GET /api/[param]/failure-api-route', + }); + + expect(await transactionEventPromise).toMatchObject({ + contexts: { + runtime: { name: 'node', version: expect.any(String) }, + trace: { + data: { + 'http.response.status_code': 500, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.nextjs', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.nextjs', + span_id: expect.any(String), + status: 'internal_error', + trace_id: (await errorEventPromise).contexts?.trace?.trace_id, + }, + }, + platform: 'node', + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.stringMatching(/^http.*\/api\/foo\/failure-api-route$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /api/[param]/failure-api-route', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('Should report a transaction event for a successful pages router api route', async ({ request }) => { + const transactionEventPromise = waitForTransaction('nextjs-13', transactionEvent => { + return ( + transactionEvent.transaction === 'GET /api/[param]/success-api-route' && + transactionEvent.contexts?.trace?.op === 'http.server' + ); + }); + + request.get('/api/foo/success-api-route').catch(e => { + // we don't care about crashes + }); + + expect(await transactionEventPromise).toMatchObject({ + contexts: { + runtime: { name: 'node', version: expect.any(String) }, + trace: { + data: { + 'http.response.status_code': 200, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.nextjs', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.nextjs', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }, + }, + platform: 'node', + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.stringMatching(/^http.*\/api\/foo\/success-api-route$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /api/[param]/success-api-route', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-component-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-component-error.test.ts new file mode 100644 index 000000000000..600e2b6eda53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-component-error.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Should capture an error thrown in a server component', async ({ page }) => { + const errorEventPromise = waitForError('nextjs-13', errorEvent => { + return errorEvent.exception?.values?.[0].value === 'RSC error'; + }); + + await page.goto('/rsc-error'); + + expect(await errorEventPromise).toMatchObject({ + contexts: { + runtime: { name: 'node', version: expect.any(String) }, + trace: { + parent_span_id: expect.any(String), + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + event_id: expect.any(String), + exception: { + values: [ + { + mechanism: { handled: false, type: 'generic' }, + type: 'Error', + value: 'RSC error', + }, + ], + }, + modules: { next: '13.2.0' }, + platform: 'node', + request: { + cookies: expect.any(Object), + headers: expect.any(Object), + }, + timestamp: expect.any(Number), + transaction: 'Page Server Component (/rsc-error)', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/utils/throw.js b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/utils/throw.js new file mode 100644 index 000000000000..0e37a4135be4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/utils/throw.js @@ -0,0 +1 @@ +throw new Error('I am throwing'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/wrapApiHandlerWithSentry.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/wrapApiHandlerWithSentry.test.ts new file mode 100644 index 000000000000..27bf728b42a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/wrapApiHandlerWithSentry.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const cases = [ + { + name: 'wrappedNoParamURL', + url: `/api/no-params`, + transactionName: 'GET /api/no-params', + }, + { + name: 'wrappedDynamicURL', + url: `/api/dog`, + transactionName: 'GET /api/[param]', + }, + { + name: 'wrappedCatchAllURL', + url: `/api/params/dog/bug`, + transactionName: 'GET /api/params/[...pathParts]', + }, +]; + +cases.forEach(({ name, url, transactionName }) => { + test(`Should capture transactions for routes with various shapes (${name})`, async ({ request }) => { + const transactionEventPromise = waitForTransaction('nextjs-13', transactionEvent => { + console.log({ t: transactionEvent.transaction }); + return transactionEvent.transaction === transactionName && transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + request.get(url).catch(() => { + // we don't care about crashes + }); + + expect(await transactionEventPromise).toMatchObject({ + contexts: { + trace: { + data: { + 'http.response.status_code': 200, + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.nextjs', + 'sentry.source': 'route', + }, + op: 'http.server', + origin: 'auto.http.nextjs', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }, + }, + platform: 'node', + request: { + url: expect.stringContaining(url), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: transactionName, + transaction_info: { source: 'route' }, + type: 'transaction', + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-13/tsconfig.json new file mode 100644 index 000000000000..ef9e351d7a7b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ], + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next.config.js", ".next/types/**/*.ts"], + "exclude": ["node_modules", "playwright.config.ts"] +} diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index a6f0822da0fa..01395202d990 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -7,7 +7,7 @@ import * as os from 'os'; import * as path from 'path'; import * as util from 'util'; import * as zlib from 'zlib'; -import type { Envelope, EnvelopeItem, Event } from '@sentry/types'; +import type { Envelope, EnvelopeItem, Event, SerializedSession } from '@sentry/types'; import { parseEnvelope } from '@sentry/utils'; const readFile = util.promisify(fs.readFile); @@ -368,7 +368,7 @@ export function waitForEnvelopeItem( /** Wait for an error to be sent. */ export function waitForError( proxyServerName: string, - callback: (transactionEvent: Event) => Promise | boolean, + callback: (errorEvent: Event) => Promise | boolean, ): Promise { const timestamp = Date.now(); return new Promise((resolve, reject) => { @@ -387,6 +387,28 @@ export function waitForError( }); } +/** Wait for an session to be sent. */ +export function waitForSession( + proxyServerName: string, + callback: (session: SerializedSession) => Promise | boolean, +): Promise { + const timestamp = Date.now(); + return new Promise((resolve, reject) => { + waitForEnvelopeItem( + proxyServerName, + async envelopeItem => { + const [envelopeItemHeader, envelopeItemBody] = envelopeItem; + if (envelopeItemHeader.type === 'session' && (await callback(envelopeItemBody as SerializedSession))) { + resolve(envelopeItemBody as SerializedSession); + return true; + } + return false; + }, + timestamp, + ).catch(reject); + }); +} + /** Wait for a transaction to be sent. */ export function waitForTransaction( proxyServerName: string, diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 49685a6b18c2..6cfbe61d9306 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -5,6 +5,7 @@ export { waitForError, waitForRequest, waitForTransaction, + waitForSession, waitForPlainRequest, } from './event-proxy-server'; diff --git a/dev-packages/test-utils/src/playwright-config.ts b/dev-packages/test-utils/src/playwright-config.ts index a48ca969ad06..da2a10d0b477 100644 --- a/dev-packages/test-utils/src/playwright-config.ts +++ b/dev-packages/test-utils/src/playwright-config.ts @@ -22,7 +22,7 @@ export function getPlaywrightConfig( const config: PlaywrightTestConfig = { testDir: './tests', /* Maximum time one test can run for. */ - timeout: 150_000, + timeout: 30_000, expect: { /** * Maximum time expect() should wait for the condition to be met.