diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.gitignore new file mode 100644 index 000000000000..dd146b53d966 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.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-cacheComponents/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/.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-16-cacheComponents/app/cache/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cache/page.tsx new file mode 100644 index 000000000000..6cf490b75430 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/cache/page.tsx @@ -0,0 +1,25 @@ +import { Suspense } from 'react'; +import * as Sentry from '@sentry/nextjs'; + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + 'use cache'; + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/favicon.ico new file mode 100644 index 000000000000..718d6fea4835 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/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-cacheComponents/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/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-cacheComponents/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx new file mode 100644 index 000000000000..433db8283beb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Next 16 CacheComponents test app

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/suspense/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/suspense/page.tsx new file mode 100644 index 000000000000..32e5a73afc14 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/app/suspense/page.tsx @@ -0,0 +1,26 @@ +import { Suspense } from 'react'; +import * as Sentry from '@sentry/nextjs'; + +export default function Page() { + return ( + <> +

This will be pre-rendered

+ Loading...}> + + + + ); +} + +async function DynamicContent() { + const getTodos = async () => { + return Sentry.startSpan({ name: 'getTodos', op: 'get.todos' }, async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + return [1, 2, 3, 4, 5]; + }); + }; + + const todos = await getTodos(); + + return
Todos fetched: {todos.length}
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/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-cacheComponents/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/instrumentation-client.ts new file mode 100644 index 000000000000..4870c64e7959 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/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-cacheComponents/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/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-cacheComponents/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/next.config.ts new file mode 100644 index 000000000000..2841f1c0c5da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/next.config.ts @@ -0,0 +1,10 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + cacheComponents: true, +}; + +export default withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json new file mode 100644 index 000000000000..de2d67b0ed4b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json @@ -0,0 +1,51 @@ +{ + "name": "nextjs-16-cacheComponents", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "dev:webpack": "next dev --webpack", + "build-webpack": "next build --webpack", + "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-latest": "pnpm install && pnpm add next@latest && pnpm build", + "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", + "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 || *", + "@sentry/core": "latest || *", + "import-in-the-middle": "^1", + "next": "16.0.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": { + "//": "TODO: Add variants for webpack once supported" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/playwright.config.mjs new file mode 100644 index 000000000000..797418b8cf7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/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-cacheComponents/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/proxy.ts @@ -0,0 +1,24 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/sentry.edge.config.ts new file mode 100644 index 000000000000..2199afc46eaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/sentry.edge.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, + // debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/sentry.server.config.ts new file mode 100644 index 000000000000..08d5d580b314 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/sentry.server.config.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, + // debug: true, + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/start-event-proxy.mjs new file mode 100644 index 000000000000..f0fae444f2eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/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-cacheComponents', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-16-cacheComponents-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts new file mode 100644 index 000000000000..9f7b0ca559be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheComponents.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should render cached component', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/cache'); + const serverTx = await serverTxPromise; + + // we want to skip creating spans in cached environments + expect(serverTx.spans?.filter(span => span.op === 'get.todos')).toHaveLength(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); +}); + +test('Should render suspense component', async ({ page }) => { + const serverTxPromise = waitForTransaction('nextjs-16-cacheComponents', async transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server'; + }); + + await page.goto('/suspense'); + const serverTx = await serverTxPromise; + + // this will be called several times in development mode, so we need to check for at least one span + expect(serverTx.spans?.filter(span => span.op === 'get.todos').length).toBeGreaterThan(0); + await expect(page.locator('#todos-fetched')).toHaveText('Todos fetched: 5'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/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/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index a171652b7221..9cc8d3b10bfe 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -14,6 +14,9 @@ import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; export * from '../common'; export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; + +// Override core span methods with Next.js-specific implementations that support Cache Components +export { startSpan, startSpanManual, startInactiveSpan } from '../common/utils/nextSpan'; export { browserTracingIntegration } from './browserTracingIntegration'; export { captureRouterTransitionStart } from './routing/appRouterRoutingInstrumentation'; diff --git a/packages/nextjs/src/common/utils/isUseCacheFunction.ts b/packages/nextjs/src/common/utils/isUseCacheFunction.ts new file mode 100644 index 000000000000..fb392c18c125 --- /dev/null +++ b/packages/nextjs/src/common/utils/isUseCacheFunction.ts @@ -0,0 +1,58 @@ +// Vendored from: https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/client-and-server-references.ts + +interface ServerReferenceInfo { + type: 'server-action' | 'use-cache'; + usedArgs: [boolean, boolean, boolean, boolean, boolean, boolean]; + hasRestArgs: boolean; +} + +export interface ServerReference { + $$typeof: symbol; + $$id: string; +} + +export type ServerFunction = ServerReference & ((...args: unknown[]) => Promise); + +function extractInfoFromServerReferenceId(id: string): ServerReferenceInfo { + const infoByte = parseInt(id.slice(0, 2), 16); + // eslint-disable-next-line no-bitwise + const typeBit = (infoByte >> 7) & 0x1; + // eslint-disable-next-line no-bitwise + const argMask = (infoByte >> 1) & 0x3f; + // eslint-disable-next-line no-bitwise + const restArgs = infoByte & 0x1; + const usedArgs = Array(6); + + for (let index = 0; index < 6; index++) { + const bitPosition = 5 - index; + // eslint-disable-next-line no-bitwise + const bit = (argMask >> bitPosition) & 0x1; + usedArgs[index] = bit === 1; + } + + return { + type: typeBit === 1 ? 'use-cache' : 'server-action', + usedArgs: usedArgs as [boolean, boolean, boolean, boolean, boolean, boolean], + hasRestArgs: restArgs === 1, + }; +} + +function isServerReference(value: T & Partial): value is T & ServerFunction { + return value.$$typeof === Symbol.for('react.server.reference'); +} + +/** + * Check if the function is a use cache function. + * + * @param value - The function to check. + * @returns true if the function is a use cache function, false otherwise. + */ +export function isUseCacheFunction(value: T & Partial): value is T & ServerFunction { + if (!isServerReference(value)) { + return false; + } + + const { type } = extractInfoFromServerReferenceId(value.$$id); + + return type === 'use-cache'; +} diff --git a/packages/nextjs/src/common/utils/nextSpan.ts b/packages/nextjs/src/common/utils/nextSpan.ts new file mode 100644 index 000000000000..1c9c47119a6d --- /dev/null +++ b/packages/nextjs/src/common/utils/nextSpan.ts @@ -0,0 +1,83 @@ +import type { Span, StartSpanOptions } from '@sentry/core'; +import { + debug, + SentryNonRecordingSpan, + startInactiveSpan as coreStartInactiveSpan, + startSpan as coreStartSpan, + startSpanManual as coreStartSpanManual, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { isBuild } from './isBuild'; +import type { ServerReference } from './isUseCacheFunction'; +import { isUseCacheFunction } from './isUseCacheFunction'; + +function shouldNoopSpan(callback?: T & Partial): boolean { + const isBuildContext = isBuild(); + const isUseCacheFunctionContext = callback ? isUseCacheFunction(callback) : false; + + if (isUseCacheFunctionContext) { + DEBUG_BUILD && debug.log('Skipping span creation in Cache Components context'); + } + + return isBuildContext || isUseCacheFunctionContext; +} + +function createNonRecordingSpan(): Span { + return new SentryNonRecordingSpan({ + traceId: '00000000000000000000000000000000', + spanId: '0000000000000000', + }); +} + +/** + * Next.js-specific implementation of `startSpan` that skips span creation + * in Cache Components contexts (which render at build time). + * + * When in a Cache Components context, we execute the callback with a non-recording span + * and return early without creating an actual span, since spans don't make sense at build/cache time. + * + * @param options - Options for starting the span + * @param callback - Callback function that receives the span + * @returns The return value of the callback + */ +export function startSpan(options: StartSpanOptions, callback: (span: Span) => T): T { + if (shouldNoopSpan(callback)) { + return callback(createNonRecordingSpan()); + } + + return coreStartSpan(options, callback); +} + +/** + * + * When in a Cache Components context, we execute the callback with a non-recording span + * and return early without creating an actual span, since spans don't make sense at build/cache time. + * + * @param options - Options for starting the span + * @param callback - Callback function that receives the span and finish function + * @returns The return value of the callback + */ +export function startSpanManual(options: StartSpanOptions, callback: (span: Span, finish: () => void) => T): T { + if (shouldNoopSpan(callback)) { + const nonRecordingSpan = createNonRecordingSpan(); + return callback(nonRecordingSpan, () => nonRecordingSpan.end()); + } + + return coreStartSpanManual(options, callback); +} + +/** + * + * When in a Cache Components context, we return a non-recording span and return early + * without creating an actual span, since spans don't make sense at build/cache time. + * + * @param options - Options for starting the span + * @returns A non-recording span (in Cache Components context) or the created span + */ +export function startInactiveSpan(options: StartSpanOptions): Span { + if (shouldNoopSpan()) { + return createNonRecordingSpan(); + } + + return coreStartInactiveSpan(options); +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 091adab98dee..2232d259ec11 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -31,6 +31,9 @@ import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegrati export * from '@sentry/vercel-edge'; export * from '../common'; export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; + +// Override core span methods with Next.js-specific implementations that support Cache Components +export { startSpan, startSpanManual, startInactiveSpan } from '../common/utils/nextSpan'; export { wrapApiHandlerWithSentry } from './wrapApiHandlerWithSentry'; export type EdgeOptions = VercelEdgeOptions; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index caec9a9f1af1..ca387d752a1d 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -47,6 +47,9 @@ export * from '@sentry/node'; export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; +// Override core span methods with Next.js-specific implementations that support Cache Components +export { startSpan, startSpanManual, startInactiveSpan } from '../common/utils/nextSpan'; + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRewritesTunnelPath?: string;