From 4c85627c6878367e5a8dd2c23e88dd080b3aea87 Mon Sep 17 00:00:00 2001 From: seoyeonkim Date: Mon, 13 Oct 2025 17:54:46 +0900 Subject: [PATCH 01/20] fix(nextjs): Fix createRouteManifest with basePath (#17838) fix https://github.com/getsentry/sentry-javascript/issues/17837 add basePath prefix to createRouteManifest closes https://github.com/getsentry/sentry-javascript/issues/17837 --------- Co-authored-by: Charly Gomez --- .../nextjs-15-basepath/.gitignore | 48 ++++++++++++ .../nextjs-15-basepath/.npmrc | 4 + .../app/dynamic/[...parameters]/page.tsx | 3 + .../app/dynamic/[parameter]/page.tsx | 3 + .../nextjs-15-basepath/app/layout.tsx | 7 ++ .../app/navigation/[param]/link/page.tsx | 5 ++ .../navigation/[param]/router-push/page.tsx | 5 ++ .../app/navigation/page.tsx | 25 +++++++ .../nextjs-15-basepath/app/page.tsx | 7 ++ .../nextjs-15-basepath/globals.d.ts | 4 + .../instrumentation-client.ts | 11 +++ .../nextjs-15-basepath/instrumentation.ts | 13 ++++ .../nextjs-15-basepath/next-env.d.ts | 5 ++ .../nextjs-15-basepath/next.config.js | 10 +++ .../nextjs-15-basepath/package.json | 30 ++++++++ .../nextjs-15-basepath/playwright.config.mjs | 25 +++++++ .../nextjs-15-basepath/sentry.edge.config.ts | 13 ++++ .../sentry.server.config.ts | 13 ++++ .../nextjs-15-basepath/start-event-proxy.mjs | 14 ++++ .../routing-basepath-transaction.test.ts | 74 +++++++++++++++++++ .../nextjs-15-basepath/tsconfig.json | 26 +++++++ .../appRouterRoutingInstrumentation.ts | 16 +++- .../config/manifest/createRouteManifest.ts | 6 +- .../nextjs/src/config/withSentryConfig.ts | 4 +- .../suites/base-path/app/about/page.tsx | 1 + .../suites/base-path/app/api/test/page.tsx | 1 + .../manifest/suites/base-path/app/page.tsx | 1 + .../suites/base-path/app/users/[id]/page.tsx | 1 + .../suites/base-path/base-path.test.ts | 45 +++++++++++ 29 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/link/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next.config.js create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json create mode 100644 packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore new file mode 100644 index 000000000000..0c60c8eeaee8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.gitignore @@ -0,0 +1,48 @@ +# 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 + +# Sentry +.sentryclirc + +.vscode + +test-results +event-dumps + +.tmp_dev_server_logs diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/.npmrc @@ -0,0 +1,4 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +public-hoist-pattern[]=*import-in-the-middle* +public-hoist-pattern[]=*require-in-the-middle* diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx new file mode 100644 index 000000000000..dab69e234139 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[...parameters]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx new file mode 100644 index 000000000000..dab69e234139 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/dynamic/[parameter]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/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-15-basepath/app/navigation/[param]/link/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/link/page.tsx new file mode 100644 index 000000000000..de789f9af524 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/link/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx new file mode 100644 index 000000000000..de789f9af524 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/[param]/router-push/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx new file mode 100644 index 000000000000..918c03de3d0a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/navigation/page.tsx @@ -0,0 +1,25 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +export default function Page() { + const router = useRouter(); + + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx new file mode 100644 index 000000000000..4f3c471f2ad3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/app/page.tsx @@ -0,0 +1,7 @@ +export default function Page() { + return ( +
+

Nextjs basePath Test App

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts new file mode 100644 index 000000000000..109dbcd55648 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/globals.d.ts @@ -0,0 +1,4 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts new file mode 100644 index 000000000000..4870c64e7959 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation-client.ts @@ -0,0 +1,11 @@ +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, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts new file mode 100644 index 000000000000..4f11a03dc6cc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// 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-15-basepath/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next.config.js new file mode 100644 index 000000000000..591aec7c1ce0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/next.config.js @@ -0,0 +1,10 @@ +const { withSentryConfig } = require('@sentry/nextjs'); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + basePath: '/my-app', +}; + +module.exports = withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json new file mode 100644 index 000000000000..48a0c69ae38a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json @@ -0,0 +1,30 @@ +{ + "name": "nextjs-15-basepath", + "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 .tmp_dev_server_logs", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@types/node": "^18.19.1", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "15.4.2-canary.1", + "react": "beta", + "react-dom": "beta", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs new file mode 100644 index 000000000000..38548e975851 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/playwright.config.mjs @@ -0,0 +1,25 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts new file mode 100644 index 000000000000..067d2ead0b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.edge.config.ts @@ -0,0 +1,13 @@ +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, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts new file mode 100644 index 000000000000..067d2ead0b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/sentry.server.config.ts @@ -0,0 +1,13 @@ +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, + transportOptions: { + // We are doing a lot of events at once in this test + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs new file mode 100644 index 000000000000..e8834a451788 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-15-basepath', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-15-basepath-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts new file mode 100644 index 000000000000..12fe814d2fff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tests/routing-basepath-transaction.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Creates a pageload transaction for basePath root route with prefix', async ({ page }) => { + const clientPageloadTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return transactionEvent?.transaction === '/my-app' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/my-app'); + + expect(await clientPageloadTransactionPromise).toBeDefined(); +}); + +test('Creates a dynamic pageload transaction for basePath dynamic route with prefix', async ({ page }) => { + const randomRoute = String(Math.random()); + + const clientPageloadTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return ( + transactionEvent?.transaction === '/my-app/dynamic/:parameter' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/my-app/dynamic/${randomRoute}`); + + expect(await clientPageloadTransactionPromise).toBeDefined(); +}); + +test('Creates a dynamic pageload transaction for basePath dynamic catch-all route with prefix', async ({ page }) => { + const randomRoute = String(Math.random()); + + const clientPageloadTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return ( + transactionEvent?.transaction === '/my-app/dynamic/:parameters*' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/my-app/dynamic/${randomRoute}/foo/bar/baz`); + + expect(await clientPageloadTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for basePath router with prefix', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return ( + transactionEvent?.transaction === '/my-app/navigation/:param/router-push' && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' + ); + }); + + await page.goto('/my-app/navigation'); + await page.waitForTimeout(1000); + await page.getByText('router.push()').click(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for basePath with prefix', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-15-basepath', transactionEvent => { + return ( + transactionEvent?.transaction === '/my-app/navigation/:param/link' && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' + ); + }); + + await page.goto('/my-app/navigation'); + await page.waitForTimeout(1000); + await page.getByText('Normal Link').click(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json new file mode 100644 index 000000000000..a2672ddb4974 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es2018", + "allowImportingTsExtensions": true, + "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/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 425daeb3e558..4006496d4a23 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -72,6 +72,10 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { }; }; +const globalWithInjectedBasePath = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath: string | undefined; +}; + /* * The routing instrumentation needs to handle a few cases: * - Router operations: @@ -87,7 +91,9 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { /** Instruments the Next.js app router for navigation. */ export function appRouterInstrumentNavigation(client: Client): void { routerTransitionHandler = (href, navigationType) => { - const unparameterizedPathname = new URL(href, WINDOW.location.href).pathname; + const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; + const normalizedHref = basePath && !href.startsWith(basePath) ? `${basePath}${href}` : href; + const unparameterizedPathname = new URL(normalizedHref, WINDOW.location.href).pathname; const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); const pathname = parameterizedPathname ?? unparameterizedPathname; @@ -206,11 +212,15 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', }; + const href = argArray[0]; + const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; + const normalizedHref = + basePath && typeof href === 'string' && !href.startsWith(basePath) ? `${basePath}${href}` : href; if (routerFunctionName === 'push') { - transactionName = transactionNameifyRouterArgument(argArray[0]); + transactionName = transactionNameifyRouterArgument(normalizedHref); transactionAttributes['navigation.type'] = 'router.push'; } else if (routerFunctionName === 'replace') { - transactionName = transactionNameifyRouterArgument(argArray[0]); + transactionName = transactionNameifyRouterArgument(normalizedHref); transactionAttributes['navigation.type'] = 'router.replace'; } else if (routerFunctionName === 'back') { transactionAttributes['navigation.type'] = 'router.back'; diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 1e905d858f73..32e7db61b57b 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -10,6 +10,10 @@ export type CreateRouteManifestOptions = { * By default, route groups are stripped from paths following Next.js convention. */ includeRouteGroups?: boolean; + /** + * Base path for the application, if any. This will be prefixed to all routes. + */ + basePath?: string; }; let manifestCache: RouteManifest | null = null; @@ -192,7 +196,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route return manifestCache; } - const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, '', options?.includeRouteGroups); + const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, options?.basePath, options?.includeRouteGroups); const manifest: RouteManifest = { dynamicRoutes, diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index ddf761998e50..9c82e3af017c 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -147,7 +147,9 @@ function getFinalConfigObject( let routeManifest: RouteManifest | undefined; if (!userSentryOptions.disableManifestInjection) { - routeManifest = createRouteManifest(); + routeManifest = createRouteManifest({ + basePath: incomingUserNextConfigObject.basePath, + }); } setUpBuildTimeVariables(incomingUserNextConfigObject, userSentryOptions, releaseName); diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx new file mode 100644 index 000000000000..e5752fa903b7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/app/about/page.tsx @@ -0,0 +1 @@ +// about page diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx new file mode 100644 index 000000000000..ec89ef596f93 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/app/api/test/page.tsx @@ -0,0 +1 @@ +// API test page diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx new file mode 100644 index 000000000000..768d7a4f7757 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/app/page.tsx @@ -0,0 +1 @@ +// root page diff --git a/packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx new file mode 100644 index 000000000000..a7307090717b --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/app/users/[id]/page.tsx @@ -0,0 +1 @@ +// users id dynamic page diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts new file mode 100644 index 000000000000..a1014b05c32c --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts @@ -0,0 +1,45 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('basePath', () => { + test('should generate routes with base path prefix', () => { + const manifest = createRouteManifest({ + basePath: '/my-app', + appDirPath: path.join(__dirname, 'app'), + }); + + expect(manifest).toEqual({ + staticRoutes: [{ path: '/my-app' }, { path: '/my-app/about' }, { path: '/my-app/api/test' }], + dynamicRoutes: [ + { + path: '/my-app/users/:id', + regex: '^/my-app/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should validate dynamic route regex with base path', () => { + const manifest = createRouteManifest({ + basePath: '/my-app', + appDirPath: path.join(__dirname, 'app'), + }); + + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path === '/my-app/users/:id'); + const regex = new RegExp(dynamicRoute?.regex ?? ''); + + // Should match valid paths with base path + expect(regex.test('/my-app/users/123')).toBe(true); + expect(regex.test('/my-app/users/john-doe')).toBe(true); + + // Should not match paths without base path + expect(regex.test('/users/123')).toBe(false); + + // Should not match invalid paths + expect(regex.test('/my-app/users/')).toBe(false); + expect(regex.test('/my-app/users/123/extra')).toBe(false); + expect(regex.test('/my-app/user/123')).toBe(false); + }); +}); From baf4ff8c20e4c67f4aee4fb45565e520c5226a38 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 13 Oct 2025 11:13:18 +0200 Subject: [PATCH 02/20] chore: Add external contributor to CHANGELOG.md (#17915) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #17838 Co-authored-by: chargome <20254395+chargome@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d065c6c800..c2bd55b5cd81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @seoyeon9888. Thank you for your contribution! + ## 10.19.0 - feat(tracemetrics): Add trace metrics behind an experiments flag ([#17883](https://github.com/getsentry/sentry-javascript/pull/17883)) From 7fc2858707962493caa8ba7556e94fb23378ebba Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 13 Oct 2025 11:17:30 +0200 Subject: [PATCH 03/20] feat(nextjs): Prepare for next 16 bundler default (#17868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next.js will switch the default bundler starting with Next.js v16 - Updated detection logic for turbopack vs webpack - Updated generic tests (app dir + pages dir) to only run on webpack (we'll need to update these as soon as next16 is released) (there are tests that won't pass on turbopack and keeping this in sync for both bundlers will become unmaintainable) - Add a bunch of unit tests - Disabled `next dev --webpack` tests for now as instrumentation breaks – tracked in [linear](https://linear.app/getsentry/issue/FE-618/webpack-breaks-instrumentation-for-dev-mode-in-next-16) - Middleware tests failing likely due to missing [Proxy support ](https://github.com/getsentry/sentry-javascript/issues/17894), will split this up in a follow up pr --- .../nextjs-app-dir/package.json | 7 +- .../nextjs-app-dir/playwright.config.mjs | 10 +- .../tests/client-errors.test.ts | 2 +- .../tests/devErrorSymbolification.test.ts | 3 +- .../nextjs-app-dir/tests/isDevMode.ts | 1 + .../nextjs-app-dir/tests/transactions.test.ts | 5 +- .../nextjs-pages-dir/package.json | 7 +- .../nextjs-pages-dir/playwright.config.mjs | 10 +- .../tests/devErrorSymbolification.test.ts | 2 +- .../nextjs-pages-dir/tests/isDevMode.ts | 1 + .../tests/transactions.test.ts | 4 +- packages/nextjs/src/config/util.ts | 65 ++++ .../nextjs/src/config/withSentryConfig.ts | 35 +- packages/nextjs/test/config/util.test.ts | 179 ++++++++++- .../test/config/withSentryConfig.test.ts | 299 ++++++++++++++++-- 15 files changed, 576 insertions(+), 54 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/isDevMode.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/isDevMode.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json index 2ac1965e180a..15c337c33c3a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -4,12 +4,14 @@ "private": true, "scripts": { "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build:webpack": "next build --webpack > .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", "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack playwright test", "test:build": "pnpm install && pnpm build", "test:test-build": "pnpm ts-node --script-mode assert-build.ts", - "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@beta && pnpm add react-dom@beta && pnpm build", + "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@latest && pnpm add react-dom@latest && pnpm build:webpack", "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", "test:build-13": "pnpm install && pnpm add next@13.5.11 && pnpm build", "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" @@ -43,7 +45,8 @@ "optionalVariants": [ { "build-command": "pnpm test:build-canary", - "label": "nextjs-app-dir (canary)" + "label": "nextjs-app-dir (canary, webpack opt-in)", + "assert-command": "pnpm test:prod" }, { "build-command": "pnpm test:build-latest", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.mjs index c675d003853a..494df5bc5432 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/playwright.config.mjs @@ -5,8 +5,16 @@ if (!testEnv) { throw new Error('No test env defined'); } +const getStartCommand = () => { + if (testEnv === 'development-webpack') { + return 'pnpm next dev -p 3030 --webpack'; + } + + return testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030'; +}; + const config = getPlaywrightConfig({ - startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + startCommand: getStartCommand(), port: 3030, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-errors.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-errors.test.ts index 580368f4b9a1..0fef36e3d9fd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-errors.test.ts @@ -1,12 +1,12 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; const packageJson = require('../package.json'); test('Sends a client-side exception to Sentry', async ({ page }) => { const nextjsVersion = packageJson.dependencies.next; const nextjsMajor = Number(nextjsVersion.split('.')[0]); - const isDevMode = process.env.TEST_ENV === 'development'; await page.goto('/'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts index ea9fd112778f..3aa412bfc856 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/devErrorSymbolification.test.ts @@ -1,8 +1,9 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; test('should have symbolicated dev errors', async ({ page }) => { - test.skip(process.env.TEST_ENV !== 'development', 'should be skipped for non-dev mode'); + test.skip(!isDevMode, 'should be skipped for non-dev mode'); await page.goto('/'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index 3ad9e43f06db..9819507f5cb9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -1,12 +1,12 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; const packageJson = require('../package.json'); test('Sends a pageload transaction', async ({ page }) => { const nextjsVersion = packageJson.dependencies.next; const nextjsMajor = Number(nextjsVersion.split('.')[0]); - const isDevMode = process.env.TEST_ENV === 'development'; const pageloadTransactionEventPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; @@ -78,8 +78,9 @@ test('Should send a transaction for instrumented server actions', async ({ page test('Should send a wrapped server action as a child of a nextjs transaction', async ({ page }) => { const nextjsVersion = packageJson.dependencies.next; const nextjsMajor = Number(nextjsVersion.split('.')[0]); + test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14'); - test.skip(process.env.TEST_ENV === 'development', 'this magically only works in production'); + test.skip(isDevMode, 'this magically only works in production'); const nextjsPostTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => { return ( diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json index 0ef1d9bbcac7..42c321a2f93a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json @@ -4,12 +4,14 @@ "private": true, "scripts": { "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build:webpack": "next build --webpack > .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", "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack playwright test", "test:build": "pnpm install && pnpm build", "test:test-build": "pnpm ts-node --script-mode assert-build.ts", - "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@beta && pnpm add react-dom@beta && pnpm build", + "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@latest && pnpm add react-dom@latest && pnpm build:webpack", "test:build-latest": "pnpm install && pnpm add next@latest && pnpm add react@latest && pnpm add react-dom@latest && pnpm build", "test:build-13": "pnpm install && pnpm add next@13.5.11 && pnpm build", "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" @@ -43,7 +45,8 @@ "optionalVariants": [ { "build-command": "pnpm test:build-canary", - "label": "nextjs-pages-dir (canary)" + "label": "nextjs-pages-dir (canary, webpack opt-in)", + "assert-command": "pnpm test:prod" }, { "build-command": "pnpm test:build-latest", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/playwright.config.mjs index c675d003853a..494df5bc5432 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/playwright.config.mjs @@ -5,8 +5,16 @@ if (!testEnv) { throw new Error('No test env defined'); } +const getStartCommand = () => { + if (testEnv === 'development-webpack') { + return 'pnpm next dev -p 3030 --webpack'; + } + + return testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030'; +}; + const config = getPlaywrightConfig({ - startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + startCommand: getStartCommand(), port: 3030, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/devErrorSymbolification.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/devErrorSymbolification.test.ts index c846fab3464c..010c49ae3aa9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/devErrorSymbolification.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/devErrorSymbolification.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; test('should have symbolicated dev errors', async ({ page }) => { - test.skip(process.env.TEST_ENV !== 'development', 'should be skipped for non-dev mode'); + test.skip(!process.env.TEST_ENV?.includes('development'), 'should be skipped for non-dev mode'); await page.goto('/'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts index 918297898de7..3569789ed995 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/transactions.test.ts @@ -1,12 +1,12 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; const packageJson = require('../package.json'); test('Sends a pageload transaction', async ({ page }) => { const nextjsVersion = packageJson.dependencies.next; const nextjsMajor = Number(nextjsVersion.split('.')[0]); - const isDevMode = process.env.TEST_ENV === 'development'; const pageloadTransactionEventPromise = waitForTransaction('nextjs-pages-dir', transactionEvent => { return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index de8ad68cac41..40eb65e4e1e9 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -65,3 +65,68 @@ export function supportsProductionCompileHook(version: string): boolean { return false; } + +/** + * Checks if the current Next.js version uses Turbopack as the default bundler. + * Starting from Next.js 15.6.0-canary.38, turbopack became the default for `next build`. + * + * @param version - Next.js version string to check. + * @returns true if the version uses Turbopack by default + */ +export function isTurbopackDefaultForVersion(version: string): boolean { + if (!version) { + return false; + } + + const { major, minor, prerelease } = parseSemver(version); + + if (major === undefined || minor === undefined) { + return false; + } + + // Next.js 16+ uses turbopack by default + if (major >= 16) { + return true; + } + + // For Next.js 15, only canary versions 15.6.0-canary.40+ use turbopack by default + // Stable 15.x releases still use webpack by default + if (major === 15 && minor >= 6 && prerelease && prerelease.startsWith('canary.')) { + if (minor >= 7) { + return true; + } + const canaryNumber = parseInt(prerelease.split('.')[1] || '0', 10); + if (canaryNumber >= 40) { + return true; + } + } + + return false; +} + +/** + * Determines which bundler is actually being used based on environment variables, + * CLI flags, and Next.js version. + * + * @param nextJsVersion - The Next.js version string + * @returns 'turbopack', 'webpack', or undefined if it cannot be determined + */ +export function detectActiveBundler(nextJsVersion: string | undefined): 'turbopack' | 'webpack' | undefined { + if (process.env.TURBOPACK || process.argv.includes('--turbo')) { + return 'turbopack'; + } + + // Explicit opt-in to webpack via --webpack flag + if (process.argv.includes('--webpack')) { + return 'webpack'; + } + + // Fallback to version-based default behavior + if (nextJsVersion) { + const turbopackIsDefault = isTurbopackDefaultForVersion(nextJsVersion); + return turbopackIsDefault ? 'turbopack' : 'webpack'; + } + + // Unlikely but at this point, we just assume webpack for older behavior + return 'webpack'; +} diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 9c82e3af017c..eaac5b084a9e 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -15,7 +15,7 @@ import type { NextConfigObject, SentryBuildOptions, } from './types'; -import { getNextjsVersion, supportsProductionCompileHook } from './util'; +import { detectActiveBundler, getNextjsVersion, supportsProductionCompileHook } from './util'; import { constructWebpackConfigFunction } from './webpack'; let showedExportModeTunnelWarning = false; @@ -260,28 +260,24 @@ function getFinalConfigObject( nextMajor = major; } - const isTurbopack = process.env.TURBOPACK; + const activeBundler = detectActiveBundler(nextJsVersion); + const isTurbopack = activeBundler === 'turbopack'; + const isWebpack = activeBundler === 'webpack'; const isTurbopackSupported = supportsProductionCompileHook(nextJsVersion ?? ''); + // Warn if using turbopack with an unsupported Next.js version if (!isTurbopackSupported && isTurbopack) { - if (process.env.NODE_ENV === 'development') { - // eslint-disable-next-line no-console - console.warn( - `[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (\`next dev --turbopack\`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on ${nextJsVersion}. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.`, - ); - } else if (process.env.NODE_ENV === 'production') { - // eslint-disable-next-line no-console - console.warn( - `[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (\`next build --turbopack\`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on ${nextJsVersion}. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.`, - ); - } + // eslint-disable-next-line no-console + console.warn( + `[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack. The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on ${nextJsVersion}. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.`, + ); } - // webpack case + // Webpack case - warn if trying to use runAfterProductionCompile hook with unsupported Next.js version if ( userSentryOptions.useRunAfterProductionCompileHook && !supportsProductionCompileHook(nextJsVersion ?? '') && - !isTurbopack + isWebpack ) { // eslint-disable-next-line no-console console.warn( @@ -369,10 +365,9 @@ function getFinalConfigObject( ], }, }), - webpack: - isTurbopack || userSentryOptions.disableSentryWebpackConfig - ? incomingUserNextConfigObject.webpack // just return the original webpack config - : constructWebpackConfigFunction({ + ...(isWebpack && !userSentryOptions.disableSentryWebpackConfig + ? { + webpack: constructWebpackConfigFunction({ userNextConfig: incomingUserNextConfigObject, userSentryOptions, releaseName, @@ -380,6 +375,8 @@ function getFinalConfigObject( nextJsVersion, useRunAfterProductionCompileHook: shouldUseRunAfterProductionCompileHook, }), + } + : {}), ...(isTurbopackSupported && isTurbopack ? { turbopack: constructTurbopackConfig({ diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index b31f71705029..2dcff9889364 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as util from '../../src/config/util'; describe('util', () => { @@ -96,4 +96,181 @@ describe('util', () => { }); }); }); + + describe('isTurbopackDefaultForVersion', () => { + describe('returns true for versions where turbopack is default', () => { + it.each([ + // Next.js 16+ stable versions + ['16.0.0', 'Next.js 16.0.0 stable'], + ['16.0.1', 'Next.js 16.0.1 stable'], + ['16.1.0', 'Next.js 16.1.0 stable'], + ['16.2.5', 'Next.js 16.2.5 stable'], + + // Next.js 16+ pre-release versions + ['16.0.0-rc.1', 'Next.js 16.0.0-rc.1'], + ['16.0.0-canary.1', 'Next.js 16.0.0-canary.1'], + ['16.1.0-beta.2', 'Next.js 16.1.0-beta.2'], + + // Next.js 17+ + ['17.0.0', 'Next.js 17.0.0'], + ['18.0.0', 'Next.js 18.0.0'], + ['20.0.0', 'Next.js 20.0.0'], + + // Next.js 15.6.0-canary.40+ (boundary case) + ['15.6.0-canary.40', 'Next.js 15.6.0-canary.40 (exact threshold)'], + ['15.6.0-canary.41', 'Next.js 15.6.0-canary.41'], + ['15.6.0-canary.42', 'Next.js 15.6.0-canary.42'], + ['15.6.0-canary.100', 'Next.js 15.6.0-canary.100'], + + // Next.js 15.7+ canary versions + ['15.7.0-canary.1', 'Next.js 15.7.0-canary.1'], + ['15.7.0-canary.50', 'Next.js 15.7.0-canary.50'], + ['15.8.0-canary.1', 'Next.js 15.8.0-canary.1'], + ['15.10.0-canary.1', 'Next.js 15.10.0-canary.1'], + ])('returns true for %s (%s)', version => { + expect(util.isTurbopackDefaultForVersion(version)).toBe(true); + }); + }); + + describe('returns false for versions where webpack is still default', () => { + it.each([ + // Next.js 15.6.0-canary.39 and below + ['15.6.0-canary.39', 'Next.js 15.6.0-canary.39 (just below threshold)'], + ['15.6.0-canary.36', 'Next.js 15.6.0-canary.36'], + ['15.6.0-canary.38', 'Next.js 15.6.0-canary.38'], + ['15.6.0-canary.0', 'Next.js 15.6.0-canary.0'], + + // Next.js 15.6.x stable releases (NOT canary) + ['15.6.0', 'Next.js 15.6.0 stable'], + ['15.6.1', 'Next.js 15.6.1 stable'], + ['15.6.2', 'Next.js 15.6.2 stable'], + ['15.6.10', 'Next.js 15.6.10 stable'], + + // Next.js 15.6.x rc releases (NOT canary) + ['15.6.0-rc.1', 'Next.js 15.6.0-rc.1'], + ['15.6.0-rc.2', 'Next.js 15.6.0-rc.2'], + + // Next.js 15.7+ stable releases (NOT canary) + ['15.7.0', 'Next.js 15.7.0 stable'], + ['15.8.0', 'Next.js 15.8.0 stable'], + ['15.10.0', 'Next.js 15.10.0 stable'], + + // Next.js 15.5 and below (all versions) + ['15.5.0', 'Next.js 15.5.0'], + ['15.5.0-canary.100', 'Next.js 15.5.0-canary.100'], + ['15.4.1', 'Next.js 15.4.1'], + ['15.0.0', 'Next.js 15.0.0'], + ['15.0.0-canary.1', 'Next.js 15.0.0-canary.1'], + + // Next.js 14.x and below + ['14.2.0', 'Next.js 14.2.0'], + ['14.0.0', 'Next.js 14.0.0'], + ['14.0.0-canary.50', 'Next.js 14.0.0-canary.50'], + ['13.5.0', 'Next.js 13.5.0'], + ['13.0.0', 'Next.js 13.0.0'], + ['12.0.0', 'Next.js 12.0.0'], + ])('returns false for %s (%s)', version => { + expect(util.isTurbopackDefaultForVersion(version)).toBe(false); + }); + }); + + describe('edge cases', () => { + it.each([ + ['', 'empty string'], + ['invalid', 'invalid version string'], + ['15', 'missing minor and patch'], + ['15.6', 'missing patch'], + ['not.a.version', 'completely invalid'], + ['15.6.0-alpha.1', 'alpha prerelease (not canary)'], + ['15.6.0-beta.1', 'beta prerelease (not canary)'], + ])('returns false for %s (%s)', version => { + expect(util.isTurbopackDefaultForVersion(version)).toBe(false); + }); + }); + + describe('canary number parsing edge cases', () => { + it.each([ + ['15.6.0-canary.', 'canary with no number'], + ['15.6.0-canary.abc', 'canary with non-numeric value'], + ['15.6.0-canary.38.extra', 'canary with extra segments'], + ])('handles malformed canary versions: %s (%s)', version => { + // Should not throw, just return appropriate boolean + expect(() => util.isTurbopackDefaultForVersion(version)).not.toThrow(); + }); + + it('handles canary.40 exactly (boundary)', () => { + expect(util.isTurbopackDefaultForVersion('15.6.0-canary.40')).toBe(true); + }); + + it('handles canary.39 exactly (boundary)', () => { + expect(util.isTurbopackDefaultForVersion('15.6.0-canary.39')).toBe(false); + }); + }); + }); + + describe('detectActiveBundler', () => { + const originalArgv = process.argv; + const originalEnv = process.env; + + beforeEach(() => { + process.argv = [...originalArgv]; + process.env = { ...originalEnv }; + delete process.env.TURBOPACK; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + }); + + it('returns turbopack when TURBOPACK env var is set', () => { + process.env.TURBOPACK = '1'; + expect(util.detectActiveBundler('15.5.0')).toBe('turbopack'); + }); + + it('returns webpack when --webpack flag is present', () => { + process.argv.push('--webpack'); + expect(util.detectActiveBundler('16.0.0')).toBe('webpack'); + }); + + it('returns turbopack for Next.js 16+ by default', () => { + expect(util.detectActiveBundler('16.0.0')).toBe('turbopack'); + expect(util.detectActiveBundler('17.0.0')).toBe('turbopack'); + }); + + it('returns turbopack for Next.js 15.6.0-canary.40+', () => { + expect(util.detectActiveBundler('15.6.0-canary.40')).toBe('turbopack'); + expect(util.detectActiveBundler('15.6.0-canary.50')).toBe('turbopack'); + }); + + it('returns webpack for Next.js 15.6.0 stable', () => { + expect(util.detectActiveBundler('15.6.0')).toBe('webpack'); + }); + + it('returns webpack for Next.js 15.5.x and below', () => { + expect(util.detectActiveBundler('15.5.0')).toBe('webpack'); + expect(util.detectActiveBundler('15.0.0')).toBe('webpack'); + expect(util.detectActiveBundler('14.2.0')).toBe('webpack'); + }); + + it('returns webpack when version is undefined', () => { + expect(util.detectActiveBundler(undefined)).toBe('webpack'); + }); + + it('prioritizes TURBOPACK env var over version detection', () => { + process.env.TURBOPACK = '1'; + expect(util.detectActiveBundler('14.0.0')).toBe('turbopack'); + }); + + it('prioritizes --webpack flag over version detection', () => { + process.argv.push('--webpack'); + expect(util.detectActiveBundler('16.0.0')).toBe('webpack'); + }); + + it('prioritizes TURBOPACK env var over --webpack flag', () => { + process.env.TURBOPACK = '1'; + process.argv.push('--webpack'); + expect(util.detectActiveBundler('15.5.0')).toBe('turbopack'); + }); + }); }); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index b437e73dfe75..f1f46c6fc6f2 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as util from '../../src/config/util'; import { DEFAULT_SERVER_EXTERNAL_PACKAGES } from '../../src/config/withSentryConfig'; import { defaultRuntimePhase, defaultsObject, exportedNextConfig, userNextConfig } from './fixtures'; @@ -269,6 +269,280 @@ describe('withSentryConfig', () => { }); }); + describe('bundler detection with version-based defaults', () => { + const originalTurbopack = process.env.TURBOPACK; + const originalArgv = process.argv; + + beforeEach(() => { + process.argv = [...originalArgv]; + delete process.env.TURBOPACK; + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.env.TURBOPACK = originalTurbopack; + process.argv = originalArgv; + }); + + describe('Next.js 16+ defaults to turbopack', () => { + it('uses turbopack config by default for Next.js 16.0.0', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); + + it('uses turbopack config by default for Next.js 17.0.0', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('17.0.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); + + it('uses webpack when --webpack flag is present on Next.js 16.0.0', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); + process.argv.push('--webpack'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + + it('prioritizes TURBOPACK env var over --webpack flag', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); + process.env.TURBOPACK = '1'; + process.argv.push('--webpack'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); + }); + + describe('Next.js 15.6.0-canary.40+ defaults to turbopack', () => { + it('uses turbopack config by default for 15.6.0-canary.40', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.40'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); + + it('uses turbopack config by default for 15.6.0-canary.50', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.50'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); + + it('uses turbopack config by default for 15.7.0-canary.1', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0-canary.1'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); + + it('uses webpack when --webpack flag is present on 15.6.0-canary.40', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.40'); + process.argv.push('--webpack'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + + it('uses webpack when --webpack flag is present on 15.7.0-canary.1', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0-canary.1'); + process.argv.push('--webpack'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + }); + + describe('Next.js 15.6.0-canary.37 and below defaults to webpack', () => { + it('uses webpack config by default for 15.6.0-canary.37', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.37'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + + it('uses webpack config by default for 15.6.0-canary.1', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.1'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + + it('uses turbopack when TURBOPACK env var is set on 15.6.0-canary.37', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.37'); + process.env.TURBOPACK = '1'; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); + }); + + describe('Next.js 15.6.x stable releases default to webpack', () => { + it('uses webpack config by default for 15.6.0 stable', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + + it('uses webpack config by default for 15.6.1 stable', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.1'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + + it('uses webpack config by default for 15.7.0 stable', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + + it('uses turbopack when explicitly requested via env var on 15.6.0 stable', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0'); + process.env.TURBOPACK = '1'; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); + }); + + describe('older Next.js versions default to webpack', () => { + it.each([['15.5.0'], ['15.0.0'], ['14.2.0'], ['13.5.0']])( + 'uses webpack config by default for Next.js %s', + version => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(version); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }, + ); + + it.each([['15.5.0-canary.100'], ['15.0.0-canary.1'], ['14.2.0-canary.50']])( + 'uses webpack config by default for Next.js %s canary', + version => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(version); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }, + ); + }); + + describe('warnings are shown for unsupported turbopack usage', () => { + let consoleWarnSpy: ReturnType; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('warns when using turbopack on unsupported version', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + process.env.TURBOPACK = '1'; + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + ); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('15.0.0')); + }); + + it('does not warn when using turbopack on supported version', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(true); + process.env.TURBOPACK = '1'; + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('does not warn when using webpack', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); + vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); + + materializeFinalNextConfig(exportedNextConfig); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + it('defaults to webpack when Next.js version cannot be determined', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + + it('uses turbopack when TURBOPACK env var is set even when version is undefined', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); + process.env.TURBOPACK = '1'; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + // Note: turbopack config won't be added when version is undefined because + // isTurbopackSupported will be false, but webpack config should still be skipped + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + // Turbopack config is only added when both isTurbopack AND isTurbopackSupported are true + expect(finalConfig.turbopack).toBeUndefined(); + }); + + it('handles malformed version strings gracefully', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('not.a.version'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); + }); + }); + describe('turbopack sourcemap configuration', () => { const originalTurbopack = process.env.TURBOPACK; @@ -994,7 +1268,7 @@ describe('withSentryConfig', () => { materializeFinalNextConfig(exportedNextConfig); expect(consoleWarnSpy).toHaveBeenCalledWith( - '[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (`next dev --turbopack`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on 15.4.0. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.', + '[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack. The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on 15.4.0. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.', ); consoleWarnSpy.mockRestore(); @@ -1011,7 +1285,7 @@ describe('withSentryConfig', () => { materializeFinalNextConfig(exportedNextConfig); expect(consoleWarnSpy).toHaveBeenCalledWith( - '[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (`next build --turbopack`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on 15.3.9. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.', + '[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack. The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on 15.3.9. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.', ); consoleWarnSpy.mockRestore(); @@ -1115,24 +1389,7 @@ describe('withSentryConfig', () => { materializeFinalNextConfig(exportedNextConfig); expect(consoleWarnSpy).toHaveBeenCalledWith( - '[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack (`next dev --turbopack`). The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on 15.4.0-canary.15. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.', - ); - - consoleWarnSpy.mockRestore(); - }); - - it('does not warn in other environments besides development and production', () => { - process.env.TURBOPACK = '1'; - // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing - process.env.NODE_ENV = 'test'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - materializeFinalNextConfig(exportedNextConfig); - - expect(consoleWarnSpy).not.toHaveBeenCalledWith( - expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), + '[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack. The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on 15.4.0-canary.15. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.', ); consoleWarnSpy.mockRestore(); From 3b04938264de637ec8a242911303b8df81a2f4be Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 14 Oct 2025 09:12:17 +0200 Subject: [PATCH 04/20] test(nextjs): Update next 15 tests (#17919) - Removes canary testing for v15, as [v16 beta](https://github.com/vercel/next.js/releases/tag/v16.0.0-beta.0) was released - Removes experimental `ppr` testing for v15 as this will not be marked as stable in v15. Will move these tests to v16 under the new name `cacheComponents` instead - Updates the basepath test to run on next@^15 --- .../nextjs-15-basepath/package.json | 6 ++--- .../nextjs-15/app/ppr-error/[param]/page.tsx | 17 -------------- .../nextjs-15/next.config.js | 6 +---- .../test-applications/nextjs-15/package.json | 16 +++++--------- .../nextjs-15/tests/ppr-error.test.ts | 22 ------------------- 5 files changed, 9 insertions(+), 58 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/ppr-error/[param]/page.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/ppr-error.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json index 48a0c69ae38a..7481ea0fca7a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json @@ -15,9 +15,9 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.4.2-canary.1", - "react": "beta", - "react-dom": "beta", + "next": "^15", + "react": "latest", + "react-dom": "latest", "typescript": "~5.0.0" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/ppr-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/ppr-error/[param]/page.tsx deleted file mode 100644 index f2e096164d04..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/app/ppr-error/[param]/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; - -export default async function Page({ searchParams }: { searchParams: any }) { - // We need to dynamically check for this because Next.js made the API async for Next.js 15 and we use this test in canary tests - const normalizedSearchParams = await searchParams; - - try { - console.log(normalizedSearchParams.id); // Accessing a field on searchParams will throw the PPR error - } catch (e) { - Sentry.captureException(e); // This error should not be reported - await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for any async event processors to run - await Sentry.flush(); - throw e; - } - - return
This server component will throw a PPR error that we do not want to catch.
; -} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js index 2be749fde774..1098c2ce5a4f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/next.config.js @@ -1,11 +1,7 @@ const { withSentryConfig } = require('@sentry/nextjs'); /** @type {import('next').NextConfig} */ -const nextConfig = { - experimental: { - ppr: true, - }, -}; +const nextConfig = {}; module.exports = withSentryConfig(nextConfig, { silent: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 7f9b3e822628..9d56bf6c3df5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -10,10 +10,8 @@ "test:dev": "TEST_ENV=development playwright test", "test:dev-turbo": "TEST_ENV=dev-turbopack playwright test", "test:build": "pnpm install && pnpm build", - "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@beta && pnpm add react-dom@beta && pnpm build", - "//": "15.0.0-canary.194 is the canary release attached to Next.js RC 1. We need to use the canary version instead of the RC because PPR will not work without. The specific react version is also attached to RC 1.", - "test:build-latest": "pnpm install && pnpm add next@15.0.0-canary.194 && pnpm add react@19.0.0-rc-cd22717c-20241013 && pnpm add react-dom@19.0.0-rc-cd22717c-20241013 && pnpm build", - "test:build-turbo": "pnpm install && pnpm add next@15.4.2-canary.1 && next build --turbopack", + "test:build-latest": "pnpm install && pnpm add next@15 && pnpm build", + "test:build-turbo": "pnpm install && next build --turbopack", "test:assert": "pnpm test:prod && pnpm test:dev" }, "dependencies": { @@ -22,9 +20,9 @@ "@types/react": "18.0.26", "@types/react-dom": "18.0.9", "ai": "^3.0.0", - "next": "15.4.2-canary.1", - "react": "beta", - "react-dom": "beta", + "next": "15.5.4", + "react": "latest", + "react-dom": "latest", "typescript": "~5.0.0", "zod": "^3.22.4" }, @@ -37,10 +35,6 @@ }, "sentryTest": { "optionalVariants": [ - { - "build-command": "pnpm test:build-canary", - "label": "nextjs-15 (canary)" - }, { "build-command": "pnpm test:build-latest", "label": "nextjs-15 (latest)" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ppr-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ppr-error.test.ts deleted file mode 100644 index 7c7c0b91eed2..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ppr-error.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; - -test('should not capture React-internal errors for PPR rendering', async ({ page }) => { - const pageServerComponentTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent?.transaction === 'GET /ppr-error/[param]'; - }); - - let errorEventReceived = false; - waitForError('nextjs-15', async errorEvent => { - return errorEvent?.transaction === 'Page Server Component (/ppr-error/[param])'; - }).then(() => { - errorEventReceived = true; - }); - - await page.goto(`/ppr-error/foobar?id=1`); - - const pageServerComponentTransaction = await pageServerComponentTransactionPromise; - expect(pageServerComponentTransaction).toBeDefined(); - - expect(errorEventReceived).toBe(false); -}); From fd8bcbd7a4a7a319ff53026e4e5104551f1c5f6d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 14 Oct 2025 10:24:47 +0200 Subject: [PATCH 05/20] feat(nextjs): Support native debugIds in turbopack (#17853) Adds support for https://github.com/vercel/next.js/pull/84319 - Switches to automatically injecting native debug Ids whenever the Next.js version supports it - Updates core functionality on supporting `sentryDebugId` alongside the more generic `debugId` that Vercel uses. - Something to consider: We write both `sentryDebugIds` and `debugIds` into the cache but since we generate them in this order, `debugIds` will have precedence when there is a bundle with both keys in it. closes https://github.com/getsentry/sentry-javascript/issues/17841 --- .size-limit.js | 4 +- packages/core/src/utils/debug-ids.ts | 82 +++++++---- packages/core/src/utils/worldwide.ts | 6 + packages/core/test/lib/prepareEvent.test.ts | 134 ++++++++++++++++++ .../config/handleRunAfterProductionCompile.ts | 13 +- .../turbopack/constructTurbopackConfig.ts | 15 +- packages/nextjs/src/config/types.ts | 1 + packages/nextjs/src/config/util.ts | 42 ++++++ .../nextjs/src/config/withSentryConfig.ts | 33 ++++- .../constructTurbopackConfig.test.ts | 4 + packages/nextjs/test/config/util.test.ts | 116 +++++++++++++++ 11 files changed, 409 insertions(+), 41 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 59ad29c3ccf8..08da6f5ce85b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -103,7 +103,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'feedbackAsyncIntegration'), gzip: true, - limit: '34 KB', + limit: '35 KB', }, // React SDK (ESM) { @@ -215,7 +215,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '41 KB', + limit: '42 KB', }, // Node-Core SDK (ESM) { diff --git a/packages/core/src/utils/debug-ids.ts b/packages/core/src/utils/debug-ids.ts index f60e74c7cd26..97f30bbe816a 100644 --- a/packages/core/src/utils/debug-ids.ts +++ b/packages/core/src/utils/debug-ids.ts @@ -6,56 +6,82 @@ type StackString = string; type CachedResult = [string, string]; let parsedStackResults: Record | undefined; -let lastKeysCount: number | undefined; +let lastSentryKeysCount: number | undefined; +let lastNativeKeysCount: number | undefined; let cachedFilenameDebugIds: Record | undefined; /** * Returns a map of filenames to debug identifiers. + * Supports both proprietary _sentryDebugIds and native _debugIds (e.g., from Vercel) formats. */ export function getFilenameToDebugIdMap(stackParser: StackParser): Record { - const debugIdMap = GLOBAL_OBJ._sentryDebugIds; - if (!debugIdMap) { + const sentryDebugIdMap = GLOBAL_OBJ._sentryDebugIds; + const nativeDebugIdMap = GLOBAL_OBJ._debugIds; + + if (!sentryDebugIdMap && !nativeDebugIdMap) { return {}; } - const debugIdKeys = Object.keys(debugIdMap); + const sentryDebugIdKeys = sentryDebugIdMap ? Object.keys(sentryDebugIdMap) : []; + const nativeDebugIdKeys = nativeDebugIdMap ? Object.keys(nativeDebugIdMap) : []; // If the count of registered globals hasn't changed since the last call, we // can just return the cached result. - if (cachedFilenameDebugIds && debugIdKeys.length === lastKeysCount) { + if ( + cachedFilenameDebugIds && + sentryDebugIdKeys.length === lastSentryKeysCount && + nativeDebugIdKeys.length === lastNativeKeysCount + ) { return cachedFilenameDebugIds; } - lastKeysCount = debugIdKeys.length; - - // Build a map of filename -> debug_id. - cachedFilenameDebugIds = debugIdKeys.reduce>((acc, stackKey) => { - if (!parsedStackResults) { - parsedStackResults = {}; - } + lastSentryKeysCount = sentryDebugIdKeys.length; + lastNativeKeysCount = nativeDebugIdKeys.length; - const result = parsedStackResults[stackKey]; + // Build a map of filename -> debug_id from both sources + cachedFilenameDebugIds = {}; - if (result) { - acc[result[0]] = result[1]; - } else { - const parsedStack = stackParser(stackKey); - - for (let i = parsedStack.length - 1; i >= 0; i--) { - const stackFrame = parsedStack[i]; - const filename = stackFrame?.filename; - const debugId = debugIdMap[stackKey]; + if (!parsedStackResults) { + parsedStackResults = {}; + } - if (filename && debugId) { - acc[filename] = debugId; - parsedStackResults[stackKey] = [filename, debugId]; - break; + const processDebugIds = (debugIdKeys: string[], debugIdMap: Record): void => { + for (const key of debugIdKeys) { + const debugId = debugIdMap[key]; + const result = parsedStackResults?.[key]; + + if (result && cachedFilenameDebugIds && debugId) { + // Use cached filename but update with current debug ID + cachedFilenameDebugIds[result[0]] = debugId; + // Update cached result with new debug ID + if (parsedStackResults) { + parsedStackResults[key] = [result[0], debugId]; + } + } else if (debugId) { + const parsedStack = stackParser(key); + + for (let i = parsedStack.length - 1; i >= 0; i--) { + const stackFrame = parsedStack[i]; + const filename = stackFrame?.filename; + + if (filename && cachedFilenameDebugIds && parsedStackResults) { + cachedFilenameDebugIds[filename] = debugId; + parsedStackResults[key] = [filename, debugId]; + break; + } } } } + }; + + if (sentryDebugIdMap) { + processDebugIds(sentryDebugIdKeys, sentryDebugIdMap); + } - return acc; - }, {}); + // Native _debugIds will override _sentryDebugIds if same file + if (nativeDebugIdMap) { + processDebugIds(nativeDebugIdKeys, nativeDebugIdMap); + } return cachedFilenameDebugIds; } diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 2eb7f39f3a24..2ea6b391c613 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -41,6 +41,12 @@ export type InternalGlobal = { * file. */ _sentryDebugIds?: Record; + /** + * Native debug IDs implementation (e.g., from Vercel). + * This uses the same format as _sentryDebugIds but with a different global name. + * Keys are `error.stack` strings, values are debug IDs. + */ + _debugIds?: Record; /** * Raw module metadata that is injected by bundler plugins. * diff --git a/packages/core/test/lib/prepareEvent.test.ts b/packages/core/test/lib/prepareEvent.test.ts index d0fd86ae63f8..6472d3680fb0 100644 --- a/packages/core/test/lib/prepareEvent.test.ts +++ b/packages/core/test/lib/prepareEvent.test.ts @@ -19,6 +19,7 @@ import { clearGlobalScope } from '../testutils'; describe('applyDebugIds', () => { afterEach(() => { GLOBAL_OBJ._sentryDebugIds = undefined; + GLOBAL_OBJ._debugIds = undefined; }); it("should put debug IDs into an event's stack frames", () => { @@ -114,6 +115,139 @@ describe('applyDebugIds', () => { debug_id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', }); }); + + it('should support native _debugIds format', () => { + GLOBAL_OBJ._debugIds = { + 'filename1.js\nfilename1.js': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + 'filename2.js\nfilename2.js': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + 'filename4.js\nfilename4.js': 'cccccccc-cccc-4ccc-cccc-cccccccccc', + }; + + const stackParser = createStackParser([0, line => ({ filename: line })]); + + const event: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { filename: 'filename1.js' }, + { filename: 'filename2.js' }, + { filename: 'filename1.js' }, + { filename: 'filename3.js' }, + ], + }, + }, + ], + }, + }; + + applyDebugIds(event, stackParser); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename1.js', + debug_id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + }); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename2.js', + debug_id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + }); + + // expect not to contain an image for the stack frame that doesn't have a corresponding debug id + expect(event.exception?.values?.[0]?.stacktrace?.frames).not.toContainEqual( + expect.objectContaining({ + filename3: 'filename3.js', + debug_id: expect.any(String), + }), + ); + }); + + it('should merge both _sentryDebugIds and _debugIds when both exist', () => { + GLOBAL_OBJ._sentryDebugIds = { + 'filename1.js\nfilename1.js': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + 'filename2.js\nfilename2.js': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + }; + + GLOBAL_OBJ._debugIds = { + 'filename3.js\nfilename3.js': 'cccccccc-cccc-4ccc-cccc-cccccccccc', + 'filename4.js\nfilename4.js': 'dddddddd-dddd-4ddd-dddd-dddddddddd', + }; + + const stackParser = createStackParser([0, line => ({ filename: line })]); + + const event: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [ + { filename: 'filename1.js' }, + { filename: 'filename2.js' }, + { filename: 'filename3.js' }, + { filename: 'filename4.js' }, + ], + }, + }, + ], + }, + }; + + applyDebugIds(event, stackParser); + + // Should have debug IDs from both sources + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename1.js', + debug_id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + }); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename2.js', + debug_id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + }); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename3.js', + debug_id: 'cccccccc-cccc-4ccc-cccc-cccccccccc', + }); + + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename4.js', + debug_id: 'dddddddd-dddd-4ddd-dddd-dddddddddd', + }); + }); + + it('should prioritize _debugIds over _sentryDebugIds for the same file', () => { + GLOBAL_OBJ._sentryDebugIds = { + 'filename1.js\nfilename1.js': 'old-debug-id-aaaa-aaaa-aaaa-aaaaaaaaaa', + }; + + GLOBAL_OBJ._debugIds = { + 'filename1.js\nfilename1.js': 'new-debug-id-bbbb-bbbb-bbbb-bbbbbbbbbb', + }; + + const stackParser = createStackParser([0, line => ({ filename: line })]); + + const event: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'filename1.js' }], + }, + }, + ], + }, + }; + + applyDebugIds(event, stackParser); + + // Should use the newer native _debugIds format + expect(event.exception?.values?.[0]?.stacktrace?.frames).toContainEqual({ + filename: 'filename1.js', + debug_id: 'new-debug-id-bbbb-bbbb-bbbb-bbbbbbbbbb', + }); + }); }); describe('applyDebugMeta', () => { diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index c8dc35918198..d5c90962e581 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -8,7 +8,12 @@ import type { SentryBuildOptions } from './types'; * It is used to upload sourcemaps to Sentry. */ export async function handleRunAfterProductionCompile( - { releaseName, distDir, buildTool }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack' }, + { + releaseName, + distDir, + buildTool, + usesNativeDebugIds, + }: { releaseName?: string; distDir: string; buildTool: 'webpack' | 'turbopack'; usesNativeDebugIds?: boolean }, sentryBuildOptions: SentryBuildOptions, ): Promise { if (sentryBuildOptions.debug) { @@ -44,7 +49,11 @@ export async function handleRunAfterProductionCompile( await sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal(); await sentryBuildPluginManager.createRelease(); - await sentryBuildPluginManager.injectDebugIds([distDir]); + + if (!usesNativeDebugIds) { + await sentryBuildPluginManager.injectDebugIds([distDir]); + } + await sentryBuildPluginManager.uploadSourcemaps([distDir], { // We don't want to prepare the artifacts because we injected debug ids manually before prepareArtifacts: false, diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 5c6372d6dec1..e46d3f6bb5c7 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,26 +1,37 @@ import { debug } from '@sentry/core'; import type { RouteManifest } from '../manifest/types'; -import type { NextConfigObject, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; +import type { NextConfigObject, SentryBuildOptions, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; +import { supportsNativeDebugIds } from '../util'; import { generateValueInjectionRules } from './generateValueInjectionRules'; /** * Construct a Turbopack config object from a Next.js config object and a Turbopack options object. * * @param userNextConfig - The Next.js config object. - * @param turbopackOptions - The Turbopack options object. + * @param userSentryOptions - The Sentry build options object. + * @param routeManifest - The route manifest object. + * @param nextJsVersion - The Next.js version. * @returns The Turbopack config object. */ export function constructTurbopackConfig({ userNextConfig, + userSentryOptions, routeManifest, nextJsVersion, }: { userNextConfig: NextConfigObject; + userSentryOptions: SentryBuildOptions; routeManifest?: RouteManifest; nextJsVersion?: string; }): TurbopackOptions { + // If sourcemaps are disabled, we don't need to enable native debug ids as this will add build time. + const shouldEnableNativeDebugIds = + (supportsNativeDebugIds(nextJsVersion ?? '') && userNextConfig?.turbopack?.debugIds) ?? + userSentryOptions.sourcemaps?.disable !== true; + const newConfig: TurbopackOptions = { ...userNextConfig.turbopack, + ...(shouldEnableNativeDebugIds ? { debugIds: true } : {}), }; const valueInjectionRules = generateValueInjectionRules({ diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 1fa245412f2c..28e038b6d0f2 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -673,4 +673,5 @@ export interface TurbopackOptions { conditions?: Record; moduleIds?: 'named' | 'deterministic'; root?: string; + debugIds?: boolean; } diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 40eb65e4e1e9..8d2d7781230b 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -66,6 +66,48 @@ export function supportsProductionCompileHook(version: string): boolean { return false; } +/** + * Checks if the current Next.js version supports native debug ids for turbopack. + * This feature was first introduced in Next.js v15.6.0-canary.36 and marked stable in Next.js v16 + * + * @param version - version string to check. + * @returns true if Next.js version supports native debug ids for turbopack builds + */ +export function supportsNativeDebugIds(version: string): boolean { + if (!version) { + return false; + } + + const { major, minor, prerelease } = parseSemver(version); + + if (major === undefined || minor === undefined) { + return false; + } + + // Next.js 16+ supports native debug ids + if (major >= 16) { + return true; + } + + // For Next.js 15, check if it's 15.6.0-canary.36+ + if (major === 15 && prerelease?.startsWith('canary.')) { + // Any canary version 15.7+ supports native debug ids + if (minor > 6) { + return true; + } + + // For 15.6 canary versions, check if it's canary.36 or higher + if (minor === 6) { + const canaryNumber = parseInt(prerelease.split('.')[1] || '0', 10); + if (canaryNumber >= 36) { + return true; + } + } + } + + return false; +} + /** * Checks if the current Next.js version uses Turbopack as the default bundler. * Starting from Next.js 15.6.0-canary.38, turbopack became the default for `next build`. diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index eaac5b084a9e..31ea63f17a9c 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -14,6 +14,7 @@ import type { NextConfigFunction, NextConfigObject, SentryBuildOptions, + TurbopackOptions, } from './types'; import { detectActiveBundler, getNextjsVersion, supportsProductionCompileHook } from './util'; import { constructWebpackConfigFunction } from './webpack'; @@ -285,6 +286,17 @@ function getFinalConfigObject( ); } + let turboPackConfig: TurbopackOptions | undefined; + + if (isTurbopack) { + turboPackConfig = constructTurbopackConfig({ + userNextConfig: incomingUserNextConfigObject, + userSentryOptions, + routeManifest, + nextJsVersion, + }); + } + // If not explicitly set, turbopack uses the runAfterProductionCompile hook (as there are no alternatives), webpack does not. const shouldUseRunAfterProductionCompileHook = userSentryOptions?.useRunAfterProductionCompileHook ?? (isTurbopack ? true : false); @@ -292,9 +304,15 @@ function getFinalConfigObject( if (shouldUseRunAfterProductionCompileHook && supportsProductionCompileHook(nextJsVersion ?? '')) { if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) { incomingUserNextConfigObject.compiler ??= {}; + incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => { await handleRunAfterProductionCompile( - { releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' }, + { + releaseName, + distDir, + buildTool: isTurbopack ? 'turbopack' : 'webpack', + usesNativeDebugIds: isTurbopack ? turboPackConfig?.debugIds : undefined, + }, userSentryOptions, ); }; @@ -306,7 +324,12 @@ function getFinalConfigObject( const { distDir }: { distDir: string } = argArray[0] ?? { distDir: '.next' }; await target.apply(thisArg, argArray); await handleRunAfterProductionCompile( - { releaseName, distDir, buildTool: isTurbopack ? 'turbopack' : 'webpack' }, + { + releaseName, + distDir, + buildTool: isTurbopack ? 'turbopack' : 'webpack', + usesNativeDebugIds: isTurbopack ? turboPackConfig?.debugIds : undefined, + }, userSentryOptions, ); }, @@ -379,11 +402,7 @@ function getFinalConfigObject( : {}), ...(isTurbopackSupported && isTurbopack ? { - turbopack: constructTurbopackConfig({ - userNextConfig: incomingUserNextConfigObject, - routeManifest, - nextJsVersion, - }), + turbopack: turboPackConfig, } : {}), }; diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index 9750e4245894..ef37711eac48 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -25,12 +25,15 @@ describe('constructTurbopackConfig', () => { ], }; + const mockSentryOptions = {}; + describe('without existing turbopack config', () => { it('should create a basic turbopack config when no manifest is provided', () => { const userNextConfig: NextConfigObject = {}; const result = constructTurbopackConfig({ userNextConfig, + userSentryOptions: mockSentryOptions, }); expect(result).toEqual({}); @@ -600,6 +603,7 @@ describe('constructTurbopackConfig', () => { testVersions.forEach(version => { const result = constructTurbopackConfig({ userNextConfig, + userSentryOptions: mockSentryOptions, nextJsVersion: version, }); diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 2dcff9889364..55fd13cf5dc4 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -97,6 +97,122 @@ describe('util', () => { }); }); + describe('supportsNativeDebugIds', () => { + describe('supported versions', () => { + it.each([ + // Next.js 16+ stable versions + ['16.0.0', 'Next.js 16.0.0 stable'], + ['16.0.1', 'Next.js 16.0.1 stable'], + ['16.1.0', 'Next.js 16.1.0 stable'], + ['16.2.5', 'Next.js 16.2.5 stable'], + + // Next.js 16+ pre-release versions + ['16.0.0-rc.1', 'Next.js 16.0.0-rc.1'], + ['16.0.0-canary.1', 'Next.js 16.0.0-canary.1'], + ['16.1.0-beta.2', 'Next.js 16.1.0-beta.2'], + + // Next.js 17+ + ['17.0.0', 'Next.js 17.0.0'], + ['18.0.0', 'Next.js 18.0.0'], + ['20.0.0', 'Next.js 20.0.0'], + + // Next.js 15.6.0-canary.36+ (boundary case) + ['15.6.0-canary.36', 'Next.js 15.6.0-canary.36 (exact threshold)'], + ['15.6.0-canary.37', 'Next.js 15.6.0-canary.37'], + ['15.6.0-canary.38', 'Next.js 15.6.0-canary.38'], + ['15.6.0-canary.40', 'Next.js 15.6.0-canary.40'], + ['15.6.0-canary.100', 'Next.js 15.6.0-canary.100'], + + // Next.js 15.7+ canary versions + ['15.7.0-canary.1', 'Next.js 15.7.0-canary.1'], + ['15.7.0-canary.50', 'Next.js 15.7.0-canary.50'], + ['15.8.0-canary.1', 'Next.js 15.8.0-canary.1'], + ['15.10.0-canary.1', 'Next.js 15.10.0-canary.1'], + ])('returns true for %s (%s)', version => { + expect(util.supportsNativeDebugIds(version)).toBe(true); + }); + }); + + describe('unsupported versions', () => { + it.each([ + // Next.js 15.6.0-canary.35 and below + ['15.6.0-canary.35', 'Next.js 15.6.0-canary.35 (just below threshold)'], + ['15.6.0-canary.34', 'Next.js 15.6.0-canary.34'], + ['15.6.0-canary.0', 'Next.js 15.6.0-canary.0'], + ['15.6.0-canary.1', 'Next.js 15.6.0-canary.1'], + + // Next.js 15.6.x stable releases (NOT canary) + ['15.6.0', 'Next.js 15.6.0 stable'], + ['15.6.1', 'Next.js 15.6.1 stable'], + ['15.6.2', 'Next.js 15.6.2 stable'], + ['15.6.10', 'Next.js 15.6.10 stable'], + + // Next.js 15.6.x rc releases (NOT canary) + ['15.6.0-rc.1', 'Next.js 15.6.0-rc.1'], + ['15.6.0-rc.2', 'Next.js 15.6.0-rc.2'], + + // Next.js 15.7+ stable releases (NOT canary) + ['15.7.0', 'Next.js 15.7.0 stable'], + ['15.8.0', 'Next.js 15.8.0 stable'], + ['15.10.0', 'Next.js 15.10.0 stable'], + + // Next.js 15.7+ rc/beta releases (NOT canary) + ['15.7.0-rc.1', 'Next.js 15.7.0-rc.1'], + ['15.7.0-beta.1', 'Next.js 15.7.0-beta.1'], + + // Next.js 15.5 and below (all versions) + ['15.5.0', 'Next.js 15.5.0'], + ['15.5.0-canary.100', 'Next.js 15.5.0-canary.100'], + ['15.4.1', 'Next.js 15.4.1'], + ['15.0.0', 'Next.js 15.0.0'], + ['15.0.0-canary.1', 'Next.js 15.0.0-canary.1'], + + // Next.js 14.x and below + ['14.2.0', 'Next.js 14.2.0'], + ['14.0.0', 'Next.js 14.0.0'], + ['14.0.0-canary.50', 'Next.js 14.0.0-canary.50'], + ['13.5.0', 'Next.js 13.5.0'], + ['13.0.0', 'Next.js 13.0.0'], + ['12.0.0', 'Next.js 12.0.0'], + ])('returns false for %s (%s)', version => { + expect(util.supportsNativeDebugIds(version)).toBe(false); + }); + }); + + describe('edge cases', () => { + it.each([ + ['', 'empty string'], + ['invalid', 'invalid version string'], + ['15', 'missing minor and patch'], + ['15.6', 'missing patch'], + ['not.a.version', 'completely invalid'], + ['15.6.0-alpha.1', 'alpha prerelease (not canary)'], + ['15.6.0-beta.1', 'beta prerelease (not canary)'], + ])('returns false for %s (%s)', version => { + expect(util.supportsNativeDebugIds(version)).toBe(false); + }); + }); + + describe('canary number parsing edge cases', () => { + it.each([ + ['15.6.0-canary.', 'canary with no number'], + ['15.6.0-canary.abc', 'canary with non-numeric value'], + ['15.6.0-canary.35.extra', 'canary with extra segments'], + ])('handles malformed canary versions: %s (%s)', version => { + // Should not throw, just return appropriate boolean + expect(() => util.supportsNativeDebugIds(version)).not.toThrow(); + }); + + it('handles canary.36 exactly (boundary)', () => { + expect(util.supportsNativeDebugIds('15.6.0-canary.36')).toBe(true); + }); + + it('handles canary.35 exactly (boundary)', () => { + expect(util.supportsNativeDebugIds('15.6.0-canary.35')).toBe(false); + }); + }); + }); + describe('isTurbopackDefaultForVersion', () => { describe('returns true for versions where turbopack is default', () => { it.each([ From da08d4907ce75840314c5b2fe1c7291457e02456 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 14 Oct 2025 11:17:59 +0200 Subject: [PATCH 06/20] chore(nextjs): Add Next.js 16 peer dependency (#17925) closes https://linear.app/getsentry/issue/FE-622/add-next-16-peer-dependency --- packages/nextjs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 0c979eb9a01a..67f3a07b69d0 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -100,7 +100,7 @@ "react-dom": "^18.3.1" }, "peerDependencies": { - "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0" + "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0" }, "scripts": { "build": "run-p build:transpile build:types", From 6b8323495533caee329d29a462e18a215a991677 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 14 Oct 2025 12:13:42 +0200 Subject: [PATCH 07/20] test(nextjs): Add next@16 e2e test (#17922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adds a new next 16 test - removes canary testing for app dir and pages dir tests (this is becoming too cumbersome to maintain across canary/bundler versions etc. – will instead move these tests into next 16 going forward) Note that next@canary will now always resolve to next@16 --- .../test-applications/nextjs-16/.gitignore | 44 ++++ .../nextjs-16/app/ai-error-test/page.tsx | 50 +++++ .../nextjs-16/app/ai-test/page.tsx | 98 +++++++++ .../nextjs-16/app/favicon.ico | Bin 0 -> 25931 bytes .../nextjs-16/app/global-error.tsx | 23 +++ .../nextjs-16/app/layout.tsx | 7 + .../app/nested-rsc-error/[param]/page.tsx | 17 ++ .../test-applications/nextjs-16/app/page.tsx | 3 + .../nextjs-16/app/pageload-tracing/layout.tsx | 8 + .../nextjs-16/app/pageload-tracing/page.tsx | 14 ++ .../parameterized/[one]/beep/[two]/page.tsx | 3 + .../app/parameterized/[one]/beep/page.tsx | 3 + .../app/parameterized/[one]/page.tsx | 3 + .../app/parameterized/static/page.tsx | 3 + .../nextjs-16/app/prefetching/page.tsx | 9 + .../app/prefetching/to-be-prefetched/page.tsx | 5 + .../app/redirect/destination/page.tsx | 7 + .../nextjs-16/app/redirect/origin/page.tsx | 18 ++ .../app/route-handler/[xoxo]/edge/route.ts | 8 + .../app/route-handler/[xoxo]/node/route.ts | 7 + .../[param]/client-page.tsx | 8 + .../app/streaming-rsc-error/[param]/page.tsx | 18 ++ .../nextjs-16/app/suspense-error/page.tsx | 15 ++ .../nextjs-16/eslint.config.mjs | 19 ++ .../nextjs-16/instrumentation-client.ts | 11 + .../nextjs-16/instrumentation.ts | 13 ++ .../nextjs-16/next.config.ts | 8 + .../test-applications/nextjs-16/package.json | 67 +++++++ .../nextjs-16/playwright.config.mjs | 29 +++ .../nextjs-16/public/file.svg | 1 + .../nextjs-16/public/globe.svg | 1 + .../nextjs-16/public/next.svg | 1 + .../nextjs-16/public/vercel.svg | 1 + .../nextjs-16/public/window.svg | 1 + .../nextjs-16/sentry.edge.config.ts | 9 + .../nextjs-16/sentry.server.config.ts | 10 + .../nextjs-16/start-event-proxy.mjs | 14 ++ .../nextjs-16/tests/ai-error.test.ts | 40 ++++ .../nextjs-16/tests/ai-test.test.ts | 72 +++++++ .../nextjs-16/tests/async-params.test.ts | 14 ++ .../nextjs-16/tests/isDevMode.ts | 1 + .../nextjs-16/tests/nested-rsc-error.test.ts | 38 ++++ .../nextjs-16/tests/pageload-tracing.test.ts | 54 +++++ .../tests/parameterized-routes.test.ts | 189 ++++++++++++++++++ .../nextjs-16/tests/prefetch-spans.test.ts | 25 +++ .../nextjs-16/tests/route-handler.test.ts | 40 ++++ .../tests/server-action-redirect.test.ts | 47 +++++ .../tests/streaming-rsc-error.test.ts | 38 ++++ .../nextjs-16/tests/suspense-error.test.ts | 25 +++ .../test-applications/nextjs-16/tsconfig.json | 27 +++ .../nextjs-app-dir/package.json | 16 +- .../nextjs-pages-dir/package.json | 18 +- 52 files changed, 1174 insertions(+), 26 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/ai-error-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/ai-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/global-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/nested-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/pageload-tracing/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/pageload-tracing/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/prefetching/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/prefetching/to-be-prefetched/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/redirect/destination/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/redirect/origin/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/route-handler/[xoxo]/edge/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/route-handler/[xoxo]/node/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/streaming-rsc-error/[param]/client-page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/streaming-rsc-error/[param]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/suspense-error/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/eslint.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/public/file.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/public/globe.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/public/next.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/public/vercel.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/public/window.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/async-params.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/isDevMode.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16/.gitignore new file mode 100644 index 000000000000..dd146b53d966 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# 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* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Config File +.env.sentry-build-plugin diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/ai-error-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/ai-error-test/page.tsx new file mode 100644 index 000000000000..bd75c0062228 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/ai-error-test/page.tsx @@ -0,0 +1,50 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +// Error trace handling in tool calls +async function runAITest() { + const result = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async args => { + throw new Error('Tool call failed'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); +} + +export default async function Page() { + await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { + return await runAITest(); + }); + + return ( +
+

AI Test Results

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/ai-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/ai-test/page.tsx new file mode 100644 index 000000000000..d28a147eb88d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/ai-test/page.tsx @@ -0,0 +1,98 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +async function runAITest() { + // First span - telemetry should be enabled automatically but no input/output recorded when sendDefaultPii: true + const result1 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // Second span - explicitly enabled telemetry, should record inputs/outputs + const result2 = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // Third span - with tool calls and tool results + const result3 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async args => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // Fourth span - explicitly disabled telemetry, should not be captured + const result4 = await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Third span here!', + }), + }), + prompt: 'Where is the third span?', + }); + + return { + result1: result1.text, + result2: result2.text, + result3: result3.text, + result4: result4.text, + }; +} + +export default async function Page() { + const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => { + return await runAITest(); + }); + + return ( +
+

AI Test Results

+
{JSON.stringify(results, null, 2)}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/global-error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import NextError from 'next/error'; +import { useEffect } from 'react'; + +export default function GlobalError({ error }: { error: Error & { digest?: string } }) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* `NextError` is the default Next.js error page component. Its type + definition requires a `statusCode` prop. However, since the App Router + does not expose status codes for errors, we simply pass 0 to render a + generic error message. */} + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/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-16/app/nested-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/nested-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..675b248026be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/nested-rsc-error/[param]/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + return ( + Loading...

}> + {/* @ts-ignore */} + ; +
+ ); +} + +async function Crash() { + throw new Error('I am technically uncatchable'); + return

unreachable

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/page.tsx new file mode 100644 index 000000000000..2bc0a407a355 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Next 16 test app

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/pageload-tracing/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/pageload-tracing/layout.tsx new file mode 100644 index 000000000000..1f0cbe478f88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/pageload-tracing/layout.tsx @@ -0,0 +1,8 @@ +import { PropsWithChildren } from 'react'; + +export const dynamic = 'force-dynamic'; + +export default async function Layout({ children }: PropsWithChildren) { + await new Promise(resolve => setTimeout(resolve, 500)); + return <>{children}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/pageload-tracing/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/pageload-tracing/page.tsx new file mode 100644 index 000000000000..689735d61ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/pageload-tracing/page.tsx @@ -0,0 +1,14 @@ +export const dynamic = 'force-dynamic'; + +export default async function Page() { + await new Promise(resolve => setTimeout(resolve, 1000)); + return

I am page 2

; +} + +export async function generateMetadata() { + (await fetch('https://example.com/', { cache: 'no-store' })).text(); + + return { + title: 'my title', + }; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..16ef0482d53b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/parameterized/static/page.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return
Static page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/prefetching/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/prefetching/page.tsx new file mode 100644 index 000000000000..4cb811ecf1b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/prefetching/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link'; + +export default function Page() { + return ( + + link + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/prefetching/to-be-prefetched/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/prefetching/to-be-prefetched/page.tsx new file mode 100644 index 000000000000..83aac90d65cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/prefetching/to-be-prefetched/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

Hello

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/redirect/destination/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/redirect/destination/page.tsx new file mode 100644 index 000000000000..5583d36b04b0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/redirect/destination/page.tsx @@ -0,0 +1,7 @@ +export default function RedirectDestinationPage() { + return ( +
+

Redirect Destination

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/redirect/origin/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/redirect/origin/page.tsx new file mode 100644 index 000000000000..52615e0a054b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/redirect/origin/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from 'next/navigation'; + +async function redirectAction() { + 'use server'; + + redirect('/redirect/destination'); +} + +export default function RedirectOriginPage() { + return ( + <> + {/* @ts-ignore */} +
+ +
+ + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/route-handler/[xoxo]/edge/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/route-handler/[xoxo]/edge/route.ts new file mode 100644 index 000000000000..7cd1fc7e332c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/route-handler/[xoxo]/edge/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'edge'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Edge Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/route-handler/[xoxo]/node/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/route-handler/[xoxo]/node/route.ts new file mode 100644 index 000000000000..5bc418f077aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/route-handler/[xoxo]/node/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + return NextResponse.json({ message: 'Hello Node Route Handler' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/streaming-rsc-error/[param]/client-page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/streaming-rsc-error/[param]/client-page.tsx new file mode 100644 index 000000000000..7b66c3fbdeef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/streaming-rsc-error/[param]/client-page.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { use } from 'react'; + +export function RenderPromise({ stringPromise }: { stringPromise: Promise }) { + const s = use(stringPromise); + return <>{s}; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/streaming-rsc-error/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/streaming-rsc-error/[param]/page.tsx new file mode 100644 index 000000000000..9531f9a42139 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/streaming-rsc-error/[param]/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from 'react'; +import { RenderPromise } from './client-page'; + +export const dynamic = 'force-dynamic'; + +export default async function Page() { + const crashingPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('I am a data streaming error')); + }, 100); + }); + + return ( + Loading...

}> + ; +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/suspense-error/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/suspense-error/page.tsx new file mode 100644 index 000000000000..ff49745d405b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/suspense-error/page.tsx @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/nextjs'; +import { use } from 'react'; +export const dynamic = 'force-dynamic'; + +export default async function Page() { + try { + use(fetch('https://example.com/')); + } catch (e) { + Sentry.captureException(e); // This error should not be reported + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for any async event processors to run + await Sentry.flush(); + } + + return

test

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/eslint.config.mjs @@ -0,0 +1,19 @@ +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'], + }, +]; + +export default eslintConfig; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts new file mode 100644 index 000000000000..4870c64e7959 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation-client.ts @@ -0,0 +1,11 @@ +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, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts new file mode 100644 index 000000000000..6699b3dd2c33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/next.config.ts @@ -0,0 +1,8 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = {}; + +export default withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json new file mode 100644 index 000000000000..1fd09523ddb2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -0,0 +1,67 @@ +{ + "name": "nextjs-16", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "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 .tmp_dev_server_logs", + "dev:webpack": "next dev --webpack", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "start": "next start", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack playwright test", + "test:build": "pnpm install && pnpm build", + "test:build-webpack": "pnpm install && pnpm build-webpack", + "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", + "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "ai": "^3.0.0", + "import-in-the-middle": "^1", + "next": "16.0.0-beta.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "canary", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-webpack", + "label": "nextjs-16 (webpack)", + "assert-command": "pnpm test:assert-webpack" + } + ], + "optionalVariants": [ + { + "build-command": "pnpm test:build-canary", + "label": "nextjs-16 (canary, turbopack)", + "assert-command": "pnpm test:assert" + }, + { + "build-command": "pnpm test:build-canary-webpack", + "label": "nextjs-16 (canary, webpack)", + "assert-command": "pnpm test:assert-webpack" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs new file mode 100644 index 000000000000..797418b8cf7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/playwright.config.mjs @@ -0,0 +1,29 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development-webpack') { + return 'pnpm next dev -p 3030 --webpack 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.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-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts new file mode 100644 index 000000000000..8da0a18497a0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -0,0 +1,10 @@ +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, + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16/start-event-proxy.mjs new file mode 100644 index 000000000000..572631b890ed --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-16', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-16-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts new file mode 100644 index 000000000000..65f118165702 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-error.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +test('should create AI spans with correct attributes and error linking', async ({ page }) => { + const aiTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent.transaction === 'GET /ai-error-test'; + }); + + const errorEventPromise = waitForError('nextjs-16', async errorEvent => { + return errorEvent.exception?.values?.[0]?.value?.includes('Tool call failed'); + }); + + await page.goto('/ai-error-test'); + + const aiTransaction = await aiTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(aiTransaction).toBeDefined(); + expect(aiTransaction.transaction).toBe('GET /ai-error-test'); + + const spans = aiTransaction.spans || []; + + // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate + // Plus a span for the tool call + // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working + // because of this, only spans that are manually opted-in at call time will be captured + // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future + const aiPipelineSpans = spans.filter(span => span.op === 'gen_ai.invoke_agent'); + const aiGenerateSpans = spans.filter(span => span.op === 'gen_ai.generate_text'); + const toolCallSpans = spans.filter(span => span.op === 'gen_ai.execute_tool'); + + expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); + expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); + expect(toolCallSpans.length).toBeGreaterThanOrEqual(0); + + expect(errorEvent).toBeDefined(); + + //Verify error is linked to the same trace as the transaction + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts new file mode 100644 index 000000000000..f7dc95e7d00d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/ai-test.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create AI spans with correct attributes', async ({ page }) => { + const aiTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent.transaction === 'GET /ai-test'; + }); + + await page.goto('/ai-test'); + + const aiTransaction = await aiTransactionPromise; + + expect(aiTransaction).toBeDefined(); + expect(aiTransaction.transaction).toBe('GET /ai-test'); + + const spans = aiTransaction.spans || []; + + // We expect spans for the first 3 AI calls (4th is disabled) + // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate + // Plus a span for the tool call + // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working + // because of this, only spans that are manually opted-in at call time will be captured + // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future + const aiPipelineSpans = spans.filter(span => span.op === 'gen_ai.invoke_agent'); + const aiGenerateSpans = spans.filter(span => span.op === 'gen_ai.generate_text'); + const toolCallSpans = spans.filter(span => span.op === 'gen_ai.execute_tool'); + + expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); + expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); + expect(toolCallSpans.length).toBeGreaterThanOrEqual(0); + + // First AI call - should have telemetry enabled and record inputs/outputs (sendDefaultPii: true) + /* const firstPipelineSpan = aiPipelineSpans[0]; + expect(firstPipelineSpan?.data?.['vercel.ai.model.id']).toBe('mock-model-id'); + expect(firstPipelineSpan?.data?.['vercel.ai.model.provider']).toBe('mock-provider'); + expect(firstPipelineSpan?.data?.['vercel.ai.prompt']).toContain('Where is the first span?'); + expect(firstPipelineSpan?.data?.['gen_ai.response.text']).toBe('First span here!'); + expect(firstPipelineSpan?.data?.['gen_ai.usage.input_tokens']).toBe(10); + expect(firstPipelineSpan?.data?.['gen_ai.usage.output_tokens']).toBe(20); */ + + // Second AI call - explicitly enabled telemetry + const secondPipelineSpan = aiPipelineSpans[0]; + expect(secondPipelineSpan?.data?.['vercel.ai.prompt']).toContain('Where is the second span?'); + expect(secondPipelineSpan?.data?.['gen_ai.response.text']).toContain('Second span here!'); + + // Third AI call - with tool calls + /* const thirdPipelineSpan = aiPipelineSpans[2]; + expect(thirdPipelineSpan?.data?.['vercel.ai.response.finishReason']).toBe('tool-calls'); + expect(thirdPipelineSpan?.data?.['gen_ai.usage.input_tokens']).toBe(15); + expect(thirdPipelineSpan?.data?.['gen_ai.usage.output_tokens']).toBe(25); */ + + // Tool call span + /* const toolSpan = toolCallSpans[0]; + expect(toolSpan?.data?.['vercel.ai.toolCall.name']).toBe('getWeather'); + expect(toolSpan?.data?.['vercel.ai.toolCall.id']).toBe('call-1'); + expect(toolSpan?.data?.['vercel.ai.toolCall.args']).toContain('San Francisco'); + expect(toolSpan?.data?.['vercel.ai.toolCall.result']).toContain('Sunny, 72°F'); */ + + // Verify the fourth call was not captured (telemetry disabled) + const promptsInSpans = spans + .map(span => span.data?.['vercel.ai.prompt']) + .filter((prompt): prompt is string => prompt !== undefined); + const hasDisabledPrompt = promptsInSpans.some(prompt => prompt.includes('Where is the third span?')); + expect(hasDisabledPrompt).toBe(false); + + // Verify results are displayed on the page + const resultsText = await page.locator('#ai-results').textContent(); + expect(resultsText).toContain('First span here!'); + expect(resultsText).toContain('Second span here!'); + expect(resultsText).toContain('Tool call completed!'); + expect(resultsText).toContain('Third span here!'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/async-params.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/async-params.test.ts new file mode 100644 index 000000000000..e8160d12aded --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/async-params.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@playwright/test'; +import fs from 'fs'; +import { isDevMode } from './isDevMode'; + +test('should not print warning for async params', async ({ page }) => { + test.skip(!isDevMode, 'should be skipped for non-dev mode'); + await page.goto('/'); + + // If the server exits with code 1, the test will fail (see instrumentation.ts) + const devStdout = fs.readFileSync('.tmp_dev_server_logs', 'utf-8'); + expect(devStdout).not.toContain('`params` should be awaited before using its properties.'); + + await expect(page.getByText('Next 16 test app')).toBeVisible(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isDevMode.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isDevMode.ts new file mode 100644 index 000000000000..d2be94232110 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isDevMode.ts @@ -0,0 +1 @@ +export const isDevMode = !!process.env.TEST_ENV && process.env.TEST_ENV.includes('development'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts new file mode 100644 index 000000000000..68731f049f2c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/nested-rsc-error.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should capture errors from nested server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ + page, +}) => { + const errorEventPromise = waitForError('nextjs-16', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'I am technically uncatchable'); + }); + + const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /nested-rsc-error/[param]'; + }); + + await page.goto(`/nested-rsc-error/123`); + const errorEvent = await errorEventPromise; + const serverTransactionEvent = await serverTransactionPromise; + + // error event is part of the transaction + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); + + expect(errorEvent.request).toMatchObject({ + headers: expect.any(Object), + method: 'GET', + }); + + expect(errorEvent.contexts?.nextjs).toEqual({ + route_type: 'render', + router_kind: 'App Router', + router_path: '/nested-rsc-error/[param]', + request_path: '/nested-rsc-error/123', + }); + + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nextjs.on_request_error', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts new file mode 100644 index 000000000000..5360f450c5fa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/pageload-tracing.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('App router transactions should be attached to the pageload request span', async ({ page }) => { + const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /pageload-tracing'; + }); + + const pageloadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === '/pageload-tracing'; + }); + + await page.goto(`/pageload-tracing`); + + const [serverTransaction, pageloadTransaction] = await Promise.all([ + serverTransactionPromise, + pageloadTransactionPromise, + ]); + + const pageloadTraceId = pageloadTransaction.contexts?.trace?.trace_id; + + expect(pageloadTraceId).toBeTruthy(); + expect(serverTransaction.contexts?.trace?.trace_id).toBe(pageloadTraceId); +}); + +test('extracts HTTP request headers as span attributes', async ({ baseURL }) => { + const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /pageload-tracing'; + }); + + await fetch(`${baseURL}/pageload-tracing`, { + headers: { + 'User-Agent': 'Custom-NextJS-Agent/15.0', + 'Content-Type': 'text/html', + 'X-NextJS-Test': 'nextjs-header-value', + Accept: 'text/html, application/xhtml+xml', + 'X-Framework': 'Next.js', + 'X-Request-ID': 'nextjs-789', + }, + }); + + const serverTransaction = await serverTransactionPromise; + + expect(serverTransaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + 'http.request.header.user_agent': 'Custom-NextJS-Agent/15.0', + 'http.request.header.content_type': 'text/html', + 'http.request.header.x_nextjs_test': 'nextjs-header-value', + 'http.request.header.accept': 'text/html, application/xhtml+xml', + 'http.request.header.x_framework': 'Next.js', + 'http.request.header.x_request_id': 'nextjs-789', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..4078ded5734d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/parameterized-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, + 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': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/static', to: '/parameterized/static' }, + 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.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, + 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': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, + 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': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts new file mode 100644 index 000000000000..0b158103d1c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/prefetch-spans.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; + +test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => { + test.skip(isDevMode, "Prefetch requests don't have the prefetch header in dev mode"); + + const pageloadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === '/prefetching'; + }); + + await page.goto(`/prefetching`); + + // Make it more likely that nextjs prefetches + await page.hover('#prefetch-link'); + + expect((await pageloadTransactionPromise).spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + 'http.request.prefetch': true, + }), + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts new file mode 100644 index 000000000000..e4c83f351d04 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/route-handler.test.ts @@ -0,0 +1,40 @@ +import test, { expect } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should create a transaction for node route handlers', async ({ request }) => { + const routehandlerTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + console.log(transactionEvent?.transaction); + return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/node'; + }); + + const response = await request.get('/route-handler/123/node', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Node Route Handler' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + + // This is flaking on dev mode + if (process.env.TEST_ENV !== 'development' && process.env.TEST_ENV !== 'dev-turbopack') { + expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); + } +}); + +test('Should create a transaction for edge route handlers', async ({ request }) => { + // This test only works for webpack builds on non-async param extraction + // todo: check if we can set request headers for edge on sdkProcessingMetadata + test.skip(); + const routehandlerTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /route-handler/[xoxo]/edge'; + }); + + const response = await request.get('/route-handler/123/edge', { headers: { 'x-charly': 'gomez' } }); + expect(await response.json()).toStrictEqual({ message: 'Hello Edge Route Handler' }); + + const routehandlerTransaction = await routehandlerTransactionPromise; + + expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok'); + expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); + expect(routehandlerTransaction.contexts?.trace?.data?.['http.request.header.x_charly']).toBe('gomez'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts new file mode 100644 index 000000000000..88e2d3ba1af1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-action-redirect.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should handle server action redirect without capturing errors', async ({ page }) => { + // Wait for the initial page load transaction + const pageLoadTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === '/redirect/origin'; + }); + + // Navigate to the origin page + await page.goto('/redirect/origin'); + + const pageLoadTransaction = await pageLoadTransactionPromise; + expect(pageLoadTransaction).toBeDefined(); + + // Wait for the redirect transaction + const redirectTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /redirect/destination'; + }); + + // No error should be captured + const redirectErrorPromise = waitForError('nextjs-16', async errorEvent => { + return !!errorEvent; + }); + + // Click the redirect button + await page.click('button[type="submit"]'); + + await redirectTransactionPromise; + + // Verify we got redirected to the destination page + await expect(page).toHaveURL('/redirect/destination'); + + // Wait for potential errors with a 2 second timeout + const errorTimeout = new Promise((_, reject) => + setTimeout(() => reject(new Error('No error captured (timeout)')), 2000), + ); + + // We expect this to timeout since no error should be captured during the redirect + try { + await Promise.race([redirectErrorPromise, errorTimeout]); + throw new Error('Expected no error to be captured, but an error was found'); + } catch (e) { + // If we get a timeout error (as expected), no error was captured + expect((e as Error).message).toBe('No error captured (timeout)'); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts new file mode 100644 index 000000000000..f22932a0c65f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/streaming-rsc-error.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should capture errors for crashing streaming promises in server components when `Sentry.captureRequestError` is added to the `onRequestError` hook', async ({ + page, +}) => { + const errorEventPromise = waitForError('nextjs-16', errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'I am a data streaming error'); + }); + + const serverTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /streaming-rsc-error/[param]'; + }); + + await page.goto(`/streaming-rsc-error/123`); + const errorEvent = await errorEventPromise; + const serverTransactionEvent = await serverTransactionPromise; + + // error event is part of the transaction + expect(errorEvent.contexts?.trace?.trace_id).toBe(serverTransactionEvent.contexts?.trace?.trace_id); + + expect(errorEvent.request).toMatchObject({ + headers: expect.any(Object), + method: 'GET', + }); + + expect(errorEvent.contexts?.nextjs).toEqual({ + route_type: 'render', + router_kind: 'App Router', + router_path: '/streaming-rsc-error/[param]', + request_path: '/streaming-rsc-error/123', + }); + + expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.function.nextjs.on_request_error', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts new file mode 100644 index 000000000000..f7a5fb83c3df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/suspense-error.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('should not capture serverside suspense errors', async ({ page }) => { + const pageServerComponentTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'GET /suspense-error'; + }); + + let errorEvent; + waitForError('nextjs-16', async errorEvent => { + return errorEvent?.transaction === 'Page Server Component (/suspense-error)'; + }).then(event => { + errorEvent = event; + }); + + await page.goto(`/suspense-error`); + + // Just to be a little bit more sure + await page.waitForTimeout(5000); + + const pageServerComponentTransaction = await pageServerComponentTransactionPromise; + expect(pageServerComponentTransaction).toBeDefined(); + + expect(errorEvent).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json index 15c337c33c3a..5502ab95e012 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/package.json @@ -4,15 +4,12 @@ "private": true, "scripts": { "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", - "build:webpack": "next build --webpack > .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", "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test", - "test:dev-webpack": "TEST_ENV=development-webpack playwright test", "test:build": "pnpm install && pnpm build", "test:test-build": "pnpm ts-node --script-mode assert-build.ts", - "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@latest && pnpm add react-dom@latest && pnpm build:webpack", - "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", + "test:build-15": "pnpm install && pnpm add next@15 && pnpm add react@latest && pnpm add react-dom@latest && pnpm build", "test:build-13": "pnpm install && pnpm add next@13.5.11 && pnpm build", "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" }, @@ -40,17 +37,10 @@ { "build-command": "pnpm test:build-13", "label": "nextjs-app-dir (next@13)" - } - ], - "optionalVariants": [ - { - "build-command": "pnpm test:build-canary", - "label": "nextjs-app-dir (canary, webpack opt-in)", - "assert-command": "pnpm test:prod" }, { - "build-command": "pnpm test:build-latest", - "label": "nextjs-app-dir (latest)" + "build-command": "pnpm test:build-15", + "label": "nextjs-app-dir (next@15)" } ] } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json index 42c321a2f93a..e236484bf51c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/package.json @@ -8,11 +8,9 @@ "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:prod": "TEST_ENV=production playwright test", "test:dev": "TEST_ENV=development playwright test", - "test:dev-webpack": "TEST_ENV=development-webpack playwright test", "test:build": "pnpm install && pnpm build", "test:test-build": "pnpm ts-node --script-mode assert-build.ts", - "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@latest && pnpm add react-dom@latest && pnpm build:webpack", - "test:build-latest": "pnpm install && pnpm add next@latest && pnpm add react@latest && pnpm add react-dom@latest && pnpm build", + "test:build-15": "pnpm install && pnpm add next@15 && pnpm add react@latest && pnpm add react-dom@latest && pnpm build", "test:build-13": "pnpm install && pnpm add next@13.5.11 && pnpm build", "test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev" }, @@ -40,18 +38,12 @@ { "build-command": "pnpm test:build-13", "label": "nextjs-pages-dir (next@13)" - } - ], - "optionalVariants": [ - { - "build-command": "pnpm test:build-canary", - "label": "nextjs-pages-dir (canary, webpack opt-in)", - "assert-command": "pnpm test:prod" }, { - "build-command": "pnpm test:build-latest", - "label": "nextjs-pages-dir (latest)" + "build-command": "pnpm test:build-15", + "label": "nextjs-pages-dir (next@15)" } - ] + ], + "optionalVariants": [] } } From 6229224db853c13c77064c47f7252499ffa407b0 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Tue, 14 Oct 2025 13:01:18 +0200 Subject: [PATCH 08/20] Merge pull request #17848 from getsentry/rolaabuhasna/js-945-expose-ai-integrations-in-browser-sdk feat(browser): Expose AI instrumentation methods --- .../tracing/ai-providers/anthropic/init.js | 9 ++ .../tracing/ai-providers/anthropic/mocks.js | 55 ++++++++ .../tracing/ai-providers/anthropic/subject.js | 19 +++ .../tracing/ai-providers/anthropic/test.ts | 36 ++++++ .../tracing/ai-providers/google-genai/init.js | 9 ++ .../ai-providers/google-genai/mocks.js | 118 ++++++++++++++++++ .../ai-providers/google-genai/subject.js | 32 +++++ .../tracing/ai-providers/google-genai/test.ts | 31 +++++ .../tracing/ai-providers/openai/init.js | 9 ++ .../tracing/ai-providers/openai/mocks.js | 47 +++++++ .../tracing/ai-providers/openai/subject.js | 22 ++++ .../tracing/ai-providers/openai/test.ts | 37 ++++++ .../utils/generatePlugin.ts | 3 + packages/browser/rollup.bundle.config.mjs | 3 + packages/browser/src/index.ts | 3 + .../index.instrumentanthropicaiclient.ts | 1 + .../index.instrumentgooglegenaiclient.ts | 1 + .../index.instrumentopenaiclient.ts | 1 + .../browser/src/utils/lazyLoadIntegration.ts | 3 + 19 files changed, 439 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/mocks.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/mocks.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/test.ts create mode 100644 packages/browser/src/integrations-bundle/index.instrumentanthropicaiclient.ts create mode 100644 packages/browser/src/integrations-bundle/index.instrumentgooglegenaiclient.ts create mode 100644 packages/browser/src/integrations-bundle/index.instrumentopenaiclient.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/mocks.js new file mode 100644 index 000000000000..01c6c31ce596 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/mocks.js @@ -0,0 +1,55 @@ +// Mock Anthropic client for browser testing +export class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + + // Main focus: messages.create functionality + this.messages = { + create: async (...args) => { + const params = args[0]; + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + const response = { + id: 'msg_mock123', + type: 'message', + role: 'assistant', + model: params.model, + content: [ + { + type: 'text', + text: 'Hello from Anthropic mock!', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }; + return response; + }, + countTokens: async (..._args) => ({ id: 'mock', type: 'model', model: 'mock', input_tokens: 0 }), + }; + + // Minimal implementations for required interface compliance + this.models = { + list: async (..._args) => ({ id: 'mock', type: 'model', model: 'mock' }), + get: async (..._args) => ({ id: 'mock', type: 'model', model: 'mock' }), + }; + + this.completions = { + create: async (..._args) => ({ id: 'mock', type: 'completion', model: 'mock' }), + }; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/subject.js new file mode 100644 index 000000000000..febfe938139e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/subject.js @@ -0,0 +1,19 @@ +import { instrumentAnthropicAiClient } from '@sentry/browser'; +import { MockAnthropic } from './mocks.js'; + +const mockClient = new MockAnthropic({ + apiKey: 'mock-api-key', +}); + +const client = instrumentAnthropicAiClient(mockClient); + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +const response = await client.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'What is the capital of France?' }], + temperature: 0.7, + max_tokens: 100, +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/test.ts new file mode 100644 index 000000000000..206e29be16e5 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/anthropic/test.ts @@ -0,0 +1,36 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForTransactionRequest } from '../../../../utils/helpers'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not crash in the browser +// and that gen_ai transactions are sent. + +sentryTest('manual Anthropic instrumentation sends gen_ai transactions', async ({ getLocalTestUrl, page }) => { + const transactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('claude-3-haiku-20240307'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const req = await transactionPromise; + + const eventData = envelopeRequestParser(req); + + // Verify it's a gen_ai transaction + expect(eventData.transaction).toBe('messages claude-3-haiku-20240307'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.messages'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.anthropic'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'messages', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_mock123', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js new file mode 100644 index 000000000000..8aab37fb3a1e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js @@ -0,0 +1,118 @@ +// Mock Google GenAI client for browser testing +export class MockGoogleGenAI { + constructor(config) { + this.apiKey = config.apiKey; + + // models.generateContent functionality + this.models = { + generateContent: async (...args) => { + const params = args[0]; + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + return { + candidates: [ + { + content: { + parts: [ + { + text: 'Hello from Google GenAI mock!', + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 12, + totalTokenCount: 20, + }, + }; + }, + generateContentStream: async () => { + // Return a promise that resolves to an async generator + return (async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Streaming response' }], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + }; + })(); + }, + }; + + // chats.create implementation + this.chats = { + create: (...args) => { + const params = args[0]; + const model = params.model; + + return { + modelVersion: model, + sendMessage: async (..._messageArgs) => { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + const response = { + candidates: [ + { + content: { + parts: [ + { + text: 'This is a joke from the chat!', + }, + ], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 12, + totalTokenCount: 20, + }, + modelVersion: model, // Include model version in response + }; + return response; + }, + sendMessageStream: async () => { + // Return a promise that resolves to an async generator + return (async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Streaming chat response' }], + role: 'model', + }, + finishReason: 'stop', + index: 0, + }, + ], + }; + })(); + }, + }; + }, + }; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js new file mode 100644 index 000000000000..14b95f2b6942 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js @@ -0,0 +1,32 @@ +import { instrumentGoogleGenAIClient } from '@sentry/browser'; +import { MockGoogleGenAI } from './mocks.js'; + +const mockClient = new MockGoogleGenAI({ + apiKey: 'mock-api-key', +}); + +const client = instrumentGoogleGenAIClient(mockClient); + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +// Test both chats and models APIs +const chat = client.chats.create({ + model: 'gemini-1.5-pro', + config: { + temperature: 0.8, + topP: 0.9, + maxOutputTokens: 150, + }, + history: [ + { + role: 'user', + parts: [{ text: 'Hello, how are you?' }], + }, + ], +}); + +const response = await chat.sendMessage({ + message: 'Tell me a joke', +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts new file mode 100644 index 000000000000..6774129f183e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts @@ -0,0 +1,31 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForTransactionRequest } from '../../../../utils/helpers'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not crash in the browser +// and that gen_ai transactions are sent. + +sentryTest('manual Google GenAI instrumentation sends gen_ai transactions', async ({ getLocalTestUrl, page }) => { + const transactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('gemini-1.5-pro'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const req = await transactionPromise; + + const eventData = envelopeRequestParser(req); + + // Verify it's a gen_ai transaction + expect(eventData.transaction).toBe('chat gemini-1.5-pro create'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.chat'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.google_genai'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-pro', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/init.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/init.js new file mode 100644 index 000000000000..d90a3acf6157 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/init.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + debug: true, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/mocks.js new file mode 100644 index 000000000000..a1fe56dd30c2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/mocks.js @@ -0,0 +1,47 @@ +// Mock OpenAI client for browser testing +export class MockOpenAi { + constructor(config) { + this.apiKey = config.apiKey; + + this.chat = { + completions: { + create: async (...args) => { + const params = args[0]; + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + const response = { + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: params.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Hello from OpenAI mock!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }; + return response; + }, + }, + }; + } +} diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/subject.js new file mode 100644 index 000000000000..aadc2864ceee --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/subject.js @@ -0,0 +1,22 @@ +import { instrumentOpenAiClient } from '@sentry/browser'; +import { MockOpenAi } from './mocks.js'; + +const mockClient = new MockOpenAi({ + apiKey: 'mock-api-key', +}); + +const client = instrumentOpenAiClient(mockClient); + +// Test that manual instrumentation doesn't crash the browser +// The instrumentation automatically creates spans +const response = await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, +}); + +console.log('Received response', response); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/test.ts new file mode 100644 index 000000000000..c71c0786ff96 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/openai/test.ts @@ -0,0 +1,37 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../../utils/fixtures'; +import { envelopeRequestParser, waitForTransactionRequest } from '../../../../utils/helpers'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the node integration tests and we merely +// want to test that the instrumentation does not crash in the browser +// and that gen_ai transactions are sent. + +sentryTest('manual OpenAI instrumentation sends gen_ai transactions', async ({ getLocalTestUrl, page }) => { + const transactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('gpt-3.5-turbo'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const req = await transactionPromise; + + const eventData = envelopeRequestParser(req); + + // Verify it's a gen_ai transaction + expect(eventData.transaction).toBe('chat gpt-3.5-turbo'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.chat'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.openai'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'chat', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'chatcmpl-mock123', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }); +}); diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index bd505473f9b7..0a90b5e2be23 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -37,6 +37,9 @@ const IMPORTED_INTEGRATION_CDN_BUNDLE_PATHS: Record = { moduleMetadataIntegration: 'modulemetadata', graphqlClientIntegration: 'graphqlclient', browserProfilingIntegration: 'browserprofiling', + instrumentAnthropicAiClient: 'instrumentanthropicaiclient', + instrumentOpenAiClient: 'instrumentopenaiclient', + instrumentGoogleGenAIClient: 'instrumentgooglegenaiclient', // technically, this is not an integration, but let's add it anyway for simplicity makeMultiplexedTransport: 'multiplexedtransport', }; diff --git a/packages/browser/rollup.bundle.config.mjs b/packages/browser/rollup.bundle.config.mjs index 705ec3dfe1c1..4893e66f49ef 100644 --- a/packages/browser/rollup.bundle.config.mjs +++ b/packages/browser/rollup.bundle.config.mjs @@ -13,6 +13,9 @@ const reexportedPluggableIntegrationFiles = [ 'modulemetadata', 'graphqlclient', 'spotlight', + 'instrumentanthropicaiclient', + 'instrumentopenaiclient', + 'instrumentgooglegenaiclient', ]; browserPluggableIntegrationFiles.forEach(integrationName => { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 5e9924fe6da5..ae13e984c85f 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -63,6 +63,9 @@ export { zodErrorsIntegration, thirdPartyErrorFilterIntegration, featureFlagsIntegration, + instrumentAnthropicAiClient, + instrumentOpenAiClient, + instrumentGoogleGenAIClient, logger, } from '@sentry/core'; export type { Span, FeatureFlagsIntegration } from '@sentry/core'; diff --git a/packages/browser/src/integrations-bundle/index.instrumentanthropicaiclient.ts b/packages/browser/src/integrations-bundle/index.instrumentanthropicaiclient.ts new file mode 100644 index 000000000000..d82909a524d8 --- /dev/null +++ b/packages/browser/src/integrations-bundle/index.instrumentanthropicaiclient.ts @@ -0,0 +1 @@ +export { instrumentAnthropicAiClient } from '@sentry/core'; diff --git a/packages/browser/src/integrations-bundle/index.instrumentgooglegenaiclient.ts b/packages/browser/src/integrations-bundle/index.instrumentgooglegenaiclient.ts new file mode 100644 index 000000000000..ec58139c0681 --- /dev/null +++ b/packages/browser/src/integrations-bundle/index.instrumentgooglegenaiclient.ts @@ -0,0 +1 @@ +export { instrumentGoogleGenAIClient } from '@sentry/core'; diff --git a/packages/browser/src/integrations-bundle/index.instrumentopenaiclient.ts b/packages/browser/src/integrations-bundle/index.instrumentopenaiclient.ts new file mode 100644 index 000000000000..5371961ff03a --- /dev/null +++ b/packages/browser/src/integrations-bundle/index.instrumentopenaiclient.ts @@ -0,0 +1 @@ +export { instrumentOpenAiClient } from '@sentry/core'; diff --git a/packages/browser/src/utils/lazyLoadIntegration.ts b/packages/browser/src/utils/lazyLoadIntegration.ts index 569e902fde28..6d5e48542f56 100644 --- a/packages/browser/src/utils/lazyLoadIntegration.ts +++ b/packages/browser/src/utils/lazyLoadIntegration.ts @@ -21,6 +21,9 @@ const LazyLoadableIntegrations = { rewriteFramesIntegration: 'rewriteframes', browserProfilingIntegration: 'browserprofiling', moduleMetadataIntegration: 'modulemetadata', + instrumentAnthropicAiClient: 'instrumentanthropicaiclient', + instrumentOpenAiClient: 'instrumentopenaiclient', + instrumentGoogleGenAIClient: 'instrumentgooglegenaiclient', } as const; const WindowWithMaybeIntegration = WINDOW as { From 245e91b0a86c3bcc56eccd6ae1a3b94a77e654b9 Mon Sep 17 00:00:00 2001 From: Madhu Chavva <46016208+madhuchavva@users.noreply.github.com> Date: Tue, 14 Oct 2025 05:57:45 -0700 Subject: [PATCH 09/20] feat(flags): Add Growthbook integration (#17440) Co-authored-by: Charly Gomez --- .../growthbook/onError/basic/test.ts | 68 ++++++++++++++ .../featureFlags/growthbook/onError/init.js | 37 ++++++++ .../growthbook/onError/subject.js | 3 + .../growthbook/onError/template.html | 12 +++ .../growthbook/onError/withScope/test.ts | 65 ++++++++++++++ .../featureFlags/growthbook/onSpan/init.js | 39 ++++++++ .../featureFlags/growthbook/onSpan/subject.js | 16 ++++ .../growthbook/onSpan/template.html | 15 ++++ .../featureFlags/growthbook/onSpan/test.ts | 65 ++++++++++++++ .../node-integration-tests/package.json | 1 + .../growthbook/onError/basic/scenario.ts | 89 +++++++++++++++++++ .../growthbook/onError/basic/test.ts | 31 +++++++ .../growthbook/onSpan/scenario.ts | 64 +++++++++++++ .../featureFlags/growthbook/onSpan/test.ts | 34 +++++++ packages/astro/src/index.server.ts | 1 + packages/astro/src/index.types.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/browser/src/index.ts | 1 + .../featureFlags/growthbook/index.ts | 1 + .../featureFlags/growthbook/integration.ts | 26 ++++++ .../featureFlags/growthbook/types.ts | 7 ++ packages/bun/src/index.ts | 1 + packages/cloudflare/src/index.ts | 1 + packages/core/src/index.ts | 1 + .../integrations/featureFlags/growthbook.ts | 77 ++++++++++++++++ .../src/integrations/featureFlags/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + packages/nextjs/src/index.types.ts | 1 + packages/node/src/index.ts | 1 + .../featureFlagShims/growthbook.ts | 7 ++ .../integrations/featureFlagShims/index.ts | 2 + packages/nuxt/src/index.types.ts | 1 + packages/react-router/src/index.types.ts | 1 + packages/remix/src/index.types.ts | 1 + packages/solidstart/src/index.types.ts | 1 + packages/sveltekit/src/index.types.ts | 1 + .../tanstackstart-react/src/index.types.ts | 1 + yarn.lock | 12 +++ 38 files changed, 688 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html create mode 100644 dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts create mode 100644 packages/browser/src/integrations/featureFlags/growthbook/index.ts create mode 100644 packages/browser/src/integrations/featureFlags/growthbook/integration.ts create mode 100644 packages/browser/src/integrations/featureFlags/growthbook/types.ts create mode 100644 packages/core/src/integrations/featureFlags/growthbook.ts create mode 100644 packages/node/src/integrations/featureFlagShims/growthbook.ts diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts new file mode 100644 index 000000000000..fc23f80927ff --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/basic/test.ts @@ -0,0 +1,68 @@ +import { expect } from '@playwright/test'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; + +sentryTest('GrowthBook onError: basic eviction/update and no async tasks', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + await page.evaluate(bufferSize => { + const gb = new (window as any).GrowthBook(); + + for (let i = 1; i <= bufferSize; i++) { + gb.isOn(`feat${i}`); + } + + gb.__setOn(`feat${bufferSize + 1}`, true); + gb.isOn(`feat${bufferSize + 1}`); // eviction + + gb.__setOn('feat3', true); + gb.isOn('feat3'); // update + + // Test getFeatureValue with boolean values (should be captured) + gb.__setFeatureValue('bool-feat', true); + gb.getFeatureValue('bool-feat', false); + + // Test getFeatureValue with non-boolean values (should be ignored) + gb.__setFeatureValue('string-feat', 'hello'); + gb.getFeatureValue('string-feat', 'default'); + gb.__setFeatureValue('number-feat', 42); + gb.getFeatureValue('number-feat', 0); + }, FLAG_BUFFER_SIZE); + + const reqPromise = waitForErrorRequest(page); + await page.locator('#error').click(); + const req = await reqPromise; + const event = envelopeRequestParser(req); + + const values = event.contexts?.flags?.values || []; + + // After the sequence of operations: + // 1. feat1-feat100 are added (100 items) + // 2. feat101 is added, evicts feat1 (100 items: feat2-feat100, feat101) + // 3. feat3 is updated to true, moves to end (100 items: feat2, feat4-feat100, feat101, feat3) + // 4. bool-feat is added, evicts feat2 (100 items: feat4-feat100, feat101, feat3, bool-feat) + + const expectedFlags = []; + for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) { + expectedFlags.push({ flag: `feat${i}`, result: false }); + } + expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true }); + expectedFlags.push({ flag: 'feat3', result: true }); + expectedFlags.push({ flag: 'bool-feat', result: true }); // Only boolean getFeatureValue should be captured + + expect(values).toEqual(expectedFlags); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js new file mode 100644 index 000000000000..e7831a1c2c0b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/init.js @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/browser'; + +// Minimal mock GrowthBook class for tests +window.GrowthBook = class { + constructor() { + this._onFlags = Object.create(null); + this._featureValues = Object.create(null); + } + + isOn(featureKey) { + return !!this._onFlags[featureKey]; + } + + getFeatureValue(featureKey, defaultValue) { + return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey) + ? this._featureValues[featureKey] + : defaultValue; + } + + // Helpers for tests + __setOn(featureKey, value) { + this._onFlags[featureKey] = !!value; + } + + __setFeatureValue(featureKey, value) { + this._featureValues[featureKey] = value; + } +}; + +window.Sentry = Sentry; +window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + integrations: [window.sentryGrowthBookIntegration], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js new file mode 100644 index 000000000000..e6697408128c --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/subject.js @@ -0,0 +1,3 @@ +document.getElementById('error').addEventListener('click', () => { + throw new Error('Button triggered error'); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html new file mode 100644 index 000000000000..da7d69a24c97 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/template.html @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts new file mode 100644 index 000000000000..48fa4718b856 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onError/withScope/test.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test'; +import type { Scope } from '@sentry/browser'; +import { sentryTest } from '../../../../../../utils/fixtures'; +import { + envelopeRequestParser, + shouldSkipFeatureFlagsTest, + waitForErrorRequest, +} from '../../../../../../utils/helpers'; + +sentryTest('GrowthBook onError: forked scopes are isolated', async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 'test-id' }) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const forkedReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === true); + const mainReqPromise = waitForErrorRequest(page, event => !!event.tags?.isForked === false); + + await page.evaluate(() => { + const Sentry = (window as any).Sentry; + const errorButton = document.querySelector('#error') as HTMLButtonElement; + const gb = new (window as any).GrowthBook(); + + gb.__setOn('shared', true); + gb.__setOn('main', true); + + gb.isOn('shared'); + + Sentry.withScope((scope: Scope) => { + gb.__setOn('forked', true); + gb.__setOn('shared', false); + gb.isOn('forked'); + gb.isOn('shared'); + scope.setTag('isForked', true); + errorButton.click(); + }); + + gb.isOn('main'); + Sentry.getCurrentScope().setTag('isForked', false); + errorButton.click(); + return true; + }); + + const forkedReq = await forkedReqPromise; + const forkedEvent = envelopeRequestParser(forkedReq); + + const mainReq = await mainReqPromise; + const mainEvent = envelopeRequestParser(mainReq); + + expect(forkedEvent.contexts?.flags?.values).toEqual([ + { flag: 'forked', result: true }, + { flag: 'shared', result: false }, + ]); + + expect(mainEvent.contexts?.flags?.values).toEqual([ + { flag: 'shared', result: true }, + { flag: 'main', result: true }, + ]); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js new file mode 100644 index 000000000000..d755d7a1d972 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/init.js @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/browser'; + +window.GrowthBook = class { + constructor() { + this._onFlags = Object.create(null); + this._featureValues = Object.create(null); + } + + isOn(featureKey) { + return !!this._onFlags[featureKey]; + } + + getFeatureValue(featureKey, defaultValue) { + return Object.prototype.hasOwnProperty.call(this._featureValues, featureKey) + ? this._featureValues[featureKey] + : defaultValue; + } + + __setOn(featureKey, value) { + this._onFlags[featureKey] = !!value; + } + + __setFeatureValue(featureKey, value) { + this._featureValues[featureKey] = value; + } +}; + +window.Sentry = Sentry; +window.sentryGrowthBookIntegration = Sentry.growthbookIntegration({ growthbookClass: window.GrowthBook }); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + integrations: [ + window.sentryGrowthBookIntegration, + Sentry.browserTracingIntegration({ instrumentNavigation: false, instrumentPageLoad: false }), + ], +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js new file mode 100644 index 000000000000..ad874b2bd697 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/subject.js @@ -0,0 +1,16 @@ +const btnStartSpan = document.getElementById('btnStartSpan'); +const btnEndSpan = document.getElementById('btnEndSpan'); +const btnStartNestedSpan = document.getElementById('btnStartNestedSpan'); +const btnEndNestedSpan = document.getElementById('btnEndNestedSpan'); + +window.withNestedSpans = callback => { + window.Sentry.startSpan({ name: 'test-root-span' }, rootSpan => { + window.traceId = rootSpan.spanContext().traceId; + + window.Sentry.startSpan({ name: 'test-span' }, _span => { + window.Sentry.startSpan({ name: 'test-nested-span' }, _nestedSpan => { + callback(); + }); + }); + }); +}; diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html new file mode 100644 index 000000000000..4efb91e75451 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/template.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts new file mode 100644 index 000000000000..6661edc9723d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/growthbook/onSpan/test.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test'; +import { _INTERNAL_MAX_FLAGS_PER_SPAN as MAX_FLAGS_PER_SPAN } from '@sentry/core'; +import { sentryTest } from '../../../../../utils/fixtures'; +import { + type EventAndTraceHeader, + eventAndTraceHeaderRequestParser, + getMultipleSentryEnvelopeRequests, + shouldSkipFeatureFlagsTest, + shouldSkipTracingTest, +} from '../../../../../utils/helpers'; + +sentryTest( + "GrowthBook onSpan: flags are added to active span's attributes on span end", + async ({ getLocalTestUrl, page }) => { + if (shouldSkipFeatureFlagsTest() || shouldSkipTracingTest()) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}) }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + const envelopeRequestPromise = getMultipleSentryEnvelopeRequests( + page, + 1, + {}, + eventAndTraceHeaderRequestParser, + ); + + await page.evaluate(maxFlags => { + (window as any).withNestedSpans(() => { + const gb = new (window as any).GrowthBook(); + for (let i = 1; i <= maxFlags; i++) { + gb.isOn(`feat${i}`); + } + gb.__setOn(`feat${maxFlags + 1}`, true); + gb.isOn(`feat${maxFlags + 1}`); // dropped + gb.__setOn('feat3', true); + gb.isOn('feat3'); // update + }); + return true; + }, MAX_FLAGS_PER_SPAN); + + const event = (await envelopeRequestPromise)[0][0]; + const innerSpan = event.spans?.[0]; + const outerSpan = event.spans?.[1]; + const outerSpanFlags = Object.entries(outerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + const innerSpanFlags = Object.entries(innerSpan?.data ?? {}).filter(([key, _val]) => + key.startsWith('flag.evaluation'), + ); + + expect(innerSpanFlags).toEqual([]); + + const expectedOuterSpanFlags = [] as Array<[string, unknown]>; + for (let i = 1; i <= MAX_FLAGS_PER_SPAN; i++) { + expectedOuterSpanFlags.push([`flag.evaluation.feat${i}`, i === 3]); + } + expect(outerSpanFlags.sort()).toEqual(expectedOuterSpanFlags.sort()); + }, +); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index f8c294738065..118db71a6b98 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -26,6 +26,7 @@ "@anthropic-ai/sdk": "0.63.0", "@aws-sdk/client-s3": "^3.552.0", "@google/genai": "^1.20.0", + "@growthbook/growthbook": "^1.6.1", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", "@nestjs/common": "^11", diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts new file mode 100644 index 000000000000..f907e320696d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/scenario.ts @@ -0,0 +1,89 @@ +import type { ClientOptions, UserContext } from '@growthbook/growthbook'; +import { GrowthBookClient } from '@growthbook/growthbook'; +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Wrapper class to instantiate GrowthBookClient +class GrowthBookWrapper { + private _gbClient: GrowthBookClient; + private _userContext: UserContext = { attributes: { id: 'test-user-123' } }; + + public constructor(..._args: unknown[]) { + // Create GrowthBookClient with proper configuration + const clientOptions: ClientOptions = { + apiHost: 'https://cdn.growthbook.io', + clientKey: 'sdk-abc123', + }; + this._gbClient = new GrowthBookClient(clientOptions); + + // Create features for testing + const features = this._createTestFeatures(); + + this._gbClient.initSync({ + payload: { features }, + }); + } + + public isOn(featureKey: string, ..._rest: unknown[]): boolean { + return this._gbClient.isOn(featureKey, this._userContext); + } + + public getFeatureValue(featureKey: string, defaultValue: unknown, ..._rest: unknown[]): unknown { + return this._gbClient.getFeatureValue(featureKey, defaultValue as boolean | string | number, this._userContext); + } + + private _createTestFeatures(): Record { + const features: Record = {}; + + // Fill buffer with flags 1-100 (all false by default) + for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { + features[`feat${i}`] = { defaultValue: false }; + } + + // Add feat101 (true), which should evict feat1 + features[`feat${FLAG_BUFFER_SIZE + 1}`] = { defaultValue: true }; + + // Update feat3 to true, which should move it to the end + features['feat3'] = { defaultValue: true }; + + // Test features with boolean values (should be captured) + features['bool-feat'] = { defaultValue: true }; + + // Test features with non-boolean values (should NOT be captured) + features['string-feat'] = { defaultValue: 'hello' }; + features['number-feat'] = { defaultValue: 42 }; + + return features; + } +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookWrapper })], +}); + +// Create GrowthBookWrapper instance +const gb = new GrowthBookWrapper(); + +// Fill buffer with flags 1-100 (all false by default) +for (let i = 1; i <= FLAG_BUFFER_SIZE; i++) { + gb.isOn(`feat${i}`); +} + +// Add feat101 (true), which should evict feat1 +gb.isOn(`feat${FLAG_BUFFER_SIZE + 1}`); + +// Update feat3 to true, which should move it to the end +gb.isOn('feat3'); + +// Test getFeatureValue with boolean values (should be captured) +gb.getFeatureValue('bool-feat', false); + +// Test getFeatureValue with non-boolean values (should NOT be captured) +gb.getFeatureValue('string-feat', 'default'); +gb.getFeatureValue('number-feat', 0); + +throw new Error('Test error'); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts new file mode 100644 index 000000000000..82e39eb62364 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onError/basic/test.ts @@ -0,0 +1,31 @@ +import { _INTERNAL_FLAG_BUFFER_SIZE as FLAG_BUFFER_SIZE } from '@sentry/core'; +import { afterAll, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('GrowthBook flags captured on error with eviction, update, and no async tasks', async () => { + const expectedFlags = []; + for (let i = 4; i <= FLAG_BUFFER_SIZE; i++) { + expectedFlags.push({ flag: `feat${i}`, result: false }); + } + expectedFlags.push({ flag: `feat${FLAG_BUFFER_SIZE + 1}`, result: true }); + expectedFlags.push({ flag: 'feat3', result: true }); + expectedFlags.push({ flag: 'bool-feat', result: true }); // Only boolean getFeatureValue should be captured + + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { values: [{ type: 'Error', value: 'Test error' }] }, + contexts: { + flags: { + values: expectedFlags, + }, + }, + }, + }) + .start() + .completed(); +}); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts new file mode 100644 index 000000000000..b25f36f00951 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/scenario.ts @@ -0,0 +1,64 @@ +import type { ClientOptions, InitSyncOptions, UserContext } from '@growthbook/growthbook'; +import { GrowthBookClient } from '@growthbook/growthbook'; +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +// Wrapper class to instantiate GrowthBookClient +class GrowthBookWrapper { + private _gbClient: GrowthBookClient; + private _userContext: UserContext = { attributes: { id: 'test-user-123' } }; + + public constructor(..._args: unknown[]) { + // Create GrowthBookClient and initialize it synchronously with payload + const clientOptions: ClientOptions = { + apiHost: 'https://cdn.growthbook.io', + clientKey: 'sdk-abc123', + }; + this._gbClient = new GrowthBookClient(clientOptions); + + // Create test features + const features = { + feat1: { defaultValue: true }, + feat2: { defaultValue: false }, + 'bool-feat': { defaultValue: true }, + 'string-feat': { defaultValue: 'hello' }, + }; + + // Initialize synchronously with payload + const initOptions: InitSyncOptions = { + payload: { features }, + }; + + this._gbClient.initSync(initOptions); + } + + public isOn(featureKey: string, ..._rest: unknown[]): boolean { + return this._gbClient.isOn(featureKey, this._userContext); + } + + public getFeatureValue(featureKey: string, defaultValue: unknown, ..._rest: unknown[]): unknown { + return this._gbClient.getFeatureValue(featureKey, defaultValue as boolean | string | number, this._userContext); + } +} + +const gb = new GrowthBookWrapper(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1.0, + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBookWrapper })], +}); + +Sentry.startSpan({ name: 'test-span', op: 'function' }, () => { + // Evaluate feature flags during the span + gb.isOn('feat1'); + gb.isOn('feat2'); + + // Test getFeatureValue with boolean values (should be captured) + gb.getFeatureValue('bool-feat', false); + + // Test getFeatureValue with non-boolean values (should NOT be captured) + gb.getFeatureValue('string-feat', 'default'); +}); diff --git a/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts new file mode 100644 index 000000000000..fbb084b98928 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/featureFlags/growthbook/onSpan/test.ts @@ -0,0 +1,34 @@ +import { afterAll, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('GrowthBook flags are added to active span attributes on span end', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + transaction: { + contexts: { + trace: { + data: { + 'flag.evaluation.feat1': true, + 'flag.evaluation.feat2': false, + 'flag.evaluation.bool-feat': true, + // string-feat should NOT be here since it's not boolean + }, + op: 'function', + origin: 'manual', + status: 'ok', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + spans: [], + transaction: 'test-span', + type: 'transaction', + }, + }) + .start() + .completed(); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index f70d6e0a3573..15158bdbb7bc 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -159,6 +159,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from '@sentry/node'; export { init } from './server/sdk'; diff --git a/packages/astro/src/index.types.ts b/packages/astro/src/index.types.ts index ceb4fc6d8a51..b09a1cfa09d5 100644 --- a/packages/astro/src/index.types.ts +++ b/packages/astro/src/index.types.ts @@ -35,5 +35,6 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export default sentryAstro; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 5a608a925edb..5ff30f069486 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -145,6 +145,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from '@sentry/node'; export { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index ae13e984c85f..03416fa41af7 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -76,6 +76,7 @@ export { browserSessionIntegration } from './integrations/browsersession'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler } from './integrations/featureFlags/launchdarkly'; export { openFeatureIntegration, OpenFeatureIntegrationHook } from './integrations/featureFlags/openfeature'; export { unleashIntegration } from './integrations/featureFlags/unleash'; +export { growthbookIntegration } from './integrations/featureFlags/growthbook'; export { statsigIntegration } from './integrations/featureFlags/statsig'; export { diagnoseSdkConnectivity } from './diagnose-sdk'; export { webWorkerIntegration, registerWebWorker } from './integrations/webWorker'; diff --git a/packages/browser/src/integrations/featureFlags/growthbook/index.ts b/packages/browser/src/integrations/featureFlags/growthbook/index.ts new file mode 100644 index 000000000000..a931e2376ab7 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/index.ts @@ -0,0 +1 @@ +export { growthbookIntegration } from './integration'; diff --git a/packages/browser/src/integrations/featureFlags/growthbook/integration.ts b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts new file mode 100644 index 000000000000..560918535cce --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/integration.ts @@ -0,0 +1,26 @@ +import type { IntegrationFn } from '@sentry/core'; +import { growthbookIntegration as coreGrowthbookIntegration } from '@sentry/core'; +import type { GrowthBookClass } from './types'; + +/** + * Sentry integration for capturing feature flag evaluations from GrowthBook. + * + * See the feature flag documentation: https://develop.sentry.dev/sdk/expected-features/#feature-flags + * + * @example + * ``` + * import { GrowthBook } from '@growthbook/growthbook'; + * import * as Sentry from '@sentry/browser'; + * + * Sentry.init({ + * dsn: '___PUBLIC_DSN___', + * integrations: [Sentry.growthbookIntegration({ growthbookClass: GrowthBook })], + * }); + * + * const gb = new GrowthBook(); + * gb.isOn('my-feature'); + * Sentry.captureException(new Error('something went wrong')); + * ``` + */ +export const growthbookIntegration = (({ growthbookClass }: { growthbookClass: GrowthBookClass }) => + coreGrowthbookIntegration({ growthbookClass })) satisfies IntegrationFn; diff --git a/packages/browser/src/integrations/featureFlags/growthbook/types.ts b/packages/browser/src/integrations/featureFlags/growthbook/types.ts new file mode 100644 index 000000000000..5a852d633da9 --- /dev/null +++ b/packages/browser/src/integrations/featureFlags/growthbook/types.ts @@ -0,0 +1,7 @@ +export interface GrowthBook { + isOn(this: GrowthBook, featureKey: string, ...rest: unknown[]): boolean; + getFeatureValue(this: GrowthBook, featureKey: string, defaultValue: unknown, ...rest: unknown[]): unknown; +} + +// We only depend on the surface we wrap; constructor args are irrelevant here. +export type GrowthBookClass = new (...args: unknown[]) => GrowthBook; diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 2775cbc0624e..5ec1568229e4 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -156,6 +156,7 @@ export { wrapMcpServerWithSentry, featureFlagsIntegration, launchDarklyIntegration, + growthbookIntegration, buildLaunchDarklyFlagUsedHandler, openFeatureIntegration, OpenFeatureIntegrationHook, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index d4afd80313b1..6f731cb8d980 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -97,6 +97,7 @@ export { consoleLoggingIntegration, createConsolaReporter, featureFlagsIntegration, + growthbookIntegration, logger, } from '@sentry/core'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 06be19c86774..2377e2ce86b0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -113,6 +113,7 @@ export { zodErrorsIntegration } from './integrations/zoderrors'; export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter'; export { consoleIntegration } from './integrations/console'; export { featureFlagsIntegration, type FeatureFlagsIntegration } from './integrations/featureFlags'; +export { growthbookIntegration } from './integrations/featureFlags'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) diff --git a/packages/core/src/integrations/featureFlags/growthbook.ts b/packages/core/src/integrations/featureFlags/growthbook.ts new file mode 100644 index 000000000000..eeb2b25341e9 --- /dev/null +++ b/packages/core/src/integrations/featureFlags/growthbook.ts @@ -0,0 +1,77 @@ +import type { Client } from '../../client'; +import { defineIntegration } from '../../integration'; +import type { Event, EventHint } from '../../types-hoist/event'; +import type { IntegrationFn } from '../../types-hoist/integration'; +import { + _INTERNAL_addFeatureFlagToActiveSpan, + _INTERNAL_copyFlagsFromScopeToEvent, + _INTERNAL_insertFlagToScope, +} from '../../utils/featureFlags'; +import { fill } from '../../utils/object'; + +interface GrowthBookLike { + isOn(this: GrowthBookLike, featureKey: string, ...rest: unknown[]): boolean; + getFeatureValue(this: GrowthBookLike, featureKey: string, defaultValue: unknown, ...rest: unknown[]): unknown; +} + +export type GrowthBookClassLike = new (...args: unknown[]) => GrowthBookLike; + +/** + * Sentry integration for capturing feature flag evaluations from GrowthBook. + * + * Only boolean results are captured at this time. + * + * @example + * ```typescript + * import { GrowthBook } from '@growthbook/growthbook'; + * import * as Sentry from '@sentry/browser'; // or '@sentry/node' + * + * Sentry.init({ + * dsn: 'your-dsn', + * integrations: [ + * Sentry.growthbookIntegration({ growthbookClass: GrowthBook }) + * ] + * }); + * ``` + */ +export const growthbookIntegration: IntegrationFn = defineIntegration( + ({ growthbookClass }: { growthbookClass: GrowthBookClassLike }) => { + return { + name: 'GrowthBook', + + setupOnce() { + const proto = growthbookClass.prototype as GrowthBookLike; + + // Type guard and wrap isOn + if (typeof proto.isOn === 'function') { + fill(proto, 'isOn', _wrapAndCaptureBooleanResult); + } + + // Type guard and wrap getFeatureValue + if (typeof proto.getFeatureValue === 'function') { + fill(proto, 'getFeatureValue', _wrapAndCaptureBooleanResult); + } + }, + + processEvent(event: Event, _hint: EventHint, _client: Client): Event { + return _INTERNAL_copyFlagsFromScopeToEvent(event); + }, + }; + }, +); + +function _wrapAndCaptureBooleanResult( + original: (this: GrowthBookLike, ...args: unknown[]) => unknown, +): (this: GrowthBookLike, ...args: unknown[]) => unknown { + return function (this: GrowthBookLike, ...args: unknown[]): unknown { + const flagName = args[0]; + const result = original.apply(this, args); + + if (typeof flagName === 'string' && typeof result === 'boolean') { + _INTERNAL_insertFlagToScope(flagName, result); + _INTERNAL_addFeatureFlagToActiveSpan(flagName, result); + } + + return result; + }; +} diff --git a/packages/core/src/integrations/featureFlags/index.ts b/packages/core/src/integrations/featureFlags/index.ts index 2106ee7accf0..f0ee5ece65b2 100644 --- a/packages/core/src/integrations/featureFlags/index.ts +++ b/packages/core/src/integrations/featureFlags/index.ts @@ -1 +1,2 @@ export { featureFlagsIntegration, type FeatureFlagsIntegration } from './featureFlagsIntegration'; +export { growthbookIntegration } from './growthbook'; diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 8f1d236f7877..db52cf357a16 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -140,6 +140,7 @@ export { featureFlagsIntegration, type FeatureFlagsIntegration, launchDarklyIntegration, + growthbookIntegration, buildLaunchDarklyFlagUsedHandler, openFeatureIntegration, OpenFeatureIntegrationHook, diff --git a/packages/nextjs/src/index.types.ts b/packages/nextjs/src/index.types.ts index fe5a75bd5c8b..d982ebbc7559 100644 --- a/packages/nextjs/src/index.types.ts +++ b/packages/nextjs/src/index.types.ts @@ -142,6 +142,7 @@ export declare function wrapPageComponentWithSentry(WrappingTarget: C): C; export { captureRequestError } from './common/captureRequestError'; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 54a90dbfcd09..b599351b5124 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -34,6 +34,7 @@ export { OpenFeatureIntegrationHook, statsigIntegration, unleashIntegration, + growthbookIntegration, } from './integrations/featureFlagShims'; export { firebaseIntegration } from './integrations/tracing/firebase'; diff --git a/packages/node/src/integrations/featureFlagShims/growthbook.ts b/packages/node/src/integrations/featureFlagShims/growthbook.ts new file mode 100644 index 000000000000..d86f3a0349bc --- /dev/null +++ b/packages/node/src/integrations/featureFlagShims/growthbook.ts @@ -0,0 +1,7 @@ +import { growthbookIntegration as coreGrowthbookIntegration } from '@sentry/core'; + +/** + * Re-export the core GrowthBook integration for Node.js usage. + * The core integration is runtime-agnostic and works in both browser and Node environments. + */ +export const growthbookIntegrationShim = coreGrowthbookIntegration; diff --git a/packages/node/src/integrations/featureFlagShims/index.ts b/packages/node/src/integrations/featureFlagShims/index.ts index 230dbaeeb7e8..ef90a562983f 100644 --- a/packages/node/src/integrations/featureFlagShims/index.ts +++ b/packages/node/src/integrations/featureFlagShims/index.ts @@ -11,3 +11,5 @@ export { export { statsigIntegrationShim as statsigIntegration } from './statsig'; export { unleashIntegrationShim as unleashIntegration } from './unleash'; + +export { growthbookIntegrationShim as growthbookIntegration } from './growthbook'; diff --git a/packages/nuxt/src/index.types.ts b/packages/nuxt/src/index.types.ts index 4f006e0b5b07..7abb16d197e3 100644 --- a/packages/nuxt/src/index.types.ts +++ b/packages/nuxt/src/index.types.ts @@ -19,6 +19,7 @@ export declare const defaultStackParser: StackParser; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/react-router/src/index.types.ts b/packages/react-router/src/index.types.ts index 150fc45a1e63..58566ba214fe 100644 --- a/packages/react-router/src/index.types.ts +++ b/packages/react-router/src/index.types.ts @@ -19,6 +19,7 @@ export declare const getDefaultIntegrations: (options: Options) => Integration[] export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/remix/src/index.types.ts b/packages/remix/src/index.types.ts index d0df7397f612..cacbac00e591 100644 --- a/packages/remix/src/index.types.ts +++ b/packages/remix/src/index.types.ts @@ -33,6 +33,7 @@ export const close = runtime === 'client' ? clientSdk.close : serverSdk.close; export const flush = runtime === 'client' ? clientSdk.flush : serverSdk.flush; export const lastEventId = runtime === 'client' ? clientSdk.lastEventId : serverSdk.lastEventId; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/solidstart/src/index.types.ts b/packages/solidstart/src/index.types.ts index 7725d1ad3d3c..7f7528a0dddb 100644 --- a/packages/solidstart/src/index.types.ts +++ b/packages/solidstart/src/index.types.ts @@ -26,6 +26,7 @@ export declare function lastEventId(): string | undefined; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/sveltekit/src/index.types.ts b/packages/sveltekit/src/index.types.ts index 108e262f9992..40c2f5ff848e 100644 --- a/packages/sveltekit/src/index.types.ts +++ b/packages/sveltekit/src/index.types.ts @@ -60,6 +60,7 @@ export declare function trackComponent(options: clientSdk.TrackingOptions): Retu export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 448ea35f637b..5a44af1b59d4 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -29,6 +29,7 @@ export declare const withErrorBoundary: typeof clientSdk.withErrorBoundary; export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; diff --git a/yarn.lock b/yarn.lock index 4d084a8cf3c7..5e9edc00f1df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4350,6 +4350,13 @@ dependencies: tslib "^2.4.0" +"@growthbook/growthbook@^1.6.1": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-1.6.1.tgz#4135c680397af3e5de8d2ab92defe2c6ed697fc5" + integrity sha512-GSvb7bNaBTfH54AZ0oQdnoyV/ZxN9NhDEIHOsRUiM+CSOPiodz0i8/+1O6Wg0wFEVgBxS5CGWffyd74fym43Xw== + dependencies: + dom-mutator "^0.6.0" + "@handlebars/parser@~2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-2.0.0.tgz#5e8b7298f31ff8f7b260e6b7363c7e9ceed7d9c5" @@ -14376,6 +14383,11 @@ dom-element-descriptors@^0.5.0, dom-element-descriptors@^0.5.1: resolved "https://registry.yarnpkg.com/dom-element-descriptors/-/dom-element-descriptors-0.5.1.tgz#3ebfcf64198f922dba928f84f7970bb571891317" integrity sha512-DLayMRQ+yJaziF4JJX1FMjwjdr7wdTr1y9XvZ+NfHELfOMcYDnCHneAYXAS4FT1gLILh4V0juMZohhH1N5FsoQ== +dom-mutator@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/dom-mutator/-/dom-mutator-0.6.0.tgz#079d7a4b3e8981a562cd777548b99baab51d65c5" + integrity sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg== + dom-serializer@^1.0.1: version "1.3.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91" From ac57cec5d32eea2a3ac397c185f9a3ca53545d93 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 14 Oct 2025 10:49:20 -0400 Subject: [PATCH 10/20] ref(core): Add weight tracking logic to browser logs/metrics (#17901) We've seen some cases where our browser logs are hitting size limits. I suspect this is because we don't have any robust size tracking mechanisms in the browser sdk. image This refactors our log flushing mechanisms in the SDK to unify everything between the browser client and server runtime client. This also means the browser SDK gets a weight tracking mechanism for buffering, which should help with making sure we don't run into size issues with logs. Given metrics has the same issue, I included it in this refactor. --- .size-limit.js | 6 +- packages/browser/src/client.ts | 37 +--- packages/browser/test/client.test.ts | 54 ----- packages/core/src/client.ts | 172 +++++++++++++-- packages/core/src/logs/internal.ts | 2 +- packages/core/src/metrics/internal.ts | 9 +- packages/core/src/server-runtime-client.ts | 159 +------------- packages/core/src/utils/trace-info.ts | 29 +++ packages/core/test/lib/client.test.ts | 207 ++++++++++++++++++ .../test/lib/integrations/consola.test.ts | 1 + .../test/lib/server-runtime-client.test.ts | 103 --------- 11 files changed, 398 insertions(+), 381 deletions(-) create mode 100644 packages/core/src/utils/trace-info.ts diff --git a/.size-limit.js b/.size-limit.js index 08da6f5ce85b..5ccf34d416c0 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '40.7 KB', + limit: '41 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', @@ -82,7 +82,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '96 KB', + limit: '97 KB', }, { name: '@sentry/browser (incl. Feedback)', @@ -128,7 +128,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '29 KB', + limit: '30 KB', }, { name: '@sentry/vue (incl. Tracing)', diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index af7a1d6ee2ec..1b4289d66992 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -26,8 +26,6 @@ import type { BrowserTransportOptions } from './transports/types'; */ declare const __SENTRY_RELEASE__: string | undefined; -const DEFAULT_FLUSH_INTERVAL = 5000; - type BrowserSpecificOptions = BrowserClientReplayOptions & BrowserClientProfilingOptions & { /** If configured, this URL will be used as base URL for lazy loading integration. */ @@ -85,8 +83,6 @@ export type BrowserClientOptions = ClientOptions & Brow * @see SentryClient for usage documentation. */ export class BrowserClient extends Client { - private _logFlushIdleTimeout: ReturnType | undefined; - private _metricFlushIdleTimeout: ReturnType | undefined; /** * Creates a new Browser SDK instance. * @@ -110,6 +106,7 @@ export class BrowserClient extends Client { const { sendDefaultPii, sendClientReports, enableLogs, _experiments } = this._options; + // Flush logs and metrics when page becomes hidden (e.g., tab switch, navigation) if (WINDOW.document && (sendClientReports || enableLogs || _experiments?.enableMetrics)) { WINDOW.document.addEventListener('visibilitychange', () => { if (WINDOW.document.visibilityState === 'hidden') { @@ -126,38 +123,6 @@ export class BrowserClient extends Client { }); } - if (enableLogs) { - this.on('flush', () => { - _INTERNAL_flushLogsBuffer(this); - }); - - this.on('afterCaptureLog', () => { - if (this._logFlushIdleTimeout) { - clearTimeout(this._logFlushIdleTimeout); - } - - this._logFlushIdleTimeout = setTimeout(() => { - _INTERNAL_flushLogsBuffer(this); - }, DEFAULT_FLUSH_INTERVAL); - }); - } - - if (_experiments?.enableMetrics) { - this.on('flush', () => { - _INTERNAL_flushMetricsBuffer(this); - }); - - this.on('afterCaptureMetric', () => { - if (this._metricFlushIdleTimeout) { - clearTimeout(this._metricFlushIdleTimeout); - } - - this._metricFlushIdleTimeout = setTimeout(() => { - _INTERNAL_flushMetricsBuffer(this); - }, DEFAULT_FLUSH_INTERVAL); - }); - } - if (sendDefaultPii) { this.on('beforeSendSession', addAutoIpAddressToSession); } diff --git a/packages/browser/test/client.test.ts b/packages/browser/test/client.test.ts index c1fcac17444b..d99e45984f0a 100644 --- a/packages/browser/test/client.test.ts +++ b/packages/browser/test/client.test.ts @@ -18,7 +18,6 @@ vi.mock('@sentry/core', async requireActual => { describe('BrowserClient', () => { let client: BrowserClient; - const DEFAULT_FLUSH_INTERVAL = 5000; afterEach(() => { vi.useRealTimers(); @@ -77,59 +76,6 @@ describe('BrowserClient', () => { expect(flushOutcomesSpy).toHaveBeenCalled(); expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client); }); - - it('flushes logs on flush event', () => { - const scope = new Scope(); - scope.setClient(client); - - // Add some logs - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope); - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope); - - // Trigger flush event - client.emit('flush'); - - expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client); - }); - - it('flushes logs after idle timeout', () => { - const scope = new Scope(); - scope.setClient(client); - - // Add a log which will trigger afterCaptureLog event - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log' }, scope); - - // Fast forward the idle timeout - vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL); - - expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client); - }); - - it('resets idle timeout when new logs are captured', () => { - const scope = new Scope(); - scope.setClient(client); - - // Add initial log - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 1' }, scope); - - // Fast forward part of the idle timeout - vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL / 2); - - // Add another log which should reset the timeout - sentryCore._INTERNAL_captureLog({ level: 'info', message: 'test log 2' }, scope); - - // Fast forward the remaining time - vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL / 2); - - // Should not have flushed yet since timeout was reset - expect(sentryCore._INTERNAL_flushLogsBuffer).not.toHaveBeenCalled(); - - // Fast forward the full timeout - vi.advanceTimersByTime(DEFAULT_FLUSH_INTERVAL); - - // Now should have flushed both logs - expect(sentryCore._INTERNAL_flushLogsBuffer).toHaveBeenCalledWith(client); - }); }); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index de6c5f9f1119..6a269a969c8d 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -1,21 +1,19 @@ /* eslint-disable max-lines */ import { getEnvelopeEndpointWithUrlEncodedAuth } from './api'; import { DEFAULT_ENVIRONMENT } from './constants'; -import { getCurrentScope, getIsolationScope, getTraceContextFromScope, withScope } from './currentScopes'; +import { getCurrentScope, getIsolationScope, getTraceContextFromScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import { createEventEnvelope, createSessionEnvelope } from './envelope'; import type { IntegrationIndex } from './integration'; import { afterSetupIntegrations, setupIntegration, setupIntegrations } from './integration'; +import { _INTERNAL_flushLogsBuffer } from './logs/internal'; +import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; import type { Scope } from './scope'; import { updateSession } from './session'; -import { - getDynamicSamplingContextFromScope, - getDynamicSamplingContextFromSpan, -} from './tracing/dynamicSamplingContext'; +import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; import type { Breadcrumb, BreadcrumbHint, FetchBreadcrumbHint, XhrBreadcrumbHint } from './types-hoist/breadcrumb'; import type { CheckIn, MonitorConfig } from './types-hoist/checkin'; import type { EventDropReason, Outcome } from './types-hoist/clientreport'; -import type { TraceContext } from './types-hoist/context'; import type { DataCategory } from './types-hoist/datacategory'; import type { DsnComponents } from './types-hoist/dsn'; import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope'; @@ -25,6 +23,7 @@ import type { FeedbackEvent } from './types-hoist/feedback'; import type { Integration } from './types-hoist/integration'; import type { Log } from './types-hoist/log'; import type { Metric } from './types-hoist/metric'; +import type { Primitive } from './types-hoist/misc'; import type { ClientOptions } from './types-hoist/options'; import type { ParameterizedString } from './types-hoist/parameterize'; import type { RequestEventData } from './types-hoist/request'; @@ -45,7 +44,7 @@ import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; -import { getActiveSpan, showSpanDropWarning, spanToTraceContext } from './utils/spanUtils'; +import { showSpanDropWarning } from './utils/spanUtils'; import { rejectedSyncPromise } from './utils/syncpromise'; import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent'; @@ -55,6 +54,9 @@ const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing const INTERNAL_ERROR_SYMBOL = Symbol.for('SentryInternalError'); const DO_NOT_SEND_EVENT_SYMBOL = Symbol.for('SentryDoNotSendEventError'); +// Default interval for flushing logs and metrics (5 seconds) +const DEFAULT_FLUSH_INTERVAL = 5000; + interface InternalError { message: string; [INTERNAL_ERROR_SYMBOL]: true; @@ -87,6 +89,57 @@ function _isDoNotSendEventError(error: unknown): error is DoNotSendEventError { return !!error && typeof error === 'object' && DO_NOT_SEND_EVENT_SYMBOL in error; } +/** + * Sets up weight-based flushing for logs or metrics. + * This helper function encapsulates the common pattern of: + * 1. Tracking accumulated weight of items + * 2. Flushing when weight exceeds threshold (800KB) + * 3. Flushing after idle timeout if no new items arrive + * + * Uses closure variables to track weight and timeout state. + */ +function setupWeightBasedFlushing< + T, + AfterCaptureHook extends 'afterCaptureLog' | 'afterCaptureMetric', + FlushHook extends 'flushLogs' | 'flushMetrics', +>( + client: Client, + afterCaptureHook: AfterCaptureHook, + flushHook: FlushHook, + estimateSizeFn: (item: T) => number, + flushFn: (client: Client) => void, +): void { + // Track weight and timeout in closure variables + let weight = 0; + let flushTimeout: ReturnType | undefined; + + // @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe + client.on(flushHook, () => { + weight = 0; + clearTimeout(flushTimeout); + }); + + // @ts-expect-error - TypeScript can't narrow generic hook types to match specific overloads, but we know this is type-safe + client.on(afterCaptureHook, (item: T) => { + weight += estimateSizeFn(item); + + // We flush the buffer if it exceeds 0.8 MB + // The weight is a rough estimate, so we flush way before the payload gets too big. + if (weight >= 800_000) { + flushFn(client); + } else { + clearTimeout(flushTimeout); + flushTimeout = setTimeout(() => { + flushFn(client); + }, DEFAULT_FLUSH_INTERVAL); + } + }); + + client.on('flush', () => { + flushFn(client); + }); +} + /** * Base implementation for all JavaScript SDK clients. * @@ -173,6 +226,22 @@ export abstract class Client { url, }); } + + // Setup log flushing with weight and timeout tracking + if (this._options.enableLogs) { + setupWeightBasedFlushing(this, 'afterCaptureLog', 'flushLogs', estimateLogSizeInBytes, _INTERNAL_flushLogsBuffer); + } + + // Setup metric flushing with weight and timeout tracking + if (this._options._experiments?.enableMetrics) { + setupWeightBasedFlushing( + this, + 'afterCaptureMetric', + 'flushMetrics', + estimateMetricSizeInBytes, + _INTERNAL_flushMetricsBuffer, + ); + } } /** @@ -1438,21 +1507,82 @@ function isTransactionEvent(event: Event): event is TransactionEvent { return event.type === 'transaction'; } -/** Extract trace information from scope */ -export function _getTraceInfoFromScope( - client: Client, - scope: Scope | undefined, -): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { - if (!scope) { - return [undefined, undefined]; +/** + * Estimate the size of a metric in bytes. + * + * @param metric - The metric to estimate the size of. + * @returns The estimated size of the metric in bytes. + */ +function estimateMetricSizeInBytes(metric: Metric): number { + let weight = 0; + + // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16. + if (metric.name) { + weight += metric.name.length * 2; } - return withScope(scope, () => { - const span = getActiveSpan(); - const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope); - const dynamicSamplingContext = span - ? getDynamicSamplingContextFromSpan(span) - : getDynamicSamplingContextFromScope(client, scope); - return [dynamicSamplingContext, traceContext]; + // Add weight for the value + if (typeof metric.value === 'string') { + weight += metric.value.length * 2; + } else { + weight += 8; // number + } + + return weight + estimateAttributesSizeInBytes(metric.attributes); +} + +/** + * Estimate the size of a log in bytes. + * + * @param log - The log to estimate the size of. + * @returns The estimated size of the log in bytes. + */ +function estimateLogSizeInBytes(log: Log): number { + let weight = 0; + + // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16. + if (log.message) { + weight += log.message.length * 2; + } + + return weight + estimateAttributesSizeInBytes(log.attributes); +} + +/** + * Estimate the size of attributes in bytes. + * + * @param attributes - The attributes object to estimate the size of. + * @returns The estimated size of the attributes in bytes. + */ +function estimateAttributesSizeInBytes(attributes: Record | undefined): number { + if (!attributes) { + return 0; + } + + let weight = 0; + + Object.values(attributes).forEach(value => { + if (Array.isArray(value)) { + weight += value.length * estimatePrimitiveSizeInBytes(value[0]); + } else if (isPrimitive(value)) { + weight += estimatePrimitiveSizeInBytes(value); + } else { + // For objects values, we estimate the size of the object as 100 bytes + weight += 100; + } }); + + return weight; +} + +function estimatePrimitiveSizeInBytes(value: Primitive): number { + if (typeof value === 'string') { + return value.length * 2; + } else if (typeof value === 'number') { + return 8; + } else if (typeof value === 'boolean') { + return 4; + } + + return 0; } diff --git a/packages/core/src/logs/internal.ts b/packages/core/src/logs/internal.ts index b3bda05d97f7..601d9be29cb6 100644 --- a/packages/core/src/logs/internal.ts +++ b/packages/core/src/logs/internal.ts @@ -1,6 +1,5 @@ import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; -import { _getTraceInfoFromScope } from '../client'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import type { Scope, ScopeData } from '../scope'; @@ -11,6 +10,7 @@ import { consoleSandbox, debug } from '../utils/debug-logger'; import { isParameterizedString } from '../utils/is'; import { _getSpanForScope } from '../utils/spanOnScope'; import { timestampInSeconds } from '../utils/time'; +import { _getTraceInfoFromScope } from '../utils/trace-info'; import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants'; import { createLogEnvelope } from './envelope'; diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 0f16d98b790e..f16352523700 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -1,15 +1,15 @@ import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; -import { _getTraceInfoFromScope } from '../client'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import type { Scope, ScopeData } from '../scope'; import type { Integration } from '../types-hoist/integration'; import type { Metric, SerializedMetric, SerializedMetricAttributeValue } from '../types-hoist/metric'; import { mergeScopeData } from '../utils/applyScopeDataToEvent'; -import { consoleSandbox, debug } from '../utils/debug-logger'; +import { debug } from '../utils/debug-logger'; import { _getSpanForScope } from '../utils/spanOnScope'; import { timestampInSeconds } from '../utils/time'; +import { _getTraceInfoFromScope } from '../utils/trace-info'; import { createMetricEnvelope } from './envelope'; const MAX_METRIC_BUFFER_SIZE = 100; @@ -210,10 +210,7 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal attributes: serializedAttributes, }; - consoleSandbox(() => { - // eslint-disable-next-line no-console - DEBUG_BUILD && console.log('[Metric]', serializedMetric); - }); + DEBUG_BUILD && debug.log('[Metric]', serializedMetric); captureSerializedMetric(client, serializedMetric); diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 761d4aca7cd7..9d037eb3b7c3 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -1,28 +1,20 @@ import { createCheckInEnvelope } from './checkin'; -import { _getTraceInfoFromScope, Client } from './client'; +import { Client } from './client'; import { getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; -import { _INTERNAL_flushLogsBuffer } from './logs/internal'; -import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; import type { Scope } from './scope'; import { registerSpanErrorInstrumentation } from './tracing'; import type { CheckIn, MonitorConfig, SerializedCheckIn } from './types-hoist/checkin'; import type { Event, EventHint } from './types-hoist/event'; -import type { Log } from './types-hoist/log'; -import type { Metric } from './types-hoist/metric'; -import type { Primitive } from './types-hoist/misc'; import type { ClientOptions } from './types-hoist/options'; import type { ParameterizedString } from './types-hoist/parameterize'; import type { SeverityLevel } from './types-hoist/severity'; import type { BaseTransportOptions } from './types-hoist/transport'; import { debug } from './utils/debug-logger'; import { eventFromMessage, eventFromUnknownInput } from './utils/eventbuilder'; -import { isPrimitive } from './utils/is'; import { uuid4 } from './utils/misc'; import { resolvedSyncPromise } from './utils/syncpromise'; - -// TODO: Make this configurable -const DEFAULT_LOG_FLUSH_INTERVAL = 5000; +import { _getTraceInfoFromScope } from './utils/trace-info'; export interface ServerRuntimeClientOptions extends ClientOptions { platform?: string; @@ -36,11 +28,6 @@ export interface ServerRuntimeClientOptions extends ClientOptions extends Client { - private _logFlushIdleTimeout: ReturnType | undefined; - private _logWeight: number; - private _metricFlushIdleTimeout: ReturnType | undefined; - private _metricWeight: number; - /** * Creates a new Edge SDK instance. * @param options Configuration options for this SDK. @@ -50,69 +37,6 @@ export class ServerRuntimeClient< registerSpanErrorInstrumentation(); super(options); - - this._logWeight = 0; - this._metricWeight = 0; - - if (this._options.enableLogs) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const client = this; - - client.on('flushLogs', () => { - client._logWeight = 0; - clearTimeout(client._logFlushIdleTimeout); - }); - - client.on('afterCaptureLog', log => { - client._logWeight += estimateLogSizeInBytes(log); - - // We flush the logs buffer if it exceeds 0.8 MB - // The log weight is a rough estimate, so we flush way before - // the payload gets too big. - if (client._logWeight >= 800_000) { - _INTERNAL_flushLogsBuffer(client); - } else { - // start an idle timeout to flush the logs buffer if no logs are captured for a while - client._logFlushIdleTimeout = setTimeout(() => { - _INTERNAL_flushLogsBuffer(client); - }, DEFAULT_LOG_FLUSH_INTERVAL); - } - }); - - client.on('flush', () => { - _INTERNAL_flushLogsBuffer(client); - }); - } - - if (this._options._experiments?.enableMetrics) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const client = this; - - client.on('flushMetrics', () => { - client._metricWeight = 0; - clearTimeout(client._metricFlushIdleTimeout); - }); - - client.on('afterCaptureMetric', metric => { - client._metricWeight += estimateMetricSizeInBytes(metric); - - // We flush the metrics buffer if it exceeds 0.8 MB - // The metric weight is a rough estimate, so we flush way before - // the payload gets too big. - if (client._metricWeight >= 800_000) { - _INTERNAL_flushMetricsBuffer(client); - } else { - // start an idle timeout to flush the metrics buffer if no metrics are captured for a while - client._metricFlushIdleTimeout = setTimeout(() => { - _INTERNAL_flushMetricsBuffer(client); - }, DEFAULT_LOG_FLUSH_INTERVAL); - } - }); - - client.on('flush', () => { - _INTERNAL_flushMetricsBuffer(client); - }); - } } /** @@ -267,82 +191,3 @@ function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void { } } } - -/** - * Estimate the size of a metric in bytes. - * - * @param metric - The metric to estimate the size of. - * @returns The estimated size of the metric in bytes. - */ -function estimateMetricSizeInBytes(metric: Metric): number { - let weight = 0; - - // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16. - if (metric.name) { - weight += metric.name.length * 2; - } - - // Add weight for the value - if (typeof metric.value === 'string') { - weight += metric.value.length * 2; - } else { - weight += 8; // number - } - - if (metric.attributes) { - Object.values(metric.attributes).forEach(value => { - if (Array.isArray(value)) { - weight += value.length * estimatePrimitiveSizeInBytes(value[0]); - } else if (isPrimitive(value)) { - weight += estimatePrimitiveSizeInBytes(value); - } else { - // For objects values, we estimate the size of the object as 100 bytes - weight += 100; - } - }); - } - - return weight; -} - -/** - * Estimate the size of a log in bytes. - * - * @param log - The log to estimate the size of. - * @returns The estimated size of the log in bytes. - */ -function estimateLogSizeInBytes(log: Log): number { - let weight = 0; - - // Estimate byte size of 2 bytes per character. This is a rough estimate JS strings are stored as UTF-16. - if (log.message) { - weight += log.message.length * 2; - } - - if (log.attributes) { - Object.values(log.attributes).forEach(value => { - if (Array.isArray(value)) { - weight += value.length * estimatePrimitiveSizeInBytes(value[0]); - } else if (isPrimitive(value)) { - weight += estimatePrimitiveSizeInBytes(value); - } else { - // For objects values, we estimate the size of the object as 100 bytes - weight += 100; - } - }); - } - - return weight; -} - -function estimatePrimitiveSizeInBytes(value: Primitive): number { - if (typeof value === 'string') { - return value.length * 2; - } else if (typeof value === 'number') { - return 8; - } else if (typeof value === 'boolean') { - return 4; - } - - return 0; -} diff --git a/packages/core/src/utils/trace-info.ts b/packages/core/src/utils/trace-info.ts new file mode 100644 index 000000000000..d7d0be69ca07 --- /dev/null +++ b/packages/core/src/utils/trace-info.ts @@ -0,0 +1,29 @@ +import type { Client } from '../client'; +import { getTraceContextFromScope, withScope } from '../currentScopes'; +import type { Scope } from '../scope'; +import { + getDynamicSamplingContextFromScope, + getDynamicSamplingContextFromSpan, +} from '../tracing/dynamicSamplingContext'; +import type { TraceContext } from '../types-hoist/context'; +import type { DynamicSamplingContext } from '../types-hoist/envelope'; +import { getActiveSpan, spanToTraceContext } from './spanUtils'; + +/** Extract trace information from scope */ +export function _getTraceInfoFromScope( + client: Client, + scope: Scope | undefined, +): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { + if (!scope) { + return [undefined, undefined]; + } + + return withScope(scope, () => { + const span = getActiveSpan(); + const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope); + const dynamicSamplingContext = span + ? getDynamicSamplingContextFromSpan(span) + : getDynamicSamplingContextFromScope(client, scope); + return [dynamicSamplingContext, traceContext]; + }); +} diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index c7cbe7ab4a97..ae324aa40f9f 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -12,6 +12,8 @@ import { withMonitor, } from '../../src'; import * as integrationModule from '../../src/integration'; +import { _INTERNAL_captureLog } from '../../src/logs/internal'; +import { _INTERNAL_captureMetric } from '../../src/metrics/internal'; import type { Envelope } from '../../src/types-hoist/envelope'; import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/event'; import type { SpanJSON } from '../../src/types-hoist/span'; @@ -2599,4 +2601,209 @@ describe('Client', () => { await expect(promise).rejects.toThrowError(error); }); }); + + describe('log weight-based flushing', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('flushes logs when weight exceeds 800KB', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // Create a large log message that will exceed the 800KB threshold + const largeMessage = 'x'.repeat(400_000); // 400KB string + _INTERNAL_captureLog({ message: largeMessage, level: 'info' }, scope); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('accumulates log weight without flushing when under threshold', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // Create a log message that won't exceed the threshold + const message = 'x'.repeat(100_000); // 100KB string + _INTERNAL_captureLog({ message, level: 'info' }, scope); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); + + it('flushes logs after idle timeout', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // Add a log which will trigger afterCaptureLog event + _INTERNAL_captureLog({ message: 'test log', level: 'info' }, scope); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + // Fast forward the idle timeout (5 seconds) + vi.advanceTimersByTime(5000); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('resets idle timeout when new logs are captured', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // Add initial log + _INTERNAL_captureLog({ message: 'test log 1', level: 'info' }, scope); + + // Fast forward part of the idle timeout + vi.advanceTimersByTime(2500); + + // Add another log which should reset the timeout + _INTERNAL_captureLog({ message: 'test log 2', level: 'info' }, scope); + + // Fast forward the remaining time + vi.advanceTimersByTime(2500); + + // Should not have flushed yet since timeout was reset + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + + // Fast forward the full timeout + vi.advanceTimersByTime(5000); + + // Now should have flushed both logs + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('flushes logs on flush event', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // Add some logs + _INTERNAL_captureLog({ message: 'test1', level: 'info' }, scope); + _INTERNAL_captureLog({ message: 'test2', level: 'info' }, scope); + + // Trigger flush event + client.emit('flush'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('does not flush logs when logs are disabled', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // Create a large log message + const largeMessage = 'x'.repeat(400_000); + _INTERNAL_captureLog({ message: largeMessage, level: 'info' }, scope); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); + }); + + describe('metric weight-based flushing', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('flushes metrics when weight exceeds 800KB', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // Create large metrics that will exceed the 800KB threshold + const largeValue = 'x'.repeat(400_000); // 400KB string + _INTERNAL_captureMetric({ name: 'large_metric', value: largeValue, type: 'counter', attributes: {} }, { scope }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + + it('accumulates metric weight without flushing when under threshold', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // Create metrics that won't exceed the threshold + _INTERNAL_captureMetric({ name: 'test_metric', value: 42, type: 'counter', attributes: {} }, { scope }); + + expect(sendEnvelopeSpy).not.toHaveBeenCalled(); + }); + + it('flushes metrics on flush event', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + _experiments: { enableMetrics: true }, + }); + const client = new TestClient(options); + const scope = new Scope(); + scope.setClient(client); + + const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); + + // Add some metrics + _INTERNAL_captureMetric({ name: 'metric1', value: 1, type: 'counter', attributes: {} }, { scope }); + _INTERNAL_captureMetric({ name: 'metric2', value: 2, type: 'counter', attributes: {} }, { scope }); + + // Trigger flush event + client.emit('flush'); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index 186e5fdc295e..a5c68184e03b 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -8,6 +8,7 @@ import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; // Mock dependencies vi.mock('../../../src/logs/internal', () => ({ _INTERNAL_captureLog: vi.fn(), + _INTERNAL_flushLogsBuffer: vi.fn(), })); vi.mock('../../../src/logs/utils', async actual => ({ diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index 9fcb431af864..525ee514c1a2 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, test, vi } from 'vitest'; import { createTransport, Scope } from '../../src'; -import { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer } from '../../src/logs/internal'; import type { ServerRuntimeClientOptions } from '../../src/server-runtime-client'; import { ServerRuntimeClient } from '../../src/server-runtime-client'; import type { Event, EventHint } from '../../src/types-hoist/event'; @@ -206,106 +205,4 @@ describe('ServerRuntimeClient', () => { ]); }); }); - - describe('log weight-based flushing', () => { - it('flushes logs when weight exceeds 800KB', () => { - const options = getDefaultClientOptions({ - dsn: PUBLIC_DSN, - enableLogs: true, - }); - client = new ServerRuntimeClient(options); - const scope = new Scope(); - scope.setClient(client); - - const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); - - // Create a large log message that will exceed the 800KB threshold - const largeMessage = 'x'.repeat(400_000); // 400KB string - _INTERNAL_captureLog({ message: largeMessage, level: 'info' }, scope); - - expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); - expect(client['_logWeight']).toBe(0); // Weight should be reset after flush - }); - - it('accumulates log weight without flushing when under threshold', () => { - const options = getDefaultClientOptions({ - dsn: PUBLIC_DSN, - enableLogs: true, - }); - client = new ServerRuntimeClient(options); - const scope = new Scope(); - scope.setClient(client); - - const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); - - // Create a log message that won't exceed the threshold - const message = 'x'.repeat(100_000); // 100KB string - _INTERNAL_captureLog({ message, level: 'info' }, scope); - - expect(sendEnvelopeSpy).not.toHaveBeenCalled(); - expect(client['_logWeight']).toBeGreaterThan(0); - }); - - it('flushes logs on flush event', () => { - const options = getDefaultClientOptions({ - dsn: PUBLIC_DSN, - enableLogs: true, - }); - client = new ServerRuntimeClient(options); - const scope = new Scope(); - scope.setClient(client); - - const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); - - // Add some logs - _INTERNAL_captureLog({ message: 'test1', level: 'info' }, scope); - _INTERNAL_captureLog({ message: 'test2', level: 'info' }, scope); - - // Trigger flush directly - _INTERNAL_flushLogsBuffer(client); - - expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); - expect(client['_logWeight']).toBe(0); // Weight should be reset after flush - }); - - it('does not flush logs when logs are disabled', () => { - const options = getDefaultClientOptions({ - dsn: PUBLIC_DSN, - }); - client = new ServerRuntimeClient(options); - const scope = new Scope(); - scope.setClient(client); - - const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); - - // Create a large log message - const largeMessage = 'x'.repeat(400_000); - _INTERNAL_captureLog({ message: largeMessage, level: 'info' }, scope); - - expect(sendEnvelopeSpy).not.toHaveBeenCalled(); - expect(client['_logWeight']).toBe(0); - }); - - it('flushes logs when flush event is triggered', () => { - const options = getDefaultClientOptions({ - dsn: PUBLIC_DSN, - enableLogs: true, - }); - client = new ServerRuntimeClient(options); - const scope = new Scope(); - scope.setClient(client); - - const sendEnvelopeSpy = vi.spyOn(client, 'sendEnvelope'); - - // Add some logs - _INTERNAL_captureLog({ message: 'test1', level: 'info' }, scope); - _INTERNAL_captureLog({ message: 'test2', level: 'info' }, scope); - - // Trigger flush event - client.emit('flush'); - - expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); - expect(client['_logWeight']).toBe(0); // Weight should be reset after flush - }); - }); }); From ed98c1fde0818247267f6b4795ddece551088056 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 14 Oct 2025 18:58:09 +0100 Subject: [PATCH 11/20] feat(node): Capture `pino` logger name (#17930) Pino loggers can be named but we didn't capture this information: ```ts import pino from 'pino'; const logger = pino({ name: 'my-component' }); ``` This PR changes the hook from `start` to `end` which means we can parse the output JSON and fetch the name if one was supplied. --- .../suites/pino/scenario-next.mjs | 2 +- .../suites/pino/scenario.mjs | 2 +- .../suites/pino/test.ts | 20 ++++++----- packages/node-core/src/integrations/pino.ts | 36 ++++++++++++------- 4 files changed, 37 insertions(+), 23 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs index 11fc038fea3a..2965038990fd 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario-next.mjs @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node'; import pino from 'pino-next'; -const logger = pino({}); +const logger = pino({ name: 'myapp' }); Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'startup' }, () => { diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs index 3ff6c0b5e08d..ea8dc5e223d0 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node'; import pino from 'pino'; -const logger = pino({}); +const logger = pino({ name: 'myapp' }); Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'startup' }, () => { diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index 15a9397ebb27..cc88f650203b 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -64,13 +64,14 @@ conditionalTest({ min: 20 })('Pino integration', () => { trace_id: expect.any(String), severity_number: 9, attributes: expect.objectContaining({ - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, - 'sentry.pino.level': { value: 30, type: 'integer' }, + 'pino.logger.name': { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { type: 'string', value: '{"more":3,"complex":"nope"}', }, + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }), @@ -82,9 +83,10 @@ conditionalTest({ min: 20 })('Pino integration', () => { trace_id: expect.any(String), severity_number: 17, attributes: expect.objectContaining({ - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, - 'sentry.pino.level': { value: 50, type: 'integer' }, + 'pino.logger.name': { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 50, type: 'integer' }, err: { value: '{}', type: 'string' }, + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }), @@ -138,13 +140,14 @@ conditionalTest({ min: 20 })('Pino integration', () => { trace_id: expect.any(String), severity_number: 9, attributes: expect.objectContaining({ - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, - 'sentry.pino.level': { value: 30, type: 'integer' }, + 'pino.logger.name': { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { type: 'string', value: '{"more":3,"complex":"nope"}', }, + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }), @@ -156,9 +159,10 @@ conditionalTest({ min: 20 })('Pino integration', () => { trace_id: expect.any(String), severity_number: 17, attributes: expect.objectContaining({ - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, - 'sentry.pino.level': { value: 50, type: 'integer' }, + 'pino.logger.name': { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 50, type: 'integer' }, err: { value: '{}', type: 'string' }, + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }), diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index af3f41735c4a..6b78bcdb4386 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -94,17 +94,23 @@ export const pinoIntegration = defineIntegration((userOptions: DeepPartial = { + ...obj, + 'sentry.origin': 'auto.logging.pino', + 'pino.logger.level': levelNumber, + }; + + const parsedResult = JSON.parse(result) as { name?: string }; + + if (parsedResult.name) { + attributes['pino.logger.name'] = parsedResult.name; + } + _INTERNAL_captureLog({ level, message, attributes }); } @@ -135,14 +141,18 @@ export const pinoIntegration = defineIntegration((userOptions: DeepPartial { - const { self, arguments: args } = data as { self: Pino; arguments: PinoHookArgs }; - onPinoStart(self, args); + injectedChannel.end.subscribe(data => { + const { self, arguments: args, result } = data as { self: Pino; arguments: PinoHookArgs; result: string }; + onPinoStart(self, args, result); }); - integratedChannel.start.subscribe(data => { - const { instance, arguments: args } = data as { instance: Pino; arguments: PinoHookArgs }; - onPinoStart(instance, args); + integratedChannel.end.subscribe(data => { + const { + instance, + arguments: args, + result, + } = data as { instance: Pino; arguments: PinoHookArgs; result: string }; + onPinoStart(instance, args, result); }); }, }; From 4e988d55ff3634d37d3196fc8206ead7c141794e Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Tue, 14 Oct 2025 20:08:54 +0200 Subject: [PATCH 12/20] chore: Add external contributor to CHANGELOG.md (#17928) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #17440 Co-authored-by: chargome <20254395+chargome@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2bd55b5cd81..1415d2a3941c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @seoyeon9888. Thank you for your contribution! +Work in this release was contributed by @seoyeon9888 and @madhuchavva. Thank you for your contributions! ## 10.19.0 From c68674a65a6d927e66670ace67a55e3d4a8174c5 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 15 Oct 2025 12:00:30 +0200 Subject: [PATCH 13/20] fix(browser): Ignore React 19.2+ component render measure entries (#17905) With 19.2, React introduced [custom perfomance tracks](https://react.dev/blog/2025/10/01/react-19-2#performance-tracks) in chrome dev tools. This track is populated by collecting `performance.measure` entries for every component (re-)render. Sounds good in theory but in reality this causes a massive performance degradation when using the Sentry SDK because we collect spans from `PerformanceMeasure` entries. In our Sentry UI, this caused 10+ second long blocks because we created thousands of spans from these render entries. This patch fixes this performance drop by inspecting the measure entries' `detail` object which we can use to _fairly well_ distinguish React's entries from users' entries. Not 100% bulletproof but I think good enough. --- .../src/metrics/browserMetrics.ts | 23 +++++++ .../test/browser/browserMetrics.test.ts | 69 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 2c61408c1d76..ec7213ae4ff6 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -425,6 +425,24 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries _measurements = {}; } +/** + * React 19.2+ creates performance.measure entries for component renders. + * We can identify them by the `detail.devtools.track` property being set to 'Components ⚛'. + * see: https://react.dev/reference/dev-tools/react-performance-tracks + * see: https://github.com/facebook/react/blob/06fcc8f380c6a905c7bc18d94453f623cf8cbc81/packages/react-reconciler/src/ReactFiberPerformanceTrack.js#L454-L473 + */ +function isReact19MeasureEntry(entry: PerformanceEntry | null): boolean | void { + if (entry?.entryType !== 'measure') { + return; + } + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return (entry as PerformanceMeasure).detail.devtools.track === 'Components ⚛'; + } catch { + return; + } +} + /** * Create measure related spans. * Exported only for tests. @@ -437,6 +455,10 @@ export function _addMeasureSpans( timeOrigin: number, ignorePerformanceApiSpans: AddPerformanceEntriesOptions['ignorePerformanceApiSpans'], ): void { + if (isReact19MeasureEntry(entry)) { + return; + } + if ( ['mark', 'measure'].includes(entry.entryType) && stringMatchesSomePattern(entry.name, ignorePerformanceApiSpans) @@ -445,6 +467,7 @@ export function _addMeasureSpans( } const navEntry = getNavigationEntry(false); + const requestTime = msToSec(navEntry ? navEntry.requestStart : 0); // Because performance.measure accepts arbitrary timestamps it can produce // spans that happen before the browser even makes a request for the page. diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts index c734ec326b47..6d6e03fa0643 100644 --- a/packages/browser-utils/test/browser/browserMetrics.test.ts +++ b/packages/browser-utils/test/browser/browserMetrics.test.ts @@ -186,6 +186,75 @@ describe('_addMeasureSpans', () => { ]), ); }); + + it('ignores React 19.2+ measure spans', () => { + const pageloadSpan = new SentrySpan({ op: 'pageload', name: '/', sampled: true }); + const spans: Span[] = []; + + getClient()?.on('spanEnd', span => { + spans.push(span); + }); + + const entries: PerformanceMeasure[] = [ + { + entryType: 'measure', + name: '\u200bLayout', + duration: 0.3, + startTime: 12, + detail: { + devtools: { + track: 'Components ⚛', + }, + }, + toJSON: () => ({ foo: 'bar' }), + }, + { + entryType: 'measure', + name: '\u200bButton', + duration: 0.1, + startTime: 13, + detail: { + devtools: { + track: 'Components ⚛', + }, + }, + toJSON: () => ({}), + }, + { + entryType: 'measure', + name: 'Unmount', + duration: 0.1, + startTime: 14, + detail: { + devtools: { + track: 'Components ⚛', + }, + }, + toJSON: () => ({}), + }, + { + entryType: 'measure', + name: 'my-measurement', + duration: 0, + startTime: 12, + detail: null, + toJSON: () => ({}), + }, + ]; + + const timeOrigin = 100; + const startTime = 23; + const duration = 356; + + entries.forEach(e => { + _addMeasureSpans(pageloadSpan, e, startTime, duration, timeOrigin, []); + }); + + expect(spans).toHaveLength(1); + expect(spans.map(spanToJSON)).toEqual( + expect.arrayContaining([expect.objectContaining({ description: 'my-measurement', op: 'measure' })]), + ); + }); }); describe('_addResourceSpans', () => { From e4e272b9e792b0b026471bbb9a89a43aa43e0694 Mon Sep 17 00:00:00 2001 From: Daniel Sanchez <4277756+thedanchez@users.noreply.github.com> Date: Wed, 15 Oct 2025 07:17:36 -0400 Subject: [PATCH 14/20] feat(solid): Add support for TanStack Router Solid (#17735) # Summary This PR adds support for TanStack Router Solid. It follows the same outline as the existing implementation of TanStack Router React for Sentry as both TanStack Router flavors are built on the same agnostic foundation. --------- Co-authored-by: Andrei Borza --- .../solid-solidrouter/README.md | 40 ---- .../solid-solidrouter/index.html | 15 -- .../solid-solidrouter/postcss.config.js | 6 - .../solid-solidrouter/src/errors/404.tsx | 8 - .../solid-solidrouter/src/index.css | 3 - .../solid-solidrouter/src/index.tsx | 22 -- .../solid-solidrouter/src/pageroot.tsx | 28 --- .../src/pages/errorboundaryexample.tsx | 24 --- .../solid-solidrouter/src/pages/home.tsx | 39 ---- .../solid-solidrouter/src/pages/user.tsx | 6 - .../solid-solidrouter/src/routes.ts | 23 -- .../solid-solidrouter/tailwind.config.ts | 11 - .../tests/errorboundary.test.ts | 75 ------- .../solid-solidrouter/tests/errors.test.ts | 28 --- .../tests/performance.test.ts | 91 -------- .../solid-solidrouter/tsconfig.json | 14 -- .../solid-solidrouter/vite.config.ts | 10 - .../solid-tanstack-router/.cta.json | 11 + .../.gitignore | 0 .../.npmrc | 0 .../solid-tanstack-router/README.md | 165 ++++++++++++++ .../solid-tanstack-router/index.html | 20 ++ .../package.json | 32 +-- .../playwright.config.mjs | 0 .../solid-tanstack-router/public/favicon.ico | Bin 0 -> 3870 bytes .../solid-tanstack-router/public/logo192.png | Bin 0 -> 5347 bytes .../solid-tanstack-router/public/logo512.png | Bin 0 -> 9664 bytes .../public/manifest.json | 25 +++ .../solid-tanstack-router/public/robots.txt | 3 + .../solid-tanstack-router/src/App.tsx | 20 ++ .../solid-tanstack-router/src/logo.svg | 120 +++++++++++ .../solid-tanstack-router/src/main.tsx | 99 +++++++++ .../solid-tanstack-router/src/styles.css | 14 ++ .../start-event-proxy.mjs | 2 +- .../tests/routing-instrumentation.test.ts | 72 +++++++ .../solid-tanstack-router/tsconfig.json | 25 +++ .../solid-tanstack-router/vite.config.ts | 12 ++ packages/solid/README.md | 37 ++++ packages/solid/package.json | 21 +- packages/solid/rollup.npm.config.mjs | 2 +- packages/solid/src/tanstackrouter.ts | 126 +++++++++++ ...types.json => tsconfig.routers-types.json} | 2 +- packages/solid/tsconfig.types.json | 4 +- yarn.lock | 202 +++++++++++++++++- 44 files changed, 984 insertions(+), 473 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/README.md delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/index.html delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/postcss.config.js delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/src/errors/404.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/src/index.css delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/src/index.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pageroot.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/errorboundaryexample.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/home.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/user.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/src/routes.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/tailwind.config.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/errorboundary.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/errors.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/performance.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/tsconfig.json delete mode 100644 dev-packages/e2e-tests/test-applications/solid-solidrouter/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/.cta.json rename dev-packages/e2e-tests/test-applications/{solid-solidrouter => solid-tanstack-router}/.gitignore (100%) rename dev-packages/e2e-tests/test-applications/{solid-solidrouter => solid-tanstack-router}/.npmrc (100%) create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/README.md create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/index.html rename dev-packages/e2e-tests/test-applications/{solid-solidrouter => solid-tanstack-router}/package.json (52%) rename dev-packages/e2e-tests/test-applications/{solid-solidrouter => solid-tanstack-router}/playwright.config.mjs (100%) create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/logo192.png create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/logo512.png create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/manifest.json create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/robots.txt create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/App.tsx create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/logo.svg create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/styles.css rename dev-packages/e2e-tests/test-applications/{solid-solidrouter => solid-tanstack-router}/start-event-proxy.mjs (71%) create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/solid-tanstack-router/vite.config.ts create mode 100644 packages/solid/src/tanstackrouter.ts rename packages/solid/{tsconfig.solidrouter-types.json => tsconfig.routers-types.json} (85%) diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/README.md b/dev-packages/e2e-tests/test-applications/solid-solidrouter/README.md deleted file mode 100644 index 81e5eb6c2d40..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/README.md +++ /dev/null @@ -1,40 +0,0 @@ -## Usage - -Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`. - -This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely -be removed once you clone a template. - -```bash -$ npm install # or pnpm install or yarn install -``` - -## Exploring the template - -This template's goal is to showcase the routing features of Solid. It also showcase how the router and Suspense work -together to parallelize data fetching tied to a route via the `.data.ts` pattern. - -You can learn more about it on the [`@solidjs/router` repository](https://github.com/solidjs/solid-router) - -### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs) - -## Available Scripts - -In the project directory, you can run: - -### `npm run dev` or `npm start` - -Runs the app in the development mode.
Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -The page will reload if you make edits.
- -### `npm run build` - -Builds the app for production to the `dist` folder.
It correctly bundles Solid in production mode and optimizes the -build for the best performance. - -The build is minified and the filenames include the hashes.
Your app is ready to be deployed! - -## Deployment - -You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.) diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/index.html b/dev-packages/e2e-tests/test-applications/solid-solidrouter/index.html deleted file mode 100644 index 1905a0429019..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Solid App - - - -
- - - - diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/postcss.config.js b/dev-packages/e2e-tests/test-applications/solid-solidrouter/postcss.config.js deleted file mode 100644 index 12a703d900da..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/errors/404.tsx b/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/errors/404.tsx deleted file mode 100644 index 56e5ad5e3be0..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/errors/404.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default function NotFound() { - return ( -
-

404: Not Found

-

It's gone 😞

-
- ); -} diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/index.css b/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/index.css deleted file mode 100644 index b5c61c956711..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/index.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/index.tsx b/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/index.tsx deleted file mode 100644 index 66773f009d1e..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* @refresh reload */ -import * as Sentry from '@sentry/solid'; -import { solidRouterBrowserTracingIntegration, withSentryRouterRouting } from '@sentry/solid/solidrouter'; -import { Router } from '@solidjs/router'; -import { render } from 'solid-js/web'; -import './index.css'; -import PageRoot from './pageroot'; -import { routes } from './routes'; - -Sentry.init({ - dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, - debug: true, - environment: 'qa', // dynamic sampling bias to keep transactions - integrations: [solidRouterBrowserTracingIntegration()], - release: 'e2e-test', - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1.0, -}); - -const SentryRouter = withSentryRouterRouting(Router); - -render(() => {routes}, document.getElementById('root')); diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pageroot.tsx b/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pageroot.tsx deleted file mode 100644 index 0919c0e362db..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pageroot.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { A } from '@solidjs/router'; - -export default function PageRoot(props) { - return ( - <> - -
{props.children}
- - ); -} diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/errorboundaryexample.tsx b/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/errorboundaryexample.tsx deleted file mode 100644 index b4cb4e93a02f..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/errorboundaryexample.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as Sentry from '@sentry/solid'; -import { ErrorBoundary } from 'solid-js'; - -const SentryErrorBoundary = Sentry.withSentryErrorBoundary(ErrorBoundary); - -export default function ErrorBoundaryExample() { - return ( - ( -
-

Error Boundary Fallback

-
- {error.message} -
- -
- )} - > - -
- ); -} diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/home.tsx b/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/home.tsx deleted file mode 100644 index 08e92728762c..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/home.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { A } from '@solidjs/router'; -import { createSignal } from 'solid-js'; - -export default function Home() { - const [count, setCount] = createSignal(0); - - return ( -
-

Home

-

This is the home page.

- -
- - - Count: {count()} - - -
-
- - - User 5 - -
-
- ); -} diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/user.tsx b/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/user.tsx deleted file mode 100644 index 639ab0be8118..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/pages/user.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { useParams } from '@solidjs/router'; - -export default function User() { - const params = useParams(); - return
User ID: {params.id}
; -} diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/routes.ts b/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/routes.ts deleted file mode 100644 index 96b78e113ef5..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/src/routes.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { lazy } from 'solid-js'; - -import ErrorBoundaryExample from './pages/errorboundaryexample'; -import Home from './pages/home'; - -export const routes = [ - { - path: '/', - component: Home, - }, - { - path: '/user/:id', - component: lazy(() => import('./pages/user')), - }, - { - path: '/error-boundary-example', - component: ErrorBoundaryExample, - }, - { - path: '**', - component: lazy(() => import('./errors/404')), - }, -]; diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tailwind.config.ts b/dev-packages/e2e-tests/test-applications/solid-solidrouter/tailwind.config.ts deleted file mode 100644 index f69a95185570..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tailwind.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Config } from 'tailwindcss'; - -const config: Config = { - content: ['./src/**/*.{js,jsx,ts,tsx}'], - theme: { - extend: {}, - }, - plugins: [], -}; - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/errorboundary.test.ts b/dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/errorboundary.test.ts deleted file mode 100644 index 14396feb2334..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/errorboundary.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('captures an exception', async ({ page }) => { - const errorEventPromise = waitForError('solid', errorEvent => { - return !errorEvent.type && errorEvent.transaction === '/error-boundary-example'; - }); - - const [, errorEvent] = await Promise.all([page.goto('/error-boundary-example'), errorEventPromise]); - - expect(errorEvent).toMatchObject({ - exception: { - values: [ - { - type: 'ReferenceError', - value: 'NonExistentComponent is not defined', - mechanism: { - type: 'auto.function.solid.error_boundary', - handled: true, - }, - }, - ], - }, - transaction: '/error-boundary-example', - }); -}); - -test('captures a second exception after resetting the boundary', async ({ page }) => { - const firstErrorEventPromise = waitForError('solid', errorEvent => { - return !errorEvent.type && errorEvent.transaction === '/error-boundary-example'; - }); - - const [, firstErrorEvent] = await Promise.all([page.goto('/error-boundary-example'), firstErrorEventPromise]); - - expect(firstErrorEvent).toMatchObject({ - exception: { - values: [ - { - type: 'ReferenceError', - value: 'NonExistentComponent is not defined', - mechanism: { - type: 'auto.function.solid.error_boundary', - handled: true, - }, - }, - ], - }, - transaction: '/error-boundary-example', - }); - - const secondErrorEventPromise = waitForError('solid', errorEvent => { - return !errorEvent.type && errorEvent.transaction === '/error-boundary-example'; - }); - - const [, secondErrorEvent] = await Promise.all([ - page.locator('#errorBoundaryResetBtn').click(), - await secondErrorEventPromise, - ]); - - expect(secondErrorEvent).toMatchObject({ - exception: { - values: [ - { - type: 'ReferenceError', - value: 'NonExistentComponent is not defined', - mechanism: { - type: 'auto.function.solid.error_boundary', - handled: true, - }, - }, - ], - }, - transaction: '/error-boundary-example', - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/errors.test.ts deleted file mode 100644 index a77f107af624..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/errors.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('sends an error', async ({ page }) => { - const errorPromise = waitForError('solid', async errorEvent => { - return !errorEvent.type && errorEvent.exception?.values?.[0]?.value === 'Error thrown from Solid E2E test app'; - }); - - await Promise.all([page.goto(`/`), page.locator('#errorBtn').click()]); - - const error = await errorPromise; - - expect(error).toMatchObject({ - exception: { - values: [ - { - type: 'Error', - value: 'Error thrown from Solid E2E test app', - mechanism: { - type: 'auto.browser.global_handlers.onerror', - handled: false, - }, - }, - ], - }, - transaction: '/', - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/performance.test.ts deleted file mode 100644 index f73ff4940527..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tests/performance.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('sends a pageload transaction', async ({ page }) => { - const transactionPromise = waitForTransaction('solid', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - const [, pageloadTransaction] = await Promise.all([page.goto('/'), transactionPromise]); - - expect(pageloadTransaction).toMatchObject({ - contexts: { - trace: { - op: 'pageload', - origin: 'auto.pageload.browser', - }, - }, - transaction: '/', - transaction_info: { - source: 'url', - }, - }); -}); - -test('sends a navigation transaction', async ({ page }) => { - const transactionPromise = waitForTransaction('solid', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - - const [, navigationTransaction] = await Promise.all([page.locator('#navLink').click(), transactionPromise]); - - expect(navigationTransaction).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.solid.solidrouter', - }, - }, - transaction: '/user/5', - transaction_info: { - source: 'url', - }, - }); -}); - -test('updates the transaction when using the back button', async ({ page }) => { - // Solid Router sends a `-1` navigation when using the back button. - // The sentry solidRouterBrowserTracingIntegration tries to update such - // transactions with the proper name once the `useLocation` hook triggers. - const navigationTxnPromise = waitForTransaction('solid', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - await page.goto(`/`); - - const [, navigationTxn] = await Promise.all([page.locator('#navLink').click(), navigationTxnPromise]); - - expect(navigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.solid.solidrouter', - }, - }, - transaction: '/user/5', - transaction_info: { - source: 'url', - }, - }); - - const backNavigationTxnPromise = waitForTransaction('solid', async transactionEvent => { - return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; - }); - - const [, backNavigationTxn] = await Promise.all([page.goBack(), backNavigationTxnPromise]); - - expect(backNavigationTxn).toMatchObject({ - contexts: { - trace: { - op: 'navigation', - origin: 'auto.navigation.solid.solidrouter', - }, - }, - transaction: '/', - transaction_info: { - source: 'url', - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tsconfig.json b/dev-packages/e2e-tests/test-applications/solid-solidrouter/tsconfig.json deleted file mode 100644 index 5d2faf0af117..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "jsx": "preserve", - "jsxImportSource": "solid-js", - "types": ["vite/client"], - "noEmit": true, - "isolatedModules": true - } -} diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/vite.config.ts b/dev-packages/e2e-tests/test-applications/solid-solidrouter/vite.config.ts deleted file mode 100644 index d1835ee1b8ff..000000000000 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vite'; -import solidPlugin from 'vite-plugin-solid'; - -export default defineConfig({ - plugins: [solidPlugin()], - build: { - target: 'esnext', - }, - envPrefix: 'PUBLIC_', -}); diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/.cta.json b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/.cta.json new file mode 100644 index 000000000000..3b9146f46e52 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/.cta.json @@ -0,0 +1,11 @@ +{ + "projectName": "solid-tanstack-router", + "mode": "code-router", + "typescript": true, + "tailwind": true, + "packageManager": "pnpm", + "git": true, + "version": 1, + "framework": "solid", + "chosenAddOns": [] +} diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/.gitignore b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/.gitignore similarity index 100% rename from dev-packages/e2e-tests/test-applications/solid-solidrouter/.gitignore rename to dev-packages/e2e-tests/test-applications/solid-tanstack-router/.gitignore diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/.npmrc b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/.npmrc similarity index 100% rename from dev-packages/e2e-tests/test-applications/solid-solidrouter/.npmrc rename to dev-packages/e2e-tests/test-applications/solid-tanstack-router/.npmrc diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/README.md b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/README.md new file mode 100644 index 000000000000..cde052fb5212 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/README.md @@ -0,0 +1,165 @@ +Welcome to your new TanStack app! + +# Getting Started + +To run this application: + +```bash +pnpm install +pnpm start +``` + +# Building For Production + +To build this application for production: + +```bash +pnpm build +``` + +## Styling + +This project uses [Tailwind CSS](https://tailwindcss.com/) for styling. + +## Routing + +This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a code based router. Which means that the routes are defined in code (in the `./src/main.tsx` file). If you like you can also use a file based routing setup by following the [File Based Routing](https://tanstack.com/router/latest/docs/framework/solid/guide/file-based-routing) guide. + +### Adding A Route + +To add a new route to your application just add another `createRoute` call to the `./src/main.tsx` file. The example below adds a new `/about`route to the root route. + +```tsx +const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>

About

, +}); +``` + +You will also need to add the route to the `routeTree` in the `./src/main.tsx` file. + +```tsx +const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]); +``` + +With this set up you should be able to navigate to `/about` and see the about page. + +Of course you don't need to implement the About page in the `main.tsx` file. You can create that component in another file and import it into the `main.tsx` file, then use it in the `component` property of the `createRoute` call, like so: + +```tsx +import About from './components/About.tsx'; + +const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: About, +}); +``` + +That is how we have the `App` component set up with the home page. + +For more information on the options you have when you are creating code based routes check out the [Code Based Routing](https://tanstack.com/router/latest/docs/framework/solid/guide/code-based-routing) documentation. + +Now that you have two routes you can use a `Link` component to navigate between them. + +### Adding Links + +To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/solid-router`. + +```tsx +import { Link } from '@tanstack/solid-router'; +``` + +Then anywhere in your JSX you can use it like so: + +```tsx +About +``` + +This will create a link that will navigate to the `/about` route. + +More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/solid/api/router/linkComponent). + +### Using A Layout + +Layouts can be used to wrap the contents of the routes in menus, headers, footers, etc. + +There is already a layout in the `src/main.tsx` file: + +```tsx +const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), +}); +``` + +You can use the Soliid component specified in the `component` property of the `rootRoute` to wrap the contents of the routes. The `` component is used to render the current route within the body of the layout. For example you could add a header to the layout like so: + +```tsx +import { Link } from '@tanstack/solid-router'; + +const rootRoute = createRootRoute({ + component: () => ( + <> +
+ +
+ + + + ), +}); +``` + +The `` component is not required so you can remove it if you don't want it in your layout. + +More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/solid/guide/routing-concepts#layouts). + +## Data Fetching + +There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered. + +For example: + +```tsx +const peopleRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/people', + loader: async () => { + const response = await fetch('https://swapi.dev/api/people'); + return response.json() as Promise<{ + results: { + name: string; + }[]; + }>; + }, + component: () => { + const data = peopleRoute.useLoaderData(); + return ( +
    + {data.results.map(person => ( +
  • {person.name}
  • + ))} +
+ ); + }, +}); +``` + +Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/solid/guide/data-loading#loader-parameters). + +# Demo files + +Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed. + +# Learn More + +You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com). diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/index.html b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/index.html new file mode 100644 index 000000000000..e1b9457f30b9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + Create TanStack App - app-ts + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/package.json b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json similarity index 52% rename from dev-packages/e2e-tests/test-applications/solid-solidrouter/package.json rename to dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json index ada4d06624ad..5dc35acaf095 100644 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/package.json +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/package.json @@ -1,32 +1,32 @@ { - "name": "solid-solidrouter", - "version": "0.0.0", - "description": "", + "name": "solid-tanstack-router", + "private": true, + "type": "module", "scripts": { "build": "vite build", "clean": "npx rimraf node_modules pnpm-lock.yaml dist", "dev": "vite", + "start": "vite preview", "preview": "vite preview", - "start": "vite", "test:prod": "TEST_ENV=production playwright test", "test:build": "pnpm install && pnpm build", "test:assert": "pnpm test:prod" }, - "license": "MIT", + "dependencies": { + "@sentry/solid": "latest || *", + "@tailwindcss/vite": "^4.0.6", + "@tanstack/solid-router": "^1.132.25", + "@tanstack/solid-router-devtools": "^1.132.25", + "@tanstack/solid-start": "^1.132.25", + "solid-js": "^1.9.5", + "tailwindcss": "^4.0.6" + }, "devDependencies": { "@playwright/test": "~1.53.2", "@sentry-internal/test-utils": "link:../../../test-utils", - "autoprefixer": "^10.4.17", - "postcss": "^8.4.33", - "solid-devtools": "^0.29.2", - "tailwindcss": "^3.4.1", - "vite": "^5.4.11", - "vite-plugin-solid": "^2.11.6" - }, - "dependencies": { - "@solidjs/router": "^0.13.5", - "solid-js": "^1.8.18", - "@sentry/solid": "latest || *" + "typescript": "^5.7.2", + "vite": "^7.1.7", + "vite-plugin-solid": "^2.11.2" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/playwright.config.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/solid-solidrouter/playwright.config.mjs rename to dev-packages/e2e-tests/test-applications/solid-tanstack-router/playwright.config.mjs diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/favicon.ico b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/logo192.png b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..fc44b0a3796c0e0a64c3d858ca038bd4570465d9 GIT binary patch literal 5347 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/manifest.json b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/manifest.json new file mode 100644 index 000000000000..078ef5011624 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "TanStack App", + "name": "Create TanStack App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/robots.txt b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/robots.txt new file mode 100644 index 000000000000..e9e57dc4d41b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/App.tsx b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/App.tsx new file mode 100644 index 000000000000..a584b2d83600 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/App.tsx @@ -0,0 +1,20 @@ +import logo from './logo.svg'; + +function App() { + return ( + + ); +} + +export default App; diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/logo.svg b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/logo.svg new file mode 100644 index 000000000000..21159f9fc0eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/logo.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx new file mode 100644 index 000000000000..4580fa6e8a90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/main.tsx @@ -0,0 +1,99 @@ +import { Link, Outlet, RouterProvider, createRootRoute, createRoute, createRouter } from '@tanstack/solid-router'; +import * as Sentry from '@sentry/solid'; +import { tanstackRouterBrowserTracingIntegration } from '@sentry/solid/tanstackrouter'; +import { render } from 'solid-js/web'; + +import './styles.css'; + +import App from './App.tsx'; + +const rootRoute = createRootRoute({ + component: () => ( + <> +
    +
  • + Home +
  • +
  • + + Post 1 + +
  • +
  • + + Post 2 + +
  • +
+
+ + + ), +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: App, +}); + +const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts/', +}); + +const postIdRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + shouldReload() { + return true; + }, + loader: ({ params }) => { + return Sentry.startSpan({ name: `loading-post-${params.postId}` }, async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + }); + }, + component: function Post() { + const params = postIdRoute.useParams(); + return
Post ID: {params().postId}
; + }, +}); + +const routeTree = rootRoute.addChildren([indexRoute, postsRoute.addChildren([postIdRoute])]); + +const router = createRouter({ + routeTree, + defaultPreload: 'intent', + scrollRestoration: true, +}); + +declare module '@tanstack/solid-router' { + interface Register { + router: typeof router; + } +} + +declare const __APP_DSN__: string; + +Sentry.init({ + dsn: __APP_DSN__, + debug: true, + environment: 'qa', // dynamic sampling bias to keep transactions + integrations: [tanstackRouterBrowserTracingIntegration(router)], + release: 'e2e-test', + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, +}); + +function MainApp() { + return ( + <> + + + ); +} + +const rootElement = document.getElementById('app'); +if (rootElement) { + render(() => , rootElement); +} diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/styles.css b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/styles.css new file mode 100644 index 000000000000..9dbc2a933202 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/src/styles.css @@ -0,0 +1,14 @@ +@import 'tailwindcss'; + +body { + @apply m-0; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', + 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; +} diff --git a/dev-packages/e2e-tests/test-applications/solid-solidrouter/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/start-event-proxy.mjs similarity index 71% rename from dev-packages/e2e-tests/test-applications/solid-solidrouter/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/solid-tanstack-router/start-event-proxy.mjs index 075d4dcb5cf5..496ea15d6c2a 100644 --- a/dev-packages/e2e-tests/test-applications/solid-solidrouter/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'solid', + proxyServerName: 'solid-tanstack-router', }); diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts new file mode 100644 index 000000000000..7119c7e76b99 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tests/routing-instrumentation.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/posts/456`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.solid.tanstack_router', + 'sentry.op': 'pageload', + 'url.path.parameter.postId': '456', + }, + op: 'pageload', + origin: 'auto.pageload.solid.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('solid-tanstack-router', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.transaction === '/posts/$postId' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + await page.locator('#nav-link').click(); + + const navigationTxn = await navigationTxnPromise; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.solid.tanstack_router', + 'sentry.op': 'navigation', + 'url.path.parameter.postId': '2', + }, + op: 'navigation', + origin: 'auto.navigation.solid.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tsconfig.json b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tsconfig.json new file mode 100644 index 000000000000..0ce9a7b137af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/tsconfig.json @@ -0,0 +1,25 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/solid-tanstack-router/vite.config.ts b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/vite.config.ts new file mode 100644 index 000000000000..bd612e95fbb8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-tanstack-router/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; + +import solidPlugin from 'vite-plugin-solid'; +import tailwindcss from '@tailwindcss/vite'; + +// https://vitejs.dev/config/ +export default defineConfig({ + define: { + __APP_DSN__: JSON.stringify(process.env.E2E_TEST_DSN), + }, + plugins: [solidPlugin(), tailwindcss()], +}); diff --git a/packages/solid/README.md b/packages/solid/README.md index e5ddd2186c02..58fa5c75c345 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -52,6 +52,43 @@ render( ); ``` +### Tanstack Router + +The Tanstack Router instrumentation uses the Tanstack Router library to create navigation spans to ensure you collect +meaningful performance data about the health of your page loads and associated requests. + +Add `tanstackRouterBrowserTracingIntegration` instead of the regular `Sentry.browserTracingIntegration`. + +Make sure `tanstackRouterBrowserTracingIntegration` is initialized by your `Sentry.init` call. Otherwise, the routing +instrumentation will not work properly. + +Pass your router instance from `createRouter` to the integration. + +```js +import * as Sentry from '@sentry/solid'; +import { tanstackRouterBrowserTracingIntegration } from '@sentry/solid/tanstackrouter'; +import { Route, Router } from '@solidjs/router'; + +const router = createRouter({ + // your router config + // ... +}); + +declare module '@tanstack/solid-router' { + interface Register { + router: typeof router; + } +} + +Sentry.init({ + dsn: '__PUBLIC_DSN__', + integrations: [tanstackRouterBrowserTracingIntegration(router)], + tracesSampleRate: 1.0, // Capture 100% of the transactions +}); + +render(() => , document.getElementById('root')); +``` + # Solid ErrorBoundary To automatically capture exceptions from inside a component tree and render a fallback component, wrap the native Solid diff --git a/packages/solid/package.json b/packages/solid/package.json index 8a614ca120a2..5fdde9a0c97a 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -38,6 +38,16 @@ "types": "./solidrouter.d.ts", "default": "./build/cjs/solidrouter.js" } + }, + "./tanstackrouter": { + "import": { + "types": "./tanstackrouter.d.ts", + "default": "./build/esm/tanstackrouter.js" + }, + "require": { + "types": "./tanstackrouter.d.ts", + "default": "./build/cjs/tanstackrouter.js" + } } }, "publishConfig": { @@ -49,15 +59,20 @@ }, "peerDependencies": { "@solidjs/router": "^0.13.4", + "@tanstack/solid-router": "^1.132.27", "solid-js": "^1.8.4" }, "peerDependenciesMeta": { "@solidjs/router": { "optional": true + }, + "@tanstack/solid-router": { + "optional": true } }, "devDependencies": { "@solidjs/router": "^0.13.4", + "@tanstack/solid-router": "^1.132.27", "@solidjs/testing-library": "0.8.5", "@testing-library/dom": "^7.21.4", "@testing-library/jest-dom": "^6.4.5", @@ -70,15 +85,15 @@ "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.mjs", - "build:types": "run-s build:types:core build:types:solidrouter", + "build:types": "run-s build:types:core build:types:routers", "build:types:core": "tsc -p tsconfig.types.json", - "build:types:solidrouter": "tsc -p tsconfig.solidrouter-types.json", + "build:types:routers": "tsc -p tsconfig.routers-types.json", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "npm pack", - "circularDepCheck": "madge --circular src/index.ts && madge --circular src/solidrouter.ts", + "circularDepCheck": "madge --circular src/index.ts && madge --circular src/solidrouter.ts && madge --circular src/tanstackrouter.ts", "clean": "rimraf build coverage sentry-solid-*.tgz ./*.d.ts ./*.d.ts.map", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", diff --git a/packages/solid/rollup.npm.config.mjs b/packages/solid/rollup.npm.config.mjs index b044fda38c75..4da78623cb50 100644 --- a/packages/solid/rollup.npm.config.mjs +++ b/packages/solid/rollup.npm.config.mjs @@ -2,6 +2,6 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/solidrouter.ts'], + entrypoints: ['src/index.ts', 'src/solidrouter.ts', 'src/tanstackrouter.ts'], }), ); diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts new file mode 100644 index 000000000000..09790530e822 --- /dev/null +++ b/packages/solid/src/tanstackrouter.ts @@ -0,0 +1,126 @@ +import { + browserTracingIntegration as originalBrowserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, + WINDOW, +} from '@sentry/browser'; +import type { Integration } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import type { AnyRouter } from '@tanstack/solid-router'; + +type RouteMatch = ReturnType[number]; + +/** + * A custom browser tracing integration for TanStack Router. + * + * The minimum compatible version of `@tanstack/solid-router` is `1.64.0 + * + * @param router A TanStack Router `Router` instance that should be used for routing instrumentation. + * @param options Sentry browser tracing configuration. + */ +export function tanstackRouterBrowserTracingIntegration( + router: R, + 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); + + const initialWindowLocation = WINDOW.location; + if (instrumentPageLoad && initialWindowLocation) { + const matchedRoutes = router.matchRoutes( + initialWindowLocation.pathname, + router.options.parseSearch(initialWindowLocation.search), + { preload: false, throwOnError: false }, + ); + + const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + + startBrowserTracingPageLoadSpan(client, { + name: lastMatch ? lastMatch.routeId : initialWindowLocation.pathname, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.solid.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: lastMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(lastMatch), + }, + }); + } + + if (instrumentNavigation) { + // The onBeforeNavigate hook is called at the very beginning of a navigation and is only called once per navigation, even when the user is redirected + router.subscribe('onBeforeNavigate', onBeforeNavigateArgs => { + // onBeforeNavigate is called during pageloads. We can avoid creating navigation spans by comparing the states of the to and from arguments. + if (onBeforeNavigateArgs.toLocation.state === onBeforeNavigateArgs.fromLocation?.state) { + return; + } + + const onResolvedMatchedRoutes = router.matchRoutes( + onBeforeNavigateArgs.toLocation.pathname, + onBeforeNavigateArgs.toLocation.search, + { preload: false, throwOnError: false }, + ); + + const onBeforeNavigateLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + + const navigationLocation = WINDOW.location; + const navigationSpan = startBrowserTracingNavigationSpan(client, { + name: onBeforeNavigateLastMatch ? onBeforeNavigateLastMatch.routeId : navigationLocation.pathname, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateLastMatch ? 'route' : 'url', + }, + }); + + // In case the user is redirected during navigation we want to update the span with the right value. + const unsubscribeOnResolved = router.subscribe('onResolved', onResolvedArgs => { + unsubscribeOnResolved(); + if (navigationSpan) { + const onResolvedMatchedRoutes = router.matchRoutes( + onResolvedArgs.toLocation.pathname, + onResolvedArgs.toLocation.search, + { preload: false, throwOnError: false }, + ); + + const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + + if (onResolvedLastMatch) { + navigationSpan.updateName(onResolvedLastMatch.routeId); + navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedLastMatch)); + } + } + }); + }); + } + }, + }; +} + +function routeMatchToParamSpanAttributes(match: RouteMatch | undefined): Record { + if (!match) { + return {}; + } + + const paramAttributes: Record = {}; + Object.entries(match.params as Record).forEach(([key, value]) => { + paramAttributes[`url.path.parameter.${key}`] = value; + paramAttributes[`params.${key}`] = value; // params.[key] is an alias + }); + + return paramAttributes; +} diff --git a/packages/solid/tsconfig.solidrouter-types.json b/packages/solid/tsconfig.routers-types.json similarity index 85% rename from packages/solid/tsconfig.solidrouter-types.json rename to packages/solid/tsconfig.routers-types.json index 055ad82a187a..e173ebc0eb87 100644 --- a/packages/solid/tsconfig.solidrouter-types.json +++ b/packages/solid/tsconfig.routers-types.json @@ -9,7 +9,7 @@ }, "//": "This type is built separately because it is for a subpath export, which has problems if it is not in the root", - "include": ["src/solidrouter.ts"], + "include": ["src/solidrouter.ts", "src/tanstackrouter.ts"], "//": "Without this, we cannot output into the root dir", "exclude": [] } diff --git a/packages/solid/tsconfig.types.json b/packages/solid/tsconfig.types.json index fa96a3ccc08b..510f8c4fae3f 100644 --- a/packages/solid/tsconfig.types.json +++ b/packages/solid/tsconfig.types.json @@ -8,6 +8,6 @@ "outDir": "build/types" }, - "//": "This is built separately in tsconfig.solidrouter-types.json", - "exclude": ["src/solidrouter.ts"] + "//": "This is built separately in tsconfig.routers-types.json", + "exclude": ["src/solidrouter.ts", "src/tanstackrouter.ts"] } diff --git a/yarn.lock b/yarn.lock index 5e9edc00f1df..70c9e3d80b73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5217,6 +5217,11 @@ "@nodelib/fs.scandir" "2.1.4" fastq "^1.6.0" +"@nothing-but/utils@~0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@nothing-but/utils/-/utils-0.17.0.tgz#eab601990c71ef29053ffc484909f2d1f26d88d8" + integrity sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ== + "@npmcli/fs@^2.1.0": version "2.1.2" resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-2.1.2.tgz#a9e2541a4a2fec2e69c29b35e6060973da79b865" @@ -7630,6 +7635,136 @@ resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== +"@solid-devtools/debugger@^0.28.1": + version "0.28.1" + resolved "https://registry.yarnpkg.com/@solid-devtools/debugger/-/debugger-0.28.1.tgz#5c2e9d533ef65ac9debb4b1c3a625c6494f811c6" + integrity sha512-6qIUI6VYkXoRnL8oF5bvh2KgH71qlJ18hNw/mwSyY6v48eb80ZR48/5PDXufUa3q+MBSuYa1uqTMwLewpay9eg== + dependencies: + "@nothing-but/utils" "~0.17.0" + "@solid-devtools/shared" "^0.20.0" + "@solid-primitives/bounds" "^0.1.1" + "@solid-primitives/event-listener" "^2.4.1" + "@solid-primitives/keyboard" "^1.3.1" + "@solid-primitives/rootless" "^1.5.1" + "@solid-primitives/scheduled" "^1.5.1" + "@solid-primitives/static-store" "^0.1.1" + "@solid-primitives/utils" "^6.3.1" + +"@solid-devtools/logger@^0.9.4": + version "0.9.11" + resolved "https://registry.yarnpkg.com/@solid-devtools/logger/-/logger-0.9.11.tgz#a94d8ec640df8887eca7a0aaf8b2788adb244228" + integrity sha512-THbiY1iQlieL6vdgJc4FIsLe7V8a57hod/Thm8zdKrTkWL88UPZjkBBfM+mVNGusd4OCnAN20tIFBhNnuT1Dew== + dependencies: + "@nothing-but/utils" "~0.17.0" + "@solid-devtools/debugger" "^0.28.1" + "@solid-devtools/shared" "^0.20.0" + "@solid-primitives/utils" "^6.3.1" + +"@solid-devtools/shared@^0.20.0": + version "0.20.0" + resolved "https://registry.yarnpkg.com/@solid-devtools/shared/-/shared-0.20.0.tgz#1dff1573ee3bc43acd6d2dc3a2ed3765b20dcd3c" + integrity sha512-o5TACmUOQsxpzpOKCjbQqGk8wL8PMi+frXG9WNu4Lh3PQVUB6hs95Kl/S8xc++zwcMguUKZJn8h5URUiMOca6Q== + dependencies: + "@nothing-but/utils" "~0.17.0" + "@solid-primitives/event-listener" "^2.4.1" + "@solid-primitives/media" "^2.3.1" + "@solid-primitives/refs" "^1.1.1" + "@solid-primitives/rootless" "^1.5.1" + "@solid-primitives/scheduled" "^1.5.1" + "@solid-primitives/static-store" "^0.1.1" + "@solid-primitives/styles" "^0.1.1" + "@solid-primitives/utils" "^6.3.1" + +"@solid-primitives/bounds@^0.1.1": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@solid-primitives/bounds/-/bounds-0.1.3.tgz#6c7cca6fb969281a1ee103efc982cb190358f81c" + integrity sha512-UbiyKMdSPmtijcEDnYLQL3zzaejpwWDAJJ4Gt5P0hgVs6A72piov0GyNw7V2SroH7NZFwxlYS22YmOr8A5xc1Q== + dependencies: + "@solid-primitives/event-listener" "^2.4.3" + "@solid-primitives/resize-observer" "^2.1.3" + "@solid-primitives/static-store" "^0.1.2" + "@solid-primitives/utils" "^6.3.2" + +"@solid-primitives/event-listener@^2.4.1", "@solid-primitives/event-listener@^2.4.3": + version "2.4.3" + resolved "https://registry.yarnpkg.com/@solid-primitives/event-listener/-/event-listener-2.4.3.tgz#e09380222e38ed1b27f3d93bc72e85ba8507b3c0" + integrity sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg== + dependencies: + "@solid-primitives/utils" "^6.3.2" + +"@solid-primitives/keyboard@^1.3.1": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@solid-primitives/keyboard/-/keyboard-1.3.3.tgz#d51ab3c66308c2551d47452ff3dbdbbc0f25c546" + integrity sha512-9dQHTTgLBqyAI7aavtO+HnpTVJgWQA1ghBSrmLtMu1SMxLPDuLfuNr+Tk5udb4AL4Ojg7h9JrKOGEEDqsJXWJA== + dependencies: + "@solid-primitives/event-listener" "^2.4.3" + "@solid-primitives/rootless" "^1.5.2" + "@solid-primitives/utils" "^6.3.2" + +"@solid-primitives/media@^2.3.1": + version "2.3.3" + resolved "https://registry.yarnpkg.com/@solid-primitives/media/-/media-2.3.3.tgz#74d669b6814c30a8308a468cfd7412133ea7d16e" + integrity sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA== + dependencies: + "@solid-primitives/event-listener" "^2.4.3" + "@solid-primitives/rootless" "^1.5.2" + "@solid-primitives/static-store" "^0.1.2" + "@solid-primitives/utils" "^6.3.2" + +"@solid-primitives/refs@^1.0.8", "@solid-primitives/refs@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@solid-primitives/refs/-/refs-1.1.2.tgz#1a37a825754bc8fe7f8845fc0c7664683646288e" + integrity sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg== + dependencies: + "@solid-primitives/utils" "^6.3.2" + +"@solid-primitives/resize-observer@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@solid-primitives/resize-observer/-/resize-observer-2.1.3.tgz#459db96f9c4a3d98a194d940c6e69f3ad4b2dad8" + integrity sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ== + dependencies: + "@solid-primitives/event-listener" "^2.4.3" + "@solid-primitives/rootless" "^1.5.2" + "@solid-primitives/static-store" "^0.1.2" + "@solid-primitives/utils" "^6.3.2" + +"@solid-primitives/rootless@^1.5.1", "@solid-primitives/rootless@^1.5.2": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@solid-primitives/rootless/-/rootless-1.5.2.tgz#0a9243a977672169cb8ed43bf4eba0c4d8eb5ac5" + integrity sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ== + dependencies: + "@solid-primitives/utils" "^6.3.2" + +"@solid-primitives/scheduled@^1.5.1": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@solid-primitives/scheduled/-/scheduled-1.5.2.tgz#616def57b9250bc0e4415c604b31ab5a71465632" + integrity sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA== + +"@solid-primitives/static-store@^0.1.1", "@solid-primitives/static-store@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@solid-primitives/static-store/-/static-store-0.1.2.tgz#acdbecee75f17a5b64416859082fca67eefbaaaa" + integrity sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw== + dependencies: + "@solid-primitives/utils" "^6.3.2" + +"@solid-primitives/styles@^0.1.1": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@solid-primitives/styles/-/styles-0.1.2.tgz#282fbf8d37add03873fd67b692b2e03ad412581e" + integrity sha512-7iX5K+J5b1PRrbgw3Ki92uvU2LgQ0Kd/QMsrAZxDg5dpUBwMyTijZkA3bbs1ikZsT1oQhS41bTyKbjrXeU0Awg== + dependencies: + "@solid-primitives/rootless" "^1.5.2" + "@solid-primitives/utils" "^6.3.2" + +"@solid-primitives/utils@^6.3.1", "@solid-primitives/utils@^6.3.2": + version "6.3.2" + resolved "https://registry.yarnpkg.com/@solid-primitives/utils/-/utils-6.3.2.tgz#13d6126fc5a498965d7c45dd41c052e42dcfd7e1" + integrity sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ== + +"@solidjs/meta@^0.29.4": + version "0.29.4" + resolved "https://registry.yarnpkg.com/@solidjs/meta/-/meta-0.29.4.tgz#28a444db5200d1c9e4e62d8762ea808d3e8beffd" + integrity sha512-zdIWBGpR9zGx1p1bzIPqF5Gs+Ks/BH8R6fWhmUa/dcK1L2rUC8BAcZJzNRYBQv74kScf1TSOs0EY//Vd/I0V8g== + "@solidjs/router@^0.13.4": version "0.13.6" resolved "https://registry.yarnpkg.com/@solidjs/router/-/router-0.13.6.tgz#210ca2761d4bf294f06ac0f9e25c16fafdabefac" @@ -7793,6 +7928,56 @@ dependencies: defer-to-connect "^1.0.1" +"@tanstack/history@1.132.21": + version "1.132.21" + resolved "https://registry.yarnpkg.com/@tanstack/history/-/history-1.132.21.tgz#09ae649b0c0c2d1093f0b1e34b9ab0cd3b2b1d2f" + integrity sha512-5ziPz3YarKU5cBJoEJ4muV8cy+5W4oWdJMqW7qosMrK5fb9Qfm+QWX+kO3emKJMu4YOUofVu3toEuuD3x1zXKw== + +"@tanstack/router-core@1.132.27": + version "1.132.27" + resolved "https://registry.yarnpkg.com/@tanstack/router-core/-/router-core-1.132.27.tgz#8869e98d10ea42338cb115af45bdcbc10eaf2b7f" + integrity sha512-mNx+nba7mXc7sJdX+kYH4rSW8f7Jx/+0hPOkX4XAnqiq7I1ng3gGqmGuf4+2BYTG2aD+aTSPExUPczy9VNgRfQ== + dependencies: + "@tanstack/history" "1.132.21" + "@tanstack/store" "^0.7.0" + cookie-es "^2.0.0" + seroval "^1.3.2" + seroval-plugins "^1.3.2" + tiny-invariant "^1.3.3" + tiny-warning "^1.0.3" + +"@tanstack/solid-router@^1.132.27": + version "1.132.27" + resolved "https://registry.yarnpkg.com/@tanstack/solid-router/-/solid-router-1.132.27.tgz#cafa331a8190fb6775f3cd3b88f31adce82e8cc8" + integrity sha512-d1JfRvl53wJpoOsqStSX5ATCWegSWo7ygrwT+uRvXIebG3fsriGHWkL0u39U515fIYX9Br3PU2iKNk5eShCgtA== + dependencies: + "@solid-devtools/logger" "^0.9.4" + "@solid-primitives/refs" "^1.0.8" + "@solidjs/meta" "^0.29.4" + "@tanstack/history" "1.132.21" + "@tanstack/router-core" "1.132.27" + "@tanstack/solid-store" "0.7.0" + isbot "^5.1.22" + tiny-invariant "^1.3.3" + tiny-warning "^1.0.3" + +"@tanstack/solid-store@0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@tanstack/solid-store/-/solid-store-0.7.0.tgz#4fd8172bb8ba6f8438ddaa5e1ed1fc8afa14a5a1" + integrity sha512-uDQYkUuH3MppitiduZLTEcItkTr8vEJ33jzp2rH2VvlNRMGbuU54GQcqf3dLIlTbZ1/Z2TtIBtBjjl+N/OhwRg== + dependencies: + "@tanstack/store" "0.7.0" + +"@tanstack/store@0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@tanstack/store/-/store-0.7.0.tgz#afef29b06c6b592e93181cee9baa62fe77454459" + integrity sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg== + +"@tanstack/store@^0.7.0": + version "0.7.7" + resolved "https://registry.yarnpkg.com/@tanstack/store/-/store-0.7.7.tgz#2c8b1d8c094f3614ae4e0483253239abd0e14488" + integrity sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ== + "@testing-library/dom@^7.21.4": version "7.31.2" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.2.tgz#df361db38f5212b88555068ab8119f5d841a8c4a" @@ -19847,6 +20032,11 @@ isbinaryfile@^5.0.0: resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-5.0.0.tgz#034b7e54989dab8986598cbcea41f66663c65234" integrity sha512-UDdnyGvMajJUWCkib7Cei/dvyJrrvo4FIrsvSFWdPpXSUorzXrDJ0S+X5Q4ZlasfPjca4yqCNNsjbCeiy8FFeg== +isbot@^5.1.22: + version "5.1.31" + resolved "https://registry.yarnpkg.com/isbot/-/isbot-5.1.31.tgz#ecbab171da577002c66f9123fe180c1e795e4e4e" + integrity sha512-DPgQshehErHAqSCKDb3rNW03pa2wS/v5evvUqtxt6TTnHRqAG8FdzcSSJs9656pK6Y+NT7K9R4acEYXLHYfpUQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -27534,12 +27724,12 @@ serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^ dependencies: randombytes "^2.1.0" -seroval-plugins@^1.0.2, seroval-plugins@~1.3.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/seroval-plugins/-/seroval-plugins-1.3.2.tgz#4200b538d699853c9bf5c3b7155c498c7c263a6a" - integrity sha512-0QvCV2lM3aj/U3YozDiVwx9zpH0q8A60CTWIv4Jszj/givcudPb48B+rkU5D51NJ0pTpweGMttHjboPa9/zoIQ== +seroval-plugins@^1.0.2, seroval-plugins@^1.3.2, seroval-plugins@~1.3.0: + version "1.3.3" + resolved "https://registry.yarnpkg.com/seroval-plugins/-/seroval-plugins-1.3.3.tgz#51bcacf09e5384080d7ea4002b08fd9f6166daf5" + integrity sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w== -seroval@^1.0.2, seroval@~1.3.0: +seroval@^1.0.2, seroval@^1.3.2, seroval@~1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/seroval/-/seroval-1.3.2.tgz#7e5be0dc1a3de020800ef013ceae3a313f20eca7" integrity sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ== @@ -29359,7 +29549,7 @@ tiny-lr@^2.0.0: object-assign "^4.1.0" qs "^6.4.0" -tiny-warning@^1.0.0: +tiny-warning@^1.0.0, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== From fdbce161c37112c08516dd08be7907298bfa9507 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 15 Oct 2025 13:32:03 +0200 Subject: [PATCH 15/20] chore(ci): Update Next.js canary testing (#17939) next@canary now resolves to next 16 which is why we need to update the testing strategy here --- .github/workflows/canary.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index fbf476c369a4..29814ffea09c 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -76,11 +76,8 @@ jobs: build-command: 'test:build-canary' label: 'create-react-app (canary)' - test-application: 'nextjs-app-dir' - build-command: 'test:build-canary' - label: 'nextjs-app-dir (canary)' - - test-application: 'nextjs-app-dir' - build-command: 'test:build-latest' - label: 'nextjs-app-dir (latest)' + build-command: 'test:build-15' + label: 'nextjs-app-dir (next@15)' - test-application: 'nextjs-13' build-command: 'test:build-latest' label: 'nextjs-13 (latest)' @@ -90,12 +87,15 @@ jobs: - test-application: 'nextjs-14' build-command: 'test:build-latest' label: 'nextjs-14 (latest)' - - test-application: 'nextjs-15' - build-command: 'test:build-canary' - label: 'nextjs-15 (canary)' - test-application: 'nextjs-15' build-command: 'test:build-latest' label: 'nextjs-15 (latest)' + - test-application: 'nextjs-16' + build-command: 'test:build-canary' + label: 'nextjs-16 (canary)' + - test-application: 'nextjs-16' + build-command: 'test:build-canary-webpack' + label: 'nextjs-16 (canary-webpack)' - test-application: 'nextjs-turbo' build-command: 'test:build-canary' label: 'nextjs-turbo (canary)' From e2fe6866857e55caef07bbb280725fcec62d321c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 15 Oct 2025 13:36:26 +0200 Subject: [PATCH 16/20] chore: Bump size limit (#17941) --- .size-limit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.size-limit.js b/.size-limit.js index 5ccf34d416c0..5b8374f81615 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -183,7 +183,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '123 KB', + limit: '124 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', From 05122e09983b30f5f5259de6beaf6262172dfa68 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 15 Oct 2025 13:38:18 +0200 Subject: [PATCH 17/20] chore: Add external contributor to CHANGELOG.md (#17940) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #17735 --------- Co-authored-by: andreiborza <168741329+andreiborza@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1415d2a3941c..1bfc1edf75a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @seoyeon9888 and @madhuchavva. Thank you for your contributions! +Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez . Thank you for your contributions! ## 10.19.0 From fc64c475dfc29f4ba671e08a872a2cc05b6ef5a7 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 15 Oct 2025 12:51:24 +0100 Subject: [PATCH 18/20] fix(react): Add `POP` guard for long-running `pageload` spans (#17867) This resolves the issue that occurs when an extra `navigation` transaction is created after a prematurely ended `pageload` transaction in React Router lazy routes. This apparently occurs when there's a long-running pageload with lazy-routes (after fetching assets, there are multiple potentially long-running API calls happening). This causes the `pageload` transaction to prematurely end, even before the fully parameterized transaction name is resolved. The reason is that there can be a `POP` event emitted, which we subscribe to create a `navigation` transaction. This ends the ongoing `pageload` transaction before its name is updated with a resolved parameterized route path, and starts a `navigation` transaction, which contains the remaining spans that were supposed to be a part of the `pageload` transaction. This fix makes sure the initial `POP` events are not necessarily treated as `navigation` pointers, which should fix both: - Duplicate / extra `navigation` transactions having a part of `pageload` spans. - Remaining wildcards in the `pageload` transaction names --- .../react-router-7-lazy-routes/src/index.tsx | 6 + .../src/pages/Index.tsx | 4 + .../src/pages/LongRunningLazyRoutes.tsx | 49 +++++++ .../tests/transactions.test.ts | 83 +++++++++++ .../instrumentation.tsx | 134 ++++++++++++++---- 5 files changed, 248 insertions(+), 28 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LongRunningLazyRoutes.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx index 2c960db9c16b..521048fd18f4 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/index.tsx @@ -55,6 +55,12 @@ const router = sentryCreateBrowserRouter( lazyChildren: () => import('./pages/AnotherLazyRoutes').then(module => module.anotherNestedRoutes), }, }, + { + path: '/long-running', + handle: { + lazyChildren: () => import('./pages/LongRunningLazyRoutes').then(module => module.longRunningNestedRoutes), + }, + }, { path: '/static', element: <>Hello World, diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx index aefa39d63811..3053aa57b887 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Index.tsx @@ -15,6 +15,10 @@ const Index = () => { Navigate to Another Deep Lazy Route +
+ + Navigate to Long Running Lazy Route + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LongRunningLazyRoutes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LongRunningLazyRoutes.tsx new file mode 100644 index 000000000000..416fb1e162f8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/LongRunningLazyRoutes.tsx @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; + +// Component that simulates a long-running component load +// This is used to test the POP guard during long-running pageloads +const SlowLoadingComponent = () => { + const { id } = useParams<{ id: string }>(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Simulate a component that takes time to initialize + // This extends the pageload duration to create a window where POP events might occur + setTimeout(() => { + setData(`Data loaded for ID: ${id}`); + setIsLoading(false); + }, 1000); + }, [id]); + + if (isLoading) { + return
Loading...
; + } + + return ( +
+
{data}
+ + Go Home + +
+ ); +}; + +export const longRunningNestedRoutes = [ + { + path: 'slow', + children: [ + { + path: ':id', + element: , + loader: async () => { + // Simulate slow data fetching in the loader + await new Promise(resolve => setTimeout(resolve, 2000)); + return null; + }, + }, + ], + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts index 34e5105f8f9d..59d43c14ae95 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/transactions.test.ts @@ -294,3 +294,86 @@ test('Does not send any duplicate navigation transaction names browsing between '/lazy/inner/:id/:anotherId', ]); }); + +test('Does not create premature navigation transaction during long-running lazy route pageload', async ({ page }) => { + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('long-running') + ); + }); + + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/long-running/slow/:id' + ); + }); + + await page.goto('/long-running/slow/12345'); + + const pageloadEvent = await pageloadPromise; + + expect(pageloadEvent.transaction).toBe('/long-running/slow/:id'); + expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); + + const slowLoadingContent = page.locator('id=slow-loading-content'); + await expect(slowLoadingContent).toBeVisible({ timeout: 5000 }); + + const result = await Promise.race([ + navigationPromise.then(() => 'navigation'), + new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 2000)), + ]); + + // Should timeout, meaning no unwanted navigation transaction was created + expect(result).toBe('timeout'); +}); + +test('Allows legitimate POP navigation (back/forward) after pageload completes', async ({ page }) => { + await page.goto('/'); + + const navigationToLongRunning = page.locator('id=navigation-to-long-running'); + await expect(navigationToLongRunning).toBeVisible(); + + const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/long-running/slow/:id' + ); + }); + + await navigationToLongRunning.click(); + + const slowLoadingContent = page.locator('id=slow-loading-content'); + await expect(slowLoadingContent).toBeVisible({ timeout: 5000 }); + + const firstNavigationEvent = await firstNavigationPromise; + + expect(firstNavigationEvent.transaction).toBe('/long-running/slow/:id'); + expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); + + // Now navigate back using browser back button (POP event) + // This should create a navigation transaction since pageload is complete + const backNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/' + ); + }); + + await page.goBack(); + + // Verify we're back at home + const homeLink = page.locator('id=navigation'); + await expect(homeLink).toBeVisible(); + + const backNavigationEvent = await backNavigationPromise; + + // Validate that the back navigation (POP) was properly tracked + expect(backNavigationEvent.transaction).toBe('/'); + expect(backNavigationEvent.contexts?.trace?.op).toBe('navigation'); +}); diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 10db32231195..bf57fdbd74dc 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -241,6 +241,12 @@ export function createV6CompatibleWrapCreateBrowserRouter< const activeRootSpan = getActiveRootSpan(); + // Track whether we've completed the initial pageload to properly distinguish + // between POPs that occur during pageload vs. legitimate back/forward navigation. + let isInitialPageloadComplete = false; + let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload'; + let hasSeenPopAfterPageload = false; + // The initial load ends when `createBrowserRouter` is called. // This is the earliest convenient time to update the transaction name. // Callbacks to `router.subscribe` are not called for the initial load. @@ -255,20 +261,31 @@ export function createV6CompatibleWrapCreateBrowserRouter< } router.subscribe((state: RouterState) => { - if (state.historyAction === 'PUSH' || state.historyAction === 'POP') { - // Wait for the next render if loading an unsettled route - if (state.navigation.state !== 'idle') { - requestAnimationFrame(() => { - handleNavigation({ - location: state.location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); - }); - } else { + // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation + if (!isInitialPageloadComplete) { + const currentRootSpan = getActiveRootSpan(); + const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload'; + + if (isCurrentlyInPageload) { + hasSeenPageloadSpan = true; + } else if (hasSeenPageloadSpan) { + // Pageload span was active but is now gone - pageload has completed + if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) { + // Pageload ended: ignore the first POP after pageload + hasSeenPopAfterPageload = true; + } else { + // Pageload ended: either non-POP action or subsequent POP + isInitialPageloadComplete = true; + } + } + // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) + } + + const shouldHandleNavigation = + state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); + + if (shouldHandleNavigation) { + const navigationHandler = (): void => { handleNavigation({ location: state.location, routes, @@ -277,6 +294,13 @@ export function createV6CompatibleWrapCreateBrowserRouter< basename, allRoutes: Array.from(allRoutes), }); + }; + + // Wait for the next render if loading an unsettled route + if (state.navigation.state !== 'idle') { + requestAnimationFrame(navigationHandler); + } else { + navigationHandler(); } } }); @@ -327,7 +351,6 @@ export function createV6CompatibleWrapCreateMemoryRouter< const router = createRouterFunction(routes, wrappedOpts); const basename = opts?.basename; - const activeRootSpan = getActiveRootSpan(); let initialEntry = undefined; const initialEntries = opts?.initialEntries; @@ -348,21 +371,68 @@ export function createV6CompatibleWrapCreateMemoryRouter< : initialEntry : router.state.location; - if (router.state.historyAction === 'POP' && activeRootSpan) { - updatePageloadTransaction({ activeRootSpan, location, routes, basename, allRoutes: Array.from(allRoutes) }); + const memoryActiveRootSpan = getActiveRootSpan(); + + if (router.state.historyAction === 'POP' && memoryActiveRootSpan) { + updatePageloadTransaction({ + activeRootSpan: memoryActiveRootSpan, + location, + routes, + basename, + allRoutes: Array.from(allRoutes), + }); } + // Track whether we've completed the initial pageload to properly distinguish + // between POPs that occur during pageload vs. legitimate back/forward navigation. + let isInitialPageloadComplete = false; + let hasSeenPageloadSpan = !!memoryActiveRootSpan && spanToJSON(memoryActiveRootSpan).op === 'pageload'; + let hasSeenPopAfterPageload = false; + router.subscribe((state: RouterState) => { + // Track pageload completion to distinguish POPs during pageload from legitimate back/forward navigation + if (!isInitialPageloadComplete) { + const currentRootSpan = getActiveRootSpan(); + const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload'; + + if (isCurrentlyInPageload) { + hasSeenPageloadSpan = true; + } else if (hasSeenPageloadSpan) { + // Pageload span was active but is now gone - pageload has completed + if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) { + // Pageload ended: ignore the first POP after pageload + hasSeenPopAfterPageload = true; + } else { + // Pageload ended: either non-POP action or subsequent POP + isInitialPageloadComplete = true; + } + } + // If we haven't seen a pageload span yet, keep waiting (don't mark as complete) + } + const location = state.location; - if (state.historyAction === 'PUSH' || state.historyAction === 'POP') { - handleNavigation({ - location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); + + const shouldHandleNavigation = + state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); + + if (shouldHandleNavigation) { + const navigationHandler = (): void => { + handleNavigation({ + location, + routes, + navigationType: state.historyAction, + version, + basename, + allRoutes: Array.from(allRoutes), + }); + }; + + // Wait for the next render if loading an unsettled route + if (state.navigation.state !== 'idle') { + requestAnimationFrame(navigationHandler); + } else { + navigationHandler(); + } } }); @@ -532,8 +602,16 @@ function wrapPatchRoutesOnNavigation( // Update navigation span after routes are patched const activeRootSpan = getActiveRootSpan(); if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { - // For memory routers, we should not access window.location; use targetPath only - const pathname = isMemoryRouter ? targetPath : targetPath || WINDOW.location?.pathname; + // Determine pathname based on router type + let pathname: string | undefined; + if (isMemoryRouter) { + // For memory routers, only use targetPath + pathname = targetPath; + } else { + // For browser routers, use targetPath or fall back to window.location + pathname = targetPath || WINDOW.location?.pathname; + } + if (pathname) { updateNavigationSpan( activeRootSpan, From e557d380b161e39e236348299786881f88e24b19 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 15 Oct 2025 14:02:05 +0200 Subject: [PATCH 19/20] fix(tracemetrics): Send boolean for internal replay attribute (#17908) --- packages/core/src/metrics/internal.ts | 2 +- packages/core/test/lib/metrics/internal.test.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index f16352523700..676814f4d4e6 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -172,7 +172,7 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal if (replayId && replay?.getRecordingMode() === 'buffer') { // We send this so we can identify cases where the replayId is attached but the replay itself might not have been sent to Sentry - setMetricAttribute(processedMetricAttributes, 'sentry._internal.replay_is_buffering', replayId); + setMetricAttribute(processedMetricAttributes, 'sentry._internal.replay_is_buffering', true); } const metric: Metric = { diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index 33f5bb0de3ae..bb2ddcc413c3 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -450,8 +450,8 @@ describe('_INTERNAL_captureMetric', () => { type: 'string', }, 'sentry._internal.replay_is_buffering': { - value: 'buffer-replay-id', - type: 'string', + value: true, + type: 'boolean', }, }); }); @@ -577,8 +577,8 @@ describe('_INTERNAL_captureMetric', () => { type: 'string', }, 'sentry._internal.replay_is_buffering': { - value: 'buffer-replay-id', - type: 'string', + value: true, + type: 'boolean', }, }); }); @@ -736,8 +736,8 @@ describe('_INTERNAL_captureMetric', () => { type: 'string', }, 'sentry._internal.replay_is_buffering': { - value: 'buffer-replay-id', - type: 'string', + value: true, + type: 'boolean', }, }); }); From baa7a0663c801193c2fee43c38de5521e53f9ba5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 15 Oct 2025 16:04:11 +0200 Subject: [PATCH 20/20] meta(changelog): Update changelog for 10.20.0 --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bfc1edf75a8..a14433358d0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.20.0 + +### Important Changes + +- **feat(flags): Add Growthbook integration ([#17440](https://github.com/getsentry/sentry-javascript/pull/17440))** + + Adds a new Growthbook integration for feature flag support. + +- **feat(solid): Add support for TanStack Router Solid ([#17735](https://github.com/getsentry/sentry-javascript/pull/17735))** + + Adds support for TanStack Router in the Solid SDK, enabling better routing instrumentation for Solid applications. + +- **feat(nextjs): Support native debugIds in turbopack ([#17853](https://github.com/getsentry/sentry-javascript/pull/17853))** + + Adds support for native Debug IDs in Turbopack, improving source map resolution and error tracking for Next.js applications using Turbopack. Native Debug ID generation will be enabled automatically for compatible versions. + +### Other Changes + +- feat(nextjs): Prepare for next 16 bundler default ([#17868](https://github.com/getsentry/sentry-javascript/pull/17868)) +- feat(node): Capture `pino` logger name ([#17930](https://github.com/getsentry/sentry-javascript/pull/17930)) +- fix(browser): Ignore React 19.2+ component render measure entries ([#17905](https://github.com/getsentry/sentry-javascript/pull/17905)) +- fix(nextjs): Fix createRouteManifest with basePath ([#17838](https://github.com/getsentry/sentry-javascript/pull/17838)) +- fix(react): Add `POP` guard for long-running `pageload` spans ([#17867](https://github.com/getsentry/sentry-javascript/pull/17867)) +- fix(tracemetrics): Send boolean for internal replay attribute ([#17908](https://github.com/getsentry/sentry-javascript/pull/17908)) +- ref(core): Add weight tracking logic to browser logs/metrics ([#17901](https://github.com/getsentry/sentry-javascript/pull/17901)) + +
+ Internal Changes +- chore(nextjs): Add Next.js 16 peer dependency ([#17925](https://github.com/getsentry/sentry-javascript/pull/17925)) +- chore(ci): Update Next.js canary testing ([#17939](https://github.com/getsentry/sentry-javascript/pull/17939)) +- chore: Bump size limit ([#17941](https://github.com/getsentry/sentry-javascript/pull/17941)) +- test(nextjs): Add next@16 e2e test ([#17922](https://github.com/getsentry/sentry-javascript/pull/17922)) +- test(nextjs): Update next 15 tests ([#17919](https://github.com/getsentry/sentry-javascript/pull/17919)) +- chore: Add external contributor to CHANGELOG.md ([#17915](https://github.com/getsentry/sentry-javascript/pull/17915)) +- chore: Add external contributor to CHANGELOG.md ([#17928](https://github.com/getsentry/sentry-javascript/pull/17928)) +- chore: Add external contributor to CHANGELOG.md ([#17940](https://github.com/getsentry/sentry-javascript/pull/17940)) +
+ Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez . Thank you for your contributions! ## 10.19.0
+ logo +

+ Edit src/App.tsx and save to reload. +

+
+ Learn Solid + + + Learn TanStack + +