From 4c85627c6878367e5a8dd2c23e88dd080b3aea87 Mon Sep 17 00:00:00 2001 From: seoyeonkim Date: Mon, 13 Oct 2025 17:54:46 +0900 Subject: [PATCH 001/190] 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 002/190] 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 003/190] 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 004/190] 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 27183d54031909aa474b4bfe893c10c2e2509e3d Mon Sep 17 00:00:00 2001 From: Guillaume M Date: Tue, 14 Oct 2025 09:32:59 +0200 Subject: [PATCH 005/190] docs: Update supported Angular version in README --- packages/angular/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/angular/README.md b/packages/angular/README.md index 384c4c2d48c8..f686fb266b23 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -16,7 +16,7 @@ ## Angular Version Compatibility -This SDK officially supports Angular 14 to 19. +This SDK officially supports Angular 14 to 20. If you're using an older Angular version please check the [compatibility table in the docs](https://docs.sentry.io/platforms/javascript/guides/angular/#angular-version-compatibility). From fd8bcbd7a4a7a319ff53026e4e5104551f1c5f6d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 14 Oct 2025 10:24:47 +0200 Subject: [PATCH 006/190] 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 007/190] 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 008/190] 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 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef 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 009/190] 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 010/190] 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 011/190] 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 012/190] 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 013/190] 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 014/190] 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 015/190] 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 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K 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 zcmZWtbyO6NvR-oO24RV%BvuJ&=?+<7=`LvyB&A_#M7mSDYw1v6DJkiYl9XjT!%$dLEBTQ8R9|wd3008in6lFF3GV-6mLi?MoP_y~}QUnaDCHI#t z7w^m$@6DI)|C8_jrT?q=f8D?0AM?L)Z}xAo^e^W>t$*Y0KlT5=@bBjT9kxb%-KNdk zeOS1tKO#ChhG7%{ApNBzE2ZVNcxbrin#E1TiAw#BlUhXllzhN$qWez5l;h+t^q#Eav8PhR2|T}y5kkflaK`ba-eoE+Z2q@o6P$)=&` z+(8}+-McnNO>e#$Rr{32ngsZIAX>GH??tqgwUuUz6kjns|LjsB37zUEWd|(&O!)DY zQLrq%Y>)Y8G`yYbYCx&aVHi@-vZ3|ebG!f$sTQqMgi0hWRJ^Wc+Ibv!udh_r%2|U) zPi|E^PK?UE!>_4`f`1k4hqqj_$+d!EB_#IYt;f9)fBOumGNyglU(ofY`yHq4Y?B%- zp&G!MRY<~ajTgIHErMe(Z8JG*;D-PJhd@RX@QatggM7+G(Lz8eZ;73)72Hfx5KDOE zkT(m}i2;@X2AT5fW?qVp?@WgN$aT+f_6eo?IsLh;jscNRp|8H}Z9p_UBO^SJXpZew zEK8fz|0Th%(Wr|KZBGTM4yxkA5CFdAj8=QSrT$fKW#tweUFqr0TZ9D~a5lF{)%-tTGMK^2tz(y2v$i%V8XAxIywrZCp=)83p(zIk6@S5AWl|Oa2hF`~~^W zI;KeOSkw1O#TiQ8;U7OPXjZM|KrnN}9arP)m0v$c|L)lF`j_rpG(zW1Qjv$=^|p*f z>)Na{D&>n`jOWMwB^TM}slgTEcjxTlUby89j1)|6ydRfWERn3|7Zd2&e7?!K&5G$x z`5U3uFtn4~SZq|LjFVrz$3iln-+ucY4q$BC{CSm7Xe5c1J<=%Oagztj{ifpaZk_bQ z9Sb-LaQMKp-qJA*bP6DzgE3`}*i1o3GKmo2pn@dj0;He}F=BgINo};6gQF8!n0ULZ zL>kC0nPSFzlcB7p41doao2F7%6IUTi_+!L`MM4o*#Y#0v~WiO8uSeAUNp=vA2KaR&=jNR2iVwG>7t%sG2x_~yXzY)7K& zk3p+O0AFZ1eu^T3s};B%6TpJ6h-Y%B^*zT&SN7C=N;g|#dGIVMSOru3iv^SvO>h4M=t-N1GSLLDqVTcgurco6)3&XpU!FP6Hlrmj}f$ zp95;b)>M~`kxuZF3r~a!rMf4|&1=uMG$;h^g=Kl;H&Np-(pFT9FF@++MMEx3RBsK?AU0fPk-#mdR)Wdkj)`>ZMl#^<80kM87VvsI3r_c@_vX=fdQ`_9-d(xiI z4K;1y1TiPj_RPh*SpDI7U~^QQ?%0&!$Sh#?x_@;ag)P}ZkAik{_WPB4rHyW#%>|Gs zdbhyt=qQPA7`?h2_8T;-E6HI#im9K>au*(j4;kzwMSLgo6u*}-K`$_Gzgu&XE)udQ zmQ72^eZd|vzI)~!20JV-v-T|<4@7ruqrj|o4=JJPlybwMg;M$Ud7>h6g()CT@wXm` zbq=A(t;RJ^{Xxi*Ff~!|3!-l_PS{AyNAU~t{h;(N(PXMEf^R(B+ZVX3 z8y0;0A8hJYp@g+c*`>eTA|3Tgv9U8#BDTO9@a@gVMDxr(fVaEqL1tl?md{v^j8aUv zm&%PX4^|rX|?E4^CkplWWNv*OKM>DxPa z!RJ)U^0-WJMi)Ksc!^ixOtw^egoAZZ2Cg;X7(5xZG7yL_;UJ#yp*ZD-;I^Z9qkP`} zwCTs0*%rIVF1sgLervtnUo&brwz?6?PXRuOCS*JI-WL6GKy7-~yi0giTEMmDs_-UX zo=+nFrW_EfTg>oY72_4Z0*uG>MnXP=c0VpT&*|rvv1iStW;*^={rP1y?Hv+6R6bxFMkxpWkJ>m7Ba{>zc_q zEefC3jsXdyS5??Mz7IET$Kft|EMNJIv7Ny8ZOcKnzf`K5Cd)&`-fTY#W&jnV0l2vt z?Gqhic}l}mCv1yUEy$%DP}4AN;36$=7aNI^*AzV(eYGeJ(Px-j<^gSDp5dBAv2#?; zcMXv#aj>%;MiG^q^$0MSg-(uTl!xm49dH!{X0){Ew7ThWV~Gtj7h%ZD zVN-R-^7Cf0VH!8O)uUHPL2mO2tmE*cecwQv_5CzWeh)ykX8r5Hi`ehYo)d{Jnh&3p z9ndXT$OW51#H5cFKa76c<%nNkP~FU93b5h-|Cb}ScHs@4Q#|}byWg;KDMJ#|l zE=MKD*F@HDBcX@~QJH%56eh~jfPO-uKm}~t7VkHxHT;)4sd+?Wc4* z>CyR*{w@4(gnYRdFq=^(#-ytb^5ESD?x<0Skhb%Pt?npNW1m+Nv`tr9+qN<3H1f<% zZvNEqyK5FgPsQ`QIu9P0x_}wJR~^CotL|n zk?dn;tLRw9jJTur4uWoX6iMm914f0AJfB@C74a;_qRrAP4E7l890P&{v<}>_&GLrW z)klculcg`?zJO~4;BBAa=POU%aN|pmZJn2{hA!d!*lwO%YSIzv8bTJ}=nhC^n}g(ld^rn#kq9Z3)z`k9lvV>y#!F4e{5c$tnr9M{V)0m(Z< z#88vX6-AW7T2UUwW`g<;8I$Jb!R%z@rCcGT)-2k7&x9kZZT66}Ztid~6t0jKb&9mm zpa}LCb`bz`{MzpZR#E*QuBiZXI#<`5qxx=&LMr-UUf~@dRk}YI2hbMsAMWOmDzYtm zjof16D=mc`^B$+_bCG$$@R0t;e?~UkF?7<(vkb70*EQB1rfUWXh$j)R2)+dNAH5%R zEBs^?N;UMdy}V};59Gu#0$q53$}|+q7CIGg_w_WlvE}AdqoS<7DY1LWS9?TrfmcvT zaypmplwn=P4;a8-%l^e?f`OpGb}%(_mFsL&GywhyN(-VROj`4~V~9bGv%UhcA|YW% zs{;nh@aDX11y^HOFXB$a7#Sr3cEtNd4eLm@Y#fc&j)TGvbbMwze zXtekX_wJqxe4NhuW$r}cNy|L{V=t#$%SuWEW)YZTH|!iT79k#?632OFse{+BT_gau zJwQcbH{b}dzKO?^dV&3nTILYlGw{27UJ72ZN){BILd_HV_s$WfI2DC<9LIHFmtyw? zQ;?MuK7g%Ym+4e^W#5}WDLpko%jPOC=aN)3!=8)s#Rnercak&b3ESRX3z{xfKBF8L z5%CGkFmGO@x?_mPGlpEej!3!AMddChabyf~nJNZxx!D&{@xEb!TDyvqSj%Y5@A{}9 zRzoBn0?x}=krh{ok3Nn%e)#~uh;6jpezhA)ySb^b#E>73e*frBFu6IZ^D7Ii&rsiU z%jzygxT-n*joJpY4o&8UXr2s%j^Q{?e-voloX`4DQyEK+DmrZh8A$)iWL#NO9+Y@!sO2f@rI!@jN@>HOA< z?q2l{^%mY*PNx2FoX+A7X3N}(RV$B`g&N=e0uvAvEN1W^{*W?zT1i#fxuw10%~))J zjx#gxoVlXREWZf4hRkgdHx5V_S*;p-y%JtGgQ4}lnA~MBz-AFdxUxU1RIT$`sal|X zPB6sEVRjGbXIP0U+?rT|y5+ev&OMX*5C$n2SBPZr`jqzrmpVrNciR0e*Wm?fK6DY& zl(XQZ60yWXV-|Ps!A{EF;=_z(YAF=T(-MkJXUoX zI{UMQDAV2}Ya?EisdEW;@pE6dt;j0fg5oT2dxCi{wqWJ<)|SR6fxX~5CzblPGr8cb zUBVJ2CQd~3L?7yfTpLNbt)He1D>*KXI^GK%<`bq^cUq$Q@uJifG>p3LU(!H=C)aEL zenk7pVg}0{dKU}&l)Y2Y2eFMdS(JS0}oZUuVaf2+K*YFNGHB`^YGcIpnBlMhO7d4@vV zv(@N}(k#REdul8~fP+^F@ky*wt@~&|(&&meNO>rKDEnB{ykAZ}k>e@lad7to>Ao$B zz<1(L=#J*u4_LB=8w+*{KFK^u00NAmeNN7pr+Pf+N*Zl^dO{LM-hMHyP6N!~`24jd zXYP|Ze;dRXKdF2iJG$U{k=S86l@pytLx}$JFFs8e)*Vi?aVBtGJ3JZUj!~c{(rw5>vuRF$`^p!P8w1B=O!skwkO5yd4_XuG^QVF z`-r5K7(IPSiKQ2|U9+`@Js!g6sfJwAHVd|s?|mnC*q zp|B|z)(8+mxXyxQ{8Pg3F4|tdpgZZSoU4P&9I8)nHo1@)9_9u&NcT^FI)6|hsAZFk zZ+arl&@*>RXBf-OZxhZerOr&dN5LW9@gV=oGFbK*J+m#R-|e6(Loz(;g@T^*oO)0R zN`N=X46b{7yk5FZGr#5&n1!-@j@g02g|X>MOpF3#IjZ_4wg{dX+G9eqS+Es9@6nC7 zD9$NuVJI}6ZlwtUm5cCAiYv0(Yi{%eH+}t)!E^>^KxB5^L~a`4%1~5q6h>d;paC9c zTj0wTCKrhWf+F#5>EgX`sl%POl?oyCq0(w0xoL?L%)|Q7d|Hl92rUYAU#lc**I&^6p=4lNQPa0 znQ|A~i0ip@`B=FW-Q;zh?-wF;Wl5!+q3GXDu-x&}$gUO)NoO7^$BeEIrd~1Dh{Tr` z8s<(Bn@gZ(mkIGnmYh_ehXnq78QL$pNDi)|QcT*|GtS%nz1uKE+E{7jdEBp%h0}%r zD2|KmYGiPa4;md-t_m5YDz#c*oV_FqXd85d@eub?9N61QuYcb3CnVWpM(D-^|CmkL z(F}L&N7qhL2PCq)fRh}XO@U`Yn<?TNGR4L(mF7#4u29{i~@k;pLsgl({YW5`Mo+p=zZn3L*4{JU;++dG9 X@eDJUQo;Ye2mwlRs?y0|+_a0zY+Zo%Dkae}+MySoIppb75o?vUW_?)>@g{U2`ERQIXV zeY$JrWnMZ$QC<=ii4X|@0H8`si75jB(ElJb00HAB%>SlLR{!zO|C9P3zxw_U8?1d8uRZ=({Ga4shyN}3 zAK}WA(ds|``G4jA)9}Bt2Hy0+f3rV1E6b|@?hpGA=PI&r8)ah|)I2s(P5Ic*Ndhn^ z*T&j@gbCTv7+8rpYbR^Ty}1AY)YH;p!m948r#%7x^Z@_-w{pDl|1S4`EM3n_PaXvK z1JF)E3qy$qTj5Xs{jU9k=y%SQ0>8E$;x?p9ayU0bZZeo{5Z@&FKX>}s!0+^>C^D#z z>xsCPvxD3Z=dP}TTOSJhNTPyVt14VCQ9MQFN`rn!c&_p?&4<5_PGm4a;WS&1(!qKE z_H$;dDdiPQ!F_gsN`2>`X}$I=B;={R8%L~`>RyKcS$72ai$!2>d(YkciA^J0@X%G4 z4cu!%Ps~2JuJ8ex`&;Fa0NQOq_nDZ&X;^A=oc1&f#3P1(!5il>6?uK4QpEG8z0Rhu zvBJ+A9RV?z%v?!$=(vcH?*;vRs*+PPbOQ3cdPr5=tOcLqmfx@#hOqX0iN)wTTO21jH<>jpmwRIAGw7`a|sl?9y9zRBh>(_%| zF?h|P7}~RKj?HR+q|4U`CjRmV-$mLW>MScKnNXiv{vD3&2@*u)-6P@h0A`eeZ7}71 zK(w%@R<4lLt`O7fs1E)$5iGb~fPfJ?WxhY7c3Q>T-w#wT&zW522pH-B%r5v#5y^CF zcC30Se|`D2mY$hAlIULL%-PNXgbbpRHgn<&X3N9W!@BUk@9g*P5mz-YnZBb*-$zMM z7Qq}ic0mR8n{^L|=+diODdV}Q!gwr?y+2m=3HWwMq4z)DqYVg0J~^}-%7rMR@S1;9 z7GFj6K}i32X;3*$SmzB&HW{PJ55kT+EI#SsZf}bD7nW^Haf}_gXciYKX{QBxIPSx2Ma? zHQqgzZq!_{&zg{yxqv3xq8YV+`S}F6A>Gtl39_m;K4dA{pP$BW0oIXJ>jEQ!2V3A2 zdpoTxG&V=(?^q?ZTj2ZUpDUdMb)T?E$}CI>r@}PFPWD9@*%V6;4Ag>D#h>!s)=$0R zRXvdkZ%|c}ubej`jl?cS$onl9Tw52rBKT)kgyw~Xy%z62Lr%V6Y=f?2)J|bZJ5(Wx zmji`O;_B+*X@qe-#~`HFP<{8$w@z4@&`q^Q-Zk8JG3>WalhnW1cvnoVw>*R@c&|o8 zZ%w!{Z+MHeZ*OE4v*otkZqz11*s!#s^Gq>+o`8Z5 z^i-qzJLJh9!W-;SmFkR8HEZJWiXk$40i6)7 zZpr=k2lp}SasbM*Nbn3j$sn0;rUI;%EDbi7T1ZI4qL6PNNM2Y%6{LMIKW+FY_yF3) zSKQ2QSujzNMSL2r&bYs`|i2Dnn z=>}c0>a}>|uT!IiMOA~pVT~R@bGlm}Edf}Kq0?*Af6#mW9f9!}RjW7om0c9Qlp;yK z)=XQs(|6GCadQbWIhYF=rf{Y)sj%^Id-ARO0=O^Ad;Ph+ z0?$eE1xhH?{T$QI>0JP75`r)U_$#%K1^BQ8z#uciKf(C701&RyLQWBUp*Q7eyn76} z6JHpC9}R$J#(R0cDCkXoFSp;j6{x{b&0yE@P7{;pCEpKjS(+1RQy38`=&Yxo%F=3y zCPeefABp34U-s?WmU#JJw23dcC{sPPFc2#J$ZgEN%zod}J~8dLm*fx9f6SpO zn^Ww3bt9-r0XaT2a@Wpw;C23XM}7_14#%QpubrIw5aZtP+CqIFmsG4`Cm6rfxl9n5 z7=r2C-+lM2AB9X0T_`?EW&Byv&K?HS4QLoylJ|OAF z`8atBNTzJ&AQ!>sOo$?^0xj~D(;kS$`9zbEGd>f6r`NC3X`tX)sWgWUUOQ7w=$TO&*j;=u%25ay-%>3@81tGe^_z*C7pb9y*Ed^H3t$BIKH2o+olp#$q;)_ zfpjCb_^VFg5fU~K)nf*d*r@BCC>UZ!0&b?AGk_jTPXaSnCuW110wjHPPe^9R^;jo3 zwvzTl)C`Zl5}O2}3lec=hZ*$JnkW#7enKKc)(pM${_$9Hc=Sr_A9Biwe*Y=T?~1CK z6eZ9uPICjy-sMGbZl$yQmpB&`ouS8v{58__t0$JP%i3R&%QR3ianbZqDs<2#5FdN@n5bCn^ZtH992~5k(eA|8|@G9u`wdn7bnpg|@{m z^d6Y`*$Zf2Xr&|g%sai#5}Syvv(>Jnx&EM7-|Jr7!M~zdAyjt*xl;OLhvW-a%H1m0 z*x5*nb=R5u><7lyVpNAR?q@1U59 zO+)QWwL8t zyip?u_nI+K$uh{y)~}qj?(w0&=SE^8`_WMM zTybjG=999h38Yes7}-4*LJ7H)UE8{mE(6;8voE+TYY%33A>S6`G_95^5QHNTo_;Ao ztIQIZ_}49%{8|=O;isBZ?=7kfdF8_@azfoTd+hEJKWE!)$)N%HIe2cplaK`ry#=pV z0q{9w-`i0h@!R8K3GC{ivt{70IWG`EP|(1g7i_Q<>aEAT{5(yD z=!O?kq61VegV+st@XCw475j6vS)_z@efuqQgHQR1T4;|-#OLZNQJPV4k$AX1Uk8Lm z{N*b*ia=I+MB}kWpupJ~>!C@xEN#Wa7V+7{m4j8c?)ChV=D?o~sjT?0C_AQ7B-vxqX30s0I_`2$in86#`mAsT-w?j{&AL@B3$;P z31G4(lV|b}uSDCIrjk+M1R!X7s4Aabn<)zpgT}#gE|mIvV38^ODy@<&yflpCwS#fRf9ZX3lPV_?8@C5)A;T zqmouFLFk;qIs4rA=hh=GL~sCFsXHsqO6_y~*AFt939UYVBSx1s(=Kb&5;j7cSowdE;7()CC2|-i9Zz+_BIw8#ll~-tyH?F3{%`QCsYa*b#s*9iCc`1P1oC26?`g<9))EJ3%xz+O!B3 zZ7$j~To)C@PquR>a1+Dh>-a%IvH_Y7^ys|4o?E%3`I&ADXfC8++hAdZfzIT#%C+Jz z1lU~K_vAm0m8Qk}K$F>|>RPK%<1SI0(G+8q~H zAsjezyP+u!Se4q3GW)`h`NPSRlMoBjCzNPesWJwVTY!o@G8=(6I%4XHGaSiS3MEBK zhgGFv6Jc>L$4jVE!I?TQuwvz_%CyO!bLh94nqK11C2W$*aa2ueGopG8DnBICVUORP zgytv#)49fVXDaR$SukloYC3u7#5H)}1K21=?DKj^U)8G;MS)&Op)g^zR2($<>C*zW z;X7`hLxiIO#J`ANdyAOJle4V%ppa*(+0i3w;8i*BA_;u8gOO6)MY`ueq7stBMJTB; z-a0R>hT*}>z|Gg}@^zDL1MrH+2hsR8 zHc}*9IvuQC^Ju)^#Y{fOr(96rQNPNhxc;mH@W*m206>Lo<*SaaH?~8zg&f&%YiOEG zGiz?*CP>Bci}!WiS=zj#K5I}>DtpregpP_tfZtPa(N<%vo^#WCQ5BTv0vr%Z{)0q+ z)RbfHktUm|lg&U3YM%lMUM(fu}i#kjX9h>GYctkx9Mt_8{@s%!K_EI zScgwy6%_fR?CGJQtmgNAj^h9B#zmaMDWgH55pGuY1Gv7D z;8Psm(vEPiwn#MgJYu4Ty9D|h!?Rj0ddE|&L3S{IP%H4^N!m`60ZwZw^;eg4sk6K{ ziA^`Sbl_4~f&Oo%n;8Ye(tiAdlZKI!Z=|j$5hS|D$bDJ}p{gh$KN&JZYLUjv4h{NY zBJ>X9z!xfDGY z+oh_Z&_e#Q(-}>ssZfm=j$D&4W4FNy&-kAO1~#3Im;F)Nwe{(*75(p=P^VI?X0GFakfh+X-px4a%Uw@fSbmp9hM1_~R>?Z8+ ziy|e9>8V*`OP}4x5JjdWp}7eX;lVxp5qS}0YZek;SNmm7tEeSF*-dI)6U-A%m6YvCgM(}_=k#a6o^%-K4{`B1+}O4x zztDT%hVb;v#?j`lTvlFQ3aV#zkX=7;YFLS$uIzb0E3lozs5`Xy zi~vF+%{z9uLjKvKPhP%x5f~7-Gj+%5N`%^=yk*Qn{`> z;xj&ROY6g`iy2a@{O)V(jk&8#hHACVDXey5a+KDod_Z&}kHM}xt7}Md@pil{2x7E~ zL$k^d2@Ec2XskjrN+IILw;#7((abu;OJii&v3?60x>d_Ma(onIPtcVnX@ELF0aL?T zSmWiL3(dOFkt!x=1O!_0n(cAzZW+3nHJ{2S>tgSK?~cFha^y(l@-Mr2W$%MN{#af8J;V*>hdq!gx=d0h$T7l}>91Wh07)9CTX zh2_ZdQCyFOQ)l(}gft0UZG`Sh2`x-w`5vC2UD}lZs*5 zG76$akzn}Xi))L3oGJ75#pcN=cX3!=57$Ha=hQ2^lwdyU#a}4JJOz6ddR%zae%#4& za)bFj)z=YQela(F#Y|Q#dp}PJghITwXouVaMq$BM?K%cXn9^Y@g43$=O)F&ZlOUom zJiad#dea;-eywBA@e&D6Pdso1?2^(pXiN91?jvcaUyYoKUmvl5G9e$W!okWe*@a<^ z8cQQ6cNSf+UPDx%?_G4aIiybZHHagF{;IcD(dPO!#=u zWfqLcPc^+7Uu#l(Bpxft{*4lv#*u7X9AOzDO z1D9?^jIo}?%iz(_dwLa{ex#T}76ZfN_Z-hwpus9y+4xaUu9cX}&P{XrZVWE{1^0yw zO;YhLEW!pJcbCt3L8~a7>jsaN{V3>tz6_7`&pi%GxZ=V3?3K^U+*ryLSb)8^IblJ0 zSRLNDvIxt)S}g30?s_3NX>F?NKIGrG_zB9@Z>uSW3k2es_H2kU;Rnn%j5qP)!XHKE zPB2mHP~tLCg4K_vH$xv`HbRsJwbZMUV(t=ez;Ec(vyHH)FbfLg`c61I$W_uBB>i^r z&{_P;369-&>23R%qNIULe=1~T$(DA`ev*EWZ6j(B$(te}x1WvmIll21zvygkS%vwG zzkR6Z#RKA2!z!C%M!O>!=Gr0(J0FP=-MN=5t-Ir)of50y10W}j`GtRCsXBakrKtG& zazmITDJMA0C51&BnLY)SY9r)NVTMs);1<=oosS9g31l{4ztjD3#+2H7u_|66b|_*O z;Qk6nalpqdHOjx|K&vUS_6ITgGll;TdaN*ta=M_YtyC)I9Tmr~VaPrH2qb6sd~=AcIxV+%z{E&0@y=DPArw zdV7z(G1hBx7hd{>(cr43^WF%4Y@PXZ?wPpj{OQ#tvc$pABJbvPGvdR`cAtHn)cSEV zrpu}1tJwQ3y!mSmH*uz*x0o|CS<^w%&KJzsj~DU0cLQUxk5B!hWE>aBkjJle8z~;s z-!A=($+}Jq_BTK5^B!`R>!MulZN)F=iXXeUd0w5lUsE5VP*H*oCy(;?S$p*TVvTxwAeWFB$jHyb0593)$zqalVlDX=GcCN1gU0 zlgU)I$LcXZ8Oyc2TZYTPu@-;7<4YYB-``Qa;IDcvydIA$%kHhJKV^m*-zxcvU4viy&Kr5GVM{IT>WRywKQ9;>SEiQD*NqplK-KK4YR`p0@JW)n_{TU3bt0 zim%;(m1=#v2}zTps=?fU5w^(*y)xT%1vtQH&}50ZF!9YxW=&7*W($2kgKyz1mUgfs zfV<*XVVIFnohW=|j+@Kfo!#liQR^x>2yQdrG;2o8WZR+XzU_nG=Ed2rK?ntA;K5B{ z>M8+*A4!Jm^Bg}aW?R?6;@QG@uQ8&oJ{hFixcfEnJ4QH?A4>P=q29oDGW;L;= z9-a0;g%c`C+Ai!UmK$NC*4#;Jp<1=TioL=t^YM)<<%u#hnnfSS`nq63QKGO1L8RzX z@MFDqs1z ztYmxDl@LU)5acvHk)~Z`RW7=aJ_nGD!mOSYD>5Odjn@TK#LY{jf?+piB5AM-CAoT_ z?S-*q7}wyLJzK>N%eMPuFgN)Q_otKP;aqy=D5f!7<=n(lNkYRXVpkB{TAYLYg{|(jtRqYmg$xH zjmq?B(RE4 zQx^~Pt}gxC2~l=K$$-sYy_r$CO(d=+b3H1MB*y_5g6WLaWTXn+TKQ|hNY^>Mp6k*$ zwkovomhu776vQATqT4blf~g;TY(MWCrf^^yfWJvSAB$p5l;jm@o#=!lqw+Lqfq>X= z$6~kxfm7`3q4zUEB;u4qa#BdJxO!;xGm)wwuisj{0y2x{R(IGMrsIzDY9LW>m!Y`= z04sx3IjnYvL<4JqxQ8f7qYd0s2Ig%`ytYPEMKI)s(LD}D@EY>x`VFtqvnADNBdeao zC96X+MxnwKmjpg{U&gP3HE}1=s!lv&D{6(g_lzyF3A`7Jn*&d_kL<;dAFx!UZ>hB8 z5A*%LsAn;VLp>3${0>M?PSQ)9s3}|h2e?TG4_F{}{Cs>#3Q*t$(CUc}M)I}8cPF6% z=+h(Kh^8)}gj(0}#e7O^FQ6`~fd1#8#!}LMuo3A0bN`o}PYsm!Y}sdOz$+Tegc=qT z8x`PH$7lvnhJp{kHWb22l;@7B7|4yL4UOOVM0MP_>P%S1Lnid)+k9{+3D+JFa#Pyf zhVc#&df87APl4W9X)F3pGS>@etfl=_E5tBcVoOfrD4hmVeTY-cj((pkn%n@EgN{0f zwb_^Rk0I#iZuHK!l*lN`ceJn(sI{$Fq6nN& zE<-=0_2WN}m+*ivmIOxB@#~Q-cZ>l136w{#TIJe478`KE7@=a{>SzPHsKLzYAyBQO zAtuuF$-JSDy_S@6GW0MOE~R)b;+0f%_NMrW(+V#c_d&U8Z9+ec4=HmOHw?gdjF(Lu zzra83M_BoO-1b3;9`%&DHfuUY)6YDV21P$C!Rc?mv&{lx#f8oc6?0?x zK08{WP65?#>(vPfA-c=MCY|%*1_<3D4NX zeVTi-JGl2uP_2@0F{G({pxQOXt_d{g_CV6b?jNpfUG9;8yle-^4KHRvZs-_2siata zt+d_T@U$&t*xaD22(fH(W1r$Mo?3dc%Tncm=C6{V9y{v&VT#^1L04vDrLM9qBoZ4@ z6DBN#m57hX7$C(=#$Y5$bJmwA$T8jKD8+6A!-IJwA{WOfs%s}yxUw^?MRZjF$n_KN z6`_bGXcmE#5e4Ym)aQJ)xg3Pg0@k`iGuHe?f(5LtuzSq=nS^5z>vqU0EuZ&75V%Z{ zYyhRLN^)$c6Ds{f7*FBpE;n5iglx5PkHfWrj3`x^j^t z7ntuV`g!9Xg#^3!x)l*}IW=(Tz3>Y5l4uGaB&lz{GDjm2D5S$CExLT`I1#n^lBH7Y zDgpMag@`iETKAI=p<5E#LTkwzVR@=yY|uBVI1HG|8h+d;G-qfuj}-ZR6fN>EfCCW z9~wRQoAPEa#aO?3h?x{YvV*d+NtPkf&4V0k4|L=uj!U{L+oLa(z#&iuhJr3-PjO3R z5s?=nn_5^*^Rawr>>Nr@K(jwkB#JK-=+HqwfdO<+P5byeim)wvqGlP-P|~Nse8=XF zz`?RYB|D6SwS}C+YQv+;}k6$-%D(@+t14BL@vM z2q%q?f6D-A5s$_WY3{^G0F131bbh|g!}#BKw=HQ7mx;Dzg4Z*bTLQSfo{ed{4}NZW zfrRm^Ca$rlE{Ue~uYv>R9{3smwATcdM_6+yWIO z*ZRH~uXE@#p$XTbCt5j7j2=86e{9>HIB6xDzV+vAo&B?KUiMP|ttOElepnl%|DPqL 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 016/190] 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 017/190] 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 018/190] 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 019/190] 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 020/190] 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 480217f7cd3c39e76932b9e778948e2ad9095a76 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 15 Oct 2025 15:56:04 +0200 Subject: [PATCH 021/190] feat(node): Allow selective tracking of `pino` loggers (#17933) - Closes #17904 This PR adds two methods to the `pinoIntegration` export. `trackLogger` and `untrackLogger`; `untrackLogger` can be used to disable capturing from a specific logger. You can also disable `autoInstrument` and only track specific loggers: ```ts import * as Sentry from '@sentry/node'; import pino from 'pino'; Sentry.init({ dsn: '__DSN__', integrations: [Sentry.pinoIntegration({ autoInstrument: false })], }); const logger = pino({}); Sentry.pinoIntegration.trackLogger(logger); logger.debug('This will be captured!'); ``` --- .../suites/pino/instrument-auto-off.mjs | 8 ++ .../suites/pino/scenario-track.mjs | 23 ++++++ .../suites/pino/scenario.mjs | 5 ++ .../suites/pino/test.ts | 50 ++++++++++++ packages/node-core/src/integrations/pino.ts | 77 +++++++++++++++++-- 5 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs create mode 100644 dev-packages/node-integration-tests/suites/pino/scenario-track.mjs diff --git a/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs b/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs new file mode 100644 index 000000000000..d2a0408eebcd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + release: '1.0', + enableLogs: true, + integrations: [Sentry.pinoIntegration({ autoInstrument: false })], +}); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs new file mode 100644 index 000000000000..2e968444a74f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino'; + +const logger = pino({ name: 'myapp' }); +Sentry.pinoIntegration.trackLogger(logger); + +const loggerIgnore = pino({ name: 'ignore' }); + +loggerIgnore.info('this should be ignored'); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'startup' }, () => { + logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'later' }, () => { + logger.error(new Error('oh no')); + }); + }); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs index ea8dc5e223d0..beb080ac3c42 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -3,6 +3,11 @@ import pino from 'pino'; const logger = pino({ name: 'myapp' }); +const ignoredLogger = pino({ name: 'ignored' }); +Sentry.pinoIntegration.untrackLogger(ignoredLogger); + +ignoredLogger.info('this will not be tracked'); + Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'startup' }, () => { logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index cc88f650203b..1982c8d686fc 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -173,4 +173,54 @@ conditionalTest({ min: 20 })('Pino integration', () => { .start() .completed(); }); + + test('captures logs when autoInstrument is false and logger is tracked', async () => { + const instrumentPath = join(__dirname, 'instrument-auto-off.mjs'); + + await createRunner(__dirname, 'scenario-track.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'hello world', + trace_id: expect.any(String), + severity_number: 9, + attributes: expect.objectContaining({ + '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' }, + }), + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'oh no', + trace_id: expect.any(String), + severity_number: 17, + attributes: expect.objectContaining({ + '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' }, + }), + }, + ], + }, + }) + .start() + .completed(); + }); }); diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index 6b78bcdb4386..dfc51d5022ff 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -1,5 +1,5 @@ import { tracingChannel } from 'node:diagnostics_channel'; -import type { IntegrationFn, LogSeverityLevel } from '@sentry/core'; +import type { Integration, IntegrationFn, LogSeverityLevel } from '@sentry/core'; import { _INTERNAL_captureLog, addExceptionMechanism, @@ -11,6 +11,8 @@ import { } from '@sentry/core'; import { addInstrumentationConfig } from '../sdk/injectLoader'; +const SENTRY_TRACK_SYMBOL = Symbol('sentry-track-pino-logger'); + type LevelMapping = { // Fortunately pino uses the same levels as Sentry labels: { [level: number]: LogSeverityLevel }; @@ -18,6 +20,7 @@ type LevelMapping = { type Pino = { levels: LevelMapping; + [SENTRY_TRACK_SYMBOL]?: 'track' | 'ignore'; }; type MergeObject = { @@ -28,6 +31,17 @@ type MergeObject = { type PinoHookArgs = [MergeObject, string, number]; type PinoOptions = { + /** + * Automatically instrument all Pino loggers. + * + * When set to `false`, only loggers marked with `pinoIntegration.trackLogger(logger)` will be captured. + * + * @default true + */ + autoInstrument: boolean; + /** + * Options to enable capturing of error events. + */ error: { /** * Levels that trigger capturing of events. @@ -43,6 +57,9 @@ type PinoOptions = { */ handled: boolean; }; + /** + * Options to enable capturing of logs. + */ log: { /** * Levels that trigger capturing of logs. Logs are only captured if @@ -55,6 +72,7 @@ type PinoOptions = { }; const DEFAULT_OPTIONS: PinoOptions = { + autoInstrument: true, error: { levels: [], handled: true }, log: { levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] }, }; @@ -63,18 +81,18 @@ type DeepPartial = { [P in keyof T]?: T[P] extends object ? Partial : T[P]; }; -/** - * Integration for Pino logging library. - * Captures Pino logs as Sentry logs and optionally captures some log levels as events. - * - * Requires Pino >=v8.0.0 and Node >=20.6.0 or >=18.19.0 - */ -export const pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { +const _pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { const options: PinoOptions = { + autoInstrument: userOptions.autoInstrument === false ? userOptions.autoInstrument : DEFAULT_OPTIONS.autoInstrument, error: { ...DEFAULT_OPTIONS.error, ...userOptions.error }, log: { ...DEFAULT_OPTIONS.log, ...userOptions.log }, }; + function shouldTrackLogger(logger: Pino): boolean { + const override = logger[SENTRY_TRACK_SYMBOL]; + return override === 'track' || (override !== 'ignore' && options.autoInstrument); + } + return { name: 'Pino', setup: client => { @@ -95,6 +113,10 @@ export const pinoIntegration = defineIntegration((userOptions: DeepPartial): Integration; + /** + * Marks a Pino logger to be tracked by the Pino integration. + * + * @param logger A Pino logger instance. + */ + trackLogger(logger: unknown): void; + /** + * Marks a Pino logger to be ignored by the Pino integration. + * + * @param logger A Pino logger instance. + */ + untrackLogger(logger: unknown): void; +} + +/** + * Integration for Pino logging library. + * Captures Pino logs as Sentry logs and optionally captures some log levels as events. + * + * By default, all Pino loggers will be captured. To ignore a specific logger, use `pinoIntegration.untrackLogger(logger)`. + * + * If you disable automatic instrumentation with `autoInstrument: false`, you can mark specific loggers to be tracked with `pinoIntegration.trackLogger(logger)`. + * + * Requires Pino >=v8.0.0 and Node >=20.6.0 or >=18.19.0 + */ +export const pinoIntegration = Object.assign(_pinoIntegration, { + trackLogger(logger: unknown): void { + if (logger && typeof logger === 'object' && 'levels' in logger) { + (logger as Pino)[SENTRY_TRACK_SYMBOL] = 'track'; + } + }, + untrackLogger(logger: unknown): void { + if (logger && typeof logger === 'object' && 'levels' in logger) { + (logger as Pino)[SENTRY_TRACK_SYMBOL] = 'ignore'; + } + }, +}) as PinoIntegrationFunction; From baa7a0663c801193c2fee43c38de5521e53f9ba5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 15 Oct 2025 16:04:11 +0200 Subject: [PATCH 022/190] 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 From b3c7141ce77d993362f5348667d22c56afad21d2 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Wed, 15 Oct 2025 14:59:23 +0000 Subject: [PATCH 023/190] release: 10.20.0 --- .../browser-integration-tests/package.json | 4 ++-- .../bundle-analyzer-scenarios/package.json | 2 +- dev-packages/clear-cache-gh-action/package.json | 2 +- .../cloudflare-integration-tests/package.json | 6 +++--- dev-packages/e2e-tests/package.json | 2 +- .../external-contributor-gh-action/package.json | 2 +- .../node-core-integration-tests/package.json | 6 +++--- dev-packages/node-integration-tests/package.json | 10 +++++----- dev-packages/node-overhead-gh-action/package.json | 4 ++-- dev-packages/rollup-utils/package.json | 2 +- dev-packages/size-limit-gh-action/package.json | 2 +- dev-packages/test-utils/package.json | 4 ++-- lerna.json | 2 +- packages/angular/package.json | 6 +++--- packages/astro/package.json | 8 ++++---- packages/aws-serverless/package.json | 6 +++--- packages/browser-utils/package.json | 4 ++-- packages/browser/package.json | 14 +++++++------- packages/bun/package.json | 6 +++--- packages/cloudflare/package.json | 4 ++-- packages/core/package.json | 2 +- packages/deno/package.json | 4 ++-- packages/ember/package.json | 6 +++--- packages/eslint-config-sdk/package.json | 6 +++--- packages/eslint-plugin-sdk/package.json | 2 +- packages/feedback/package.json | 4 ++-- packages/gatsby/package.json | 6 +++--- packages/google-cloud-serverless/package.json | 6 +++--- packages/integration-shims/package.json | 4 ++-- packages/nestjs/package.json | 6 +++--- packages/nextjs/package.json | 14 +++++++------- packages/node-core/package.json | 6 +++--- packages/node-native/package.json | 6 +++--- packages/node/package.json | 8 ++++---- packages/nuxt/package.json | 12 ++++++------ packages/opentelemetry/package.json | 4 ++-- packages/profiling-node/package.json | 6 +++--- packages/react-router/package.json | 10 +++++----- packages/react/package.json | 6 +++--- packages/remix/package.json | 8 ++++---- packages/replay-canvas/package.json | 6 +++--- packages/replay-internal/package.json | 8 ++++---- packages/replay-worker/package.json | 2 +- packages/solid/package.json | 8 ++++---- packages/solidstart/package.json | 8 ++++---- packages/svelte/package.json | 6 +++--- packages/sveltekit/package.json | 10 +++++----- packages/tanstackstart-react/package.json | 10 +++++----- packages/tanstackstart/package.json | 2 +- packages/types/package.json | 4 ++-- packages/typescript/package.json | 2 +- packages/vercel-edge/package.json | 6 +++--- packages/vue/package.json | 6 +++--- packages/wasm/package.json | 6 +++--- 54 files changed, 153 insertions(+), 153 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 329c061c49d5..336ecd045f40 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "10.19.0", + "version": "10.20.0", "main": "index.js", "license": "MIT", "engines": { @@ -43,7 +43,7 @@ "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.53.2", "@sentry-internal/rrweb": "2.34.0", - "@sentry/browser": "10.19.0", + "@sentry/browser": "10.20.0", "@supabase/supabase-js": "2.49.3", "axios": "^1.12.2", "babel-loader": "^8.2.2", diff --git a/dev-packages/bundle-analyzer-scenarios/package.json b/dev-packages/bundle-analyzer-scenarios/package.json index 35de87f20481..124361baaa25 100644 --- a/dev-packages/bundle-analyzer-scenarios/package.json +++ b/dev-packages/bundle-analyzer-scenarios/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundle-analyzer-scenarios", - "version": "10.19.0", + "version": "10.20.0", "description": "Scenarios to test bundle analysis with", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/dev-packages/bundle-analyzer-scenarios", diff --git a/dev-packages/clear-cache-gh-action/package.json b/dev-packages/clear-cache-gh-action/package.json index 23a7628cf659..21c4ce680659 100644 --- a/dev-packages/clear-cache-gh-action/package.json +++ b/dev-packages/clear-cache-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/clear-cache-gh-action", "description": "An internal Github Action to clear GitHub caches.", - "version": "10.19.0", + "version": "10.20.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index fbc4488b04c4..fbc4f1c991a4 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/cloudflare-integration-tests", - "version": "10.19.0", + "version": "10.20.0", "license": "MIT", "engines": { "node": ">=18" @@ -13,11 +13,11 @@ "test:watch": "yarn test --watch" }, "dependencies": { - "@sentry/cloudflare": "10.19.0" + "@sentry/cloudflare": "10.20.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", - "@sentry-internal/test-utils": "10.19.0", + "@sentry-internal/test-utils": "10.20.0", "vitest": "^3.2.4", "wrangler": "4.22.0" }, diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 0f85503b8aca..a5892494bd16 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/e2e-tests", - "version": "10.19.0", + "version": "10.20.0", "license": "MIT", "private": true, "scripts": { diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json index e95bea01993a..d3848bd8c087 100644 --- a/dev-packages/external-contributor-gh-action/package.json +++ b/dev-packages/external-contributor-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/external-contributor-gh-action", "description": "An internal Github Action to add external contributors to the CHANGELOG.md file.", - "version": "10.19.0", + "version": "10.20.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 3103c6cebac2..f78ace6957b3 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-core-integration-tests", - "version": "10.19.0", + "version": "10.20.0", "license": "MIT", "engines": { "node": ">=18" @@ -34,8 +34,8 @@ "@opentelemetry/resources": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.19.0", - "@sentry/node-core": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/node-core": "10.20.0", "body-parser": "^1.20.3", "cors": "^2.8.5", "cron": "^3.1.6", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 118db71a6b98..5d7a02f7327c 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-integration-tests", - "version": "10.19.0", + "version": "10.20.0", "license": "MIT", "engines": { "node": ">=18" @@ -33,9 +33,9 @@ "@nestjs/core": "^11", "@nestjs/platform-express": "^11", "@prisma/client": "6.15.0", - "@sentry/aws-serverless": "10.19.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", + "@sentry/aws-serverless": "10.20.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", @@ -80,7 +80,7 @@ "yargs": "^16.2.0" }, "devDependencies": { - "@sentry-internal/test-utils": "10.19.0", + "@sentry-internal/test-utils": "10.20.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", diff --git a/dev-packages/node-overhead-gh-action/package.json b/dev-packages/node-overhead-gh-action/package.json index d840c04630df..2e48ecfc5d12 100644 --- a/dev-packages/node-overhead-gh-action/package.json +++ b/dev-packages/node-overhead-gh-action/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-overhead-gh-action", - "version": "10.19.0", + "version": "10.20.0", "license": "MIT", "engines": { "node": ">=18" @@ -23,7 +23,7 @@ "fix": "eslint . --format stylish --fix" }, "dependencies": { - "@sentry/node": "10.19.0", + "@sentry/node": "10.20.0", "express": "^4.21.1", "mysql2": "^3.14.4" }, diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json index a7a0fd4bbd27..c733d1a7a10b 100644 --- a/dev-packages/rollup-utils/package.json +++ b/dev-packages/rollup-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/rollup-utils", - "version": "10.19.0", + "version": "10.20.0", "description": "Rollup utilities used at Sentry for the Sentry JavaScript SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/rollup-utils", diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index 20e6698315b9..b5da92310446 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/size-limit-gh-action", "description": "An internal Github Action to compare the current size of a PR against the one on develop.", - "version": "10.19.0", + "version": "10.20.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 0399a33a4dd9..2f1d906fd4e1 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "10.19.0", + "version": "10.20.0", "name": "@sentry-internal/test-utils", "author": "Sentry", "license": "MIT", @@ -48,7 +48,7 @@ }, "devDependencies": { "@playwright/test": "~1.53.2", - "@sentry/core": "10.19.0" + "@sentry/core": "10.20.0" }, "volta": { "extends": "../../package.json" diff --git a/lerna.json b/lerna.json index f5c2529aa310..d4b1a98ac901 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "10.19.0", + "version": "10.20.0", "npmClient": "yarn" } diff --git a/packages/angular/package.json b/packages/angular/package.json index f9c2f2cc6fbd..758a6aa17e53 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Angular", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular", @@ -21,8 +21,8 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "10.19.0", - "@sentry/core": "10.19.0", + "@sentry/browser": "10.20.0", + "@sentry/core": "10.20.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/package.json b/packages/astro/package.json index 8ff573585f31..c3ae4e4116ff 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/astro", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Astro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/astro", @@ -56,9 +56,9 @@ "astro": ">=3.x || >=4.0.0-beta || >=5.x" }, "dependencies": { - "@sentry/browser": "10.19.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", + "@sentry/browser": "10.20.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 2d51746e65a6..2c9d74fcd065 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/aws-serverless", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for AWS Lambda and AWS Serverless Environments", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless", @@ -69,8 +69,8 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-aws-sdk": "0.59.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", "@types/aws-lambda": "^8.10.62" }, "devDependencies": { diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index dccc16088efc..363b725915ba 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-utils", - "version": "10.19.0", + "version": "10.20.0", "description": "Browser Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser-utils", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.19.0" + "@sentry/core": "10.20.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser/package.json b/packages/browser/package.json index 870f08827298..3b01f272299f 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for browsers", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser", @@ -39,14 +39,14 @@ "access": "public" }, "dependencies": { - "@sentry-internal/browser-utils": "10.19.0", - "@sentry-internal/feedback": "10.19.0", - "@sentry-internal/replay": "10.19.0", - "@sentry-internal/replay-canvas": "10.19.0", - "@sentry/core": "10.19.0" + "@sentry-internal/browser-utils": "10.20.0", + "@sentry-internal/feedback": "10.20.0", + "@sentry-internal/replay": "10.20.0", + "@sentry-internal/replay-canvas": "10.20.0", + "@sentry/core": "10.20.0" }, "devDependencies": { - "@sentry-internal/integration-shims": "10.19.0", + "@sentry-internal/integration-shims": "10.20.0", "fake-indexeddb": "^4.0.1" }, "scripts": { diff --git a/packages/bun/package.json b/packages/bun/package.json index 379663fe571c..15eb9481f99a 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bun", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for bun", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0" + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0" }, "devDependencies": { "bun-types": "^1.2.9" diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 4dd4a49a954b..df04b7ad30ac 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/cloudflare", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Cloudflare Workers and Pages", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare", @@ -50,7 +50,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.19.0" + "@sentry/core": "10.20.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" diff --git a/packages/core/package.json b/packages/core/package.json index d9638e2b22b4..2e7f2d55018a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/core", - "version": "10.19.0", + "version": "10.20.0", "description": "Base implementation for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/core", diff --git a/packages/deno/package.json b/packages/deno/package.json index 5dc3fdd89d1d..9b72fb7e82da 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/deno", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Deno", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", @@ -25,7 +25,7 @@ ], "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.19.0" + "@sentry/core": "10.20.0" }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", diff --git a/packages/ember/package.json b/packages/ember/package.json index 66078a75505c..9fbfc634baaa 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/ember", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Ember.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/ember", @@ -32,8 +32,8 @@ "dependencies": { "@babel/core": "^7.27.7", "@embroider/macros": "^1.16.0", - "@sentry/browser": "10.19.0", - "@sentry/core": "10.19.0", + "@sentry/browser": "10.20.0", + "@sentry/core": "10.20.0", "ember-auto-import": "^2.7.2", "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^6.1.1", diff --git a/packages/eslint-config-sdk/package.json b/packages/eslint-config-sdk/package.json index dee5c2ccfb2d..f013f8c41e05 100644 --- a/packages/eslint-config-sdk/package.json +++ b/packages/eslint-config-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-config-sdk", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK eslint config", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-config-sdk", @@ -22,8 +22,8 @@ "access": "public" }, "dependencies": { - "@sentry-internal/eslint-plugin-sdk": "10.19.0", - "@sentry-internal/typescript": "10.19.0", + "@sentry-internal/eslint-plugin-sdk": "10.20.0", + "@sentry-internal/typescript": "10.20.0", "@typescript-eslint/eslint-plugin": "^5.48.0", "@typescript-eslint/parser": "^5.48.0", "eslint-config-prettier": "^6.11.0", diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index c3e4331c6c67..43a55a198577 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-plugin-sdk", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK eslint plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-plugin-sdk", diff --git a/packages/feedback/package.json b/packages/feedback/package.json index 8d82e10db7ef..cfd607c98884 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/feedback", - "version": "10.19.0", + "version": "10.20.0", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.19.0" + "@sentry/core": "10.20.0" }, "devDependencies": { "preact": "^10.19.4" diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 72c7b2b1b7ac..e54d827079d9 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/gatsby", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Gatsby.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby", @@ -45,8 +45,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.19.0", - "@sentry/react": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/react": "10.20.0", "@sentry/webpack-plugin": "^4.1.1" }, "peerDependencies": { diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index bafd8a30f117..a4507d3482f3 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/google-cloud-serverless", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Google Cloud Functions", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/google-cloud-serverless", @@ -48,8 +48,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", "@types/express": "^4.17.14" }, "devDependencies": { diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index 58b212042162..b13790dbfb95 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/integration-shims", - "version": "10.19.0", + "version": "10.20.0", "description": "Shims for integrations in Sentry SDK.", "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -56,7 +56,7 @@ "url": "https://github.com/getsentry/sentry-javascript/issues" }, "dependencies": { - "@sentry/core": "10.19.0" + "@sentry/core": "10.20.0" }, "engines": { "node": ">=18" diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 82981f8be2bf..db33f3f20025 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nestjs", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for NestJS", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nestjs", @@ -49,8 +49,8 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-nestjs-core": "0.50.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0" + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 67f3a07b69d0..9464ced6a3ed 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nextjs", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Next.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs", @@ -79,13 +79,13 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@rollup/plugin-commonjs": "28.0.1", - "@sentry-internal/browser-utils": "10.19.0", + "@sentry-internal/browser-utils": "10.20.0", "@sentry/bundler-plugin-core": "^4.3.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", - "@sentry/opentelemetry": "10.19.0", - "@sentry/react": "10.19.0", - "@sentry/vercel-edge": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", + "@sentry/opentelemetry": "10.20.0", + "@sentry/react": "10.20.0", + "@sentry/vercel-edge": "10.20.0", "@sentry/webpack-plugin": "^4.3.0", "chalk": "3.0.0", "resolve": "1.22.8", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index ab0109031266..701991d15783 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-core", - "version": "10.19.0", + "version": "10.20.0", "description": "Sentry Node-Core SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-core", @@ -67,8 +67,8 @@ }, "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.19.0", - "@sentry/opentelemetry": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/opentelemetry": "10.20.0", "import-in-the-middle": "^1.14.2" }, "devDependencies": { diff --git a/packages/node-native/package.json b/packages/node-native/package.json index 765e488ad07d..eaf0b9f4f15e 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-native", - "version": "10.19.0", + "version": "10.20.0", "description": "Native Tools for the Official Sentry Node.js SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-native", @@ -64,8 +64,8 @@ }, "dependencies": { "@sentry-internal/node-native-stacktrace": "^0.2.2", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0" + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/node/package.json b/packages/node/package.json index 672709429dd4..a78a670a6cdd 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node", - "version": "10.19.0", + "version": "10.20.0", "description": "Sentry Node SDK using OpenTelemetry for performance instrumentation", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node", @@ -95,9 +95,9 @@ "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.15.0", - "@sentry/core": "10.19.0", - "@sentry/node-core": "10.19.0", - "@sentry/opentelemetry": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/node-core": "10.20.0", + "@sentry/opentelemetry": "10.20.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" }, diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index bc8d083373fc..b43fbf854d2e 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nuxt", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Nuxt", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nuxt", @@ -47,13 +47,13 @@ }, "dependencies": { "@nuxt/kit": "^3.13.2", - "@sentry/browser": "10.19.0", - "@sentry/cloudflare": "10.19.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", + "@sentry/browser": "10.20.0", + "@sentry/cloudflare": "10.20.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", "@sentry/rollup-plugin": "^4.3.0", "@sentry/vite-plugin": "^4.3.0", - "@sentry/vue": "10.19.0" + "@sentry/vue": "10.20.0" }, "devDependencies": { "@nuxt/module-builder": "^0.8.4", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 4b0c279191e4..8be9d3fe62dd 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/opentelemetry", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry utilities for OpenTelemetry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/opentelemetry", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.19.0" + "@sentry/core": "10.20.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json index ea9fabc0e999..177badb3987c 100644 --- a/packages/profiling-node/package.json +++ b/packages/profiling-node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/profiling-node", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Node.js Profiling", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/profiling-node", @@ -63,8 +63,8 @@ }, "dependencies": { "@sentry-internal/node-cpu-profiler": "^2.2.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0" + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 078879f13c97..ed996110c024 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react-router", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for React Router (Framework)", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react-router", @@ -49,11 +49,11 @@ "@opentelemetry/core": "^2.1.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/browser": "10.19.0", + "@sentry/browser": "10.20.0", "@sentry/cli": "^2.56.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", - "@sentry/react": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", + "@sentry/react": "10.20.0", "@sentry/vite-plugin": "^4.1.0", "glob": "11.0.1" }, diff --git a/packages/react/package.json b/packages/react/package.json index 4274cbad3aa2..8b4c9be1e4fa 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for React.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.19.0", - "@sentry/core": "10.19.0", + "@sentry/browser": "10.20.0", + "@sentry/core": "10.20.0", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { diff --git a/packages/remix/package.json b/packages/remix/package.json index 18ee7e8202a6..49f8547e9e47 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/remix", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Remix", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/remix", @@ -69,9 +69,9 @@ "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", "@sentry/cli": "^2.56.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", - "@sentry/react": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", + "@sentry/react": "10.20.0", "glob": "^10.3.4", "yargs": "^17.6.0" }, diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index f6e6180a6f54..2d5cbe1f4979 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-canvas", - "version": "10.19.0", + "version": "10.20.0", "description": "Replay canvas integration", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -69,8 +69,8 @@ "@sentry-internal/rrweb": "2.37.0" }, "dependencies": { - "@sentry-internal/replay": "10.19.0", - "@sentry/core": "10.19.0" + "@sentry-internal/replay": "10.20.0", + "@sentry/core": "10.20.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index aa72638ec1f7..ba9f70516b01 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay", - "version": "10.19.0", + "version": "10.20.0", "description": "User replays for Sentry", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -81,7 +81,7 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@babel/core": "^7.27.7", - "@sentry-internal/replay-worker": "10.19.0", + "@sentry-internal/replay-worker": "10.20.0", "@sentry-internal/rrweb": "2.37.0", "@sentry-internal/rrweb-snapshot": "2.37.0", "fflate": "0.8.2", @@ -90,8 +90,8 @@ "node-fetch": "^2.6.7" }, "dependencies": { - "@sentry-internal/browser-utils": "10.19.0", - "@sentry/core": "10.19.0" + "@sentry-internal/browser-utils": "10.20.0", + "@sentry/core": "10.20.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index 22da1f58008b..3e02a1ef7fd7 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-worker", - "version": "10.19.0", + "version": "10.20.0", "description": "Worker for @sentry-internal/replay", "main": "build/esm/index.js", "module": "build/esm/index.js", diff --git a/packages/solid/package.json b/packages/solid/package.json index 5fdde9a0c97a..74851199d7d7 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solid", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Solid", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solid", @@ -54,8 +54,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.19.0", - "@sentry/core": "10.19.0" + "@sentry/browser": "10.20.0", + "@sentry/core": "10.20.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4", @@ -72,8 +72,8 @@ }, "devDependencies": { "@solidjs/router": "^0.13.4", - "@tanstack/solid-router": "^1.132.27", "@solidjs/testing-library": "0.8.5", + "@tanstack/solid-router": "^1.132.27", "@testing-library/dom": "^7.21.4", "@testing-library/jest-dom": "^6.4.5", "@testing-library/user-event": "^14.5.2", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 4bece108f10b..09cd15891644 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solidstart", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Solid Start", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solidstart", @@ -66,9 +66,9 @@ } }, "dependencies": { - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", - "@sentry/solid": "10.19.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", + "@sentry/solid": "10.20.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 877f3c1b1907..e65fa84fd435 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/svelte", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Svelte", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/svelte", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.19.0", - "@sentry/core": "10.19.0", + "@sentry/browser": "10.20.0", + "@sentry/core": "10.20.0", "magic-string": "^0.30.0" }, "peerDependencies": { diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 23414f884d9c..b0fb07508ef7 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/sveltekit", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for SvelteKit", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/sveltekit", @@ -48,10 +48,10 @@ }, "dependencies": { "@babel/parser": "7.26.9", - "@sentry/cloudflare": "10.19.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", - "@sentry/svelte": "10.19.0", + "@sentry/cloudflare": "10.20.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", + "@sentry/svelte": "10.20.0", "@sentry/vite-plugin": "^4.1.0", "magic-string": "0.30.7", "recast": "0.23.11", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index cd219cb1fe5f..71ae6f8c4e53 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart-react", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for TanStack Start React", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart-react", @@ -52,10 +52,10 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry-internal/browser-utils": "10.19.0", - "@sentry/core": "10.19.0", - "@sentry/node": "10.19.0", - "@sentry/react": "10.19.0" + "@sentry-internal/browser-utils": "10.20.0", + "@sentry/core": "10.20.0", + "@sentry/node": "10.20.0", + "@sentry/react": "10.20.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/tanstackstart/package.json b/packages/tanstackstart/package.json index f1eeee1f5432..b03139402bdb 100644 --- a/packages/tanstackstart/package.json +++ b/packages/tanstackstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart", - "version": "10.19.0", + "version": "10.20.0", "description": "Utilities for the Sentry TanStack Start SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart", diff --git a/packages/types/package.json b/packages/types/package.json index 6bf8384a85f3..e393534729c7 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/types", - "version": "10.19.0", + "version": "10.20.0", "description": "Types for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/types", @@ -57,7 +57,7 @@ "yalc:publish": "yalc publish --push --sig" }, "dependencies": { - "@sentry/core": "10.19.0" + "@sentry/core": "10.20.0" }, "volta": { "extends": "../../package.json" diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 84cc81b54d0d..dc465ec207dd 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/typescript", - "version": "10.19.0", + "version": "10.20.0", "description": "Typescript configuration used at Sentry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/typescript", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index f501491e9656..94ce4856d199 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vercel-edge", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for the Vercel Edge Runtime", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vercel-edge", @@ -41,14 +41,14 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/resources": "^2.1.0", - "@sentry/core": "10.19.0" + "@sentry/core": "10.20.0" }, "devDependencies": { "@edge-runtime/types": "3.0.1", "@opentelemetry/core": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/opentelemetry": "10.19.0" + "@sentry/opentelemetry": "10.20.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/vue/package.json b/packages/vue/package.json index 005d556acb73..ba35ef861b38 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vue", - "version": "10.19.0", + "version": "10.20.0", "description": "Official Sentry SDK for Vue.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vue", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.19.0", - "@sentry/core": "10.19.0" + "@sentry/browser": "10.20.0", + "@sentry/core": "10.20.0" }, "peerDependencies": { "pinia": "2.x || 3.x", diff --git a/packages/wasm/package.json b/packages/wasm/package.json index d733dab7a353..35515307262d 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/wasm", - "version": "10.19.0", + "version": "10.20.0", "description": "Support for WASM.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/wasm", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.19.0", - "@sentry/core": "10.19.0" + "@sentry/browser": "10.20.0", + "@sentry/core": "10.20.0" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", From 7bc13809dd5a3cc640ac06f85cf396f3b5de8f9e Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:33:21 +0200 Subject: [PATCH 024/190] chore(solid): Remove unnecessary import from README (#17947) --- packages/solid/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/solid/README.md b/packages/solid/README.md index 58fa5c75c345..29336b5ba250 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -67,7 +67,6 @@ 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 From 6230aed2f5ebecc752a0e5fa05d7c23e3e719a59 Mon Sep 17 00:00:00 2001 From: 0xbad0c0d3 <0xbad0c0d3@gmail.com> Date: Thu, 16 Oct 2025 11:47:31 +0300 Subject: [PATCH 025/190] fix(cloudflare): copy execution context in durable objects and handlers (#17786) I was running tests locally and noticed that they are stays running if something wents wrong that's why I've added: 04d2fbfa187a36595b940599d551dc4ca9f3d138 fixes #17514 Is there a way to run e2e tests pipeline without merging to develop? --- > [!NOTE] > Introduce `copyExecutionContext` with tests; make Cloudflare integration test runner accept `AbortSignal` and update tests; ignore JUnit reports. > > - **Cloudflare SDK**: > - **Utility**: Add `packages/cloudflare/src/utils/copyExecutionContext.ts` to clone `ExecutionContext`/`DurableObjectState` with overridable, bound methods. > - **Tests**: Add `packages/cloudflare/test/copy-execution-context.test.ts` covering method overriding, immutability safety, and symbol property preservation. > - **Integration Tests (Cloudflare)**: > - **Runner**: `dev-packages/cloudflare-integration-tests/runner.ts` `start` now accepts optional `AbortSignal` and forwards it to `spawn`. > - **Suites**: Update tests (`basic`, `tracing/anthropic-ai`, `tracing/durableobject`, `tracing/openai`) to pass `{ signal }` from Vitest and call `.start(signal)`. > - **Repo**: > - `.gitignore`: Ignore `packages/**/*.junit.xml` JUnit reports. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cb97187046d8b9c618757aba0594dfd171c438eb. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: cod1k --- .gitignore | 3 + .../cloudflare-integration-tests/runner.ts | 4 +- .../suites/basic/test.ts | 4 +- .../suites/tracing/anthropic-ai/test.ts | 4 +- .../suites/tracing/durableobject/test.ts | 4 +- .../suites/tracing/openai/test.ts | 4 +- packages/cloudflare/src/durableobject.ts | 4 +- packages/cloudflare/src/handler.ts | 23 +++++-- .../src/utils/copyExecutionContext.ts | 69 +++++++++++++++++++ packages/cloudflare/src/workflows.ts | 8 ++- .../test/copy-execution-context.test.ts | 56 +++++++++++++++ 11 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 packages/cloudflare/src/utils/copyExecutionContext.ts create mode 100644 packages/cloudflare/test/copy-execution-context.test.ts diff --git a/.gitignore b/.gitignore index f381e7e6e24d..36f8a3f6b9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ packages/gatsby/gatsby-node.d.ts # intellij *.iml /**/.wrangler/* + +#junit reports +packages/**/*.junit.xml diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 849b011250f9..b945bee2eeea 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -86,7 +86,7 @@ export function createRunner(...paths: string[]) { } return this; }, - start: function (): StartResult { + start: function (signal?: AbortSignal): StartResult { const { resolve, reject, promise: isComplete } = deferredPromise(cleanupChildProcesses); const expectedEnvelopeCount = expectedEnvelopes.length; @@ -155,7 +155,7 @@ export function createRunner(...paths: string[]) { '--var', `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, ], - { stdio }, + { stdio, signal }, ); CLEANUP_STEPS.add(() => { diff --git a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts index b785e6e37fd1..347c0d3530d8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest'; import { eventEnvelope } from '../../expect'; import { createRunner } from '../../runner'; -it('Basic error in fetch handler', async () => { +it('Basic error in fetch handler', async ({ signal }) => { const runner = createRunner(__dirname) .expect( eventEnvelope({ @@ -26,7 +26,7 @@ it('Basic error in fetch handler', async () => { }, }), ) - .start(); + .start(signal); await runner.makeRequest('get', '/', { expectError: true }); await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts index 13966caaf460..c9e112b32241 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts @@ -6,7 +6,7 @@ import { createRunner } from '../../../runner'; // want to test that the instrumentation does not break in our // cloudflare SDK. -it('traces a basic message creation request', async () => { +it('traces a basic message creation request', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { @@ -35,7 +35,7 @@ it('traces a basic message creation request', async () => { ]), ); }) - .start(); + .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts index a9daae21480f..e86508c0f101 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; -it('traces a durable object method', async () => { +it('traces a durable object method', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1]; @@ -21,7 +21,7 @@ it('traces a durable object method', async () => { }), ); }) - .start(); + .start(signal); await runner.makeRequest('get', '/hello'); await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index c1aee24136a4..eb15fd80fc97 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -6,7 +6,7 @@ import { createRunner } from '../../../runner'; // want to test that the instrumentation does not break in our // cloudflare SDK. -it('traces a basic chat completion request', async () => { +it('traces a basic chat completion request', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { @@ -37,7 +37,7 @@ it('traces a basic chat completion request', async () => { ]), ); }) - .start(); + .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); }); diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 0f139a80ccd0..64467aad9d8f 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -18,6 +18,7 @@ import { isInstrumented, markAsInstrumented } from './instrument'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { init } from './sdk'; +import { copyExecutionContext } from './utils/copyExecutionContext'; type MethodWrapperOptions = { spanName?: string; @@ -192,8 +193,9 @@ export function instrumentDurableObjectWithSentry< C extends new (state: DurableObjectState, env: E) => T, >(optionsCallback: (env: E) => CloudflareOptions, DurableObjectClass: C): C { return new Proxy(DurableObjectClass, { - construct(target, [context, env]) { + construct(target, [ctx, env]) { setAsyncLocalStorageAsyncContextStrategy(); + const context = copyExecutionContext(ctx); const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 969cb6be72ee..e3e108b913d7 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -15,6 +15,7 @@ import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; +import { copyExecutionContext } from './utils/copyExecutionContext'; /** * Wrapper for Cloudflare handlers. @@ -38,7 +39,9 @@ export function withSentry>) { - const [request, env, context] = args; + const [request, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; const options = getFinalOptions(optionsCallback(env), env); @@ -72,7 +75,10 @@ export function withSentry>) { - const [event, env, context] = args; + const [event, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; + return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); const waitUntil = context.waitUntil.bind(context); @@ -115,7 +121,10 @@ export function withSentry>) { - const [emailMessage, env, context] = args; + const [emailMessage, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; + return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); const waitUntil = context.waitUntil.bind(context); @@ -156,7 +165,9 @@ export function withSentry>) { - const [batch, env, context] = args; + const [batch, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); @@ -206,7 +217,9 @@ export function withSentry>) { - const [, env, context] = args; + const [, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; return withIsolationScope(async isolationScope => { const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/utils/copyExecutionContext.ts b/packages/cloudflare/src/utils/copyExecutionContext.ts new file mode 100644 index 000000000000..85a007f16e18 --- /dev/null +++ b/packages/cloudflare/src/utils/copyExecutionContext.ts @@ -0,0 +1,69 @@ +import { type DurableObjectState, type ExecutionContext } from '@cloudflare/workers-types'; + +type ContextType = ExecutionContext | DurableObjectState; +type OverridesStore = Map unknown>; + +/** + * Creates a new copy of the given execution context, optionally overriding methods. + * + * @param {ContextType|void} ctx - The execution context to be copied. Can be of type `ContextType` or `void`. + * @return {ContextType|void} A new execution context with the same properties and overridden methods if applicable. + */ +export function copyExecutionContext(ctx: T): T { + if (!ctx) return ctx; + + const overrides: OverridesStore = new Map(); + const contextPrototype = Object.getPrototypeOf(ctx); + const prototypeMethodNames = Object.getOwnPropertyNames(contextPrototype) as unknown as (keyof T)[]; + const ownPropertyNames = Object.getOwnPropertyNames(ctx) as unknown as (keyof T)[]; + const instrumented = new Set(['constructor']); + const descriptors = [...ownPropertyNames, ...prototypeMethodNames].reduce((prevDescriptors, methodName) => { + if (instrumented.has(methodName)) return prevDescriptors; + if (typeof ctx[methodName] !== 'function') return prevDescriptors; + instrumented.add(methodName); + const overridableDescriptor = makeOverridableDescriptor(overrides, ctx, methodName); + return { + ...prevDescriptors, + [methodName]: overridableDescriptor, + }; + }, {}); + + return Object.create(ctx, descriptors); +} + +/** + * Creates a property descriptor that allows overriding of a method on the given context object. + * + * This descriptor supports property overriding with functions only. It delegates method calls to + * the provided store if an override exists or to the original method on the context otherwise. + * + * @param {OverridesStore} store - The storage for overridden methods specific to the context type. + * @param {ContextType} ctx - The context object that contains the method to be overridden. + * @param {keyof ContextType} method - The method on the context object to create the overridable descriptor for. + * @return {PropertyDescriptor} A property descriptor enabling the overriding of the specified method. + */ +function makeOverridableDescriptor( + store: OverridesStore, + ctx: T, + method: keyof T, +): PropertyDescriptor { + return { + configurable: true, + enumerable: true, + set: newValue => { + if (typeof newValue == 'function') { + store.set(method, newValue); + return; + } + Reflect.set(ctx, method, newValue); + }, + + get: () => { + if (store.has(method)) return store.get(method); + const methodFunction = Reflect.get(ctx, method); + if (typeof methodFunction !== 'function') return methodFunction; + // We should do bind() to make sure that the method is bound to the context object - otherwise it will not work + return methodFunction.bind(ctx); + }, + }; +} diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 16327ea71ccf..17ec17e9cd85 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -22,6 +22,7 @@ import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; +import { copyExecutionContext } from './utils/copyExecutionContext'; const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; @@ -157,6 +158,9 @@ export function instrumentWorkflowWithSentry< return new Proxy(WorkFlowClass, { construct(target: C, args: [ctx: ExecutionContext, env: E], newTarget) { const [ctx, env] = args; + const context = copyExecutionContext(ctx); + args[0] = context; + const options = optionsCallback(env); const instance = Reflect.construct(target, args, newTarget) as T; return new Proxy(instance, { @@ -179,10 +183,10 @@ export function instrumentWorkflowWithSentry< return await obj.run.call( obj, event, - new WrappedWorkflowStep(event.instanceId, ctx, options, step), + new WrappedWorkflowStep(event.instanceId, context, options, step), ); } finally { - ctx.waitUntil(flush(2000)); + context.waitUntil(flush(2000)); } }); }); diff --git a/packages/cloudflare/test/copy-execution-context.test.ts b/packages/cloudflare/test/copy-execution-context.test.ts new file mode 100644 index 000000000000..3ee71a10b695 --- /dev/null +++ b/packages/cloudflare/test/copy-execution-context.test.ts @@ -0,0 +1,56 @@ +import { type Mocked, describe, expect, it, vi } from 'vitest'; +import { copyExecutionContext } from '../src/utils/copyExecutionContext'; + +describe('Copy of the execution context', () => { + describe.for([ + 'waitUntil', + 'passThroughOnException', + 'acceptWebSocket', + 'blockConcurrencyWhile', + 'getWebSockets', + 'arbitraryMethod', + 'anythingElse', + ])('%s', method => { + it('Override without changing original', async () => { + const context = { + [method]: vi.fn(), + } as any; + const copy = copyExecutionContext(context); + copy[method] = vi.fn(); + expect(context[method]).not.toBe(copy[method]); + }); + + it('Overridden method was called', async () => { + const context = { + [method]: vi.fn(), + } as any; + const copy = copyExecutionContext(context); + const overridden = vi.fn(); + copy[method] = overridden; + copy[method](); + expect(overridden).toBeCalled(); + expect(context[method]).not.toBeCalled(); + }); + }); + + it('No side effects', async () => { + const context = makeExecutionContextMock(); + expect(() => copyExecutionContext(Object.freeze(context))).not.toThrow( + /Cannot define property \w+, object is not extensible/, + ); + }); + it('Respects symbols', async () => { + const s = Symbol('test'); + const context = makeExecutionContextMock(); + context[s] = {}; + const copy = copyExecutionContext(context); + expect(copy[s]).toBe(context[s]); + }); +}); + +function makeExecutionContextMock() { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + } as unknown as Mocked; +} From a680e0c5dd3c17be6253215f302443b06b92f671 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Oct 2025 11:58:11 +0300 Subject: [PATCH 026/190] feat(browser): Add `onRequestSpanEnd` hook to browser tracing integration (#17884) This PR aims to address #9643 partially by introducing a `onRequestSpanEnd` hook to the browser integration. These changes make it easier for users to enrich tracing spans with response header data. #### Example ```ts import * as Sentry from '@sentry/browser'; Sentry.init({ // ... integrations: [ Sentry.browserTracingIntegration({ onRequestSpanEnd(span, responseInformation) { span.setAttributes({ response_type: 'JSON', }); }, }), ], }); ``` #### Tracing Integration and API Improvements * Added `onRequestSpanEnd` callback to `BrowserTracingOptions` and `RequestInstrumentationOptions`, allowing users to access response headers when a request span ends. This enables custom span annotation based on response data. * Updated internal request instrumentation logic to call `onRequestSpanEnd` for both Fetch and XHR requests, passing parsed response headers to the callback. #### Utility and Refactoring * Centralized header parsing and filtering utilities (`parseXhrResponseHeaders`, `getFetchResponseHeaders`, `filterAllowedHeaders`) in `networkUtils.ts`, and exported them for reuse across packages. * Moved helper functions for baggage header checking, URL resolution, performance timing checks, and safe header creation to a new `utils.ts` file to avoid failing the file size limit lint rule. I was hesitant to hoist up those replay utils initially but a few of them were needed to expose them on the hook callback. #### Type and API Consistency * Introduced new types `RequestHookInfo` and `ResponseHookInfo` to standardize the information passed to request span hooks, and exported them from the core package for use in integrations. I also added the necessary tests to test out the new hook. --- .size-limit.js | 2 +- .../on-request-span-end/init.js | 18 ++++++ .../on-request-span-end/subject.js | 11 ++++ .../on-request-span-end/test.ts | 61 ++++++++++++++++++ packages/browser-utils/src/index.ts | 2 +- packages/browser-utils/src/networkUtils.ts | 26 ++++++++ .../src/tracing/browserTracingIntegration.ts | 19 +++++- packages/browser/src/tracing/request.ts | 62 +++++++++---------- packages/browser/src/tracing/utils.ts | 46 ++++++++++++++ packages/core/src/fetch.ts | 23 +++++++ packages/core/src/index.ts | 8 ++- packages/core/src/types-hoist/request.ts | 18 ++++++ .../src/coreHandlers/util/xhrUtils.ts | 20 +----- 13 files changed, 259 insertions(+), 57 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts create mode 100644 packages/browser/src/tracing/utils.ts diff --git a/.size-limit.js b/.size-limit.js index 5b8374f81615..17e33dd7ff21 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -206,7 +206,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '45 KB', + limit: '46 KB', }, // SvelteKit SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js new file mode 100644 index 000000000000..9627bfc003e7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + onRequestSpanEnd(span, { headers }) { + if (headers) { + span.setAttribute('hook.called.response-type', headers.get('x-response-type')); + } + }, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js new file mode 100644 index 000000000000..8a1ec65972f2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js @@ -0,0 +1,11 @@ +fetch('http://sentry-test.io/fetch', { + headers: { + foo: 'fetch', + }, +}); + +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test.io/xhr'); +xhr.setRequestHeader('foo', 'xhr'); +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts new file mode 100644 index 000000000000..03bfc13814af --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts @@ -0,0 +1,61 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should call onRequestSpanEnd hook', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('http://sentry-test.io/fetch', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'fetch', + 'access-control-expose-headers': '*', + }, + body: '', + }); + }); + await page.route('http://sentry-test.io/xhr', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'xhr', + 'access-control-expose-headers': '*', + }, + body: '', + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url, timeout: 10000 }); + + const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + type: 'xhr', + 'hook.called.response-type': 'xhr', + }), + }), + ); + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + type: 'fetch', + 'hook.called.response-type': 'fetch', + }), + }), + ); +}); diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index accf3cb3a278..a4d0960b1ccb 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -28,7 +28,7 @@ export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; -export { getBodyString, getFetchRequestArgBody, serializeFormData } from './networkUtils'; +export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrResponseHeaders } from './networkUtils'; export { resourceTimingToSpanAttributes } from './metrics/resourceTiming'; diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index 607434251872..b8df5886e7ee 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -54,3 +54,29 @@ export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit[' return (fetchArgs[1] as RequestInit).body; } + +/** + * Parses XMLHttpRequest response headers into a Record. + * Extracted from replay internals to be reusable. + */ +export function parseXhrResponseHeaders(xhr: XMLHttpRequest): Record { + let headers: string | undefined; + try { + headers = xhr.getAllResponseHeaders(); + } catch (error) { + DEBUG_BUILD && debug.error(error, 'Failed to get xhr response headers', xhr); + return {}; + } + + if (!headers) { + return {}; + } + + return headers.split('\r\n').reduce((acc: Record, line: string) => { + const [key, value] = line.split(': ') as [string, string | undefined]; + if (value) { + acc[key.toLowerCase()] = value; + } + return acc; + }, {}); +} diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index a79f629855d7..2e3eebe86845 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -1,5 +1,13 @@ /* eslint-disable max-lines */ -import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core'; +import type { + Client, + IntegrationFn, + RequestHookInfo, + ResponseHookInfo, + Span, + StartSpanOptions, + TransactionSource, +} from '@sentry/core'; import { addNonEnumerableProperty, browserPerformanceTimeOrigin, @@ -297,7 +305,12 @@ export interface BrowserTracingOptions { * You can use it to annotate the span with additional data or attributes, for example by setting * attributes based on the passed request headers. */ - onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void; + onRequestSpanStart?(span: Span, requestInformation: RequestHookInfo): void; + + /** + * Is called when spans end for outgoing requests, providing access to response headers. + */ + onRequestSpanEnd?(span: Span, responseInformation: ResponseHookInfo): void; } const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { @@ -365,6 +378,7 @@ export const browserTracingIntegration = ((options: Partial(); @@ -125,6 +138,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, { propagateTraceparent, + onRequestSpanEnd, }); if (handlerData.response && handlerData.fetchData.__span) { @@ -205,6 +220,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial boolean, spans: Record, propagateTraceparent?: boolean, + onRequestSpanEnd?: RequestInstrumentationOptions['onRequestSpanEnd'], ): Span | undefined { const xhr = handlerData.xhr; const sentryXhrData = xhr?.[SENTRY_XHR_DATA_KEY]; @@ -337,6 +341,11 @@ function xhrCallback( setHttpStatus(span, sentryXhrData.status_code); span.end(); + onRequestSpanEnd?.(span, { + headers: createHeadersSafely(parseXhrResponseHeaders(xhr as XMLHttpRequest & SentryWrappedXMLHttpRequest)), + error: handlerData.error, + }); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete spans[spanId]; } @@ -438,18 +447,3 @@ function setHeaderOnXhr( // Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED. } } - -function baggageHeaderHasSentryValues(baggageHeader: string): boolean { - return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-')); -} - -function getFullURL(url: string): string | undefined { - try { - // By adding a base URL to new URL(), this will also work for relative urls - // If `url` is a full URL, the base URL is ignored anyhow - const parsed = new URL(url, WINDOW.location.origin); - return parsed.href; - } catch { - return undefined; - } -} diff --git a/packages/browser/src/tracing/utils.ts b/packages/browser/src/tracing/utils.ts new file mode 100644 index 000000000000..c422e3438fd9 --- /dev/null +++ b/packages/browser/src/tracing/utils.ts @@ -0,0 +1,46 @@ +import { WINDOW } from '../helpers'; + +/** + * Checks if the baggage header has Sentry values. + */ +export function baggageHeaderHasSentryValues(baggageHeader: string): boolean { + return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-')); +} + +/** + * Gets the full URL from a given URL string. + */ +export function getFullURL(url: string): string | undefined { + try { + // By adding a base URL to new URL(), this will also work for relative urls + // If `url` is a full URL, the base URL is ignored anyhow + const parsed = new URL(url, WINDOW.location.origin); + return parsed.href; + } catch { + return undefined; + } +} + +/** + * Checks if the entry is a PerformanceResourceTiming. + */ +export function isPerformanceResourceTiming(entry: PerformanceEntry): entry is PerformanceResourceTiming { + return ( + entry.entryType === 'resource' && + 'initiatorType' in entry && + typeof (entry as PerformanceResourceTiming).nextHopProtocol === 'string' && + (entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') + ); +} + +/** + * Creates a Headers object from a record of string key-value pairs, and returns undefined if it fails. + */ +export function createHeadersSafely(headers: Record | undefined): Headers | undefined { + try { + return new Headers(headers); + } catch { + // noop + return undefined; + } +} diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 9ab62ec732da..b16672430a5d 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -4,6 +4,7 @@ import { setHttpStatus, SPAN_STATUS_ERROR, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; import type { FetchBreadcrumbHint } from './types-hoist/breadcrumb'; import type { HandlerDataFetch } from './types-hoist/instrument'; +import type { ResponseHookInfo } from './types-hoist/request'; import type { Span, SpanAttributes, SpanOrigin } from './types-hoist/span'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils/baggage'; import { hasSpansEnabled } from './utils/hasSpansEnabled'; @@ -24,6 +25,7 @@ type PolymorphicRequestHeaders = interface InstrumentFetchRequestOptions { spanOrigin?: SpanOrigin; propagateTraceparent?: boolean; + onRequestSpanEnd?: (span: Span, responseInformation: ResponseHookInfo) => void; } /** @@ -82,6 +84,8 @@ export function instrumentFetchRequest( if (span) { endSpan(span, handlerData); + _callOnRequestSpanEnd(span, handlerData, spanOriginOrOptions); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete spans[spanId]; } @@ -141,6 +145,25 @@ export function instrumentFetchRequest( return span; } +/** + * Calls the onRequestSpanEnd callback if it is defined. + */ +export function _callOnRequestSpanEnd( + span: Span, + handlerData: HandlerDataFetch, + spanOriginOrOptions?: SpanOrigin | InstrumentFetchRequestOptions, +): void { + const onRequestSpanEnd = + typeof spanOriginOrOptions === 'object' && spanOriginOrOptions !== null + ? spanOriginOrOptions.onRequestSpanEnd + : undefined; + + onRequestSpanEnd?.(span, { + headers: handlerData.response?.headers, + error: handlerData.error, + }); +} + /** * Adds sentry-trace and baggage headers to the various forms of fetch headers. * exported only for testing purposes diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2377e2ce86b0..7a6c5c2e17d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -399,7 +399,13 @@ export type { SendFeedbackParams, UserFeedback, } from './types-hoist/feedback'; -export type { QueryParams, RequestEventData, SanitizedRequestData } from './types-hoist/request'; +export type { + QueryParams, + RequestEventData, + RequestHookInfo, + ResponseHookInfo, + SanitizedRequestData, +} from './types-hoist/request'; export type { Runtime } from './types-hoist/runtime'; export type { SdkInfo } from './types-hoist/sdkinfo'; export type { SdkMetadata } from './types-hoist/sdkmetadata'; diff --git a/packages/core/src/types-hoist/request.ts b/packages/core/src/types-hoist/request.ts index 834249cdd24e..028acbe9f77e 100644 --- a/packages/core/src/types-hoist/request.ts +++ b/packages/core/src/types-hoist/request.ts @@ -1,3 +1,5 @@ +import type { WebFetchHeaders } from './webfetchapi'; + /** * Request data included in an event as sent to Sentry. */ @@ -24,3 +26,19 @@ export type SanitizedRequestData = { 'http.fragment'?: string; 'http.query'?: string; }; + +export interface RequestHookInfo { + headers?: WebFetchHeaders; +} + +export interface ResponseHookInfo { + /** + * Headers from the response. + */ + headers?: WebFetchHeaders; + + /** + * Error that may have occurred during the request. + */ + error?: unknown; +} diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index bb7c631eddef..be3c205d60d9 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -1,6 +1,6 @@ import type { Breadcrumb, XhrBreadcrumbData } from '@sentry/core'; import type { NetworkMetaWarning, XhrHint } from '@sentry-internal/browser-utils'; -import { getBodyString, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import { getBodyString, parseXhrResponseHeaders, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../../debug-build'; import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData } from '../../types'; import { debug } from '../../util/logger'; @@ -104,7 +104,7 @@ function _prepareXhrData( const networkRequestHeaders = xhrInfo ? getAllowedHeaders(xhrInfo.request_headers, options.networkRequestHeaders) : {}; - const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); + const networkResponseHeaders = getAllowedHeaders(parseXhrResponseHeaders(xhr), options.networkResponseHeaders); const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input, debug) : [undefined]; const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined]; @@ -123,22 +123,6 @@ function _prepareXhrData( }; } -function getResponseHeaders(xhr: XMLHttpRequest): Record { - const headers = xhr.getAllResponseHeaders(); - - if (!headers) { - return {}; - } - - return headers.split('\r\n').reduce((acc: Record, line: string) => { - const [key, value] = line.split(': ') as [string, string | undefined]; - if (value) { - acc[key.toLowerCase()] = value; - } - return acc; - }, {}); -} - function _getXhrResponseBody(xhr: XMLHttpRequest): [string | undefined, NetworkMetaWarning?] { // We collect errors that happen, but only log them if we can't get any response body const errors: unknown[] = []; From a38eed1cca3f65945bc4926af8eea25b71e6f009 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 16 Oct 2025 11:09:29 +0200 Subject: [PATCH 027/190] test(nextjs): Skip webpack dev test for next 16 --- dev-packages/e2e-tests/test-applications/nextjs-16/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 1fd09523ddb2..46fdc5ecffa1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -18,7 +18,7 @@ "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" + "test:assert-webpack": "pnpm test:prod" }, "dependencies": { "@sentry/nextjs": "latest || *", From ee16e3570694db088271cc7e326059b10eb1697d Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:42:39 +0200 Subject: [PATCH 028/190] chore(ci): Fix external contributor action when multiple contributions existed (#17950) https://github.com/getsentry/sentry-javascript/pull/13335 added a plural `s` to the contribution message when multiple people contributed to the repo but the regex for the lookup lacks this. --- dev-packages/external-contributor-gh-action/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/external-contributor-gh-action/index.mjs b/dev-packages/external-contributor-gh-action/index.mjs index ffa9369ee2df..2bad8a16f0bd 100644 --- a/dev-packages/external-contributor-gh-action/index.mjs +++ b/dev-packages/external-contributor-gh-action/index.mjs @@ -7,7 +7,7 @@ const UNRELEASED_HEADING = `## Unreleased - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott `; -const contributorMessageRegex = /Work in this release was contributed by (.+)\. Thank you for your contribution!/; +const contributorMessageRegex = /Work in this release was contributed by (.+)\. Thank you for your contributions?!/; async function run() { const { getInput } = core; From 4dc6c7b71a82da02d379b37d021129ba5c56da90 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 16 Oct 2025 12:03:51 +0200 Subject: [PATCH 029/190] chore: Add external contributor to CHANGELOG.md (#17949) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #17786 Co-authored-by: JPeer264 <10677263+JPeer264@users.noreply.github.com> --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a14433358d0c..2c68921b62c4 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 @0xbad0c0d3. Thank you for your contribution! + ## 10.20.0 ### Important Changes @@ -42,7 +44,7 @@ - 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! +Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez. Thank you for your contributions! ## 10.19.0 From 8e3afe278275a91a0214fba7668dd93814ce06fb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 00:16:21 +0300 Subject: [PATCH 030/190] feat(nuxt): Instrument storage API (#17858) ## What This PR adds automatic instrumentation for Nuxt's storage layer (powered by [unstorage](https://unstorage.unjs.io/)), enabling performance monitoring for cache and key-value storage operations in Nuxt/Nitro applications. Storage operations will now automatically create performance spans with detailed attributes for observability in Sentry. ### What's New - **Automatic Storage Instrumentation**: Instruments all storage drivers configured in `nuxt.config.ts` via `nitro.storage` - **Comprehensive Coverage**: Tracks all storage operations including: - `getItem`, `setItem`, `hasItem`, `removeItem` - Raw variants: `getItemRaw`, `setItemRaw` - Batch operations: `getItems`, `setItems` - Utility methods: `getKeys`, `clear` - Aliases: `get`, `set`, `has`, `del`, `remove` ### Implementation Details **Span Attributes:** - `sentry.op`: `cache.{operation}` (e.g., `cache.get_item`, `cache.set_item`) - `sentry.origin`: `auto.cache.nuxt` - `cache.key`: Full key including mount prefix - `cache.hit`: `true` for successful get/has operations - `db.operation.name`: Original method name - `db.collection.name`: Storage mount point - `db.system.name`: Driver name (e.g., `memory`, `fs`, `redis`) **Files Changed:** - `packages/nuxt/src/runtime/plugins/storage.server.ts` - Runtime instrumentation plugin - `packages/nuxt/src/vite/storageConfig.ts` - Build-time configuration - `packages/nuxt/src/module.ts` - Module integration - E2E tests for Nuxt 3 & 4 --- .../test-applications/nuxt-3/nuxt.config.ts | 7 + .../nuxt-3/server/api/storage-aliases-test.ts | 46 ++++ .../nuxt-3/server/api/storage-test.ts | 54 ++++ .../nuxt-3/tests/storage-aliases.test.ts | 108 ++++++++ .../nuxt-3/tests/storage.test.ts | 154 ++++++++++++ .../test-applications/nuxt-4/nuxt.config.ts | 7 + .../nuxt-4/server/api/storage-aliases-test.ts | 46 ++++ .../nuxt-4/server/api/storage-test.ts | 54 ++++ .../nuxt-4/tests/storage-aliases.test.ts | 108 ++++++++ .../nuxt-4/tests/storage.test.ts | 151 +++++++++++ packages/nuxt/src/module.ts | 2 + .../src/runtime/plugins/storage.server.ts | 236 ++++++++++++++++++ packages/nuxt/src/vendor/server-template.ts | 17 ++ packages/nuxt/src/vite/storageConfig.ts | 21 ++ 14 files changed, 1011 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts create mode 100644 packages/nuxt/src/runtime/plugins/storage.server.ts create mode 100644 packages/nuxt/src/vendor/server-template.ts create mode 100644 packages/nuxt/src/vite/storageConfig.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts index 0fcccd560af9..8ea55702863c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts @@ -11,4 +11,11 @@ export default defineNuxtConfig({ }, }, }, + nitro: { + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..a721df04b40c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts new file mode 100644 index 000000000000..2c451be51135 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index d0ae045f1e9d..50924877649d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -21,4 +21,11 @@ export default defineNuxtConfig({ }, }, }, + nitro: { + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..1e2fc1eb16b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts new file mode 100644 index 000000000000..c171c9b6956f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 1e806e4dc2eb..947eb2710f4d 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -12,6 +12,7 @@ import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; +import { addStorageInstrumentation } from './vite/storageConfig'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; @@ -126,6 +127,7 @@ export default defineNuxtModule({ // Preps the the middleware instrumentation module. if (serverConfigFile) { addMiddlewareImports(); + addStorageInstrumentation(nuxt); } nuxt.hooks.hook('nitro:init', nitro => { diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts new file mode 100644 index 000000000000..05932394384d --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -0,0 +1,236 @@ +import { + type SpanAttributes, + type StartSpanOptions, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpan, +} from '@sentry/core'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useStorage } from 'nitropack/runtime'; +import type { Driver, Storage } from 'unstorage'; +// @ts-expect-error - This is a virtual module +import { userStorageMounts } from '#sentry/storage-config.mjs'; + +type MaybeInstrumented = T & { + __sentry_instrumented__?: boolean; +}; + +type MaybeInstrumentedDriver = MaybeInstrumented; + +type DriverMethod = keyof Driver; + +/** + * Methods that should have a attribute to indicate a cache hit. + */ +const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +/** + * Creates a Nitro plugin that instruments the storage driver. + */ +export default defineNitroPlugin(async _nitroApp => { + // This runs at runtime when the Nitro server starts + const storage = useStorage(); + // Mounts are suffixed with a colon, so we need to add it to the set items + const userMounts = new Set((userStorageMounts as string[]).map(m => `${m}:`)); + + debug.log('[storage] Starting to instrument storage drivers...'); + + // Get all mounted storage drivers + const mounts = storage.getMounts(); + for (const mount of mounts) { + // Skip excluded mounts and root mount + if (!userMounts.has(mount.base)) { + continue; + } + + instrumentDriver(mount.driver, mount.base); + } + + // Wrap the mount method to instrument future mounts + storage.mount = wrapStorageMount(storage); +}); + +/** + * Instruments a driver by wrapping all method calls using proxies. + */ +function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { + // Already instrumented, skip... + if (driver.__sentry_instrumented__) { + debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); + + return driver; + } + + debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); + + // List of driver methods to instrument + // get/set/remove are aliases and already use their {method}Item methods + const methodsToInstrument: DriverMethod[] = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', + ]; + + for (const methodName of methodsToInstrument) { + const original = driver[methodName]; + // Skip if method doesn't exist on this driver + if (typeof original !== 'function') { + continue; + } + + // Replace with instrumented + driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); + } + + // Mark as instrumented + driver.__sentry_instrumented__ = true; + + return driver; +} + +/** + * Creates an instrumented method for the given method. + */ +function createMethodWrapper( + original: (...args: unknown[]) => unknown, + methodName: DriverMethod, + driver: Driver, + mountBase: string, +): (...args: unknown[]) => unknown { + return new Proxy(original, { + async apply(target, thisArg, args) { + const options = createSpanStartOptions(methodName, driver, mountBase, args); + + debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); + + return startSpan(options, async span => { + try { + const result = await target.apply(thisArg, args); + span.setStatus({ code: SPAN_STATUS_OK }); + + if (CACHE_HIT_METHODS.has(methodName)) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isEmptyValue(result)); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }); + }, + }); +} + +/** + * Wraps the storage mount method to instrument the driver. + */ +function wrapStorageMount(storage: Storage): Storage['mount'] { + const original: MaybeInstrumented = storage.mount; + if (original.__sentry_instrumented__) { + return original; + } + + function mountWithInstrumentation(base: string, driver: Driver): Storage { + debug.log(`[storage] Instrumenting mount: "${base}"`); + + const instrumentedDriver = instrumentDriver(driver, base); + + return original(base, instrumentedDriver); + } + + mountWithInstrumentation.__sentry_instrumented__ = true; + + return mountWithInstrumentation; +} +/** + * Normalizes the method name to snake_case to be used in span names or op. + */ +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** + * Checks if the value is empty, used for cache hit detection. + */ +function isEmptyValue(value: unknown): boolean { + return value === null || value === undefined; +} + +/** + * Creates the span start options for the storage method. + */ +function createSpanStartOptions( + methodName: keyof Driver, + driver: Driver, + mountBase: string, + args: unknown[], +): StartSpanOptions { + const keys = getCacheKeys(args?.[0], mountBase); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], + 'db.operation.name': methodName, + 'db.collection.name': mountBase.replace(/:$/, ''), + 'db.system.name': driver.name ?? 'unknown', + }; + + return { + name: keys.join(', '), + attributes, + }; +} + +/** + * Gets a normalized array of cache keys. + */ +function getCacheKeys(key: unknown, prefix: string): string[] { + // Handles an array of keys + if (Array.isArray(key)) { + return key.map(k => normalizeKey(k, prefix)); + } + + return [normalizeKey(key, prefix)]; +} + +/** + * Normalizes the key to a string for `cache.key` attribute. + */ +function normalizeKey(key: unknown, prefix: string): string { + if (typeof key === 'string') { + return `${prefix}${key}`; + } + + // Handles an object with a key property + if (typeof key === 'object' && key !== null && 'key' in key) { + return `${prefix}${key.key}`; + } + + return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; +} diff --git a/packages/nuxt/src/vendor/server-template.ts b/packages/nuxt/src/vendor/server-template.ts new file mode 100644 index 000000000000..afdb46345d5c --- /dev/null +++ b/packages/nuxt/src/vendor/server-template.ts @@ -0,0 +1,17 @@ +import { useNuxt } from '@nuxt/kit'; +import type { NuxtTemplate } from 'nuxt/schema'; + +/** + * Adds a virtual file that can be used within the Nuxt Nitro server build. + * Available in NuxtKit v4, so we are porting it here. + * https://github.com/nuxt/nuxt/blob/d6df732eec1a3bd442bdb325b0335beb7e10cd64/packages/kit/src/template.ts#L55-L62 + */ +export function addServerTemplate(template: NuxtTemplate): NuxtTemplate { + const nuxt = useNuxt(); + if (template.filename) { + nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {}; + nuxt.options.nitro.virtual[template.filename] = template.getContents; + } + + return template; +} diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts new file mode 100644 index 000000000000..c0838ad154b8 --- /dev/null +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -0,0 +1,21 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import type { Nuxt } from 'nuxt/schema'; +import { addServerTemplate } from '../vendor/server-template'; + +/** + * Prepares the storage config export to be used in the runtime storage instrumentation. + */ +export function addStorageInstrumentation(nuxt: Nuxt): void { + const moduleDirResolver = createResolver(import.meta.url); + const userStorageMounts = Object.keys(nuxt.options.nitro.storage || {}); + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/storage-config.mjs', + getContents: () => { + return `export const userStorageMounts = ${JSON.stringify(userStorageMounts)};`; + }, + }); + + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); +} From 66dc9a20f53502d676d2f98f277355afcfa8cb86 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 14:26:06 +0300 Subject: [PATCH 031/190] feat(nuxt): Instrument server cache API (#17886) Adds [Nitro/Nuxt Cache API](https://nitro.build/guide/cache) instrumentation by building upon the storage instrumentation in #17858 since both use `unstorage` under the hood. #### How it works Nitro injects the cache storage on either `cache:` or the root mount depending on user configuration, also in production the `cache` storage is placed on the root mount unless the user configures it explicitly to redis or something else. We instrument both mount drivers to cover other cache use cases. --- I made sure to add e2e tests as well for `cachedEventListner` and `cachedFunction` calls which are the main ways to use the Cache API. This PR depends on the storage PR #17858. --------- Co-authored-by: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> --- .../nuxt-3/server/api/cache-test.ts | 84 +++++++++ .../nuxt-3/tests/cache.test.ts | 161 ++++++++++++++++++ .../nuxt-4/server/api/cache-test.ts | 84 +++++++++ .../nuxt-4/tests/cache.test.ts | 161 ++++++++++++++++++ .../src/runtime/plugins/storage.server.ts | 82 ++++++++- packages/nuxt/src/server/sdk.ts | 11 +- 6 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts new file mode 100644 index 000000000000..b19530e18c96 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts @@ -0,0 +1,84 @@ +import { cachedFunction, defineCachedEventHandler, defineEventHandler, getQuery } from '#imports'; + +// Test cachedFunction +const getCachedUser = cachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = cachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedEventHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineEventHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? ''); + const dataKey = String(getQuery(event).data ?? ''); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts new file mode 100644 index 000000000000..a1697136ef01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test?user=123&data=test-key'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts new file mode 100644 index 000000000000..0fb4ace46bd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts @@ -0,0 +1,84 @@ +import { cachedFunction, defineCachedEventHandler, defineEventHandler, getQuery } from '#imports'; + +// Test cachedFunction +const getCachedUser = cachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = cachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedEventHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineEventHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? '123'); + const dataKey = String(getQuery(event).data ?? 'test-key'); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts new file mode 100644 index 000000000000..1295de002145 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts index 05932394384d..710424d6995e 100644 --- a/packages/nuxt/src/runtime/plugins/storage.server.ts +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -14,6 +14,7 @@ import { } from '@sentry/core'; // eslint-disable-next-line import/no-extraneous-dependencies import { defineNitroPlugin, useStorage } from 'nitropack/runtime'; +import type { CacheEntry, ResponseCacheEntry } from 'nitropack/types'; import type { Driver, Storage } from 'unstorage'; // @ts-expect-error - This is a virtual module import { userStorageMounts } from '#sentry/storage-config.mjs'; @@ -42,6 +43,14 @@ export default defineNitroPlugin(async _nitroApp => { debug.log('[storage] Starting to instrument storage drivers...'); + // Adds cache mount to handle Nitro's cache calls + // Nitro uses the mount to cache functions and event handlers + // https://nitro.build/guide/cache + userMounts.add('cache:'); + // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. + // Either way, we need to instrument the root mount as well. + userMounts.add(''); + // Get all mounted storage drivers const mounts = storage.getMounts(); for (const mount of mounts) { @@ -123,7 +132,7 @@ function createMethodWrapper( span.setStatus({ code: SPAN_STATUS_OK }); if (CACHE_HIT_METHODS.has(methodName)) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isEmptyValue(result)); + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); } return result; @@ -177,7 +186,7 @@ function normalizeMethodName(methodName: string): string { /** * Checks if the value is empty, used for cache hit detection. */ -function isEmptyValue(value: unknown): boolean { +function isEmptyValue(value: unknown): value is null | undefined { return value === null || value === undefined; } @@ -234,3 +243,72 @@ function normalizeKey(key: unknown, prefix: string): string { return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; } + +const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; + +/** + * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. + * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. + * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. + * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. + */ +function isCacheHit(key: string, value: unknown): boolean { + try { + const isEmpty = isEmptyValue(value); + // Empty value means no cache hit either way + // Or if key doesn't match the cached function or handler patterns, we can return the empty value check + if (isEmpty || !CACHED_FN_HANDLERS_RE.test(key)) { + return !isEmpty; + } + + return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); + } catch (error) { + // this is a best effort, so we return false if we can't validate the cache entry + return false; + } +} + +/** + * Validates the cache entry. + */ +function validateCacheEntry( + key: string, + entry: CacheEntry | CacheEntry, +): boolean { + if (isEmptyValue(entry.value)) { + return false; + } + + // Date.now is used by Nitro internally, so safe to use here. + // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 + if (Date.now() > (entry.expires || 0)) { + return false; + } + + /** + * Pulled from Nitro's cache entry validation + * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 + */ + if (isResponseCacheEntry(key, entry)) { + if (entry.value.status >= 400) { + return false; + } + + if (entry.value.body === undefined) { + return false; + } + + if (entry.value.headers.etag === 'undefined' || entry.value.headers['last-modified'] === 'undefined') { + return false; + } + } + + return true; +} + +/** + * Checks if the cache entry is a response cache entry. + */ +function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { + return key.startsWith('nitro:handlers:'); +} diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index dcd2f46caec9..edbd26b3d707 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -1,5 +1,5 @@ import * as path from 'node:path'; -import type { Client, EventProcessor, Integration } from '@sentry/core'; +import type { Client, Event, EventProcessor, Integration } from '@sentry/core'; import { applySdkMetadata, debug, flush, getGlobalScope, vercelWaitUntil } from '@sentry/core'; import { type NodeOptions, @@ -40,7 +40,7 @@ export function init(options: SentryNuxtServerOptions): Client | undefined { export function lowQualityTransactionsFilter(options: SentryNuxtServerOptions): EventProcessor { return Object.assign( (event => { - if (event.type !== 'transaction' || !event.transaction) { + if (event.type !== 'transaction' || !event.transaction || isCacheEvent(event)) { return event; } @@ -111,3 +111,10 @@ async function flushSafelyWithTimeout(): Promise { DEBUG_BUILD && debug.log('Error while flushing events:\n', e); } } + +/** + * Checks if the event is a cache event. + */ +function isCacheEvent(e: Event): boolean { + return e.contexts?.trace?.origin === 'auto.cache.nuxt'; +} From af83b8778a2454b8eb6d6a61fb45d37c07ab97be Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 14:59:37 +0300 Subject: [PATCH 032/190] fix(nextjs): Inconsistent transaction naming for i18n routing (#17927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Problem When using Next.js 15 App Router with `next-intl` and `localePrefix: "as-needed"`, Web Vitals and transaction names were inconsistent across locales: - `/foo` (default locale, no prefix) → Transaction: `/:locale` ❌ - `/ar/foo` (non-default locale, with prefix) → Transaction: `/:locale/foo` ✅ This caused all default locale pages to collapse into a single `/:locale` transaction, making Web Vitals data unusable for apps with i18n routing. After investigation it seems like the route parameterization logic couldn't match `/foo` (1 segment) to the `/:locale/foo` pattern (expects 2 segments) because the locale prefix is omitted in default locale URLs. ### Solution Implemented enhanced route matching with automatic i18n prefix detection: 1. **Route Manifest Metadata** - Added `hasOptionalPrefix` flag to route info to identify routes with common i18n parameter names (`locale`, `lang`, `language`) 2. **Smart Fallback Matching** - When a route doesn't match directly, the matcher now tries prepending a placeholder segment for routes flagged with `hasOptionalPrefix` - Example: `/foo` → tries matching as `/PLACEHOLDER/foo` → matches `/:locale/foo` ✓ 3. **Updated Specificity Scoring** - changed route specificity calculation to prefer longer routes when dynamic segment counts are equal - Example: `/:locale/foo` (2 segments) now beats `/:locale` (1 segment) ### Result **After fix:** ``` URL: /foo → Transaction: /:locale/foo ✅ URL: /ar/foo → Transaction: /:locale/foo ✅ URL: /products → Transaction: /:locale/products ✅ URL: /ar/products → Transaction: /:locale/products ✅ ``` All routes now consistently use the same parameterized transaction name regardless of locale, making Web Vitals properly grouped and usable. ### Backwards Compatibility - No breaking changes - only applies when direct matching would fail - Only affects routes with first param named `locale`/`lang`/`language` - Non-i18n apps completely unaffected - Direct matches always take precedence over optional prefix matching Fixes #17775 --- Maybe we should make certain aspects of this configurable, like the `['locale', 'lang', 'language']` collection --- .../nextjs-15-intl/.gitignore | 51 ++++ .../test-applications/nextjs-15-intl/.npmrc | 2 + .../app/[locale]/i18n-test/page.tsx | 9 + .../nextjs-15-intl/app/[locale]/page.tsx | 9 + .../nextjs-15-intl/app/layout.tsx | 11 + .../nextjs-15-intl/i18n/request.ts | 14 + .../nextjs-15-intl/i18n/routing.ts | 10 + .../nextjs-15-intl/instrumentation-client.ts | 11 + .../nextjs-15-intl/instrumentation.ts | 13 + .../nextjs-15-intl/middleware.ts | 8 + .../nextjs-15-intl/next.config.js | 11 + .../nextjs-15-intl/package.json | 31 ++ .../nextjs-15-intl/playwright.config.mjs | 25 ++ .../nextjs-15-intl/sentry.edge.config.ts | 9 + .../nextjs-15-intl/sentry.server.config.ts | 12 + .../nextjs-15-intl/start-event-proxy.mjs | 14 + .../nextjs-15-intl/tests/i18n-routing.test.ts | 90 ++++++ .../nextjs-15-intl/tsconfig.json | 27 ++ .../nextjs-15/app/[locale]/i18n-test/page.tsx | 10 + .../nextjs-15/tests/i18n-routing.test.ts | 56 ++++ .../src/client/routing/parameterization.ts | 28 ++ .../config/manifest/createRouteManifest.ts | 24 +- packages/nextjs/src/config/manifest/types.ts | 5 + .../test/client/parameterization.test.ts | 289 ++++++++++++++++++ .../suites/base-path/base-path.test.ts | 1 + .../catchall-at-root/catchall-at-root.test.ts | 1 + .../manifest/suites/catchall/catchall.test.ts | 1 + .../manifest/suites/dynamic/dynamic.test.ts | 4 + .../suites/route-groups/route-groups.test.ts | 2 + 29 files changed, 775 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore new file mode 100644 index 000000000000..2d0dd371dc86 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore @@ -0,0 +1,51 @@ +# 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* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry +.sentryclirc + +pnpm-lock.yaml +.tmp_dev_server_logs +.tmp_build_stdout +.tmp_build_stderr +event-dumps +test-results + diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx new file mode 100644 index 000000000000..7e2e8d45db06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx @@ -0,0 +1,9 @@ +export default async function I18nTestPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return ( +
+

I18n Test Page

+

Current locale: {locale}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx new file mode 100644 index 000000000000..23e7b3213a3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx @@ -0,0 +1,9 @@ +export default async function LocaleRootPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return ( +
+

Locale Root

+

Current locale: {locale}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx new file mode 100644 index 000000000000..60b3740fd7a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx @@ -0,0 +1,11 @@ +export const metadata = { + title: 'Next.js 15 i18n Test', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts new file mode 100644 index 000000000000..5ed375a9107a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts @@ -0,0 +1,14 @@ +import { getRequestConfig } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { routing } from './routing'; + +export default getRequestConfig(async ({ requestLocale }) => { + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale; + + return { + locale, + messages: {}, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts new file mode 100644 index 000000000000..efa95881eabc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts @@ -0,0 +1,10 @@ +import { defineRouting } from 'next-intl/routing'; +import { createNavigation } from 'next-intl/navigation'; + +export const routing = defineRouting({ + locales: ['en', 'ar', 'fr'], + defaultLocale: 'en', + localePrefix: 'as-needed', +}); + +export const { Link, redirect, usePathname, useRouter } = createNavigation(routing); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts new file mode 100644 index 000000000000..c232101a75e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/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-intl/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts new file mode 100644 index 000000000000..14e2b3ce738a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts @@ -0,0 +1,8 @@ +import createMiddleware from 'next-intl/middleware'; +import { routing } from './i18n/routing'; + +export default createMiddleware(routing); + +export const config = { + matcher: ['/((?!api|_next|.*\\..*).*)'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js new file mode 100644 index 000000000000..edd191e14b38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js @@ -0,0 +1,11 @@ +const { withSentryConfig } = require('@sentry/nextjs'); +const createNextIntlPlugin = require('next-intl/plugin'); + +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = withSentryConfig(withNextIntl(nextConfig), { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json new file mode 100644 index 000000000000..359b939eaf50 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -0,0 +1,31 @@ +{ + "name": "nextjs-15-intl", + "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.5.4", + "next-intl": "^4.3.12", + "react": "latest", + "react-dom": "latest", + "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-intl/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs new file mode 100644 index 000000000000..38548e975851 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/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-intl/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts new file mode 100644 index 000000000000..e9521895498e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.SENTRY_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts new file mode 100644 index 000000000000..760b8b581a29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs new file mode 100644 index 000000000000..8f6b9b5886d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/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-intl', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/nextjs-15-intl-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts new file mode 100644 index 000000000000..0943df8c7216 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create consistent parameterized transaction for default locale without prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); + +test('should create consistent parameterized transaction for non-default locale with prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/ar/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); + +test('should parameterize locale root page correctly for default locale without prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); + +test('should parameterize locale root page correctly for non-default locale with prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/fr`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json new file mode 100644 index 000000000000..64c21044c49f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "target": "ES2017" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx new file mode 100644 index 000000000000..10c32a944514 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx @@ -0,0 +1,10 @@ +export default async function I18nTestPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return ( +
+

I18n Test Page

+

Current locale: {locale || 'default'}

+

This page tests i18n route parameterization

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts new file mode 100644 index 000000000000..fda0645fa1a3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create consistent parameterized transaction for i18n routes - locale: en', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/en/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + 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', + }, + }, + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create consistent parameterized transaction for i18n routes - locale: ar', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/ar/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + 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', + }, + }, + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index c20d71614234..d13097435f41 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -37,6 +37,16 @@ function getRouteSpecificity(routePath: string): number { // Static segments add 0 to score as they are most specific } + if (segments.length > 0) { + // Add a small penalty based on inverse of segment count + // This ensures that routes with more segments are preferred + // e.g., '/:locale/foo' is more specific than '/:locale' + // We use a small value (1 / segments.length) so it doesn't override the main scoring + // but breaks ties between routes with the same number of dynamic segments + const segmentCountPenalty = 1 / segments.length; + score += segmentCountPenalty; + } + return score; } @@ -134,6 +144,24 @@ function findMatchingRoutes( } } + // Try matching with optional prefix segments (for i18n routing patterns) + // This handles cases like '/foo' matching '/:locale/foo' when using next-intl with localePrefix: "as-needed" + // We do this regardless of whether we found direct matches, as we want the most specific match + if (!route.startsWith('/:')) { + for (const dynamicRoute of dynamicRoutes) { + if (dynamicRoute.hasOptionalPrefix && dynamicRoute.regex) { + // Prepend a placeholder segment to simulate the optional prefix + // e.g., '/foo' becomes '/PLACEHOLDER/foo' to match '/:locale/foo' + // Special case: '/' becomes '/PLACEHOLDER' (not '/PLACEHOLDER/') to match '/:locale' pattern + const routeWithPrefix = route === '/' ? '/SENTRY_OPTIONAL_PREFIX' : `/SENTRY_OPTIONAL_PREFIX${route}`; + const regex = getCompiledRegex(dynamicRoute.regex); + if (regex?.test(routeWithPrefix)) { + matches.push(dynamicRoute.path); + } + } + } + } + return matches; } diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 32e7db61b57b..5e2a99f66285 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -47,7 +47,11 @@ function getDynamicRouteSegment(name: string): string { return `:${name.slice(1, -1)}`; } -function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } { +function buildRegexForDynamicRoute(routePath: string): { + regex: string; + paramNames: string[]; + hasOptionalPrefix: boolean; +} { const segments = routePath.split('/').filter(Boolean); const regexSegments: string[] = []; const paramNames: string[] = []; @@ -95,7 +99,20 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam pattern = `^/${regexSegments.join('/')}$`; } - return { regex: pattern, paramNames }; + return { regex: pattern, paramNames, hasOptionalPrefix: hasOptionalPrefix(paramNames) }; +} + +/** + * Detect if the first parameter is a common i18n prefix segment + * Common patterns: locale, lang, language + */ +function hasOptionalPrefix(paramNames: string[]): boolean { + const firstParam = paramNames[0]; + if (firstParam === undefined) { + return false; + } + + return firstParam === 'locale' || firstParam === 'lang' || firstParam === 'language'; } function scanAppDirectory( @@ -116,11 +133,12 @@ function scanAppDirectory( const isDynamic = routePath.includes(':'); if (isDynamic) { - const { regex, paramNames } = buildRegexForDynamicRoute(routePath); + const { regex, paramNames, hasOptionalPrefix } = buildRegexForDynamicRoute(routePath); dynamicRoutes.push({ path: routePath, regex, paramNames, + hasOptionalPrefix, }); } else { staticRoutes.push({ diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index e3a26adfce2f..0a0946be70f7 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -14,6 +14,11 @@ export type RouteInfo = { * (Optional) The names of dynamic parameters in the route */ paramNames?: string[]; + /** + * (Optional) Indicates if the first segment is an optional prefix (e.g., for i18n routing) + * When true, routes like '/foo' should match '/:locale/foo' patterns + */ + hasOptionalPrefix?: boolean; }; /** diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index e9f484e71827..e593596aa8c1 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -644,4 +644,293 @@ describe('maybeParameterizeRoute', () => { expect(maybeParameterizeRoute('/some/random/path')).toBe('/:catchall*'); }); }); + + describe('i18n routing with optional prefix', () => { + it('should match routes with optional locale prefix for default locale paths', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/foo', + regex: '^/([^/]+)/foo$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/bar', + regex: '^/([^/]+)/bar$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/products', + regex: '^/([^/]+)/products$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Default locale paths (without prefix) should match parameterized routes + expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/bar')).toBe('/:locale/bar'); + expect(maybeParameterizeRoute('/products')).toBe('/:locale/products'); + + // Non-default locale paths (with prefix) should also match + expect(maybeParameterizeRoute('/ar/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/ar/bar')).toBe('/:locale/bar'); + expect(maybeParameterizeRoute('/ar/products')).toBe('/:locale/products'); + expect(maybeParameterizeRoute('/en/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/fr/products')).toBe('/:locale/products'); + }); + + it('should handle nested routes with optional locale prefix', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale/foo/:id', + regex: '^/([^/]+)/foo/([^/]+)$', + paramNames: ['locale', 'id'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/products/:productId', + regex: '^/([^/]+)/products/([^/]+)$', + paramNames: ['locale', 'productId'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Default locale (no prefix) + expect(maybeParameterizeRoute('/foo/123')).toBe('/:locale/foo/:id'); + expect(maybeParameterizeRoute('/products/abc')).toBe('/:locale/products/:productId'); + + // Non-default locale (with prefix) + expect(maybeParameterizeRoute('/ar/foo/123')).toBe('/:locale/foo/:id'); + expect(maybeParameterizeRoute('/ar/products/abc')).toBe('/:locale/products/:productId'); + expect(maybeParameterizeRoute('/en/foo/456')).toBe('/:locale/foo/:id'); + }); + + it('should prioritize direct matches over optional prefix matches', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/foo/:id', + regex: '^/foo/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/:locale/foo', + regex: '^/([^/]+)/foo$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Direct match should win + expect(maybeParameterizeRoute('/foo/123')).toBe('/foo/:id'); + + // Optional prefix match when direct match isn't available + expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/ar/foo')).toBe('/:locale/foo'); + }); + + it('should handle lang and language parameters as optional prefixes', () => { + const manifestWithLang: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:lang/page', + regex: '^/([^/]+)/page$', + paramNames: ['lang'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifestWithLang); + expect(maybeParameterizeRoute('/page')).toBe('/:lang/page'); + expect(maybeParameterizeRoute('/en/page')).toBe('/:lang/page'); + + const manifestWithLanguage: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:language/page', + regex: '^/([^/]+)/page$', + paramNames: ['language'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifestWithLanguage); + expect(maybeParameterizeRoute('/page')).toBe('/:language/page'); + expect(maybeParameterizeRoute('/en/page')).toBe('/:language/page'); + }); + + it('should not apply optional prefix logic to non-i18n dynamic segments', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:userId/profile', + regex: '^/([^/]+)/profile$', + paramNames: ['userId'], + hasOptionalPrefix: false, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Should not match without the userId segment + expect(maybeParameterizeRoute('/profile')).toBeUndefined(); + + // Should match with the userId segment + expect(maybeParameterizeRoute('/123/profile')).toBe('/:userId/profile'); + }); + + it('should handle real-world next-intl scenario', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/hola', + regex: '^/([^/]+)/hola$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/products', + regex: '^/([^/]+)/products$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Root should not be parameterized (it's a static route) + expect(maybeParameterizeRoute('/')).toBeUndefined(); + + // Default locale (English, no prefix) - this was the bug + expect(maybeParameterizeRoute('/hola')).toBe('/:locale/hola'); + expect(maybeParameterizeRoute('/products')).toBe('/:locale/products'); + + // Non-default locale (Arabic, with prefix) + expect(maybeParameterizeRoute('/ar')).toBe('/:locale'); + expect(maybeParameterizeRoute('/ar/hola')).toBe('/:locale/hola'); + expect(maybeParameterizeRoute('/ar/products')).toBe('/:locale/products'); + + // Other locales + expect(maybeParameterizeRoute('/en/hola')).toBe('/:locale/hola'); + expect(maybeParameterizeRoute('/fr/products')).toBe('/:locale/products'); + }); + + it('should prefer more specific routes over optional prefix matches', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/foo/:id', + regex: '^/([^/]+)/foo/([^/]+)$', + paramNames: ['locale', 'id'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/foo', + regex: '^/([^/]+)/foo$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // More specific route should win (specificity score) + expect(maybeParameterizeRoute('/foo/123')).toBe('/:locale/foo/:id'); + expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/about')).toBe('/:locale'); + }); + + it('should handle deeply nested i18n routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale/users/:userId/posts/:postId/comments/:commentId', + regex: '^/([^/]+)/users/([^/]+)/posts/([^/]+)/comments/([^/]+)$', + paramNames: ['locale', 'userId', 'postId', 'commentId'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Without locale prefix (default locale) + expect(maybeParameterizeRoute('/users/123/posts/456/comments/789')).toBe( + '/:locale/users/:userId/posts/:postId/comments/:commentId', + ); + + // With locale prefix + expect(maybeParameterizeRoute('/ar/users/123/posts/456/comments/789')).toBe( + '/:locale/users/:userId/posts/:postId/comments/:commentId', + ); + }); + + it('should handle root path with optional locale prefix', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/about', + regex: '^/([^/]+)/about$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Root path without locale prefix (default locale) + expect(maybeParameterizeRoute('/')).toBe('/:locale'); + + // Root path with locale prefix + expect(maybeParameterizeRoute('/en')).toBe('/:locale'); + expect(maybeParameterizeRoute('/ar')).toBe('/:locale'); + + // Nested routes still work + expect(maybeParameterizeRoute('/about')).toBe('/:locale/about'); + expect(maybeParameterizeRoute('/fr/about')).toBe('/:locale/about'); + }); + }); }); 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 index a1014b05c32c..097e3f603693 100644 --- 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 @@ -16,6 +16,7 @@ describe('basePath', () => { path: '/my-app/users/:id', regex: '^/my-app/users/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts index b7108b6f6f23..8d78f24a0986 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts @@ -13,6 +13,7 @@ describe('catchall', () => { path: '/:path*?', regex: '^/(.*)$', paramNames: ['path'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index b1c417970ba4..d259a1a38223 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -13,6 +13,7 @@ describe('catchall', () => { path: '/catchall/:path*?', regex: '^/catchall(?:/(.*))?$', paramNames: ['path'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index fdcae299d7cf..2ea4b4aca5d8 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -13,21 +13,25 @@ describe('dynamic', () => { path: '/dynamic/:id', regex: '^/dynamic/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, { path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, { path: '/users/:id/posts/:postId', regex: '^/users/([^/]+)/posts/([^/]+)$', paramNames: ['id', 'postId'], + hasOptionalPrefix: false, }, { path: '/users/:id/settings', regex: '^/users/([^/]+)/settings$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 36ac9077df7e..8e1fe463190e 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -23,6 +23,7 @@ describe('route-groups', () => { path: '/dashboard/:id', regex: '^/dashboard/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); @@ -55,6 +56,7 @@ describe('route-groups', () => { path: '/(dashboard)/dashboard/:id', regex: '^/\\(dashboard\\)/dashboard/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); From 55f03e019f6edb380166c84ae62b805fd019789c Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 17 Oct 2025 14:33:29 +0200 Subject: [PATCH 033/190] build: Update to typescript 5.8.0 (#17710) This updates the TS version we use to 5.8.0. We still downlevel to 3.8 so this should not be breaking (even if we were to use newer features eventually), downlevel-dts will fail/or our tests anyhow if we use some features that cannot be downlevelled. --- > [!NOTE] > Upgrade TypeScript to 5.8 across the repo, adjust tsconfigs and deps, and fix minor type issues to satisfy stricter checks. > > - **Tooling/Versions**: > - Bump `typescript` to `~5.8.0` and update version guard in `scripts/verify-packages-versions.js`. > - Update `yarn.lock` and package constraints; add new e2e app `dev-packages/e2e-tests/test-applications/generic-ts5.0`. > - **TS Configs**: > - Add `moduleResolution: "Node16"` in multiple `tsconfig.json` files and test configs. > - Add `@types/node` and include `"node"` in `types` where needed (e.g., Hydrogen test app). > - **Type/Code Adjustments**: > - Remove unnecessary casts and add explicit non-null assertions for `Map.keys().next().value`. > - Simplify handler registration (`handlers[type].push(handler)`), and minor TS cleanups in profiling/LRU/debug-id utilities. > - Cloudflare/Nuxt/SvelteKit: relax Request typing and use `@ts-expect-error` for `cf` init property; avoid unused CF type import. > - Next.js webpack: remove unnecessary non-null assertion when joining `appDirPath`. > - Remix: streamline FormData attribute handling and vendor instrumentation check. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5ec5959c166604506a9099b875de48c042f4811c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: s1gr1d --- .../test-applications/generic-ts5.0/.npmrc | 2 ++ .../test-applications/generic-ts5.0/index.ts | 5 ++++ .../generic-ts5.0/package.json | 26 +++++++++++++++++++ .../generic-ts5.0/tsconfig.json | 11 ++++++++ .../hydrogen-react-router-7/package.json | 1 + .../hydrogen-react-router-7/tsconfig.json | 2 +- package.json | 2 +- .../browser-utils/src/metrics/instrument.ts | 2 +- packages/browser/src/profiling/utils.ts | 3 ++- packages/core/src/instrument/handlers.ts | 2 +- packages/core/src/utils/debug-ids.ts | 2 +- packages/core/src/utils/lru.ts | 4 ++- packages/nextjs/src/config/webpack.ts | 2 +- packages/nextjs/tsconfig.test.json | 1 + .../node-core/src/integrations/anr/worker.ts | 2 +- .../node-core/test/helpers/mockSdkInit.ts | 2 +- packages/node-core/tsconfig.json | 3 ++- .../src/event-loop-block-watchdog.ts | 4 +-- packages/node/test/helpers/mockSdkInit.ts | 2 +- packages/node/tsconfig.json | 3 ++- packages/nuxt/src/module.ts | 8 +++--- .../plugins/sentry-cloudflare.server.ts | 4 +-- packages/remix/src/utils/utils.ts | 2 +- packages/remix/src/vendor/instrumentation.ts | 6 +---- packages/remix/test/integration/package.json | 2 +- packages/remix/tsconfig.test.json | 1 + packages/sveltekit/src/worker/cloudflare.ts | 2 +- packages/tanstackstart-react/tsconfig.json | 3 ++- packages/tanstackstart/tsconfig.json | 3 ++- packages/typescript/package.json | 2 +- scripts/verify-packages-versions.js | 2 +- yarn.lock | 7 +---- 32 files changed, 85 insertions(+), 38 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json create mode 100644 dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc b/dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts b/dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts new file mode 100644 index 000000000000..beb10260da38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts @@ -0,0 +1,5 @@ +import * as _SentryReplay from '@sentry-internal/replay'; +import * as _SentryBrowser from '@sentry/browser'; +import * as _SentryCore from '@sentry/core'; +import * as _SentryNode from '@sentry/node'; +import * as _SentryWasm from '@sentry/wasm'; diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json b/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json new file mode 100644 index 000000000000..1079d8f4c793 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json @@ -0,0 +1,26 @@ +{ + "name": "@sentry-internal/ts5.0-test", + "private": true, + "license": "MIT", + "scripts": { + "build:types": "pnpm run type-check", + "ts-version": "tsc --version", + "type-check": "tsc --project tsconfig.json", + "test:build": "pnpm install && pnpm run build:types", + "test:assert": "pnpm -v" + }, + "devDependencies": { + "typescript": "5.0.2", + "@types/node": "^18.19.1" + }, + "dependencies": { + "@sentry/browser": "latest || *", + "@sentry/core": "latest || *", + "@sentry/node": "latest || *", + "@sentry-internal/replay": "latest || *", + "@sentry/wasm": "latest || *" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json b/dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json new file mode 100644 index 000000000000..95de9c93fc38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["index.ts"], + "compilerOptions": { + "lib": ["es2018", "DOM"], + "skipLibCheck": false, + "noEmit": true, + "types": [], + "target": "es2018", + "moduleResolution": "node" + } +} diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json index 503ad2758767..bf0b59ca0adf 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json @@ -41,6 +41,7 @@ "@total-typescript/ts-reset": "^0.4.2", "@types/eslint": "^8.4.10", "@types/react": "^18.2.22", + "@types/node": "^18.19.1", "@types/react-dom": "^18.2.7", "esbuild": "0.25.0", "eslint": "^9.18.0", diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json index 0d4c4dc2e4de..6b1b95f76f6f 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json @@ -14,7 +14,7 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "baseUrl": ".", - "types": ["@shopify/oxygen-workers-types"], + "types": ["@shopify/oxygen-workers-types", "node"], "paths": { "~/*": ["app/*"] }, diff --git a/package.json b/package.json index edbd645b3c97..de0b46add91b 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "size-limit": "~11.1.6", "sucrase": "^3.35.0", "ts-node": "10.9.1", - "typescript": "~5.0.0", + "typescript": "~5.8.0", "vitest": "^3.2.4", "yalc": "^1.0.0-pre.53", "yarn-deduplicate": "6.0.2" diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 8017bd4c89e1..4c461ec6776c 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -301,7 +301,7 @@ function instrumentPerformanceObserver(type: InstrumentHandlerTypePerformanceObs function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void { handlers[type] = handlers[type] || []; - (handlers[type] as InstrumentHandlerCallback[]).push(handler); + handlers[type].push(handler); } // Get a callback which can be called to remove the instrumentation handler diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 66b202c8517f..8b7039be7a9b 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -564,7 +564,8 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi PROFILE_MAP.set(profile_id, profile); if (PROFILE_MAP.size > 30) { - const last: string = PROFILE_MAP.keys().next().value; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const last = PROFILE_MAP.keys().next().value!; PROFILE_MAP.delete(last); } } diff --git a/packages/core/src/instrument/handlers.ts b/packages/core/src/instrument/handlers.ts index 86c5a90b7c52..74dbc9902348 100644 --- a/packages/core/src/instrument/handlers.ts +++ b/packages/core/src/instrument/handlers.ts @@ -21,7 +21,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; /** Add a handler function. */ export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void { handlers[type] = handlers[type] || []; - (handlers[type] as InstrumentHandlerCallback[]).push(handler); + handlers[type].push(handler); } /** diff --git a/packages/core/src/utils/debug-ids.ts b/packages/core/src/utils/debug-ids.ts index 97f30bbe816a..fd31009ae32d 100644 --- a/packages/core/src/utils/debug-ids.ts +++ b/packages/core/src/utils/debug-ids.ts @@ -105,7 +105,7 @@ export function getDebugImagesForResources( images.push({ type: 'sourcemap', code_file: path, - debug_id: filenameDebugIdMap[path] as string, + debug_id: filenameDebugIdMap[path], }); } } diff --git a/packages/core/src/utils/lru.ts b/packages/core/src/utils/lru.ts index 2a3b7bfc8ac0..3158dff7d413 100644 --- a/packages/core/src/utils/lru.ts +++ b/packages/core/src/utils/lru.ts @@ -27,7 +27,9 @@ export class LRUMap { public set(key: K, value: V): void { if (this._cache.size >= this._maxSize) { // keys() returns an iterator in insertion order so keys().next() gives us the oldest key - this._cache.delete(this._cache.keys().next().value); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const nextKey = this._cache.keys().next().value!; + this._cache.delete(nextKey); } this._cache.set(key, value); } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 6ba07cd09f8f..0b0506d373b0 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -331,7 +331,7 @@ export function constructWebpackConfigFunction({ .map(extension => `global-error.${extension}`) .some( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - globalErrorFile => fs.existsSync(path.join(appDirPath!, globalErrorFile)), + globalErrorFile => fs.existsSync(path.join(appDirPath, globalErrorFile)), ); if ( diff --git a/packages/nextjs/tsconfig.test.json b/packages/nextjs/tsconfig.test.json index 633c4212a0e9..be787654b1a0 100644 --- a/packages/nextjs/tsconfig.test.json +++ b/packages/nextjs/tsconfig.test.json @@ -9,6 +9,7 @@ // require for top-level await "module": "Node16", + "moduleResolution": "Node16", "target": "es2020", // other package-specific, test-specific options diff --git a/packages/node-core/src/integrations/anr/worker.ts b/packages/node-core/src/integrations/anr/worker.ts index 7c2ac91f30af..3ae9e009625c 100644 --- a/packages/node-core/src/integrations/anr/worker.ts +++ b/packages/node-core/src/integrations/anr/worker.ts @@ -110,7 +110,7 @@ function applyDebugMeta(event: Event): void { for (const frame of exception.stacktrace?.frames || []) { const filename = frame.abs_path || frame.filename; if (filename && normalisedDebugImages[filename]) { - filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + filenameToDebugId.set(filename, normalisedDebugImages[filename]); } } } diff --git a/packages/node-core/test/helpers/mockSdkInit.ts b/packages/node-core/test/helpers/mockSdkInit.ts index 0ea8a93cb064..8d4cb28bfd66 100644 --- a/packages/node-core/test/helpers/mockSdkInit.ts +++ b/packages/node-core/test/helpers/mockSdkInit.ts @@ -149,7 +149,7 @@ export function getSpanProcessor(): SentrySpanProcessor | undefined { const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( (spanProcessor: SpanProcessor) => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + ); return spanProcessor; } diff --git a/packages/node-core/tsconfig.json b/packages/node-core/tsconfig.json index 07c7602c1fdd..28abec410557 100644 --- a/packages/node-core/tsconfig.json +++ b/packages/node-core/tsconfig.json @@ -5,6 +5,7 @@ "compilerOptions": { "lib": ["ES2020", "ES2021.WeakRef"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index 26b9bb683930..492070a2d1dc 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -149,7 +149,7 @@ function applyDebugMeta(event: Event, debugImages: Record): void for (const frame of exception.stacktrace?.frames || []) { const filename = stripFileProtocol(frame.abs_path || frame.filename); if (filename && normalisedDebugImages[filename]) { - filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + filenameToDebugId.set(filename, normalisedDebugImages[filename]); } } } @@ -158,7 +158,7 @@ function applyDebugMeta(event: Event, debugImages: Record): void for (const frame of thread.stacktrace?.frames || []) { const filename = stripFileProtocol(frame.abs_path || frame.filename); if (filename && normalisedDebugImages[filename]) { - filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + filenameToDebugId.set(filename, normalisedDebugImages[filename]); } } } diff --git a/packages/node/test/helpers/mockSdkInit.ts b/packages/node/test/helpers/mockSdkInit.ts index dc4c3586d978..8f8be9e8af68 100644 --- a/packages/node/test/helpers/mockSdkInit.ts +++ b/packages/node/test/helpers/mockSdkInit.ts @@ -61,7 +61,7 @@ export function getSpanProcessor(): SentrySpanProcessor | undefined { const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( (spanProcessor: SpanProcessor) => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + ); return spanProcessor; } diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index 64d6f3a1b9e0..d5f034ad1048 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -5,6 +5,7 @@ "compilerOptions": { "lib": ["es2020"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 947eb2710f4d..9e85209eeb55 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -76,14 +76,16 @@ export default defineNuxtModule({ // Add the sentry config file to the include array nuxt.hook('prepare:types', options => { - if (!options.tsConfig.include) { - options.tsConfig.include = []; + const tsConfig = options.tsConfig as { include?: string[] }; + + if (!tsConfig.include) { + tsConfig.include = []; } // Add type references for useRuntimeConfig in root files for nuxt v4 // Should be relative to `root/.nuxt` const relativePath = path.relative(nuxt.options.buildDir, clientConfigFile); - options.tsConfig.include.push(relativePath); + tsConfig.include.push(relativePath); }); } diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 5438ac829d8a..d45d45d0d4ed 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -1,4 +1,3 @@ -import type { IncomingRequestCfProperties } from '@cloudflare/workers-types'; import type { CloudflareOptions } from '@sentry/cloudflare'; import { setAsyncLocalStorageAsyncContextStrategy, wrapRequestHandler } from '@sentry/cloudflare'; import { debug, getDefaultIsolationScope, getIsolationScope, getTraceData } from '@sentry/core'; @@ -64,8 +63,9 @@ export const sentryCloudflareNitroPlugin = const request = new Request(url, { method: event.method, headers: event.headers, + // @ts-expect-error - 'cf' is a valid property in the RequestInit type for Cloudflare cf: getCfProperties(event), - }) as Request>; + }); const requestHandlerOptions = { options: cloudflareOptions, diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts index 5485cff5e0a3..9cc02341ac0a 100644 --- a/packages/remix/src/utils/utils.ts +++ b/packages/remix/src/utils/utils.ts @@ -29,7 +29,7 @@ export async function storeFormDataKeys( if (formDataKeys?.[key]) { if (typeof formDataKeys[key] === 'string') { - attrKey = formDataKeys[key] as string; + attrKey = formDataKeys[key]; } span.setAttribute( diff --git a/packages/remix/src/vendor/instrumentation.ts b/packages/remix/src/vendor/instrumentation.ts index 317a17da663d..6ccc56c7a88f 100644 --- a/packages/remix/src/vendor/instrumentation.ts +++ b/packages/remix/src/vendor/instrumentation.ts @@ -310,11 +310,7 @@ export class RemixInstrumentation extends InstrumentationBase { const { actionFormDataAttributes: actionFormAttributes } = plugin.getConfig(); formData.forEach((value: unknown, key: string) => { - if ( - actionFormAttributes?.[key] && - actionFormAttributes[key] !== false && - typeof value === 'string' - ) { + if (actionFormAttributes?.[key] && typeof value === 'string') { const keyName = actionFormAttributes[key] === true ? key : actionFormAttributes[key]; span.setAttribute(`formData.${keyName}`, value.toString()); } diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json index 04e20e5f3a56..d138a0eb8eaa 100644 --- a/packages/remix/test/integration/package.json +++ b/packages/remix/test/integration/package.json @@ -21,7 +21,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "nock": "^13.5.5", - "typescript": "~5.0.0" + "typescript": "~5.8.0" }, "resolutions": { "@sentry/browser": "file:../../../browser", diff --git a/packages/remix/tsconfig.test.json b/packages/remix/tsconfig.test.json index f62d7ff34d09..dace64b4fd9a 100644 --- a/packages/remix/tsconfig.test.json +++ b/packages/remix/tsconfig.test.json @@ -8,6 +8,7 @@ "types": ["node"], // Required for top-level await in tests "module": "Node16", + "moduleResolution": "Node16", "target": "es2020", "esModuleInterop": true diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 612b174f6c69..9cacad6f4cb8 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -39,7 +39,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { return wrapRequestHandler( { options: opts, - request: event.request as Request>, + request: event.request, // @ts-expect-error This will exist in Cloudflare context: event.platform.context, // We don't want to capture errors here, as we want to capture them in the `sentryHandle` handler diff --git a/packages/tanstackstart-react/tsconfig.json b/packages/tanstackstart-react/tsconfig.json index ff4cadba841a..220ba3fa2b86 100644 --- a/packages/tanstackstart-react/tsconfig.json +++ b/packages/tanstackstart-react/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src/**/*"], "compilerOptions": { "lib": ["es2020"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/tanstackstart/tsconfig.json b/packages/tanstackstart/tsconfig.json index ff4cadba841a..220ba3fa2b86 100644 --- a/packages/tanstackstart/tsconfig.json +++ b/packages/tanstackstart/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src/**/*"], "compilerOptions": { "lib": ["es2020"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/typescript/package.json b/packages/typescript/package.json index dc465ec207dd..430842c16d09 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -13,7 +13,7 @@ "tsconfig.json" ], "peerDependencies": { - "typescript": "~5.0.0" + "typescript": "~5.8.0" }, "scripts": { "clean": "yarn rimraf sentry-internal-typescript-*.tgz", diff --git a/scripts/verify-packages-versions.js b/scripts/verify-packages-versions.js index e6f0837cb38c..81eac62e9c90 100644 --- a/scripts/verify-packages-versions.js +++ b/scripts/verify-packages-versions.js @@ -1,6 +1,6 @@ const pkg = require('../package.json'); -const TYPESCRIPT_VERSION = '~5.0.0'; +const TYPESCRIPT_VERSION = '~5.8.0'; if (pkg.devDependencies.typescript !== TYPESCRIPT_VERSION) { console.error(` diff --git a/yarn.lock b/yarn.lock index 70c9e3d80b73..3917862a705f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30045,7 +30045,7 @@ typescript@4.6.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== -"typescript@>=3 < 6", typescript@^5.0.4, typescript@^5.4.4, typescript@^5.7.3: +"typescript@>=3 < 6", typescript@^5.0.4, typescript@^5.4.4, typescript@^5.7.3, typescript@~5.8.0: version "5.8.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== @@ -30060,11 +30060,6 @@ typescript@next: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.0-dev.20230530.tgz#4251ade97a9d8a86850c4d5c3c4f3e1cb2ccf52c" integrity sha512-bIoMajCZWzLB+pWwncaba/hZc6dRnw7x8T/fenOnP9gYQB/gc4xdm48AXp5SH5I/PvvSeZ/dXkUMtc8s8BiDZw== -typescript@~5.0.0: - version "5.0.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" - integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== - typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" From 2e652f381ab28d0b33e917a82944b1f38975f5d7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 17 Oct 2025 15:30:22 +0200 Subject: [PATCH 034/190] feat(nextjs): Support Next.js proxy files (#17926) - Supports providing a `proxy.ts` file for global middleware as `middleware.ts` will be deprecated with Next.js 16 - Forks Isolation Scope on span start in the edge SDK as we don't wrap middleware/proxy files anymore when using turbopack - Adds middleware e2e tests for next-16 closes https://github.com/getsentry/sentry-javascript/issues/17894 --- .../test-applications/nextjs-16/.npmrc | 4 + .../api/endpoint-behind-middleware/route.ts | 3 + .../test-applications/nextjs-16/package.json | 1 + .../test-applications/nextjs-16/proxy.ts | 24 ++++ .../nextjs-16/sentry.edge.config.ts | 1 + .../nextjs-16/sentry.server.config.ts | 1 + .../nextjs-16/tests/middleware.test.ts | 105 ++++++++++++++++++ .../nextjs-pages-dir/tests/middleware.test.ts | 8 +- .../src/common/wrapMiddlewareWithSentry.ts | 2 +- .../templates/middlewareWrapperTemplate.ts | 5 + packages/nextjs/src/config/webpack.ts | 7 +- packages/nextjs/src/edge/index.ts | 32 +++++- packages/nextjs/test/config/loaders.test.ts | 21 ++++ 13 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/.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/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts new file mode 100644 index 000000000000..2733cc918f44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ name: 'John Doe' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 46fdc5ecffa1..3d1df82b1748 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@sentry/nextjs": "latest || *", + "@sentry/core": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^1", "next": "16.0.0-beta.0", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/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/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts index 85bd765c9c44..2199afc46eaf 100644 --- 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 @@ -6,4 +6,5 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + // debug: 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 index 8da0a18497a0..08d5d580b314 100644 --- 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 @@ -6,5 +6,6 @@ Sentry.init({ 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/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts new file mode 100644 index 000000000000..a8096ab7bc69 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should create a transaction for middleware', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const response = await request.get('/api/endpoint-behind-middleware'); + expect(await response.json()).toStrictEqual({ name: 'John Doe' }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(middlewareTransaction.transaction_info?.source).toBe('url'); + + // Assert that isolation scope works properly + expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); +}); + +test('Faulty middlewares', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const errorEventPromise = waitForError('nextjs-16', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + // Noop + }); + + await test.step('should record transactions', async () => { + const middlewareTransaction = await middlewareTransactionPromise; + expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(middlewareTransaction.transaction_info?.source).toBe('url'); + }); + + await test.step('should record exceptions', async () => { + const errorEvent = await errorEventPromise; + + // Assert that isolation scope works properly + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // this differs between webpack and turbopack + expect(['middleware GET', '/middleware']).toContain(errorEvent.transaction); + }); +}); + +test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent?.transaction === 'middleware GET' && + !!transactionEvent.spans?.find(span => span.op === 'http.client') + ); + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => { + // Noop + }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.spans).toEqual( + expect.arrayContaining([ + { + data: { + 'http.method': 'GET', + 'http.response.status_code': 200, + type: 'fetch', + url: 'http://localhost:3030/', + 'http.url': 'http://localhost:3030/', + 'server.address': 'localhost:3030', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.wintercg_fetch', + }, + description: 'GET http://localhost:3030/', + op: 'http.client', + origin: 'auto.http.wintercg_fetch', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]), + ); + expect(middlewareTransaction.breadcrumbs).toEqual( + expect.arrayContaining([ + { + category: 'fetch', + data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' }, + timestamp: expect.any(Number), + type: 'http', + }, + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index b9c0e7b4b602..45a89f683be4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -3,7 +3,7 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware'; + return transactionEvent?.transaction === 'middleware GET'; }); const response = await request.get('/api/endpoint-behind-middleware'); @@ -23,7 +23,7 @@ test('Should create a transaction for middleware', async ({ request }) => { test('Faulty middlewares', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-faulty-middleware'; + return transactionEvent?.transaction === 'middleware GET'; }); const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { @@ -48,14 +48,14 @@ test('Faulty middlewares', async ({ request }) => { // Assert that isolation scope works properly expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect(errorEvent.transaction).toBe('middleware GET /api/endpoint-behind-faulty-middleware'); + expect(errorEvent.transaction).toBe('middleware GET'); }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( - transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware' && + transactionEvent?.transaction === 'middleware GET' && !!transactionEvent.spans?.find(span => span.op === 'http.client') ); }); diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 07694d659e57..ba4f7a852d45 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -64,7 +64,7 @@ export function wrapMiddlewareWithSentry( isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req), }); - spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; + spanName = `middleware ${req.method}`; spanSource = 'url'; } else { spanName = 'middleware'; diff --git a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts index 6d44af1275b5..236f4eff3999 100644 --- a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts +++ b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts @@ -15,6 +15,7 @@ type NextApiModule = // ESM export default?: EdgeRouteHandler; middleware?: EdgeRouteHandler; + proxy?: EdgeRouteHandler; } // CJS export | EdgeRouteHandler; @@ -29,6 +30,9 @@ let userProvidedDefaultHandler: EdgeRouteHandler | undefined = undefined; if ('middleware' in userApiModule && typeof userApiModule.middleware === 'function') { // Handle when user defines via named ESM export: `export { middleware };` userProvidedNamedHandler = userApiModule.middleware; +} else if ('proxy' in userApiModule && typeof userApiModule.proxy === 'function') { + // Handle when user defines via named ESM export (Next.js 16): `export { proxy };` + userProvidedNamedHandler = userApiModule.proxy; } else if ('default' in userApiModule && typeof userApiModule.default === 'function') { // Handle when user defines via ESM export: `export default myFunction;` userProvidedDefaultHandler = userApiModule.default; @@ -40,6 +44,7 @@ if ('middleware' in userApiModule && typeof userApiModule.middleware === 'functi export const middleware = userProvidedNamedHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) : undefined; +export const proxy = userProvidedNamedHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) : undefined; export default userProvidedDefaultHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedDefaultHandler) : undefined; // Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 0b0506d373b0..14f064ae2b0a 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -183,8 +183,11 @@ export function constructWebpackConfigFunction({ ); }; - const possibleMiddlewareLocations = pageExtensions.map(middlewareFileEnding => { - return path.join(middlewareLocationFolder, `middleware.${middlewareFileEnding}`); + const possibleMiddlewareLocations = pageExtensions.flatMap(middlewareFileEnding => { + return [ + path.join(middlewareLocationFolder, `middleware.${middlewareFileEnding}`), + path.join(middlewareLocationFolder, `proxy.${middlewareFileEnding}`), + ]; }); const isMiddlewareResource = (resourcePath: string): boolean => { const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 6469e3c6a2c8..6ee523fe72dc 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,5 +1,8 @@ +import { context } from '@opentelemetry/api'; import { applySdkMetadata, + getCapturedScopesOnSpan, + getCurrentScope, getGlobalScope, getIsolationScope, getRootSpan, @@ -8,10 +11,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setCapturedScopesOnSpan, spanToJSON, stripUrlQueryAndFragment, vercelWaitUntil, } from '@sentry/core'; +import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; @@ -73,6 +78,19 @@ export function init(options: VercelEdgeOptions = {}): void { if (spanAttributes?.['next.span_type'] === 'Middleware.execute') { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server.middleware'); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + + if (isRootSpan) { + // Fork isolation scope for middleware requests + const scopes = getCapturedScopesOnSpan(span); + const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); + const scope = scopes.scope || getCurrentScope(); + const currentScopesPointer = getScopesFromContext(context.active()); + if (currentScopesPointer) { + currentScopesPointer.isolationScope = isolationScope; + } + + setCapturedScopesOnSpan(span, scope, isolationScope); + } } if (isRootSpan) { @@ -93,7 +111,19 @@ export function init(options: VercelEdgeOptions = {}): void { event.contexts?.trace?.data?.['next.span_name'] ) { if (event.transaction) { - event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); + // Older nextjs versions pass the full url appended to the middleware name, which results in high cardinality transaction names. + // We want to remove the url from the name here. + const spanName = event.contexts.trace.data['next.span_name']; + + if (typeof spanName === 'string') { + const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + if (match) { + const normalizedName = `middleware ${match[1]}`; + event.transaction = normalizedName; + } else { + event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); + } + } } } }); diff --git a/packages/nextjs/test/config/loaders.test.ts b/packages/nextjs/test/config/loaders.test.ts index 1b290796acb3..a2c1551ae4d1 100644 --- a/packages/nextjs/test/config/loaders.test.ts +++ b/packages/nextjs/test/config/loaders.test.ts @@ -129,6 +129,27 @@ describe('webpack loaders', () => { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/middleware.tsx', expectedWrappingTargetKind: undefined, }, + // Next.js 16+ renamed middleware to proxy + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.js', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.ts', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: './src/proxy.ts', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.tsx', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/proxy.tsx', + expectedWrappingTargetKind: undefined, + }, { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/testApiRoute.ts', expectedWrappingTargetKind: 'api-route', From f94b203a7e088469b2cd098fc076a8094e0c67cd Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 18:37:10 +0300 Subject: [PATCH 035/190] feat(nuxt): Instrument Database (#17899) This pull request introduces automatic instrumentation for database queries in Nuxt applications in server side handlers using Sentry. #### Implementation Details - Instruments database `.sql`, `.prepare` and `.exec` calls. - Adds breadcrumbs and spans following cloudflare's D1 implementation. This relies on the work done in #17858 and #17886 --- .../test-applications/nuxt-3/nuxt.config.ts | 17 ++ .../test-applications/nuxt-3/package.json | 3 +- .../nuxt-3/server/api/db-multi-test.ts | 102 ++++++++ .../nuxt-3/server/api/db-test.ts | 70 ++++++ .../nuxt-3/tests/database-multi.test.ts | 156 ++++++++++++ .../nuxt-3/tests/database.test.ts | 197 +++++++++++++++ .../test-applications/nuxt-4/nuxt.config.ts | 18 +- .../test-applications/nuxt-4/package.json | 3 +- .../nuxt-4/server/api/db-multi-test.ts | 102 ++++++++ .../nuxt-4/server/api/db-test.ts | 70 ++++++ .../nuxt-4/tests/database-multi.test.ts | 156 ++++++++++++ .../nuxt-4/tests/database.test.ts | 197 +++++++++++++++ packages/nuxt/src/module.ts | 2 + .../src/runtime/plugins/database.server.ts | 232 ++++++++++++++++++ .../src/runtime/utils/database-span-data.ts | 46 ++++ packages/nuxt/src/vite/databaseConfig.ts | 38 +++ .../runtime/utils/database-span-data.test.ts | 199 +++++++++++++++ 17 files changed, 1605 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts create mode 100644 packages/nuxt/src/runtime/plugins/database.server.ts create mode 100644 packages/nuxt/src/runtime/utils/database-span-data.ts create mode 100644 packages/nuxt/src/vite/databaseConfig.ts create mode 100644 packages/nuxt/test/runtime/utils/database-span-data.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts index 8ea55702863c..8f920a41e76e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts @@ -12,6 +12,23 @@ export default defineNuxtConfig({ }, }, nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, storage: { 'test-storage': { driver: 'memory', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index b38943d6e3eb..bbf0ced23c12 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -33,6 +33,7 @@ ] }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.20.0" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts new file mode 100644 index 000000000000..383617421ec7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts @@ -0,0 +1,102 @@ +import { defineEventHandler, getQuery, useDatabase } from '#imports'; + +export default defineEventHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts new file mode 100644 index 000000000000..2241afdee14d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts @@ -0,0 +1,70 @@ +import { defineEventHandler, getQuery, useDatabase } from '#imports'; + +export default defineEventHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + case 'prepare-all': { + await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); + await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES + (1, 'Product A', 10.99), + (2, 'Product B', 20.50), + (3, 'Product C', 15.25)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + case 'prepare-bind': { + await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); + await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES + (1, 'electronics', 100), + (2, 'books', 50), + (3, 'electronics', 200)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts new file mode 100644 index 000000000000..a229b4db34cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts new file mode 100644 index 000000000000..ecb0e32133db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-3', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + expect(error).toBeDefined(); + expect(error.exception?.values?.[0]?.value).toContain('no such table'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index 50924877649d..c7acd2b12328 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -13,7 +13,6 @@ export default defineNuxtConfig({ }, modules: ['@pinia/nuxt', '@sentry/nuxt/module'], - runtimeConfig: { public: { sentry: { @@ -22,6 +21,23 @@ export default defineNuxtConfig({ }, }, nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, storage: { 'test-storage': { driver: 'memory', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index b16b7ee2b236..a5d36c1f6a61 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -25,7 +25,8 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.20.0" }, "sentryTest": { "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts new file mode 100644 index 000000000000..53f110c1ce28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts @@ -0,0 +1,102 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts new file mode 100644 index 000000000000..4460758ab414 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts @@ -0,0 +1,70 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + case 'prepare-all': { + await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); + await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES + (1, 'Product A', 10.99), + (2, 'Product B', 20.50), + (3, 'Product C', 15.25)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + case 'prepare-bind': { + await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); + await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES + (1, 'electronics', 100), + (2, 'books', 50), + (3, 'electronics', 200)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts new file mode 100644 index 000000000000..9d995fa1b37c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts new file mode 100644 index 000000000000..9b9fdd892563 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-4', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + expect(error).toBeDefined(); + expect(error.exception?.values?.[0]?.value).toContain('no such table'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 9e85209eeb55..3656eac56e63 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -10,6 +10,7 @@ import { consoleSandbox } from '@sentry/core'; import * as path from 'path'; import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; +import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; @@ -130,6 +131,7 @@ export default defineNuxtModule({ if (serverConfigFile) { addMiddlewareImports(); addStorageInstrumentation(nuxt); + addDatabaseInstrumentation(nuxt.options.nitro); } nuxt.hooks.hook('nitro:init', nitro => { diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts new file mode 100644 index 000000000000..9cdff58d336e --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -0,0 +1,232 @@ +import { + type Span, + type StartSpanOptions, + addBreadcrumb, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startSpan, +} from '@sentry/core'; +import type { Database, PreparedStatement } from 'db0'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +// @ts-expect-error - This is a virtual module +import { databaseConfig } from '#sentry/database-config.mjs'; +import { type DatabaseSpanData, getDatabaseSpanData } from '../utils/database-span-data'; + +type MaybeInstrumentedDatabase = Database & { + __sentry_instrumented__?: boolean; +}; + +/** + * Keeps track of prepared statements that have been patched. + */ +const patchedStatement = new WeakSet(); + +/** + * The Sentry origin for the database plugin. + */ +const SENTRY_ORIGIN = 'auto.db.nuxt'; + +/** + * Creates a Nitro plugin that instruments the database calls. + */ +export default defineNitroPlugin(() => { + try { + const _databaseConfig = databaseConfig as Record; + const databaseInstances = Object.keys(databaseConfig); + debug.log('[Nitro Database Plugin]: Instrumenting databases...'); + + for (const instance of databaseInstances) { + debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); + const db = useDatabase(instance); + instrumentDatabase(db, _databaseConfig[instance]); + } + + debug.log('[Nitro Database Plugin]: Databases instrumented.'); + } catch (error) { + // During build time, we can't use the useDatabase function, so we just log an error. + if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { + debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); + return; + } + + debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); + } +}); + +/** + * Instruments a database instance with Sentry. + */ +function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConfig): void { + if (db.__sentry_instrumented__) { + debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); + return; + } + + const metadata: DatabaseSpanData = { + 'db.system.name': config?.connector ?? db.dialect, + ...getDatabaseSpanData(config), + }; + + db.prepare = new Proxy(db.prepare, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + + return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); + }, + }); + + // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly + // So we have to patch it manually, and would mean we would have less info in the spans. + // https://github.com/unjs/db0/blob/main/src/database.ts#L64 + db.sql = new Proxy(db.sql, { + apply(target, thisArg, args: Parameters) { + const query = args[0]?.[0] ?? ''; + const opts = createStartSpanOptions(query, metadata); + + return startSpan( + opts, + handleSpanStart(() => target.apply(thisArg, args)), + ); + }, + }); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(args[0], metadata), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), + ); + }, + }); + + db.__sentry_instrumented__ = true; +} + +/** + * Instruments a DB prepared statement with Sentry. + * + * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` + * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. + */ +function instrumentPreparedStatement( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.bind = new Proxy(statement.bind, { + apply(target, thisArg, args: Parameters) { + return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); + }, + }); + + return instrumentPreparedStatementQueries(statement, query, data); +} + +/** + * Patches the query methods of a DB prepared statement with Sentry. + */ +function instrumentPreparedStatementQueries( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + if (patchedStatement.has(statement)) { + return statement; + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.get = new Proxy(statement.get, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.run = new Proxy(statement.run, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.all = new Proxy(statement.all, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + patchedStatement.add(statement); + + return statement; +} + +/** + * Creates a span start callback handler + */ +function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { + return async (span: Span) => { + try { + const result = await fn(); + if (breadcrumbOpts) { + createBreadcrumb(breadcrumbOpts.query); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: SENTRY_ORIGIN, + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }; +} + +function createBreadcrumb(query: string): void { + addBreadcrumb({ + category: 'query', + message: query, + data: { + 'db.query.text': query, + }, + }); +} + +/** + * Creates a start span options object. + */ +function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { + return { + name: query, + attributes: { + 'db.query.text': query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + ...data, + }, + }; +} diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts new file mode 100644 index 000000000000..e5d9c8dc7cec --- /dev/null +++ b/packages/nuxt/src/runtime/utils/database-span-data.ts @@ -0,0 +1,46 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; + +export interface DatabaseSpanData { + [key: string]: string | number | undefined; +} + +/** + * Extracts span attributes from the database configuration. + */ +export function getDatabaseSpanData(config?: DatabaseConfig): Partial { + try { + if (!config?.connector) { + // Default to SQLite if no connector is configured + return { + 'db.namespace': 'db.sqlite', + }; + } + + if (config.connector === 'postgresql' || config.connector === 'mysql2') { + return { + 'server.address': config.options?.host, + 'server.port': config.options?.port, + }; + } + + if (config.connector === 'pglite') { + return { + 'db.namespace': config.options?.dataDir, + }; + } + + if ((['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[]).includes(config.connector)) { + return { + // DB is the default file name in nitro for sqlite-like connectors + 'db.namespace': `${config.options?.name ?? 'db'}.sqlite`, + }; + } + + return {}; + } catch { + // This is a best effort to get some attributes, so it is not an absolute must + // Since the user can configure invalid options, we should not fail the whole instrumentation. + return {}; + } +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts new file mode 100644 index 000000000000..dfe27fd9821d --- /dev/null +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -0,0 +1,38 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import { consoleSandbox } from '@sentry/core'; +import type { NitroConfig } from 'nitropack/types'; +import { addServerTemplate } from '../vendor/server-template'; + +/** + * Sets up the database instrumentation. + */ +export function addDatabaseInstrumentation(nitro: NitroConfig): void { + if (!nitro.experimental?.database) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log( + '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', + ); + }); + + return; + } + + /** + * This is a different option than the one in `experimental.database`, this configures multiple database instances. + * keys represent database names to be passed to `useDatabase(name?)`. + * We also use the config to populate database span attributes. + * https://nitro.build/guide/database#configuration + */ + const databaseConfig = nitro.database || { default: {} }; + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/database-config.mjs', + getContents: () => { + return `export const databaseConfig = ${JSON.stringify(databaseConfig)};`; + }, + }); + + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); +} diff --git a/packages/nuxt/test/runtime/utils/database-span-data.test.ts b/packages/nuxt/test/runtime/utils/database-span-data.test.ts new file mode 100644 index 000000000000..fc4f4b376af8 --- /dev/null +++ b/packages/nuxt/test/runtime/utils/database-span-data.test.ts @@ -0,0 +1,199 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +import { describe, expect, it } from 'vitest'; +import { getDatabaseSpanData } from '../../../src/runtime/utils/database-span-data'; + +describe('getDatabaseSpanData', () => { + describe('no config', () => { + it('should return default SQLite namespace when no config provided', () => { + const result = getDatabaseSpanData(); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + + it('should return default SQLite namespace when config has no connector', () => { + const result = getDatabaseSpanData({} as DatabaseConfig); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + }); + + describe('PostgreSQL connector', () => { + it('should extract host and port for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'localhost', + port: 5432, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'localhost', + 'server.port': 5432, + }); + }); + + it('should handle missing options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + + it('should handle partial options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'pg-host', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'pg-host', + 'server.port': undefined, + }); + }); + }); + + describe('MySQL connector', () => { + it('should extract host and port for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + options: { + host: 'mysql-host', + port: 3306, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'mysql-host', + 'server.port': 3306, + }); + }); + + it('should handle missing options for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + }); + + describe('PGLite connector', () => { + it('should extract dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: { + dataDir: '/path/to/data', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': '/path/to/data', + }); + }); + + it('should handle missing dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': undefined, + }); + }); + }); + + describe('SQLite-like connectors', () => { + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should extract database name for %s', + connector => { + const config: DatabaseConfig = { + connector, + options: { + name: 'custom-db', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'custom-db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should use default name for %s when name is not provided', + connector => { + const config: DatabaseConfig = { + connector, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should handle missing options for %s', + connector => { + const config: DatabaseConfig = { + connector, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + }); + + describe('unsupported connector', () => { + it('should return empty object for unsupported connector', () => { + const config: DatabaseConfig = { + connector: 'unknown-connector' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({}); + }); + }); + + describe('error handling', () => { + it('should return empty object when accessing invalid config throws', () => { + // Simulate a config that might throw during access + const invalidConfig = { + connector: 'postgresql' as ConnectorName, + get options(): never { + throw new Error('Invalid access'); + }, + }; + + const result = getDatabaseSpanData(invalidConfig as unknown as DatabaseConfig); + expect(result).toEqual({}); + }); + }); +}); From 24ecd3a81522cdb726a6c3f211b0d43c64d918af Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 17 Oct 2025 13:16:18 -0400 Subject: [PATCH 036/190] feat(replay): Record outcome when event buffer size exceeded (#17946) Change to record an outcome when failed to add to replay event buffer due to size limitations. This also moves up the `internal_sdk_error` outcome to be recorded before we stop the replay. Note we use the `buffer_overflow` outcome as it is the closest in meaning (source https://github.com/getsentry/snuba/blob/6c73be60716c2fb1c30ca627883207887c733cbd/rust_snuba/src/processors/outcomes.rs#L39) --- .size-limit.js | 2 +- packages/replay-internal/src/util/addEvent.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 17e33dd7ff21..5de4268a53d6 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -75,7 +75,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '84 KB', + limit: '85 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', diff --git a/packages/replay-internal/src/util/addEvent.ts b/packages/replay-internal/src/util/addEvent.ts index a133d9de6303..0cd76227379c 100644 --- a/packages/replay-internal/src/util/addEvent.ts +++ b/packages/replay-internal/src/util/addEvent.ts @@ -83,6 +83,14 @@ async function _addEvent( } catch (error) { const isExceeded = error && error instanceof EventBufferSizeExceededError; const reason = isExceeded ? 'addEventSizeExceeded' : 'addEvent'; + const client = getClient(); + + if (client) { + // We are limited in the drop reasons: + // https://github.com/getsentry/snuba/blob/6c73be60716c2fb1c30ca627883207887c733cbd/rust_snuba/src/processors/outcomes.rs#L39 + const dropReason = isExceeded ? 'buffer_overflow' : 'internal_sdk_error'; + client.recordDroppedEvent(dropReason, 'replay'); + } if (isExceeded && isBufferMode) { // Clear buffer and wait for next checkout @@ -95,12 +103,6 @@ async function _addEvent( replay.handleException(error); await replay.stop({ reason }); - - const client = getClient(); - - if (client) { - client.recordDroppedEvent('internal_sdk_error', 'replay'); - } } } From 9742f9ebbf75bd0b7a8b4e1f5016d0f94e503765 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 20 Oct 2025 13:47:04 +0200 Subject: [PATCH 037/190] test(nextjs): Fix proxy/middleware test (#17970) closes https://github.com/getsentry/sentry-javascript/issues/17968 closes https://github.com/getsentry/sentry-javascript/issues/17967 --- .../test-applications/nextjs-16/tests/middleware.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index a8096ab7bc69..4ed289eb6215 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -48,8 +48,11 @@ test('Faulty middlewares', async ({ request }) => { // Assert that isolation scope works properly expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - // this differs between webpack and turbopack - expect(['middleware GET', '/middleware']).toContain(errorEvent.transaction); + expect([ + 'middleware GET', // non-otel webpack versions + '/middleware', // middleware file + '/proxy', // proxy file + ]).toContain(errorEvent.transaction); }); }); From f6645059133981ff225436dfb792c447b4092ecd Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 20 Oct 2025 10:18:01 -0400 Subject: [PATCH 038/190] chore(build): Upgrade nodemon to 3.1.10 (#17956) Got inspired at jsconf to do some dep upgrades. There are no breaking changes for nodemon that affect us when going from v2 -> v3: https://github.com/remy/nodemon/releases --- package.json | 2 +- yarn.lock | 328 +++++---------------------------------------------- 2 files changed, 31 insertions(+), 299 deletions(-) diff --git a/package.json b/package.json index de0b46add91b..0c3d47a3c7b3 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "jsdom": "^21.1.2", "lerna": "7.1.1", "madge": "7.0.0", - "nodemon": "^2.0.16", + "nodemon": "^3.1.10", "npm-run-all2": "^6.2.0", "prettier": "^3.6.2", "prettier-plugin-astro": "^0.14.1", diff --git a/yarn.lock b/yarn.lock index 3917862a705f..c0bc7ba27923 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7110,11 +7110,6 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@sindresorhus/is@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" - integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== - "@sindresorhus/is@^7.0.2": version "7.0.2" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-7.0.2.tgz#a0df078a8d29f9741503c5a9c302de474ec8564a" @@ -7921,13 +7916,6 @@ dependencies: tslib "^2.4.0" -"@szmarczak/http-timer@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" - integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== - 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" @@ -10379,7 +10367,7 @@ amqplib@^0.10.7: buffer-more-ints "~1.0.0" url-parse "~1.5.10" -ansi-align@^3.0.0, ansi-align@^3.0.1: +ansi-align@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== @@ -11689,20 +11677,6 @@ bowser@^2.11.0: resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== -boxen@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" - integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^6.2.0" - chalk "^4.1.0" - cli-boxes "^2.2.1" - string-width "^4.2.2" - type-fest "^0.20.2" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" - boxen@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/boxen/-/boxen-7.1.1.tgz#f9ba525413c2fec9cdb88987d835c4f7cad9c8f4" @@ -12460,19 +12434,6 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" -cacheable-request@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" - integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== - dependencies: - clone-response "^1.0.2" - get-stream "^5.1.0" - http-cache-semantics "^4.0.0" - keyv "^3.0.0" - lowercase-keys "^2.0.0" - normalize-url "^4.1.0" - responselike "^1.0.2" - calculate-cache-key-for-tree@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/calculate-cache-key-for-tree/-/calculate-cache-key-for-tree-2.0.0.tgz#7ac57f149a4188eacb0a45b210689215d3fef8d6" @@ -12544,7 +12505,7 @@ camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0, camelcase@^6.3.0: +camelcase@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== @@ -12744,11 +12705,6 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== - ci-info@^3.2.0, ci-info@^3.4.0, ci-info@^3.6.1, ci-info@^3.7.0, ci-info@^3.8.0: version "3.9.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" @@ -12825,11 +12781,6 @@ clear@^0.1.0: resolved "https://registry.yarnpkg.com/clear/-/clear-0.1.0.tgz#b81b1e03437a716984fd7ac97c87d73bdfe7048a" integrity sha512-qMjRnoL+JDPJHeLePZJuao6+8orzHMGP04A8CdwCNsKhRbOnKRjefxONR7bwILT3MHecxKBjHkKL/tkZ8r4Uzw== -cli-boxes@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" - integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== - cli-boxes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-3.0.0.tgz#71a10c716feeba005e4504f36329ef0b17cf3145" @@ -12933,13 +12884,6 @@ clone-deep@4.0.1, clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" -clone-response@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" - integrity sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q== - dependencies: - mimic-response "^1.0.0" - clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -13961,10 +13905,10 @@ debug@3.1.0: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1: - version "4.4.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" - integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== +debug@4, debug@^4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" @@ -14026,13 +13970,6 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== -decompress-response@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" - integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M= - dependencies: - mimic-response "^1.0.0" - decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -14152,11 +14089,6 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" -defer-to-connect@^1.0.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" - integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== - define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -14710,11 +14642,6 @@ dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -duplexer3@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" - integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= - duplexer@^0.1.1, duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" @@ -16151,11 +16078,6 @@ escalade@^3.1.1, escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-goat@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" - integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== - escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -17791,7 +17713,7 @@ get-stream@6.0.0: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== -get-stream@^4.0.0, get-stream@^4.1.0: +get-stream@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== @@ -18055,13 +17977,6 @@ global-directory@^4.0.1: dependencies: ini "4.1.1" -global-dirs@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" - integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== - dependencies: - ini "2.0.0" - global-modules@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" @@ -18211,23 +18126,6 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -got@^9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" - integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== - dependencies: - "@sindresorhus/is" "^0.14.0" - "@szmarczak/http-timer" "^1.1.2" - cacheable-request "^6.0.0" - decompress-response "^3.3.0" - duplexer3 "^0.1.4" - get-stream "^4.1.0" - lowercase-keys "^1.0.1" - mimic-response "^1.0.1" - p-cancelable "^1.0.0" - to-readable-stream "^1.0.0" - url-parse-lax "^3.0.0" - graceful-fs@4.2.11, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.5, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -18443,11 +18341,6 @@ has-values@^1.0.0: is-number "^3.0.0" kind-of "^4.0.0" -has-yarn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" - integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== - has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -18914,7 +18807,7 @@ htmlparser2@^6.1.0: domutils "^2.5.2" entities "^2.0.0" -http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0, http-cache-semantics@^4.1.1: +http-cache-semantics@^4.1.0, http-cache-semantics@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== @@ -19178,11 +19071,6 @@ import-in-the-middle@^1.14.2, import-in-the-middle@^1.8.1: cjs-module-lexer "^1.2.2" module-details-from-path "^1.0.3" -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A== - import-local@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" @@ -19255,11 +19143,6 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -ini@2.0.0, ini@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" - integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== - ini@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.0.tgz#2f6de95006923aa75feed8894f5686165adc08f1" @@ -19275,6 +19158,11 @@ ini@^1.3.2, ini@^1.3.4, ini@^1.3.5, ini@^1.3.8, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +ini@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + init-package-json@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-5.0.0.tgz#030cf0ea9c84cfc1b0dc2e898b45d171393e4b40" @@ -19543,13 +19431,6 @@ is-ci@3.0.1: dependencies: ci-info "^3.2.0" -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== - dependencies: - ci-info "^2.0.0" - is-core-module@^2.13.0, is-core-module@^2.16.0, is-core-module@^2.3.0, is-core-module@^2.5.0, is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.16.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" @@ -19657,14 +19538,6 @@ is-inside-container@^1.0.0: dependencies: is-docker "^3.0.0" -is-installed-globally@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" - integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== - dependencies: - global-dirs "^3.0.0" - is-path-inside "^3.0.2" - is-installed-globally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-1.0.0.tgz#08952c43758c33d815692392f7f8437b9e436d5a" @@ -19710,11 +19583,6 @@ is-negative-zero@^2.0.2: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== -is-npm@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8" - integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA== - is-number-object@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" @@ -19742,11 +19610,6 @@ is-obj@^2.0.0: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== -is-path-inside@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-path-inside@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-4.0.0.tgz#805aeb62c47c1b12fc3fd13bfb3ed1e7430071db" @@ -19995,11 +19858,6 @@ is-wsl@^3.0.0, is-wsl@^3.1.0: dependencies: is-inside-container "^1.0.0" -is-yarn-global@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" - integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== - is64bit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is64bit/-/is64bit-2.0.0.tgz#198c627cbcb198bbec402251f88e5e1a51236c07" @@ -20323,11 +20181,6 @@ json-bigint@^1.0.0: dependencies: bignumber.js "^9.0.0" -json-buffer@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" - integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= - json-parse-better-errors@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" @@ -20547,13 +20400,6 @@ karma-source-map-support@1.4.0: dependencies: source-map-support "^0.5.5" -keyv@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" - integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== - dependencies: - json-buffer "3.0.0" - kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -20649,13 +20495,6 @@ language-tags@^1.0.5: dependencies: language-subtag-registry "~0.3.2" -latest-version@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" - integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== - dependencies: - package-json "^6.3.0" - launch-editor@^2.9.1: version "2.9.1" resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.9.1.tgz#253f173bd441e342d4344b4dae58291abb425047" @@ -21359,16 +21198,6 @@ lower-case@^2.0.2: dependencies: tslib "^2.0.3" -lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== - -lowercase-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" - integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== - lru-cache@6.0.0, lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -22356,11 +22185,6 @@ mimic-fn@^4.0.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== -mimic-response@^1.0.0, mimic-response@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" - integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== - mimic-response@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" @@ -23387,21 +23211,21 @@ node-watch@0.7.3: resolved "https://registry.yarnpkg.com/node-watch/-/node-watch-0.7.3.tgz#6d4db88e39c8d09d3ea61d6568d80e5975abc7ab" integrity sha512-3l4E8uMPY1HdMMryPRUAl+oIHtXtyiTlIiESNSVSNxcPfzAFzeTbXFQkZfAwBbo0B1qMSG8nUABx+Gd+YrbKrQ== -nodemon@^2.0.16: - version "2.0.16" - resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.16.tgz#d71b31bfdb226c25de34afea53486c8ef225fdef" - integrity sha512-zsrcaOfTWRuUzBn3P44RDliLlp263Z/76FPoHFr3cFFkOz0lTPAcIw8dCzfdVIx/t3AtDYCZRCDkoCojJqaG3w== +nodemon@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.10.tgz#5015c5eb4fffcb24d98cf9454df14f4fecec9bc1" + integrity sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw== dependencies: chokidar "^3.5.2" - debug "^3.2.7" + debug "^4" ignore-by-default "^1.0.1" - minimatch "^3.0.4" + minimatch "^3.1.2" pstree.remy "^1.1.8" - semver "^5.7.1" + semver "^7.5.3" + simple-update-notifier "^2.0.0" supports-color "^5.5.0" touch "^3.1.0" undefsafe "^2.0.5" - update-notifier "^5.1.0" nopt@^3.0.6: version "3.0.6" @@ -23497,11 +23321,6 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= -normalize-url@^4.1.0: - version "4.5.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" - integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== - npm-bundled@^1.1.1, npm-bundled@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1" @@ -24230,11 +24049,6 @@ osenv@^0.1.3: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -p-cancelable@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" - integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== - p-defer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" @@ -24441,16 +24255,6 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== -package-json@^6.3.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" - integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== - dependencies: - got "^9.6.0" - registry-auth-token "^4.0.0" - registry-url "^5.0.0" - semver "^6.2.0" - package-manager-detector@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea" @@ -25800,11 +25604,6 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prepend-http@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" - integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= - prettier-plugin-astro@^0.14.1: version "0.14.1" resolved "https://registry.yarnpkg.com/prettier-plugin-astro/-/prettier-plugin-astro-0.14.1.tgz#50bff8a659f2a6a4ff3b1d7ea73f2de93c95b213" @@ -26086,13 +25885,6 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0, punycode@^2.3.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -pupa@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" - integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== - dependencies: - escape-goat "^2.0.0" - pure-rand@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" @@ -26765,20 +26557,6 @@ regextras@^0.7.1: resolved "https://registry.yarnpkg.com/regextras/-/regextras-0.7.1.tgz#be95719d5f43f9ef0b9fa07ad89b7c606995a3b2" integrity sha512-9YXf6xtW+qzQ+hcMQXx95MOvfqXFgsKDZodX3qZB0x2n5Z94ioetIITsBtvJbiOyxa/6s9AtyweBLCdPmPko/w== -registry-auth-token@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250" - integrity sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw== - dependencies: - rc "^1.2.8" - -registry-url@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" - integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== - dependencies: - rc "^1.2.8" - regjsparser@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709" @@ -27101,13 +26879,6 @@ resolve@^2.0.0-next.1, resolve@^2.0.0-next.3: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -responselike@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" - integrity sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ== - dependencies: - lowercase-keys "^1.0.0" - restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -27647,13 +27418,6 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -semver-diff@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" - integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== - dependencies: - semver "^6.3.0" - "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" @@ -27666,7 +27430,7 @@ semver@7.5.3: dependencies: lru-cache "^6.0.0" -semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0, semver@^6.3.1: +semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -28073,6 +27837,13 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +simple-update-notifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" + integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== + dependencies: + semver "^7.5.3" + sinon@19.0.2: version "19.0.2" resolved "https://registry.yarnpkg.com/sinon/-/sinon-19.0.2.tgz#944cf771d22236aa84fc1ab70ce5bffc3a215dad" @@ -28737,7 +28508,7 @@ string-template@~0.2.1: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -29645,11 +29416,6 @@ to-object-path@^0.3.0: dependencies: kind-of "^3.0.2" -to-readable-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" - integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== - to-regex-range@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" @@ -30686,26 +30452,6 @@ update-browserslist-db@^1.1.1: escalade "^3.2.0" picocolors "^1.1.1" -update-notifier@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9" - integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw== - dependencies: - boxen "^5.0.0" - chalk "^4.1.0" - configstore "^5.0.1" - has-yarn "^2.1.0" - import-lazy "^2.1.0" - is-ci "^2.0.0" - is-installed-globally "^0.4.0" - is-npm "^5.0.0" - is-yarn-global "^0.3.0" - latest-version "^5.1.0" - pupa "^2.1.1" - semver "^7.3.4" - semver-diff "^3.1.1" - xdg-basedir "^4.0.0" - uqr@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/uqr/-/uqr-0.1.2.tgz#5c6cd5dcff9581f9bb35b982cb89e2c483a41d7d" @@ -30723,13 +30469,6 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= -url-parse-lax@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" - integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww= - dependencies: - prepend-http "^2.0.0" - url-parse@^1.5.3, url-parse@~1.5.10: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" @@ -31779,13 +31518,6 @@ wide-align@^1.1.5: dependencies: string-width "^1.0.2 || 2 || 3 || 4" -widest-line@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" - integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== - dependencies: - string-width "^4.0.0" - widest-line@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-4.0.1.tgz#a0fc673aaba1ea6f0a0d35b3c2795c9a9cc2ebf2" From 910b40bb38635d1889a97f701c46c44fb3038b93 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 20 Oct 2025 20:00:25 +0200 Subject: [PATCH 039/190] fix(nextjs): Update bundler detection (#17976) Test were failing due to missing value injection, because the bundler was incorrectly detected. We can rely on `process.env.TURBOPACK` being set, confirmed this with Vercel. So this PR - simplifies the bundler detection by just checking the env var - brings back webpack dev tests closes https://linear.app/getsentry/issue/FE-618/webpack-breaks-instrumentation-for-dev-mode-in-next-16 --- .../test-applications/nextjs-16/package.json | 2 +- packages/nextjs/src/config/util.ts | 64 +---- .../nextjs/src/config/withSentryConfig.ts | 2 +- packages/nextjs/test/config/util.test.ts | 161 +---------- .../test/config/withSentryConfig.test.ts | 268 +++++------------- 5 files changed, 89 insertions(+), 408 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 3d1df82b1748..2da23b152807 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -18,7 +18,7 @@ "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" + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" }, "dependencies": { "@sentry/nextjs": "latest || *", diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 8d2d7781230b..0970e9573ba9 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -109,66 +109,20 @@ export function supportsNativeDebugIds(version: string): boolean { } /** - * 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`. + * Determines which bundler is actually being used based on environment variables, + * and CLI flags. * - * @param version - Next.js version string to check. - * @returns true if the version uses Turbopack by default + * @returns 'turbopack' or 'webpack' */ -export function isTurbopackDefaultForVersion(version: string): boolean { - if (!version) { - return false; - } - - const { major, minor, prerelease } = parseSemver(version); - - if (major === undefined || minor === undefined) { - return false; - } +export function detectActiveBundler(): 'turbopack' | 'webpack' { + const turbopackEnv = process.env.TURBOPACK; - // Next.js 16+ uses turbopack by default - if (major >= 16) { - return true; - } + // Check if TURBOPACK env var is set to a truthy value (excluding falsy strings like 'false', '0', '') + const isTurbopackEnabled = turbopackEnv && turbopackEnv !== 'false' && turbopackEnv !== '0'; - // 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')) { + if (isTurbopackEnabled || process.argv.includes('--turbo')) { return 'turbopack'; - } - - // Explicit opt-in to webpack via --webpack flag - if (process.argv.includes('--webpack')) { + } else { 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 31ea63f17a9c..1f3f14479656 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -261,7 +261,7 @@ function getFinalConfigObject( nextMajor = major; } - const activeBundler = detectActiveBundler(nextJsVersion); + const activeBundler = detectActiveBundler(); const isTurbopack = activeBundler === 'turbopack'; const isWebpack = activeBundler === 'webpack'; const isTurbopackSupported = supportsProductionCompileHook(nextJsVersion ?? ''); diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 55fd13cf5dc4..37e4079376cd 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -213,117 +213,6 @@ 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; @@ -341,52 +230,26 @@ describe('util', () => { 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'); + expect(util.detectActiveBundler()).toBe('turbopack'); }); - it('returns webpack for Next.js 15.6.0 stable', () => { - expect(util.detectActiveBundler('15.6.0')).toBe('webpack'); + it('returns turbopack when TURBOPACK env var is set to auto', () => { + process.env.TURBOPACK = 'auto'; + expect(util.detectActiveBundler()).toBe('turbopack'); }); - 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 TURBOPACK env var is undefined', () => { + process.env.TURBOPACK = undefined; + expect(util.detectActiveBundler()).toBe('webpack'); }); - it('returns webpack when version is undefined', () => { - expect(util.detectActiveBundler(undefined)).toBe('webpack'); + it('returns webpack when TURBOPACK env var is false', () => { + process.env.TURBOPACK = 'false'; + expect(util.detectActiveBundler()).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'); + it('returns webpack when TURBOPACK env var is not set', () => { + expect(util.detectActiveBundler()).toBe('webpack'); }); }); }); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index f1f46c6fc6f2..b67a05845a7e 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -269,7 +269,7 @@ describe('withSentryConfig', () => { }); }); - describe('bundler detection with version-based defaults', () => { + describe('bundler detection', () => { const originalTurbopack = process.env.TURBOPACK; const originalArgv = process.argv; @@ -284,192 +284,107 @@ describe('withSentryConfig', () => { 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'); + it('uses webpack config by default when TURBOPACK env var is not set', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - 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'); + it('uses turbopack config when TURBOPACK env var is set (supported version)', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); }); - 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'); + it('uses turbopack config when TURBOPACK env var is set (16.0.0)', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); - it('uses webpack config by default for 15.6.0-canary.1', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.1'); + it('skips webpack config when TURBOPACK env var is set, even with unsupported version', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + // turbopack config won't be added when version is unsupported, + // but webpack config should still be skipped + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + expect(finalConfig.turbopack).toBeUndefined(); + }); - 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'; + it('defaults to webpack when Next.js version cannot be determined and no TURBOPACK env var', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - 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 turbopack when TURBOPACK env var is set even when version is undefined', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); + process.env.TURBOPACK = '1'; - it('uses webpack config by default for 15.6.1 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.1'); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeUndefined(); + }); - it('uses webpack config by default for 15.7.0 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0'); + it('uses turbopack when TURBOPACK env var is truthy string', () => { + process.env.TURBOPACK = 'true'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); - 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'; + it('uses webpack when TURBOPACK env var is empty string', () => { + process.env.TURBOPACK = ''; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - 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); + it('uses webpack when TURBOPACK env var is false string', () => { + process.env.TURBOPACK = 'false'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }, - ); + 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); + it('handles malformed version strings gracefully', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('not.a.version'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }, - ); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - describe('warnings are shown for unsupported turbopack usage', () => { + describe('warnings for unsupported turbopack usage', () => { let consoleWarnSpy: ReturnType; beforeEach(() => { @@ -508,39 +423,6 @@ describe('withSentryConfig', () => { 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', () => { @@ -1411,24 +1293,6 @@ describe('withSentryConfig', () => { consoleWarnSpy.mockRestore(); }); - - it('warns when TURBOPACK=0 (truthy string) with unsupported version', () => { - process.env.TURBOPACK = '0'; - // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing - process.env.NODE_ENV = 'development'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - materializeFinalNextConfig(exportedNextConfig); - - // Note: '0' is truthy in JavaScript, so this will trigger the warning - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), - ); - - consoleWarnSpy.mockRestore(); - }); }); describe('useRunAfterProductionCompileHook warning logic', () => { From 063ad998f24a605e95cc54d11a401c5ad8cdd8ad Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 21 Oct 2025 10:45:52 +0200 Subject: [PATCH 040/190] fix(nextjs): Don't set experimental instrumentation hook flag for next 16 (#17978) Updated the logic to determine if the instrumentation hook is required or not, as this was wrongly set in next.js 16 apps. closes https://github.com/getsentry/sentry-javascript/issues/17965 --- packages/nextjs/src/config/util.ts | 55 +++++++++++++ .../nextjs/src/config/withSentryConfig.ts | 58 ++++--------- packages/nextjs/test/config/util.test.ts | 82 +++++++++++++++++++ 3 files changed, 154 insertions(+), 41 deletions(-) diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 0970e9573ba9..0d4a55687d2f 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -108,6 +108,61 @@ export function supportsNativeDebugIds(version: string): boolean { return false; } +/** + * Checks if the given Next.js version requires the `experimental.instrumentationHook` option. + * Next.js 15.0.0 and higher (including certain RC and canary versions) no longer require this option + * and will print a warning if it is set. + * + * @param version - version string to check. + * @returns true if the version requires the instrumentationHook option to be set + */ +export function requiresInstrumentationHook(version: string): boolean { + if (!version) { + return true; // Default to requiring it if version cannot be determined + } + + const { major, minor, patch, prerelease } = parseSemver(version); + + if (major === undefined || minor === undefined || patch === undefined) { + return true; // Default to requiring it if parsing fails + } + + // Next.js 16+ never requires the hook + if (major >= 16) { + return false; + } + + // Next.js 14 and below always require the hook + if (major < 15) { + return true; + } + + // At this point, we know it's Next.js 15.x.y + // Stable releases (15.0.0+) don't require the hook + if (!prerelease) { + return false; + } + + // Next.js 15.x.y with x > 0 or y > 0 don't require the hook + if (minor > 0 || patch > 0) { + return false; + } + + // Check specific prerelease versions that don't require the hook + if (prerelease.startsWith('rc.')) { + const rcNumber = parseInt(prerelease.split('.')[1] || '0', 10); + return rcNumber === 0; // Only rc.0 requires the hook + } + + if (prerelease.startsWith('canary.')) { + const canaryNumber = parseInt(prerelease.split('.')[1] || '0', 10); + return canaryNumber < 124; // canary.124+ doesn't require the hook + } + + // All other 15.0.0 prerelease versions (alpha, beta, etc.) require the hook + return true; +} + /** * Determines which bundler is actually being used based on environment variables, * and CLI flags. diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 1f3f14479656..7ac61d73aa73 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -16,7 +16,12 @@ import type { SentryBuildOptions, TurbopackOptions, } from './types'; -import { detectActiveBundler, getNextjsVersion, supportsProductionCompileHook } from './util'; +import { + detectActiveBundler, + getNextjsVersion, + requiresInstrumentationHook, + supportsProductionCompileHook, +} from './util'; import { constructWebpackConfigFunction } from './webpack'; let showedExportModeTunnelWarning = false; @@ -178,47 +183,18 @@ function getFinalConfigObject( // From Next.js version (15.0.0-canary.124) onwards, Next.js does no longer require the `experimental.instrumentationHook` option and will // print a warning when it is set, so we need to conditionally provide it for lower versions. - if (nextJsVersion) { - const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); - const isFullySupportedRelease = - major !== undefined && - minor !== undefined && - patch !== undefined && - major >= 15 && - ((minor === 0 && patch === 0 && prerelease === undefined) || minor > 0 || patch > 0); - const isSupportedV15Rc = - major !== undefined && - minor !== undefined && - patch !== undefined && - prerelease !== undefined && - major === 15 && - minor === 0 && - patch === 0 && - prerelease.startsWith('rc.') && - parseInt(prerelease.split('.')[1] || '', 10) > 0; - const isSupportedCanary = - minor !== undefined && - patch !== undefined && - prerelease !== undefined && - major === 15 && - minor === 0 && - patch === 0 && - prerelease.startsWith('canary.') && - parseInt(prerelease.split('.')[1] || '', 10) >= 124; - - if (!isFullySupportedRelease && !isSupportedV15Rc && !isSupportedCanary) { - if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] You turned off the `experimental.instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.(js|ts)`.', - ); - } - incomingUserNextConfigObject.experimental = { - instrumentationHook: true, - ...incomingUserNextConfigObject.experimental, - }; + if (nextJsVersion && requiresInstrumentationHook(nextJsVersion)) { + if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You turned off the `experimental.instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.(js|ts)`.', + ); } - } else { + incomingUserNextConfigObject.experimental = { + instrumentationHook: true, + ...incomingUserNextConfigObject.experimental, + }; + } else if (!nextJsVersion) { // If we cannot detect a Next.js version for whatever reason, the sensible default is to set the `experimental.instrumentationHook`, even though it may create a warning. if ( incomingUserNextConfigObject.experimental && diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 37e4079376cd..7335139b5037 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -213,6 +213,88 @@ describe('util', () => { }); }); + describe('requiresInstrumentationHook', () => { + describe('versions that do NOT require the hook (returns false)', () => { + it.each([ + // Fully supported releases (15.0.0 or higher) + ['15.0.0', 'Next.js 15.0.0'], + ['15.0.1', 'Next.js 15.0.1'], + ['15.1.0', 'Next.js 15.1.0'], + ['15.2.0', 'Next.js 15.2.0'], + ['16.0.0', 'Next.js 16.0.0'], + ['17.0.0', 'Next.js 17.0.0'], + ['20.0.0', 'Next.js 20.0.0'], + + // Supported v15.0.0-rc.1 or higher + ['15.0.0-rc.1', 'Next.js 15.0.0-rc.1'], + ['15.0.0-rc.2', 'Next.js 15.0.0-rc.2'], + ['15.0.0-rc.5', 'Next.js 15.0.0-rc.5'], + ['15.0.0-rc.100', 'Next.js 15.0.0-rc.100'], + + // Supported v15.0.0-canary.124 or higher + ['15.0.0-canary.124', 'Next.js 15.0.0-canary.124 (exact threshold)'], + ['15.0.0-canary.125', 'Next.js 15.0.0-canary.125'], + ['15.0.0-canary.130', 'Next.js 15.0.0-canary.130'], + ['15.0.0-canary.200', 'Next.js 15.0.0-canary.200'], + + // Next.js 16+ prerelease versions (all supported) + ['16.0.0-beta.0', 'Next.js 16.0.0-beta.0'], + ['16.0.0-beta.1', 'Next.js 16.0.0-beta.1'], + ['16.0.0-rc.0', 'Next.js 16.0.0-rc.0'], + ['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.0.0-alpha.1', 'Next.js 16.0.0-alpha.1'], + ['17.0.0-canary.1', 'Next.js 17.0.0-canary.1'], + ])('returns false for %s (%s)', version => { + expect(util.requiresInstrumentationHook(version)).toBe(false); + }); + }); + + describe('versions that DO require the hook (returns true)', () => { + it.each([ + // Next.js 14 and below + ['14.2.0', 'Next.js 14.2.0'], + ['14.0.0', 'Next.js 14.0.0'], + ['13.5.0', 'Next.js 13.5.0'], + ['12.0.0', 'Next.js 12.0.0'], + + // Unsupported v15.0.0-rc.0 + ['15.0.0-rc.0', 'Next.js 15.0.0-rc.0'], + + // Unsupported v15.0.0-canary versions below 124 + ['15.0.0-canary.123', 'Next.js 15.0.0-canary.123'], + ['15.0.0-canary.100', 'Next.js 15.0.0-canary.100'], + ['15.0.0-canary.50', 'Next.js 15.0.0-canary.50'], + ['15.0.0-canary.1', 'Next.js 15.0.0-canary.1'], + ['15.0.0-canary.0', 'Next.js 15.0.0-canary.0'], + + // Other prerelease versions + ['15.0.0-alpha.1', 'Next.js 15.0.0-alpha.1'], + ['15.0.0-beta.1', 'Next.js 15.0.0-beta.1'], + ])('returns true for %s (%s)', version => { + expect(util.requiresInstrumentationHook(version)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('returns true for empty string', () => { + expect(util.requiresInstrumentationHook('')).toBe(true); + }); + + it('returns true for invalid version strings', () => { + expect(util.requiresInstrumentationHook('invalid.version')).toBe(true); + }); + + it('returns true for versions missing patch number', () => { + expect(util.requiresInstrumentationHook('15.4')).toBe(true); + }); + + it('returns true for versions missing minor number', () => { + expect(util.requiresInstrumentationHook('15')).toBe(true); + }); + }); + }); + describe('detectActiveBundler', () => { const originalArgv = process.argv; const originalEnv = process.env; From 1bd76c0d6c1c2cda7e6d507112d44e65ad7082ea Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:13:57 +0200 Subject: [PATCH 041/190] fix(ember): Use updated version for `clean-css` (#17979) [Ember E2E tests fail](https://github.com/getsentry/sentry-javascript/actions/runs/18656635954/job/53191164591) with `util.isRegExp is not a function`. Apparently, this is a JSDoc/Node issue that was already resolved last year: https://github.com/jsdoc/jsdoc/issues/2126 It is happening since the tests run with Node 24.10.0 instead of 22.20.0. The `isRegExp` API was removed from Node, so I updated the `clean-css` package as it's mentioned in the error stack. However, it could be that we either need to update `ember-cli` or just downgrade the node version for this test. --- .../e2e-tests/test-applications/ember-classic/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/ember-classic/package.json b/dev-packages/e2e-tests/test-applications/ember-classic/package.json index 260a8f8032ae..949b2b05f816 100644 --- a/dev-packages/e2e-tests/test-applications/ember-classic/package.json +++ b/dev-packages/e2e-tests/test-applications/ember-classic/package.json @@ -75,7 +75,8 @@ "node": ">=18" }, "resolutions": { - "@babel/traverse": "~7.25.9" + "@babel/traverse": "~7.25.9", + "clean-css": "^5.3.0" }, "ember": { "edition": "octane" From d551d23f508468faaec4884d948671e8dc872939 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:05:06 +0200 Subject: [PATCH 042/190] feat(browserProfiling): Add `trace` lifecycle mode for UI profiling (#17619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `trace` lifecycle mode and sends `profile_chunk` envelopes. Also adds test for either overlapping root spans (one chunk) or single root spans (multiple chunks). The "manual" mode comes in another PR to keep this from growing too large. **Browser trace-lifecycle profiler (v2):** - Starts when the first sampled root span starts - Stops when the last sampled root span ends - While running, periodically stops and restarts the JS self-profiling API to collect chunks **Profiles are emitted as standalone `profile_chunk` envelopes either when:** - there are no more sampled root spans, or - the 60s chunk timer elapses while profiling is running. **Handling never-ending root spans** In the trace lifecycle, profiling continues as long as a root span is active. To prevent profiling endlessly, each root span has its own profile timeout and is terminated if it is too long (5 minutes). If another root span is still active, profiling will continue regardless. part of https://github.com/getsentry/sentry-javascript/issues/17279 --- > [!NOTE] > Adds UI profiling trace lifecycle mode that samples sessions, streams profile_chunk envelopes, and attaches thread data, with accompanying tests and type options. > > - **Browser Profiling (UI Profiling v2)**: > - Add `profileLifecycle: 'trace'` with session sampling via `profileSessionSampleRate`; defaults lifecycle to `manual` when unspecified. > - Stream profiling as `profile_chunk` envelopes; periodic chunking (60s) and 5‑min root-span timeout. > - New `BrowserTraceLifecycleProfiler` manages start/stop across root spans and chunk sending. > - Attach profiled thread data to events/spans; warn if trace mode without tracing. > - **Profiling Utils**: > - Convert JS self profile to continuous format; validate chunks; main/worker thread constants; helper to attach thread info. > - Split legacy logic: `hasLegacyProfiling`, `shouldProfileSpanLegacy`, `shouldProfileSession`. > - **Integration Changes**: > - Browser integration branches between legacy and trace lifecycle; adds `processEvent` to attach thread data. > - Minor fix in `startProfileForSpan` (processed profile handling). > - **Tests**: > - Add Playwright suites for trace lifecycle (multiple chunks, overlapping spans) and adjust legacy tests. > - Add unit tests for lifecycle behavior, warnings, profiler_id reuse, and option defaults. > - **Types/Config**: > - Extend `BrowserClientProfilingOptions` with `profileSessionSampleRate` and `profileLifecycle`; refine Node types docs. > - Size-limit: add entry for `@sentry/browser` incl. Tracing, Profiling (48 KB). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 765f89de7de8b262daaaafe7cbcdfba66cea9f18. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .size-limit.js | 7 + .../suites/profiling/legacyMode/subject.js | 2 +- .../suites/profiling/legacyMode/test.ts | 16 +- .../subject.js | 48 ++ .../test.ts | 206 ++++++ .../subject.js | 52 ++ .../test.ts | 187 ++++++ packages/browser/src/profiling/integration.ts | 162 +++-- .../lifecycleMode/traceLifecycleProfiler.ts | 355 ++++++++++ .../src/profiling/startProfileForSpan.ts | 3 +- packages/browser/src/profiling/utils.ts | 277 +++++++- .../test/profiling/integration.test.ts | 44 ++ .../profiling/traceLifecycleProfiler.test.ts | 631 ++++++++++++++++++ .../core/src/types-hoist/browseroptions.ts | 22 + packages/node/src/types.ts | 13 +- 15 files changed, 1947 insertions(+), 78 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts create mode 100644 packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts create mode 100644 packages/browser/test/profiling/traceLifecycleProfiler.test.ts diff --git a/.size-limit.js b/.size-limit.js index 5de4268a53d6..9cebd30285e4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -40,6 +40,13 @@ module.exports = [ gzip: true, limit: '41 KB', }, + { + name: '@sentry/browser (incl. Tracing, Profiling)', + path: 'packages/browser/build/npm/esm/index.js', + import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), + gzip: true, + limit: '48 KB', + }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js index 230e9ee1fb9e..aad9fd2a764c 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js @@ -17,7 +17,7 @@ function fibonacci(n) { return fibonacci(n - 1) + fibonacci(n - 2); } -await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { +await Sentry.startSpanManual({ name: 'root-fibonacci', parentSpan: null, forceTransaction: true }, async span => { fibonacci(30); // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts index 35f4e17bec0a..d473236cdfda 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -73,14 +73,16 @@ sentryTest('sends profile envelope in legacy mode', async ({ page, getLocalTestU expect(profile.frames.length).toBeGreaterThan(0); for (const frame of profile.frames) { expect(frame).toHaveProperty('function'); - expect(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - expect(typeof frame.function).toBe('string'); - expect(typeof frame.abs_path).toBe('string'); - expect(typeof frame.lineno).toBe('number'); - expect(typeof frame.colno).toBe('number'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } } const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js new file mode 100644 index 000000000000..0095eb5743d9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js @@ -0,0 +1,48 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +// Create two NON-overlapping root spans so that the profiler stops and emits a chunk +// after each span (since active root span count returns to 0 between them). +await Sentry.startSpanManual({ name: 'root-fibonacci-1', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +// Small delay to ensure the first chunk is collected and sent +await new Promise(r => setTimeout(r, 25)); + +await Sentry.startSpanManual({ name: 'root-largeSum-2', parentSpan: null, forceTransaction: true }, async span => { + largeSum(); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +const client = Sentry.getClient(); +await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts new file mode 100644 index 000000000000..702140b8823e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -0,0 +1,206 @@ +import { expect } from '@playwright/test'; +import type { ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + countEnvelopes, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../utils/helpers'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Assert that no profile_chunk envelope is sent without policy header + const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 }); + expect(chunkCount).toBe(0); + }, +); + +sentryTest( + 'sends profile_chunk envelopes in trace mode (multiple chunks)', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + + // Expect at least 2 chunks because subject creates two separate root spans, + // causing the profiler to stop and emit a chunk after each root span ends. + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( + page, + 2, + { url, envelopeType: 'profile_chunk', timeout: 5000 }, + properFullEnvelopeRequestParser, + ); + + expect(profileChunkEnvelopes.length).toBeGreaterThanOrEqual(2); + + // Validate the first chunk thoroughly + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload1 = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + + expect(envelopeItemPayload1.profile).toBeDefined(); + expect(envelopeItemPayload1.version).toBe('2'); + expect(envelopeItemPayload1.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + expect(typeof envelopeItemPayload1.profiler_id).toBe('string'); + expect(envelopeItemPayload1.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload1.chunk_id).toBe('string'); + expect(envelopeItemPayload1.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload1.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload1.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload1.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload1.release).toBe('string'); + expect(envelopeItemPayload1.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload1?.debug_meta?.images)).toBe(true); + + const profile1 = envelopeItemPayload1.profile; + + expect(profile1.samples).toBeDefined(); + expect(profile1.stacks).toBeDefined(); + expect(profile1.frames).toBeDefined(); + expect(profile1.thread_metadata).toBeDefined(); + + // Samples + expect(profile1.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp = Number.NEGATIVE_INFINITY; + for (const sample of profile1.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile1.stacks.length); + + // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) + expect(typeof (sample as any).timestamp).toBe('number'); + const ts = (sample as any).timestamp as number; + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeGreaterThan(0); + // Monotonic non-decreasing timestamps + expect(ts).toBeGreaterThanOrEqual(previousTimestamp); + previousTimestamp = ts; + + expect(sample.thread_id).toBe('0'); // Should be main thread + } + + // Stacks + expect(profile1.stacks.length).toBeGreaterThan(0); + for (const stack of profile1.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile1.frames.length); + } + } + + // Frames + expect(profile1.frames.length).toBeGreaterThan(0); + for (const frame of profile1.frames) { + expect(frame).toHaveProperty('function'); + expect(typeof frame.function).toBe('string'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + const functionNames = profile1.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // first function is captured (other one is in other chunk) + 'fibonacci', + ]), + ); + } + + expect(profile1.thread_metadata).toHaveProperty('0'); + expect(profile1.thread_metadata['0']).toHaveProperty('name'); + expect(profile1.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTimeSec = (profile1.samples[0] as any).timestamp as number; + const endTimeSec = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number; + const durationSec = endTimeSec - startTimeSec; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationSec).toBeGreaterThan(0.2); + + // === PROFILE CHUNK 2 === + + const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0]; + const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; + const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; + + // Basic sanity on the second chunk: has correct envelope type and structure + expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload2.profile).toBeDefined(); + expect(envelopeItemPayload2.version).toBe('2'); + expect(envelopeItemPayload2.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + expect(typeof envelopeItemPayload2.profiler_id).toBe('string'); + expect(envelopeItemPayload2.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload2.chunk_id).toBe('string'); + expect(envelopeItemPayload2.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload2.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload2.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload2.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload2.release).toBe('string'); + expect(envelopeItemPayload2.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload2?.debug_meta?.images)).toBe(true); + + const profile2 = envelopeItemPayload2.profile; + + const functionNames2 = profile2.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames2.length).toBeGreaterThan(0); + expect((functionNames2 as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames2).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // second function is captured (other one is in other chunk) + 'largeSum', + ]), + ); + } + }, +); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js new file mode 100644 index 000000000000..071afe1ed059 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -0,0 +1,52 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +let firstSpan; + +Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransaction: true }, span => { + largeSum(); + firstSpan = span; +}); + +await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + + Sentry.startSpan({ name: 'child-fibonacci', parentSpan: span }, childSpan => { + console.log('child span'); + }); + + // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled + await new Promise(resolve => setTimeout(resolve, 21)); + span.end(); +}); + +await new Promise(r => setTimeout(r, 21)); + +firstSpan.end(); + +const client = Sentry.getClient(); +await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts new file mode 100644 index 000000000000..60744def96cd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -0,0 +1,187 @@ +import { expect } from '@playwright/test'; +import type { Event, Profile, ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + getMultipleSentryEnvelopeRequests, + properEnvelopeRequestParser, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, + waitForTransactionRequestOnUrl, +} from '../../../utils/helpers'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const req = await waitForTransactionRequestOnUrl(page, url); + const transactionEvent = properEnvelopeRequestParser(req, 0); + const profileEvent = properEnvelopeRequestParser(req, 1); + + expect(transactionEvent).toBeDefined(); + + expect(profileEvent).toBeUndefined(); + }, +); + +sentryTest( + 'sends profile envelope in trace mode (single chunk for overlapping spans)', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + await page.goto(url); + + const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'profile_chunk' }, + properFullEnvelopeRequestParser, + ); + + const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + + expect(envelopeItemPayload.profile).toBeDefined(); + expect(envelopeItemPayload.version).toBe('2'); + expect(envelopeItemPayload.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + expect(typeof envelopeItemPayload.profiler_id).toBe('string'); + expect(envelopeItemPayload.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload.chunk_id).toBe('string'); + expect(envelopeItemPayload.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload.release).toBe('string'); + expect(envelopeItemPayload.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload?.debug_meta?.images)).toBe(true); + + const profile = envelopeItemPayload.profile; + + expect(profile.samples).toBeDefined(); + expect(profile.stacks).toBeDefined(); + expect(profile.frames).toBeDefined(); + expect(profile.thread_metadata).toBeDefined(); + + // Samples + expect(profile.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp = Number.NEGATIVE_INFINITY; + for (const sample of profile.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile.stacks.length); + + // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) + expect(typeof sample.timestamp).toBe('number'); + const ts = sample.timestamp; + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeGreaterThan(0); + // Monotonic non-decreasing timestamps + expect(ts).toBeGreaterThanOrEqual(previousTimestamp); + previousTimestamp = ts; + + expect(sample.thread_id).toBe('0'); // Should be main thread + } + + // Stacks + expect(profile.stacks.length).toBeGreaterThan(0); + for (const stack of profile.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile.frames.length); + } + } + + // Frames + expect(profile.frames.length).toBeGreaterThan(0); + for (const frame of profile.frames) { + expect(frame).toHaveProperty('function'); + expect(typeof frame.function).toBe('string'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // both functions are captured + 'fibonacci', + 'largeSum', + ]), + ); + } + + expect(profile.thread_metadata).toHaveProperty('0'); + expect(profile.thread_metadata['0']).toHaveProperty('name'); + expect(profile.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTimeSec = (profile.samples[0] as any).timestamp as number; + const endTimeSec = (profile.samples[profile.samples.length - 1] as any).timestamp as number; + const durationSec = endTimeSec - startTimeSec; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationSec).toBeGreaterThan(0.2); + }, +); + +sentryTest('attaches thread data to child spans (trace mode)', async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + const req = await waitForTransactionRequestOnUrl(page, url); + const rootSpan = properEnvelopeRequestParser(req, 0) as any; + + expect(rootSpan?.type).toBe('transaction'); + expect(rootSpan.transaction).toBe('root-fibonacci-2'); + + const profilerId = rootSpan?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId).toBe('string'); + + expect(profilerId).toMatch(/^[a-f0-9]{32}$/); + + const spans = (rootSpan?.spans ?? []) as Array<{ data?: Record }>; + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.data).toBeDefined(); + expect(span.data?.['thread.id']).toBe('0'); + expect(span.data?.['thread.name']).toBe('main'); + } +}); diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 7ad77d8920e5..415282698d45 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,15 +1,21 @@ import type { EventEnvelope, IntegrationFn, Profile, Span } from '@sentry/core'; -import { debug, defineIntegration, getActiveSpan, getRootSpan } from '@sentry/core'; +import { debug, defineIntegration, getActiveSpan, getRootSpan, hasSpansEnabled } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; +import { BrowserTraceLifecycleProfiler } from './lifecycleMode/traceLifecycleProfiler'; import { startProfileForSpan } from './startProfileForSpan'; import type { ProfiledEvent } from './utils'; import { addProfilesToEnvelope, + attachProfiledThreadToEvent, createProfilingEvent, findProfiledTransactionsFromEnvelope, getActiveProfilesCount, + hasLegacyProfiling, isAutomatedPageLoadSpan, - shouldProfileSpan, + shouldProfileSession, + shouldProfileSpanLegacy, takeProfileFromGlobalCache, } from './utils'; @@ -19,73 +25,133 @@ const _browserProfilingIntegration = (() => { return { name: INTEGRATION_NAME, setup(client) { + const options = client.getOptions() as BrowserOptions; + + if (!hasLegacyProfiling(options) && !options.profileLifecycle) { + // Set default lifecycle mode + options.profileLifecycle = 'manual'; + } + + if (hasLegacyProfiling(options) && !options.profilesSampleRate) { + DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no profiling options found.'); + return; + } + const activeSpan = getActiveSpan(); const rootSpan = activeSpan && getRootSpan(activeSpan); - if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { - if (shouldProfileSpan(rootSpan)) { - startProfileForSpan(rootSpan); - } + if (hasLegacyProfiling(options) && options.profileSessionSampleRate !== undefined) { + DEBUG_BUILD && + debug.warn( + '[Profiling] Both legacy profiling (`profilesSampleRate`) and UI profiling settings are defined. `profileSessionSampleRate` has no effect when legacy profiling is enabled.', + ); } - client.on('spanStart', (span: Span) => { - if (span === getRootSpan(span) && shouldProfileSpan(span)) { - startProfileForSpan(span); + // UI PROFILING (Profiling V2) + if (!hasLegacyProfiling(options)) { + const sessionSampled = shouldProfileSession(options); + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); } - }); - client.on('beforeEnvelope', (envelope): void => { - // if not profiles are in queue, there is nothing to add to the envelope. - if (!getActiveProfilesCount()) { - return; - } + const lifecycleMode = options.profileLifecycle; - const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); - if (!profiledTransactionEvents.length) { - return; - } + if (lifecycleMode === 'trace') { + if (!hasSpansEnabled(options)) { + DEBUG_BUILD && + debug.warn( + "[Profiling] `profileLifecycle` is 'trace' but tracing is disabled. Set a `tracesSampleRate` or `tracesSampler` to enable span tracing.", + ); + return; + } - const profilesToAddToEnvelope: Profile[] = []; + const traceLifecycleProfiler = new BrowserTraceLifecycleProfiler(); + traceLifecycleProfiler.initialize(client, sessionSampled); - for (const profiledTransaction of profiledTransactionEvents) { - const context = profiledTransaction?.contexts; - const profile_id = context?.profile?.['profile_id']; - const start_timestamp = context?.profile?.['start_timestamp']; + // If there is an active, sampled root span already, notify the profiler + if (rootSpan) { + traceLifecycleProfiler.notifyRootSpanActive(rootSpan); + } - if (typeof profile_id !== 'string') { - DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); - continue; + // In case rootSpan is created slightly after setup -> schedule microtask to re-check and notify. + WINDOW.setTimeout(() => { + const laterActiveSpan = getActiveSpan(); + const laterRootSpan = laterActiveSpan && getRootSpan(laterActiveSpan); + if (laterRootSpan) { + traceLifecycleProfiler.notifyRootSpanActive(laterRootSpan); + } + }, 0); + } + } else { + // LEGACY PROFILING (v1) + if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { + if (shouldProfileSpanLegacy(rootSpan)) { + startProfileForSpan(rootSpan); } + } - if (!profile_id) { - DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); - continue; + client.on('spanStart', (span: Span) => { + if (span === getRootSpan(span) && shouldProfileSpanLegacy(span)) { + startProfileForSpan(span); } + }); - // Remove the profile from the span context before sending, relay will take care of the rest. - if (context?.profile) { - delete context.profile; + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!getActiveProfilesCount()) { + return; } - const profile = takeProfileFromGlobalCache(profile_id); - if (!profile) { - DEBUG_BUILD && debug.log(`[Profiling] Could not retrieve profile for span: ${profile_id}`); - continue; + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; } - const profileEvent = createProfilingEvent( - profile_id, - start_timestamp as number | undefined, - profile, - profiledTransaction as ProfiledEvent, - ); - if (profileEvent) { - profilesToAddToEnvelope.push(profileEvent); + const profilesToAddToEnvelope: Profile[] = []; + + for (const profiledTransaction of profiledTransactionEvents) { + const context = profiledTransaction?.contexts; + const profile_id = context?.profile?.['profile_id']; + const start_timestamp = context?.profile?.['start_timestamp']; + + if (typeof profile_id !== 'string') { + DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); + continue; + } + + if (!profile_id) { + DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); + continue; + } + + // Remove the profile from the span context before sending, relay will take care of the rest. + if (context?.profile) { + delete context.profile; + } + + const profile = takeProfileFromGlobalCache(profile_id); + if (!profile) { + DEBUG_BUILD && debug.log(`[Profiling] Could not retrieve profile for span: ${profile_id}`); + continue; + } + + const profileEvent = createProfilingEvent( + profile_id, + start_timestamp as number | undefined, + profile, + profiledTransaction as ProfiledEvent, + ); + if (profileEvent) { + profilesToAddToEnvelope.push(profileEvent); + } } - } - addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); - }); + addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); + }); + } + }, + processEvent(event) { + return attachProfiledThreadToEvent(event); }, }; }) satisfies IntegrationFn; diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts new file mode 100644 index 000000000000..3ce773fe01ff --- /dev/null +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -0,0 +1,355 @@ +import type { Client, ProfileChunk, Span } from '@sentry/core'; +import { + type ProfileChunkEnvelope, + createEnvelope, + debug, + dsnToString, + getGlobalScope, + getRootSpan, + getSdkMetadataForEnvelopeHeader, + uuid4, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { JSSelfProfiler } from '../jsSelfProfiling'; +import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils'; + +const CHUNK_INTERVAL_MS = 60_000; // 1 minute +// Maximum length for trace lifecycle profiling per root span (e.g. if spanEnd never fires) +const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes + +/** + * Browser trace-lifecycle profiler (UI Profiling / Profiling V2): + * - Starts when the first sampled root span starts + * - Stops when the last sampled root span ends + * - While running, periodically stops and restarts the JS self-profiling API to collect chunks + * + * Profiles are emitted as standalone `profile_chunk` envelopes either when: + * - there are no more sampled root spans, or + * - the 60s chunk timer elapses while profiling is running. + */ +export class BrowserTraceLifecycleProfiler { + private _client: Client | undefined; + private _profiler: JSSelfProfiler | undefined; + private _chunkTimer: ReturnType | undefined; + // For keeping track of active root spans + private _activeRootSpanIds: Set; + private _rootSpanTimeouts: Map>; + // ID for Profiler session + private _profilerId: string | undefined; + private _isRunning: boolean; + private _sessionSampled: boolean; + + public constructor() { + this._client = undefined; + this._profiler = undefined; + this._chunkTimer = undefined; + this._activeRootSpanIds = new Set(); + this._rootSpanTimeouts = new Map>(); + this._profilerId = undefined; + this._isRunning = false; + this._sessionSampled = false; + } + + /** + * Initialize the profiler with client and session sampling decision computed by the integration. + */ + public initialize(client: Client, sessionSampled: boolean): void { + // One Profiler ID per profiling session (user session) + this._profilerId = uuid4(); + + DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); + + this._client = client; + this._sessionSampled = sessionSampled; + + client.on('spanStart', span => { + if (!this._sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled because of negative sampling decision.'); + return; + } + if (span !== getRootSpan(span)) { + return; + } + // Only count sampled root spans + if (!span.isRecording()) { + DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); + return; + } + + // Matching root spans with profiles + getGlobalScope().setContext('profile', { + profiler_id: this._profilerId, + }); + + const spanId = span.spanContext().spanId; + if (!spanId) { + return; + } + if (this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.add(spanId); + const rootSpanCount = this._activeRootSpanIds.size; + + const timeout = setTimeout(() => { + this._onRootSpanTimeout(spanId); + }, MAX_ROOT_SPAN_PROFILE_MS); + this._rootSpanTimeouts.set(spanId, timeout); + + if (rootSpanCount === 1) { + DEBUG_BUILD && + debug.log( + `[Profiling] Root span with ID ${spanId} started. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, + ); + + this.start(); + } + }); + + client.on('spanEnd', span => { + if (!this._sessionSampled) { + return; + } + + const spanId = span.spanContext().spanId; + if (!spanId || !this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.delete(spanId); + const rootSpanCount = this._activeRootSpanIds.size; + + DEBUG_BUILD && + debug.log( + `[Profiling] Root span with ID ${spanId} ended. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, + ); + if (rootSpanCount === 0) { + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `spanEnd`:', e); + }); + + this.stop(); + } + }); + } + + /** + * Handle an already-active root span at integration setup time. + */ + public notifyRootSpanActive(rootSpan: Span): void { + if (!this._sessionSampled) { + return; + } + + const spanId = rootSpan.spanContext().spanId; + if (!spanId || this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.add(spanId); + + const rootSpanCount = this._activeRootSpanIds.size; + + if (rootSpanCount === 1) { + DEBUG_BUILD && + debug.log('[Profiling] Detected already active root span during setup. Active root spans now:', rootSpanCount); + + this.start(); + } + } + + /** + * Start profiling if not already running. + */ + public start(): void { + if (this._isRunning) { + return; + } + this._isRunning = true; + + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); + + this._startProfilerInstance(); + + if (!this._profiler) { + DEBUG_BUILD && debug.log('[Profiling] Stopping trace lifecycle profiling.'); + this._resetProfilerInfo(); + return; + } + + this._startPeriodicChunking(); + } + + /** + * Stop profiling; final chunk will be collected and sent. + */ + public stop(): void { + if (!this._isRunning) { + return; + } + + this._isRunning = false; + if (this._chunkTimer) { + clearTimeout(this._chunkTimer); + this._chunkTimer = undefined; + } + + this._clearAllRootSpanTimeouts(); + + // Collect whatever was currently recording + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); + }); + } + + /** + * Resets profiling information from scope and resets running state + */ + private _resetProfilerInfo(): void { + this._isRunning = false; + getGlobalScope().setContext('profile', {}); + } + + /** + * Clear and reset all per-root-span timeouts. + */ + private _clearAllRootSpanTimeouts(): void { + this._rootSpanTimeouts.forEach(timeout => clearTimeout(timeout)); + this._rootSpanTimeouts.clear(); + } + + /** + * Start a profiler instance if needed. + */ + private _startProfilerInstance(): void { + if (this._profiler?.stopped === false) { + return; + } + const profiler = startJSSelfProfile(); + if (!profiler) { + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in trace lifecycle.'); + return; + } + this._profiler = profiler; + } + + /** + * Schedule the next 60s chunk while running. + * Each tick collects a chunk and restarts the profiler. + * A chunk should be closed when there are no active root spans anymore OR when the maximum chunk interval is reached. + */ + private _startPeriodicChunking(): void { + if (!this._isRunning) { + return; + } + + this._chunkTimer = setTimeout(() => { + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk during periodic chunking:', e); + }); + + if (this._isRunning) { + this._startProfilerInstance(); + + if (!this._profiler) { + // If restart failed, stop scheduling further chunks and reset context. + this._resetProfilerInfo(); + return; + } + + this._startPeriodicChunking(); + } + }, CHUNK_INTERVAL_MS); + } + + /** + * Handle timeout for a specific root span ID to avoid indefinitely running profiler if `spanEnd` never fires. + * If this was the last active root span, collect the current chunk and stop profiling. + */ + private _onRootSpanTimeout(rootSpanId: string): void { + // If span already ended, ignore + if (!this._rootSpanTimeouts.has(rootSpanId)) { + return; + } + this._rootSpanTimeouts.delete(rootSpanId); + + if (!this._activeRootSpanIds.has(rootSpanId)) { + return; + } + + DEBUG_BUILD && + debug.log( + `[Profiling] Reached 5-minute timeout for root span ${rootSpanId}. You likely started a manual root span that never called \`.end()\`.`, + ); + + this._activeRootSpanIds.delete(rootSpanId); + + const rootSpanCount = this._activeRootSpanIds.size; + if (rootSpanCount === 0) { + this.stop(); + } + } + + /** + * Stop the current profiler, convert and send a profile chunk. + */ + private async _collectCurrentChunk(): Promise { + const prevProfiler = this._profiler; + this._profiler = undefined; + + if (!prevProfiler) { + return; + } + + try { + const profile = await prevProfiler.stop(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); + + // Validate chunk before sending + const validationReturn = validateProfileChunk(chunk); + if ('reason' in validationReturn) { + DEBUG_BUILD && + debug.log( + '[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):', + validationReturn.reason, + ); + return; + } + + this._sendProfileChunk(chunk); + + DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.'); + } catch (e) { + DEBUG_BUILD && debug.log('[Profiling] Error while stopping JS Profiler for chunk:', e); + } + } + + /** + * Send a profile chunk as a standalone envelope. + */ + private _sendProfileChunk(chunk: ProfileChunk): void { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const client = this._client!; + + const sdkInfo = getSdkMetadataForEnvelopeHeader(client.getSdkMetadata?.()); + const dsn = client.getDsn(); + const tunnel = client.getOptions().tunnel; + + const envelope = createEnvelope( + { + event_id: uuid4(), + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }, + [[{ type: 'profile_chunk' }, chunk]], + ); + + client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); + }); + } +} diff --git a/packages/browser/src/profiling/startProfileForSpan.ts b/packages/browser/src/profiling/startProfileForSpan.ts index b60a207abbce..6eaaa016d822 100644 --- a/packages/browser/src/profiling/startProfileForSpan.ts +++ b/packages/browser/src/profiling/startProfileForSpan.ts @@ -41,7 +41,7 @@ export function startProfileForSpan(span: Span): void { // event of an error or user mistake (calling span.finish multiple times), it is important that the behavior of onProfileHandler // is idempotent as we do not want any timings or profiles to be overridden by the last call to onProfileHandler. // After the original finish method is called, the event will be reported through the integration and delegated to transport. - const processedProfile: JSSelfProfile | null = null; + let processedProfile: JSSelfProfile | null = null; getCurrentScope().setContext('profile', { profile_id: profileId, @@ -90,6 +90,7 @@ export function startProfileForSpan(span: Span): void { return; } + processedProfile = profile; addProfileToGlobalCache(profileId, profile); }) .catch(error => { diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 8b7039be7a9b..ed794a40a98b 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -1,5 +1,16 @@ /* eslint-disable max-lines */ -import type { DebugImage, Envelope, Event, EventEnvelope, Profile, Span, ThreadCpuProfile } from '@sentry/core'; +import type { + Client, + ContinuousThreadCpuProfile, + DebugImage, + Envelope, + Event, + EventEnvelope, + Profile, + ProfileChunk, + Span, + ThreadCpuProfile, +} from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, @@ -7,19 +18,24 @@ import { forEachEnvelopeItem, getClient, getDebugImagesForResources, + GLOBAL_OBJ, spanToJSON, timestampInSeconds, uuid4, } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor, JSSelfProfileStack } from './jsSelfProfiling'; const MS_TO_NS = 1e6; -// Use 0 as main thread id which is identical to threadId in node:worker_threads -// where main logs 0 and workers seem to log in increments of 1 -const THREAD_ID_STRING = String(0); -const THREAD_NAME = 'main'; + +// Checking if we are in Main or Worker thread: `self` (not `window`) is the `globalThis` in Web Workers and `importScripts` are only available in Web Workers +const isMainThread = 'window' in GLOBAL_OBJ && GLOBAL_OBJ.window === GLOBAL_OBJ && typeof importScripts === 'undefined'; + +// Setting ID to 0 as we cannot get an ID from Web Workers +export const PROFILER_THREAD_ID_STRING = String(0); +export const PROFILER_THREAD_NAME = isMainThread ? 'main' : 'worker'; // We force make this optional to be on the safe side... const navigator = WINDOW.navigator as typeof WINDOW.navigator | undefined; @@ -185,7 +201,7 @@ export function createProfilePayload( name: event.transaction || '', id: event.event_id || uuid4(), trace_id: traceId, - active_thread_id: THREAD_ID_STRING, + active_thread_id: PROFILER_THREAD_ID_STRING, relative_start_ns: '0', relative_end_ns: ((transactionEndMs - transactionStartMs) * 1e6).toFixed(0), }, @@ -195,6 +211,161 @@ export function createProfilePayload( return profile; } +/** + * Create a profile chunk envelope item + */ +export function createProfileChunkPayload( + jsSelfProfile: JSSelfProfile, + client: Client, + profilerId?: string, +): ProfileChunk { + // only == to catch null and undefined + if (jsSelfProfile == null) { + throw new TypeError( + `Cannot construct profiling event envelope without a valid profile. Got ${jsSelfProfile} instead.`, + ); + } + + const continuousProfile = convertToContinuousProfile(jsSelfProfile); + + const options = client.getOptions(); + const sdk = client.getSdkMetadata?.()?.sdk; + + return { + chunk_id: uuid4(), + client_sdk: { + name: sdk?.name ?? 'sentry.javascript.browser', + version: sdk?.version ?? '0.0.0', + }, + profiler_id: profilerId || uuid4(), + platform: 'javascript', + version: '2', + release: options.release ?? '', + environment: options.environment ?? 'production', + debug_meta: { + // function name obfuscation + images: applyDebugMetadata(jsSelfProfile.resources), + }, + profile: continuousProfile, + }; +} + +/** + * Validate a profile chunk against the Sample Format V2 requirements. + * https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + * - Presence of samples, stacks, frames + * - Required metadata fields + */ +export function validateProfileChunk(chunk: ProfileChunk): { valid: true } | { reason: string } { + try { + // Required metadata + if (!chunk || typeof chunk !== 'object') { + return { reason: 'chunk is not an object' }; + } + + // profiler_id and chunk_id must be 32 lowercase hex chars + const isHex32 = (val: unknown): boolean => typeof val === 'string' && /^[a-f0-9]{32}$/.test(val); + if (!isHex32(chunk.profiler_id)) { + return { reason: 'missing or invalid profiler_id' }; + } + if (!isHex32(chunk.chunk_id)) { + return { reason: 'missing or invalid chunk_id' }; + } + + if (!chunk.client_sdk) { + return { reason: 'missing client_sdk metadata' }; + } + + // Profile data must have frames, stacks, samples + const profile = chunk.profile as { frames?: unknown[]; stacks?: unknown[]; samples?: unknown[] } | undefined; + if (!profile) { + return { reason: 'missing profile data' }; + } + + if (!Array.isArray(profile.frames) || !profile.frames.length) { + return { reason: 'profile has no frames' }; + } + if (!Array.isArray(profile.stacks) || !profile.stacks.length) { + return { reason: 'profile has no stacks' }; + } + if (!Array.isArray(profile.samples) || !profile.samples.length) { + return { reason: 'profile has no samples' }; + } + + return { valid: true }; + } catch (e) { + return { reason: `unknown validation error: ${e}` }; + } +} + +/** + * Convert from JSSelfProfile format to ContinuousThreadCpuProfile format. + */ +function convertToContinuousProfile(input: { + frames: { name: string; resourceId?: number; line?: number; column?: number }[]; + stacks: { frameId: number; parentId?: number }[]; + samples: { timestamp: number; stackId?: number }[]; + resources: string[]; +}): ContinuousThreadCpuProfile { + // Frames map 1:1 by index; fill only when present to avoid sparse writes + const frames: ContinuousThreadCpuProfile['frames'] = []; + for (let i = 0; i < input.frames.length; i++) { + const frame = input.frames[i]; + if (!frame) { + continue; + } + frames[i] = { + function: frame.name, + abs_path: typeof frame.resourceId === 'number' ? input.resources[frame.resourceId] : undefined, + lineno: frame.line, + colno: frame.column, + }; + } + + // Build stacks by following parent links, top->down order (root last) + const stacks: ContinuousThreadCpuProfile['stacks'] = []; + for (let i = 0; i < input.stacks.length; i++) { + const stackHead = input.stacks[i]; + if (!stackHead) { + continue; + } + const list: number[] = []; + let current: { frameId: number; parentId?: number } | undefined = stackHead; + while (current) { + list.push(current.frameId); + current = current.parentId === undefined ? undefined : input.stacks[current.parentId]; + } + stacks[i] = list; + } + + // Align timestamps to SDK time origin to match span/event timelines + const perfOrigin = browserPerformanceTimeOrigin(); + const origin = typeof performance.timeOrigin === 'number' ? performance.timeOrigin : perfOrigin || 0; + const adjustForOriginChange = origin - (perfOrigin || origin); + + const samples: ContinuousThreadCpuProfile['samples'] = []; + for (let i = 0; i < input.samples.length; i++) { + const sample = input.samples[i]; + if (!sample) { + continue; + } + // Convert ms to seconds epoch-based timestamp + const timestampSeconds = (origin + (sample.timestamp - adjustForOriginChange)) / 1000; + samples[i] = { + stack_id: sample.stackId ?? 0, + thread_id: PROFILER_THREAD_ID_STRING, + timestamp: timestampSeconds, + }; + } + + return { + frames, + stacks, + samples, + thread_metadata: { [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME } }, + }; +} + /** * */ @@ -226,7 +397,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi stacks: [], frames: [], thread_metadata: { - [THREAD_ID_STRING]: { name: THREAD_NAME }, + [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME }, }, }; @@ -258,7 +429,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: EMPTY_STACK_ID, - thread_id: THREAD_ID_STRING, + thread_id: PROFILER_THREAD_ID_STRING, }; return; } @@ -291,7 +462,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: STACK_ID, - thread_id: THREAD_ID_STRING, + thread_id: PROFILER_THREAD_ID_STRING, }; profile['stacks'][STACK_ID] = stack; @@ -459,7 +630,7 @@ export function startJSSelfProfile(): JSSelfProfiler | undefined { /** * Determine if a profile should be profiled. */ -export function shouldProfileSpan(span: Span): boolean { +export function shouldProfileSpanLegacy(span: Span): boolean { // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (DEBUG_BUILD) { @@ -469,9 +640,7 @@ export function shouldProfileSpan(span: Span): boolean { } if (!span.isRecording()) { - if (DEBUG_BUILD) { - debug.log('[Profiling] Discarding profile because transaction was not sampled.'); - } + DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); return false; } @@ -518,6 +687,46 @@ export function shouldProfileSpan(span: Span): boolean { return true; } +/** + * Determine if a profile should be created for the current session (lifecycle profiling mode). + */ +export function shouldProfileSession(options: BrowserOptions): boolean { + // If constructor failed once, it will always fail, so we can early return. + if (PROFILING_CONSTRUCTOR_FAILED) { + if (DEBUG_BUILD) { + debug.log('[Profiling] Profiling has been disabled for the duration of the current user session.'); + } + return false; + } + + if (options.profileLifecycle !== 'trace') { + return false; + } + + // Session sampling: profileSessionSampleRate gates whether profiling is enabled for this session + const profileSessionSampleRate = options.profileSessionSampleRate; + + if (!isValidSampleRate(profileSessionSampleRate)) { + DEBUG_BUILD && debug.warn('[Profiling] Discarding profile because of invalid profileSessionSampleRate.'); + return false; + } + + if (!profileSessionSampleRate) { + DEBUG_BUILD && + debug.log('[Profiling] Discarding profile because profileSessionSampleRate is not defined or set to 0'); + return false; + } + + return Math.random() <= profileSessionSampleRate; +} + +/** + * Checks if legacy profiling is configured. + */ +export function hasLegacyProfiling(options: BrowserOptions): boolean { + return typeof options.profilesSampleRate !== 'undefined'; +} + /** * Creates a profiling envelope item, if the profile does not pass validation, returns null. * @param event @@ -564,8 +773,44 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi PROFILE_MAP.set(profile_id, profile); if (PROFILE_MAP.size > 30) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const last = PROFILE_MAP.keys().next().value!; - PROFILE_MAP.delete(last); + const last = PROFILE_MAP.keys().next().value; + if (last !== undefined) { + PROFILE_MAP.delete(last); + } } } + +/** + * Attaches the profiled thread information to the event's trace context. + */ +export function attachProfiledThreadToEvent(event: Event): Event { + if (!event?.contexts?.profile) { + return event; + } + + if (!event.contexts) { + return event; + } + + // @ts-expect-error the trace fallback value is wrong, though it should never happen + // and in case it does, we dont want to override whatever was passed initially. + event.contexts.trace = { + ...(event.contexts?.trace ?? {}), + data: { + ...(event.contexts?.trace?.data ?? {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }, + }; + + // Attach thread info to individual spans so that spans can be associated with the profiled thread on the UI even if contexts are missing. + event.spans?.forEach(span => { + span.data = { + ...(span.data || {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }; + }); + + return event; +} diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index 2af3cb662689..f9d97230701c 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -3,7 +3,9 @@ */ import * as Sentry from '@sentry/browser'; +import { debug } from '@sentry/core'; import { describe, expect, it, vi } from 'vitest'; +import type { BrowserClient } from '../../src/index'; import type { JSSelfProfile } from '../../src/profiling/jsSelfProfiling'; describe('BrowserProfilingIntegration', () => { @@ -65,4 +67,46 @@ describe('BrowserProfilingIntegration', () => { expect(profile_timestamp_ms).toBeGreaterThan(transaction_timestamp_ms); expect(profile.profile.frames[0]).toMatchObject({ function: 'pageload_fn', lineno: 1, colno: 1 }); }); + + it("warns when profileLifecycle is 'trace' but tracing is disabled", async () => { + debug.enable(); + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + + // @ts-expect-error mock constructor + window.Profiler = class { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return Promise.resolve({ frames: [], stacks: [], samples: [], resources: [] }); + } + }; + + Sentry.init({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + // no tracesSampleRate and no tracesSampler → tracing disabled + profileLifecycle: 'trace', + profileSessionSampleRate: 1, + integrations: [Sentry.browserProfilingIntegration()], + }); + + expect( + warnSpy.mock.calls.some(call => + String(call?.[1] ?? call?.[0]).includes("`profileLifecycle` is 'trace' but tracing is disabled"), + ), + ).toBe(true); + + warnSpy.mockRestore(); + }); + + it("auto-sets profileLifecycle to 'manual' when not specified", async () => { + Sentry.init({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + integrations: [Sentry.browserProfilingIntegration()], + }); + + const client = Sentry.getClient(); + const lifecycle = client?.getOptions()?.profileLifecycle; + expect(lifecycle).toBe('manual'); + }); }); diff --git a/packages/browser/test/profiling/traceLifecycleProfiler.test.ts b/packages/browser/test/profiling/traceLifecycleProfiler.test.ts new file mode 100644 index 000000000000..f28880960256 --- /dev/null +++ b/packages/browser/test/profiling/traceLifecycleProfiler.test.ts @@ -0,0 +1,631 @@ +/** + * @vitest-environment jsdom + */ + +import * as Sentry from '@sentry/browser'; +import type { Span } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('Browser Profiling v2 trace lifecycle', () => { + afterEach(async () => { + const client = Sentry.getClient(); + await client?.close(); + // reset profiler constructor + (window as any).Profiler = undefined; + vi.restoreAllMocks(); + }); + + function mockProfiler() { + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + const mockConstructor = vi.fn().mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => { + return new MockProfilerImpl(opts); + }); + + (window as any).Profiler = mockConstructor; + return { stop, mockConstructor }; + } + + it('does not start profiler when tracing is disabled (logs a warning)', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + Sentry.init({ + // tracing disabled + dsn: 'https://public@o.ingest.sentry.io/1', + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + // no tracesSampleRate/tracesSampler + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + // warning is logged by our debug logger only when DEBUG_BUILD, so just assert no throw and no profiler + const client = Sentry.getClient(); + expect(client).toBeDefined(); + expect(stop).toHaveBeenCalledTimes(0); + expect(mockConstructor).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + describe('profiling lifecycle behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts on first sampled root span and sends a chunk on stop', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-1', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + // Ending the only root span should flush one chunk immediately + spanRef.end(); + + // Resolve any pending microtasks + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(2); // one for transaction, one for profile_chunk + const transactionEnvelopeHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + const profileChunkEnvelopeHeader = send.mock.calls?.[1]?.[0]?.[1]?.[0]?.[0]; + expect(profileChunkEnvelopeHeader?.type).toBe('profile_chunk'); + expect(transactionEnvelopeHeader?.type).toBe('transaction'); + }); + + it('continues while any sampled root span is active; stops after last ends', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanA: any; + Sentry.startSpanManual({ name: 'root-A', parentSpan: null, forceTransaction: true }, span => { + spanA = span; + }); + + let spanB: any; + Sentry.startSpanManual({ name: 'root-B', parentSpan: null, forceTransaction: true }, span => { + spanB = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // End first root span -> still one active sampled root span; no send yet + spanA.end(); + await Promise.resolve(); + expect(stop).toHaveBeenCalledTimes(0); + expect(send).toHaveBeenCalledTimes(1); // only transaction so far + const envelopeHeadersTxn = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeadersTxn?.type).toBe('transaction'); + + // End last root span -> should flush one chunk + spanB.end(); + await Promise.resolve(); + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(3); + const envelopeHeadersTxn1 = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + const envelopeHeadersTxn2 = send.mock.calls?.[1]?.[0]?.[1]?.[0]?.[0]; + const envelopeHeadersProfile = send.mock.calls?.[2]?.[0]?.[1]?.[0]?.[0]; + + expect(envelopeHeadersTxn1?.type).toBe('transaction'); + expect(envelopeHeadersTxn2?.type).toBe('transaction'); + expect(envelopeHeadersProfile?.type).toBe('profile_chunk'); + }); + + it('sends a periodic chunk every 60s while running and restarts profiler', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-interval', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Advance timers by 60s to trigger scheduled chunk collection + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // One chunk sent and profiler restarted (second constructor call) + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + expect(mockConstructor).toHaveBeenCalledTimes(2); + const envelopeHeaders = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeaders?.type).toBe('profile_chunk'); + + // Clean up + spanRef.end(); + await Promise.resolve(); + }); + + it('emits periodic chunks every 60s while span is stuck (no spanEnd)', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-interval', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Advance timers by 60s to trigger first periodic chunk while still running + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // One chunk sent and profiler restarted for the next period + expect(stop.mock.calls.length).toBe(1); + expect(send.mock.calls.length).toBe(1); + expect(mockConstructor.mock.calls.length).toBe(2); + const firstChunkHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(firstChunkHeader?.type).toBe('profile_chunk'); + + // Second chunk after another 60s + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(2); + expect(send.mock.calls.length).toBe(2); + expect(mockConstructor.mock.calls.length).toBe(3); + + // Third chunk after another 60s + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(3); + expect(send.mock.calls.length).toBe(3); + expect(mockConstructor.mock.calls.length).toBe(4); + + spanRef.end(); + vi.advanceTimersByTime(100_000); + await Promise.resolve(); + + // All chunks should have been sent (4 total) + expect(stop.mock.calls.length).toBe(4); + expect(mockConstructor.mock.calls.length).toBe(4); // still 4 + expect(send.mock.calls.length).toBe(5); // 4 chunks + 1 transaction (tested below) + + const countProfileChunks = send.mock.calls.filter(obj => obj?.[0]?.[1]?.[0]?.[0].type === 'profile_chunk').length; + const countTransactions = send.mock.calls.filter(obj => obj?.[0]?.[1]?.[0]?.[0].type === 'transaction').length; + expect(countProfileChunks).toBe(4); + expect(countTransactions).toBe(1); + }); + + it('emits periodic chunks and stops after timeout if manual root span never ends', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { + // keep open - don't end + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Creates 2 profile chunks + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // At least two chunks emitted and profiler restarted in between + const stopsBeforeKill = stop.mock.calls.length; + const sendsBeforeKill = send.mock.calls.length; + const constructorCallsBeforeKill = mockConstructor.mock.calls.length; + expect(stopsBeforeKill).toBe(2); + expect(sendsBeforeKill).toBe(2); + expect(constructorCallsBeforeKill).toBe(3); + + // Advance to session kill switch (~5 minutes total since start) + vi.advanceTimersByTime(180_000); // now 300s total + await Promise.resolve(); + + const stopsAtKill = stop.mock.calls.length; + const sendsAtKill = send.mock.calls.length; + const constructorCallsAtKill = mockConstructor.mock.calls.length; + // 5min/60sec interval = 5 send/stop calls and 5 calls of constructor total + expect(constructorCallsAtKill).toBe(constructorCallsBeforeKill + 2); // constructor was already called 3 times + expect(stopsAtKill).toBe(stopsBeforeKill + 3); + expect(sendsAtKill).toBe(sendsBeforeKill + 3); + + // No calls should happen after kill + vi.advanceTimersByTime(120_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(stopsAtKill); + expect(send.mock.calls.length).toBe(sendsAtKill); + expect(mockConstructor.mock.calls.length).toBe(constructorCallsAtKill); + }); + + it('continues profiling for another rootSpan after one rootSpan profile timed-out', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { + // keep open - don't end + }); + + vi.advanceTimersByTime(300_000); // 5 minutes (kill switch) + await Promise.resolve(); + + const stopsAtKill = stop.mock.calls.length; + const sendsAtKill = send.mock.calls.length; + const constructorCallsAtKill = mockConstructor.mock.calls.length; + // 5min/60sec interval = 5 send/stop calls and 5 calls of constructor total + expect(constructorCallsAtKill).toBe(5); + expect(stopsAtKill).toBe(5); + expect(sendsAtKill).toBe(5); + + let spanRef: Span | undefined; + Sentry.startSpanManual({ name: 'root-manual-will-end', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + vi.advanceTimersByTime(119_000); // create 2 chunks + await Promise.resolve(); + + spanRef?.end(); + + expect(mockConstructor.mock.calls.length).toBe(sendsAtKill + 2); + expect(stop.mock.calls.length).toBe(constructorCallsAtKill + 2); + expect(send.mock.calls.length).toBe(stopsAtKill + 2); + }); + }); + + describe('profile context', () => { + it('sets global profile context on transaction', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'root-for-context', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + // Allow async tasks to resolve and flush queued envelopes + const client = Sentry.getClient(); + await client?.flush(1000); + + // Find the transaction envelope among sent envelopes + const calls = send.mock.calls; + const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + expect(txnCall).toBeDefined(); + + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: expect.objectContaining({ + ['thread.id']: expect.any(String), + ['thread.name']: expect.any(String), + }), + }, + profile: { + profiler_id: expect.any(String), + }, + }, + }); + }); + + it('reuses the same profiler_id across multiple root transactions within one session', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'rootSpan-1', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + Sentry.startSpan({ name: 'rootSpan-2', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + await Sentry.getClient()?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toEqual(2); + + const firstProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + const secondProfilerId = transactionEvents[1]?.contexts?.profile?.profiler_id; + + expect(typeof firstProfilerId).toBe('string'); + expect(typeof secondProfilerId).toBe('string'); + expect(firstProfilerId).toBe(secondProfilerId); + }); + + it('emits profile_chunk items with the same profiler_id as the transactions within a session', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'rootSpan-chunk-1', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + Sentry.startSpan({ name: 'rootSpan-chunk-2', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + await Sentry.getClient()?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toBe(2); + const expectedProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + expect(typeof expectedProfilerId).toBe('string'); + + const profileChunks = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(profileChunks.length).toBe(2); + + for (const chunk of profileChunks) { + expect(chunk?.profiler_id).toBe(expectedProfilerId); + } + }); + + it('changes profiler_id when a new user session starts (new SDK init)', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + // Session 1 + const send1 = vi.fn().mockResolvedValue(undefined); + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send1 }), + }); + + Sentry.startSpan({ name: 'session-1-rootSpan', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + let client = Sentry.getClient(); + await client?.flush(1000); + + // Extract first session profiler_id from transaction and a chunk + const calls1 = send1.mock.calls; + const txnEvt1 = calls1.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction')?.[0]?.[1]?.[0]?.[1]; + const chunks1 = calls1 + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + const profilerId1 = txnEvt1?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId1).toBe('string'); + expect(chunks1.length).toBe(1); + for (const chunk of chunks1) { + expect(chunk?.profiler_id).toBe(profilerId1); + } + + // End Session 1 + await client?.close(); + + // Session 2 (new init simulates new user session) + const send2 = vi.fn().mockResolvedValue(undefined); + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send2 }), + }); + + Sentry.startSpan({ name: 'session-2-rootSpan', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + client = Sentry.getClient(); + await client?.flush(1000); + + const calls2 = send2.mock.calls; + const txnEvt2 = calls2.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction')?.[0]?.[1]?.[0]?.[1]; + const chunks2 = calls2 + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + const profilerId2 = txnEvt2?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId2).toBe('string'); + expect(profilerId2).not.toBe(profilerId1); + expect(chunks2.length).toBe(1); + for (const chunk of chunks2) { + expect(chunk?.profiler_id).toBe(profilerId2); + } + }); + }); +}); diff --git a/packages/core/src/types-hoist/browseroptions.ts b/packages/core/src/types-hoist/browseroptions.ts index 1df40c6fd614..18bbd46af09c 100644 --- a/packages/core/src/types-hoist/browseroptions.ts +++ b/packages/core/src/types-hoist/browseroptions.ts @@ -18,9 +18,31 @@ export type BrowserClientReplayOptions = { }; export type BrowserClientProfilingOptions = { + // todo: add deprecation warning for profilesSampleRate: @deprecated Use `profileSessionSampleRate` and `profileLifecycle` instead. /** * The sample rate for profiling * 1.0 will profile all transactions and 0 will profile none. */ profilesSampleRate?: number; + + /** + * Sets profiling session sample rate for the entire profiling session. + * + * A profiling session corresponds to a user session, meaning it is set once at integration initialization and + * persisted until the next page reload. This rate determines what percentage of user sessions will have profiling enabled. + * @default 0 + */ + profileSessionSampleRate?: number; + + /** + * Set the lifecycle mode of the profiler. + * - **manual**: The profiler will be manually started and stopped via `startProfiler`/`stopProfiler`. + * If a session is sampled, is dependent on the `profileSessionSampleRate`. + * - **trace**: The profiler will be automatically started when a root span exists and stopped when there are no + * more sampled root spans. Whether a session is sampled, is dependent on the `profileSessionSampleRate` and the + * existing sampling configuration for tracing (`tracesSampleRate`/`tracesSampler`). + * + * @default 'manual' + */ + profileLifecycle?: 'manual' | 'trace'; }; diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 3d3463d0b5cf..1f84b69a9f28 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -46,16 +46,19 @@ export interface BaseNodeOptions { profilesSampler?: (samplingContext: SamplingContext) => number | boolean; /** - * Sets profiling session sample rate - only evaluated once per SDK initialization. + * Sets profiling session sample rate for the entire profiling session (evaluated once per SDK initialization). + * * @default 0 */ profileSessionSampleRate?: number; /** - * Set the lifecycle of the profiler. - * - * - `manual`: The profiler will be manually started and stopped. - * - `trace`: The profiler will be automatically started when when a span is sampled and stopped when there are no more sampled spans. + * Set the lifecycle mode of the profiler. + * - **manual**: The profiler will be manually started and stopped via `startProfiler`/`stopProfiler`. + * If a session is sampled, is dependent on the `profileSessionSampleRate`. + * - **trace**: The profiler will be automatically started when a root span exists and stopped when there are no + * more sampled root spans. Whether a session is sampled, is dependent on the `profileSessionSampleRate` and the + * existing sampling configuration for tracing (`tracesSampleRate`/`tracesSampler`). * * @default 'manual' */ From 75f68c7f3e06d2776693593567ab87759975fde6 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Tue, 21 Oct 2025 15:13:25 +0200 Subject: [PATCH 043/190] fix(core): Fix and add missing cache attributes in Vercel AI (#17982) With the relay now handling cache token attributes (instead of scrubbing them), some Anthropic related token attributes were still missing. This PR adds the missing cache attributes and corrects the types in the Anthropic provider metadata used for extracting token data. Fixes: https://github.com/getsentry/sentry-javascript/issues/17890 --- .../core/src/utils/ai/gen-ai-attributes.ts | 10 ++++++ packages/core/src/utils/vercel-ai/index.ts | 33 +++++++++++-------- .../utils/vercel-ai/vercel-ai-attributes.ts | 23 +++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/core/src/utils/ai/gen-ai-attributes.ts b/packages/core/src/utils/ai/gen-ai-attributes.ts index 9124602644e4..d55851927cb6 100644 --- a/packages/core/src/utils/ai/gen-ai-attributes.ts +++ b/packages/core/src/utils/ai/gen-ai-attributes.ts @@ -129,6 +129,16 @@ export const GEN_AI_RESPONSE_STREAMING_ATTRIBUTE = 'gen_ai.response.streaming'; */ export const GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE = 'gen_ai.response.tool_calls'; +/** + * The number of cache write input tokens used + */ +export const GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE = 'gen_ai.usage.input_tokens.cache_write'; + +/** + * The number of cached input tokens that were used + */ +export const GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE = 'gen_ai.usage.input_tokens.cached'; + // ============================================================================= // OPENAI-SPECIFIC ATTRIBUTES // ============================================================================= diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 9b1cc2bc8aae..8f353e88d394 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -2,6 +2,10 @@ import type { Client } from '../../client'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { Event } from '../../types-hoist/event'; import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, SpanOrigin } from '../../types-hoist/span'; +import { + GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; @@ -23,6 +27,7 @@ import { AI_TOOL_CALL_ID_ATTRIBUTE, AI_TOOL_CALL_NAME_ATTRIBUTE, AI_TOOL_CALL_RESULT_ATTRIBUTE, + AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, @@ -107,6 +112,7 @@ function processEndedVercelAiSpan(span: SpanJSON): void { renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE); + renameAttributeKey(attributes, AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE); if ( typeof attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] === 'number' && @@ -287,7 +293,7 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { if (providerMetadataObject.openai) { setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cached', + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, providerMetadataObject.openai.cachedPromptTokens, ); setAttributeIfDefined( @@ -309,27 +315,26 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { } if (providerMetadataObject.anthropic) { - setAttributeIfDefined( - attributes, - 'gen_ai.usage.input_tokens.cached', - providerMetadataObject.anthropic.cacheReadInputTokens, - ); - setAttributeIfDefined( - attributes, - 'gen_ai.usage.input_tokens.cache_write', - providerMetadataObject.anthropic.cacheCreationInputTokens, - ); + const cachedInputTokens = + providerMetadataObject.anthropic.usage?.cache_read_input_tokens ?? + providerMetadataObject.anthropic.cacheReadInputTokens; + setAttributeIfDefined(attributes, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, cachedInputTokens); + + const cacheWriteInputTokens = + providerMetadataObject.anthropic.usage?.cache_creation_input_tokens ?? + providerMetadataObject.anthropic.cacheCreationInputTokens; + setAttributeIfDefined(attributes, GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, cacheWriteInputTokens); } if (providerMetadataObject.bedrock?.usage) { setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cached', + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, providerMetadataObject.bedrock.usage.cacheReadInputTokens, ); setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cache_write', + GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, providerMetadataObject.bedrock.usage.cacheWriteInputTokens, ); } @@ -337,7 +342,7 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { if (providerMetadataObject.deepseek) { setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cached', + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, providerMetadataObject.deepseek.promptCacheHitTokens, ); setAttributeIfDefined( diff --git a/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts b/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts index ac6774b08a02..95052fc1265a 100644 --- a/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts +++ b/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts @@ -288,6 +288,14 @@ export const AI_RESPONSE_PROVIDER_METADATA_ATTRIBUTE = 'ai.response.providerMeta */ export const AI_SETTINGS_MAX_RETRIES_ATTRIBUTE = 'ai.settings.maxRetries'; +/** + * Basic LLM span information + * Multiple spans + * + * The number of cached input tokens that were used + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE = 'ai.usage.cachedInputTokens'; /** * Basic LLM span information * Multiple spans @@ -863,6 +871,21 @@ interface AnthropicProviderMetadata { * @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control */ cacheReadInputTokens?: number; + + /** + * Usage metrics for the Anthropic model. + */ + usage?: { + input_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + cache_creation?: { + ephemeral_5m_input_tokens?: number; + ephemeral_1h_input_tokens?: number; + }; + output_tokens?: number; + service_tier?: string; + }; } /** From 40bcc3d3cc79c9165300b95d645014407c1a1d98 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 21 Oct 2025 15:15:26 +0200 Subject: [PATCH 044/190] fix(core): Improve uuid performance (#17938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I did some benchmarking: - https://github.com/getsentry/sentry-javascript/issues/15862#issuecomment-3405607434 ``` ┌─────────┬─────────────────────────────────────────┬──────────────────┬──────────────────┬────────────────────────┬────────────────────────┬─────────┐ │ (index) │ Task name │ Latency avg (ns) │ Latency med (ns) │ Throughput avg (ops/s) │ Throughput med (ops/s) │ Samples │ ├─────────┼─────────────────────────────────────────┼──────────────────┼──────────────────┼────────────────────────┼────────────────────────┼─────────┤ │ 0 │ 'crypto.randomUUID()' │ '269.64 ± 0.24%' │ '250.00 ± 0.00' │ '3879372 ± 0.01%' │ '4000000 ± 0' │ 3708647 │ │ 1 │ 'crypto.randomUUID().replace(/-/g, "")' │ '445.09 ± 6.08%' │ '417.00 ± 0.00' │ '2377301 ± 0.01%' │ '2398082 ± 0' │ 2246729 │ │ 2 │ 'crypto.getRandomValues()' │ '32130 ± 9.11%' │ '28083 ± 834.00' │ '35202 ± 0.07%' │ '35609 ± 1076' │ 31124 │ │ 3 │ 'Math.random()' │ '1929.0 ± 1.02%' │ '1916.0 ± 42.00' │ '525124 ± 0.01%' │ '521921 ± 11413' │ 518396 │ │ 4 │ '@lukeed/uuid' │ '273.79 ± 0.07%' │ '250.00 ± 0.00' │ '3770742 ± 0.01%' │ '4000000 ± 0' │ 3652395 │ │ 5 │ '@lukeed/uuid (custom no hyphens)' │ '262.20 ± 5.68%' │ '250.00 ± 0.00' │ '4089440 ± 0.01%' │ '4000000 ± 0' │ 3813889 │ └─────────┴─────────────────────────────────────────┴──────────────────┴──────────────────┴────────────────────────┴────────────────────────┴─────────┘ ``` I found that in Node.js at least, getting a single byte via `crypto.getRandomValues()` is 10x slower than the `Math.random()` version so we should drop `getRandomValues` usage entirely. I also found that for the `Math.random()` fallback code, we generated the base starting string (`10000000100040008000100000000000`) on every call to `uuid4()`. If we cache this value we get a ~20% improvement in this path. In the browser `crypto.randomUUID()` is only available in secure contexts so our fallback code should have good performance too! --- .../startNewTraceSampling/init.js | 9 ++++ packages/core/src/utils/misc.ts | 30 +++++------- packages/core/test/lib/utils/misc.test.ts | 47 ++++--------------- 3 files changed, 30 insertions(+), 56 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js index 09af5f3e4ab4..d32f77f4cb6b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js @@ -5,6 +5,15 @@ window.Sentry = Sentry; // Force this so that the initial sampleRand is consistent Math.random = () => 0.45; +// Polyfill crypto.randomUUID +crypto.randomUUID = function randomUUID() { + return ([1e7] + 1e3 + 4e3 + 8e3 + 1e11).replace( + /[018]/g, + // eslint-disable-next-line no-bitwise + c => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16), + ); +}; + Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration()], diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 607eff129fe5..69cd217345b8 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -7,7 +7,6 @@ import { snipLine } from './string'; import { GLOBAL_OBJ } from './worldwide'; interface CryptoInternal { - getRandomValues(array: Uint8Array): Uint8Array; randomUUID?(): string; } @@ -22,37 +21,34 @@ function getCrypto(): CryptoInternal | undefined { return gbl.crypto || gbl.msCrypto; } +let emptyUuid: string | undefined; + +function getRandomByte(): number { + return Math.random() * 16; +} + /** * UUID4 generator * @param crypto Object that provides the crypto API. * @returns string Generated UUID4. */ export function uuid4(crypto = getCrypto()): string { - let getRandomByte = (): number => Math.random() * 16; try { if (crypto?.randomUUID) { return crypto.randomUUID().replace(/-/g, ''); } - if (crypto?.getRandomValues) { - getRandomByte = () => { - // crypto.getRandomValues might return undefined instead of the typed array - // in old Chromium versions (e.g. 23.0.1235.0 (151422)) - // However, `typedArray` is still filled in-place. - // @see https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues#typedarray - const typedArray = new Uint8Array(1); - crypto.getRandomValues(typedArray); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return typedArray[0]!; - }; - } } catch { // some runtimes can crash invoking crypto // https://github.com/getsentry/sentry-javascript/issues/8935 } - // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 - // Concatenating the following numbers as strings results in '10000000100040008000100000000000' - return (([1e7] as unknown as string) + 1e3 + 4e3 + 8e3 + 1e11).replace(/[018]/g, c => + if (!emptyUuid) { + // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 + // Concatenating the following numbers as strings results in '10000000100040008000100000000000' + emptyUuid = ([1e7] as unknown as string) + 1e3 + 4e3 + 8e3 + 1e11; + } + + return emptyUuid.replace(/[018]/g, c => // eslint-disable-next-line no-bitwise ((c as unknown as number) ^ ((getRandomByte() & 15) >> ((c as unknown as number) / 4))).toString(16), ); diff --git a/packages/core/test/lib/utils/misc.test.ts b/packages/core/test/lib/utils/misc.test.ts index 83e7f4c05b66..885e2dc64b8d 100644 --- a/packages/core/test/lib/utils/misc.test.ts +++ b/packages/core/test/lib/utils/misc.test.ts @@ -292,28 +292,21 @@ describe('checkOrSetAlreadyCaught()', () => { describe('uuid4 generation', () => { const uuid4Regex = /^[0-9A-F]{12}[4][0-9A-F]{3}[89AB][0-9A-F]{15}$/i; - it('returns valid uuid v4 ids via Math.random', () => { + it('returns valid and unique uuid v4 ids via Math.random', () => { + const uuids = new Set(); for (let index = 0; index < 1_000; index++) { - expect(uuid4()).toMatch(uuid4Regex); - } - }); - - it('returns valid uuid v4 ids via crypto.getRandomValues', () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const cryptoMod = require('crypto'); - - const crypto = { getRandomValues: cryptoMod.getRandomValues }; - - for (let index = 0; index < 1_000; index++) { - expect(uuid4(crypto)).toMatch(uuid4Regex); + const id = uuid4(); + expect(id).toMatch(uuid4Regex); + uuids.add(id); } + expect(uuids.size).toBe(1_000); }); it('returns valid uuid v4 ids via crypto.randomUUID', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires const cryptoMod = require('crypto'); - const crypto = { getRandomValues: cryptoMod.getRandomValues, randomUUID: cryptoMod.randomUUID }; + const crypto = { randomUUID: cryptoMod.randomUUID }; for (let index = 0; index < 1_000; index++) { expect(uuid4(crypto)).toMatch(uuid4Regex); @@ -321,7 +314,7 @@ describe('uuid4 generation', () => { }); it("return valid uuid v4 even if crypto doesn't exists", () => { - const crypto = { getRandomValues: undefined, randomUUID: undefined }; + const crypto = { randomUUID: undefined }; for (let index = 0; index < 1_000; index++) { expect(uuid4(crypto)).toMatch(uuid4Regex); @@ -330,9 +323,6 @@ describe('uuid4 generation', () => { it('return valid uuid v4 even if crypto invoked causes an error', () => { const crypto = { - getRandomValues: () => { - throw new Error('yo'); - }, randomUUID: () => { throw new Error('yo'); }, @@ -342,25 +332,4 @@ describe('uuid4 generation', () => { expect(uuid4(crypto)).toMatch(uuid4Regex); } }); - - // Corner case related to crypto.getRandomValues being only - // semi-implemented (e.g. Chromium 23.0.1235.0 (151422)) - it('returns valid uuid v4 even if crypto.getRandomValues does not return a typed array', () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const cryptoMod = require('crypto'); - - const getRandomValues = (typedArray: Uint8Array) => { - if (cryptoMod.getRandomValues) { - cryptoMod.getRandomValues(typedArray); - } - }; - - const crypto = { getRandomValues }; - - for (let index = 0; index < 1_000; index++) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - we are testing a corner case - expect(uuid4(crypto)).toMatch(uuid4Regex); - } - }); }); From 5bc35a7a00371799c90a27df2e0d1b7236b18b8e Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 21 Oct 2025 15:11:52 +0200 Subject: [PATCH 045/190] meta(changelog): Update changelog for 10.21.0 --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c68921b62c4..9b9b82b9bc8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.21.0 + +### Important Changes + +- **feat(browserProfiling): Add `trace` lifecycle mode for UI profiling ([#17619](https://github.com/getsentry/sentry-javascript/pull/17619))** + + Adds a new `trace` lifecycle mode for UI profiling, allowing profiles to be captured for the duration of a trace. A `manual` mode will be added in a future release. + +- **feat(nuxt): Instrument Database ([#17899](https://github.com/getsentry/sentry-javascript/pull/17899))** + + Adds instrumentation for Nuxt database operations, enabling better performance tracking of database queries. + +- **feat(nuxt): Instrument server cache API ([#17886](https://github.com/getsentry/sentry-javascript/pull/17886))** + + Adds instrumentation for Nuxt's server cache API, providing visibility into cache operations. + +- **feat(nuxt): Instrument storage API ([#17858](https://github.com/getsentry/sentry-javascript/pull/17858))** + + Adds instrumentation for Nuxt's storage API, enabling tracking of storage operations. + +### Other Changes + +- feat(browser): Add `onRequestSpanEnd` hook to browser tracing integration ([#17884](https://github.com/getsentry/sentry-javascript/pull/17884)) +- feat(nextjs): Support Next.js proxy files ([#17926](https://github.com/getsentry/sentry-javascript/pull/17926)) +- feat(replay): Record outcome when event buffer size exceeded ([#17946](https://github.com/getsentry/sentry-javascript/pull/17946)) +- fix(cloudflare): copy execution context in durable objects and handlers ([#17786](https://github.com/getsentry/sentry-javascript/pull/17786)) +- fix(core): Fix and add missing cache attributes in Vercel AI ([#17982](https://github.com/getsentry/sentry-javascript/pull/17982)) +- fix(core): Improve uuid performance ([#17938](https://github.com/getsentry/sentry-javascript/pull/17938)) +- fix(ember): Use updated version for `clean-css` ([#17979](https://github.com/getsentry/sentry-javascript/pull/17979)) +- fix(nextjs): Don't set experimental instrumentation hook flag for next 16 ([#17978](https://github.com/getsentry/sentry-javascript/pull/17978)) +- fix(nextjs): Inconsistent transaction naming for i18n routing ([#17927](https://github.com/getsentry/sentry-javascript/pull/17927)) +- fix(nextjs): Update bundler detection ([#17976](https://github.com/getsentry/sentry-javascript/pull/17976)) + +
+ Internal Changes + +- build: Update to typescript 5.8.0 ([#17710](https://github.com/getsentry/sentry-javascript/pull/17710)) +- chore: Add external contributor to CHANGELOG.md ([#17949](https://github.com/getsentry/sentry-javascript/pull/17949)) +- chore(build): Upgrade nodemon to 3.1.10 ([#17956](https://github.com/getsentry/sentry-javascript/pull/17956)) +- chore(ci): Fix external contributor action when multiple contributions existed ([#17950](https://github.com/getsentry/sentry-javascript/pull/17950)) +- chore(solid): Remove unnecessary import from README ([#17947](https://github.com/getsentry/sentry-javascript/pull/17947)) +- test(nextjs): Fix proxy/middleware test ([#17970](https://github.com/getsentry/sentry-javascript/pull/17970)) + +
+ Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution! ## 10.20.0 From 349a372d63b2215e4e1769a0c4125f1331ae571e Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 21 Oct 2025 14:49:04 +0000 Subject: [PATCH 046/190] release: 10.21.0 --- .../browser-integration-tests/package.json | 4 ++-- .../bundle-analyzer-scenarios/package.json | 2 +- dev-packages/clear-cache-gh-action/package.json | 2 +- .../cloudflare-integration-tests/package.json | 6 +++--- dev-packages/e2e-tests/package.json | 2 +- .../external-contributor-gh-action/package.json | 2 +- .../node-core-integration-tests/package.json | 6 +++--- dev-packages/node-integration-tests/package.json | 10 +++++----- dev-packages/node-overhead-gh-action/package.json | 4 ++-- dev-packages/rollup-utils/package.json | 2 +- dev-packages/size-limit-gh-action/package.json | 2 +- dev-packages/test-utils/package.json | 4 ++-- lerna.json | 2 +- packages/angular/package.json | 6 +++--- packages/astro/package.json | 8 ++++---- packages/aws-serverless/package.json | 6 +++--- packages/browser-utils/package.json | 4 ++-- packages/browser/package.json | 14 +++++++------- packages/bun/package.json | 6 +++--- packages/cloudflare/package.json | 4 ++-- packages/core/package.json | 2 +- packages/deno/package.json | 4 ++-- packages/ember/package.json | 6 +++--- packages/eslint-config-sdk/package.json | 6 +++--- packages/eslint-plugin-sdk/package.json | 2 +- packages/feedback/package.json | 4 ++-- packages/gatsby/package.json | 6 +++--- packages/google-cloud-serverless/package.json | 6 +++--- packages/integration-shims/package.json | 4 ++-- packages/nestjs/package.json | 6 +++--- packages/nextjs/package.json | 14 +++++++------- packages/node-core/package.json | 6 +++--- packages/node-native/package.json | 6 +++--- packages/node/package.json | 8 ++++---- packages/nuxt/package.json | 12 ++++++------ packages/opentelemetry/package.json | 4 ++-- packages/profiling-node/package.json | 6 +++--- packages/react-router/package.json | 10 +++++----- packages/react/package.json | 6 +++--- packages/remix/package.json | 8 ++++---- packages/replay-canvas/package.json | 6 +++--- packages/replay-internal/package.json | 8 ++++---- packages/replay-worker/package.json | 2 +- packages/solid/package.json | 6 +++--- packages/solidstart/package.json | 8 ++++---- packages/svelte/package.json | 6 +++--- packages/sveltekit/package.json | 10 +++++----- packages/tanstackstart-react/package.json | 10 +++++----- packages/tanstackstart/package.json | 2 +- packages/types/package.json | 4 ++-- packages/typescript/package.json | 2 +- packages/vercel-edge/package.json | 6 +++--- packages/vue/package.json | 6 +++--- packages/wasm/package.json | 6 +++--- 54 files changed, 152 insertions(+), 152 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 336ecd045f40..9f2b606114c5 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "10.20.0", + "version": "10.21.0", "main": "index.js", "license": "MIT", "engines": { @@ -43,7 +43,7 @@ "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.53.2", "@sentry-internal/rrweb": "2.34.0", - "@sentry/browser": "10.20.0", + "@sentry/browser": "10.21.0", "@supabase/supabase-js": "2.49.3", "axios": "^1.12.2", "babel-loader": "^8.2.2", diff --git a/dev-packages/bundle-analyzer-scenarios/package.json b/dev-packages/bundle-analyzer-scenarios/package.json index 124361baaa25..8336749e288c 100644 --- a/dev-packages/bundle-analyzer-scenarios/package.json +++ b/dev-packages/bundle-analyzer-scenarios/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundle-analyzer-scenarios", - "version": "10.20.0", + "version": "10.21.0", "description": "Scenarios to test bundle analysis with", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/dev-packages/bundle-analyzer-scenarios", diff --git a/dev-packages/clear-cache-gh-action/package.json b/dev-packages/clear-cache-gh-action/package.json index 21c4ce680659..fea1ff7db1e1 100644 --- a/dev-packages/clear-cache-gh-action/package.json +++ b/dev-packages/clear-cache-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/clear-cache-gh-action", "description": "An internal Github Action to clear GitHub caches.", - "version": "10.20.0", + "version": "10.21.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index fbc4f1c991a4..fb0c0e47dcaa 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/cloudflare-integration-tests", - "version": "10.20.0", + "version": "10.21.0", "license": "MIT", "engines": { "node": ">=18" @@ -13,11 +13,11 @@ "test:watch": "yarn test --watch" }, "dependencies": { - "@sentry/cloudflare": "10.20.0" + "@sentry/cloudflare": "10.21.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", - "@sentry-internal/test-utils": "10.20.0", + "@sentry-internal/test-utils": "10.21.0", "vitest": "^3.2.4", "wrangler": "4.22.0" }, diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index a5892494bd16..85be1cdc297b 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/e2e-tests", - "version": "10.20.0", + "version": "10.21.0", "license": "MIT", "private": true, "scripts": { diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json index d3848bd8c087..dea185d3b8f5 100644 --- a/dev-packages/external-contributor-gh-action/package.json +++ b/dev-packages/external-contributor-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/external-contributor-gh-action", "description": "An internal Github Action to add external contributors to the CHANGELOG.md file.", - "version": "10.20.0", + "version": "10.21.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index f78ace6957b3..788efcccdc3c 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-core-integration-tests", - "version": "10.20.0", + "version": "10.21.0", "license": "MIT", "engines": { "node": ">=18" @@ -34,8 +34,8 @@ "@opentelemetry/resources": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.20.0", - "@sentry/node-core": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/node-core": "10.21.0", "body-parser": "^1.20.3", "cors": "^2.8.5", "cron": "^3.1.6", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 5d7a02f7327c..5f2ab2023405 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-integration-tests", - "version": "10.20.0", + "version": "10.21.0", "license": "MIT", "engines": { "node": ">=18" @@ -33,9 +33,9 @@ "@nestjs/core": "^11", "@nestjs/platform-express": "^11", "@prisma/client": "6.15.0", - "@sentry/aws-serverless": "10.20.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", + "@sentry/aws-serverless": "10.21.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", @@ -80,7 +80,7 @@ "yargs": "^16.2.0" }, "devDependencies": { - "@sentry-internal/test-utils": "10.20.0", + "@sentry-internal/test-utils": "10.21.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", diff --git a/dev-packages/node-overhead-gh-action/package.json b/dev-packages/node-overhead-gh-action/package.json index 2e48ecfc5d12..3d48173943e3 100644 --- a/dev-packages/node-overhead-gh-action/package.json +++ b/dev-packages/node-overhead-gh-action/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-overhead-gh-action", - "version": "10.20.0", + "version": "10.21.0", "license": "MIT", "engines": { "node": ">=18" @@ -23,7 +23,7 @@ "fix": "eslint . --format stylish --fix" }, "dependencies": { - "@sentry/node": "10.20.0", + "@sentry/node": "10.21.0", "express": "^4.21.1", "mysql2": "^3.14.4" }, diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json index c733d1a7a10b..4f3d0398ada3 100644 --- a/dev-packages/rollup-utils/package.json +++ b/dev-packages/rollup-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/rollup-utils", - "version": "10.20.0", + "version": "10.21.0", "description": "Rollup utilities used at Sentry for the Sentry JavaScript SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/rollup-utils", diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index b5da92310446..7801552bb832 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/size-limit-gh-action", "description": "An internal Github Action to compare the current size of a PR against the one on develop.", - "version": "10.20.0", + "version": "10.21.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 2f1d906fd4e1..d9b663d1bb3f 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "10.20.0", + "version": "10.21.0", "name": "@sentry-internal/test-utils", "author": "Sentry", "license": "MIT", @@ -48,7 +48,7 @@ }, "devDependencies": { "@playwright/test": "~1.53.2", - "@sentry/core": "10.20.0" + "@sentry/core": "10.21.0" }, "volta": { "extends": "../../package.json" diff --git a/lerna.json b/lerna.json index d4b1a98ac901..ad4eeababaf1 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "10.20.0", + "version": "10.21.0", "npmClient": "yarn" } diff --git a/packages/angular/package.json b/packages/angular/package.json index 758a6aa17e53..43de1601cce5 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Angular", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular", @@ -21,8 +21,8 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "10.20.0", - "@sentry/core": "10.20.0", + "@sentry/browser": "10.21.0", + "@sentry/core": "10.21.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/package.json b/packages/astro/package.json index c3ae4e4116ff..95e2b6fd29db 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/astro", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Astro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/astro", @@ -56,9 +56,9 @@ "astro": ">=3.x || >=4.0.0-beta || >=5.x" }, "dependencies": { - "@sentry/browser": "10.20.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", + "@sentry/browser": "10.21.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 2c9d74fcd065..9e680b775210 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/aws-serverless", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for AWS Lambda and AWS Serverless Environments", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless", @@ -69,8 +69,8 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-aws-sdk": "0.59.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", "@types/aws-lambda": "^8.10.62" }, "devDependencies": { diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 363b725915ba..2947d63ebb45 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-utils", - "version": "10.20.0", + "version": "10.21.0", "description": "Browser Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser-utils", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.20.0" + "@sentry/core": "10.21.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser/package.json b/packages/browser/package.json index 3b01f272299f..556ef9e83db9 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for browsers", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser", @@ -39,14 +39,14 @@ "access": "public" }, "dependencies": { - "@sentry-internal/browser-utils": "10.20.0", - "@sentry-internal/feedback": "10.20.0", - "@sentry-internal/replay": "10.20.0", - "@sentry-internal/replay-canvas": "10.20.0", - "@sentry/core": "10.20.0" + "@sentry-internal/browser-utils": "10.21.0", + "@sentry-internal/feedback": "10.21.0", + "@sentry-internal/replay": "10.21.0", + "@sentry-internal/replay-canvas": "10.21.0", + "@sentry/core": "10.21.0" }, "devDependencies": { - "@sentry-internal/integration-shims": "10.20.0", + "@sentry-internal/integration-shims": "10.21.0", "fake-indexeddb": "^4.0.1" }, "scripts": { diff --git a/packages/bun/package.json b/packages/bun/package.json index 15eb9481f99a..4c57b5e3c93b 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bun", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for bun", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0" + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0" }, "devDependencies": { "bun-types": "^1.2.9" diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index df04b7ad30ac..46d21eaa7b0e 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/cloudflare", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Cloudflare Workers and Pages", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare", @@ -50,7 +50,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.20.0" + "@sentry/core": "10.21.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" diff --git a/packages/core/package.json b/packages/core/package.json index 2e7f2d55018a..3fc1bdf1d520 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/core", - "version": "10.20.0", + "version": "10.21.0", "description": "Base implementation for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/core", diff --git a/packages/deno/package.json b/packages/deno/package.json index 9b72fb7e82da..36fe4193606c 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/deno", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Deno", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", @@ -25,7 +25,7 @@ ], "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.20.0" + "@sentry/core": "10.21.0" }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", diff --git a/packages/ember/package.json b/packages/ember/package.json index 9fbfc634baaa..92a8a337eb89 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/ember", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Ember.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/ember", @@ -32,8 +32,8 @@ "dependencies": { "@babel/core": "^7.27.7", "@embroider/macros": "^1.16.0", - "@sentry/browser": "10.20.0", - "@sentry/core": "10.20.0", + "@sentry/browser": "10.21.0", + "@sentry/core": "10.21.0", "ember-auto-import": "^2.7.2", "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^6.1.1", diff --git a/packages/eslint-config-sdk/package.json b/packages/eslint-config-sdk/package.json index f013f8c41e05..91bd214d8736 100644 --- a/packages/eslint-config-sdk/package.json +++ b/packages/eslint-config-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-config-sdk", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK eslint config", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-config-sdk", @@ -22,8 +22,8 @@ "access": "public" }, "dependencies": { - "@sentry-internal/eslint-plugin-sdk": "10.20.0", - "@sentry-internal/typescript": "10.20.0", + "@sentry-internal/eslint-plugin-sdk": "10.21.0", + "@sentry-internal/typescript": "10.21.0", "@typescript-eslint/eslint-plugin": "^5.48.0", "@typescript-eslint/parser": "^5.48.0", "eslint-config-prettier": "^6.11.0", diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index 43a55a198577..5884e3ab52be 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-plugin-sdk", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK eslint plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-plugin-sdk", diff --git a/packages/feedback/package.json b/packages/feedback/package.json index cfd607c98884..8f2d83cee62a 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/feedback", - "version": "10.20.0", + "version": "10.21.0", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.20.0" + "@sentry/core": "10.21.0" }, "devDependencies": { "preact": "^10.19.4" diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index e54d827079d9..8ab9743297d5 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/gatsby", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Gatsby.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby", @@ -45,8 +45,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.20.0", - "@sentry/react": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/react": "10.21.0", "@sentry/webpack-plugin": "^4.1.1" }, "peerDependencies": { diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index a4507d3482f3..2aac33028f8d 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/google-cloud-serverless", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Google Cloud Functions", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/google-cloud-serverless", @@ -48,8 +48,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", "@types/express": "^4.17.14" }, "devDependencies": { diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index b13790dbfb95..6d3416fd15bf 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/integration-shims", - "version": "10.20.0", + "version": "10.21.0", "description": "Shims for integrations in Sentry SDK.", "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -56,7 +56,7 @@ "url": "https://github.com/getsentry/sentry-javascript/issues" }, "dependencies": { - "@sentry/core": "10.20.0" + "@sentry/core": "10.21.0" }, "engines": { "node": ">=18" diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index db33f3f20025..1b94378f1450 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nestjs", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for NestJS", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nestjs", @@ -49,8 +49,8 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-nestjs-core": "0.50.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0" + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 9464ced6a3ed..40924e8abd31 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nextjs", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Next.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs", @@ -79,13 +79,13 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@rollup/plugin-commonjs": "28.0.1", - "@sentry-internal/browser-utils": "10.20.0", + "@sentry-internal/browser-utils": "10.21.0", "@sentry/bundler-plugin-core": "^4.3.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", - "@sentry/opentelemetry": "10.20.0", - "@sentry/react": "10.20.0", - "@sentry/vercel-edge": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", + "@sentry/opentelemetry": "10.21.0", + "@sentry/react": "10.21.0", + "@sentry/vercel-edge": "10.21.0", "@sentry/webpack-plugin": "^4.3.0", "chalk": "3.0.0", "resolve": "1.22.8", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 701991d15783..a68fb2505b48 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-core", - "version": "10.20.0", + "version": "10.21.0", "description": "Sentry Node-Core SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-core", @@ -67,8 +67,8 @@ }, "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.20.0", - "@sentry/opentelemetry": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/opentelemetry": "10.21.0", "import-in-the-middle": "^1.14.2" }, "devDependencies": { diff --git a/packages/node-native/package.json b/packages/node-native/package.json index eaf0b9f4f15e..1c2b7e97e476 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-native", - "version": "10.20.0", + "version": "10.21.0", "description": "Native Tools for the Official Sentry Node.js SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-native", @@ -64,8 +64,8 @@ }, "dependencies": { "@sentry-internal/node-native-stacktrace": "^0.2.2", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0" + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/node/package.json b/packages/node/package.json index a78a670a6cdd..bd41dc271567 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node", - "version": "10.20.0", + "version": "10.21.0", "description": "Sentry Node SDK using OpenTelemetry for performance instrumentation", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node", @@ -95,9 +95,9 @@ "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.15.0", - "@sentry/core": "10.20.0", - "@sentry/node-core": "10.20.0", - "@sentry/opentelemetry": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/node-core": "10.21.0", + "@sentry/opentelemetry": "10.21.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" }, diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index b43fbf854d2e..1224d9538d5c 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nuxt", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Nuxt", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nuxt", @@ -47,13 +47,13 @@ }, "dependencies": { "@nuxt/kit": "^3.13.2", - "@sentry/browser": "10.20.0", - "@sentry/cloudflare": "10.20.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", + "@sentry/browser": "10.21.0", + "@sentry/cloudflare": "10.21.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", "@sentry/rollup-plugin": "^4.3.0", "@sentry/vite-plugin": "^4.3.0", - "@sentry/vue": "10.20.0" + "@sentry/vue": "10.21.0" }, "devDependencies": { "@nuxt/module-builder": "^0.8.4", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 8be9d3fe62dd..be55cf14347a 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/opentelemetry", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry utilities for OpenTelemetry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/opentelemetry", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.20.0" + "@sentry/core": "10.21.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json index 177badb3987c..2d3ad1f66c8c 100644 --- a/packages/profiling-node/package.json +++ b/packages/profiling-node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/profiling-node", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Node.js Profiling", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/profiling-node", @@ -63,8 +63,8 @@ }, "dependencies": { "@sentry-internal/node-cpu-profiler": "^2.2.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0" + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/react-router/package.json b/packages/react-router/package.json index ed996110c024..7571c6e4b414 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react-router", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for React Router (Framework)", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react-router", @@ -49,11 +49,11 @@ "@opentelemetry/core": "^2.1.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/browser": "10.20.0", + "@sentry/browser": "10.21.0", "@sentry/cli": "^2.56.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", - "@sentry/react": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", + "@sentry/react": "10.21.0", "@sentry/vite-plugin": "^4.1.0", "glob": "11.0.1" }, diff --git a/packages/react/package.json b/packages/react/package.json index 8b4c9be1e4fa..af6059cffa4f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for React.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.20.0", - "@sentry/core": "10.20.0", + "@sentry/browser": "10.21.0", + "@sentry/core": "10.21.0", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { diff --git a/packages/remix/package.json b/packages/remix/package.json index 49f8547e9e47..653e63cb3a46 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/remix", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Remix", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/remix", @@ -69,9 +69,9 @@ "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", "@sentry/cli": "^2.56.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", - "@sentry/react": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", + "@sentry/react": "10.21.0", "glob": "^10.3.4", "yargs": "^17.6.0" }, diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index 2d5cbe1f4979..d85775b60f9e 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-canvas", - "version": "10.20.0", + "version": "10.21.0", "description": "Replay canvas integration", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -69,8 +69,8 @@ "@sentry-internal/rrweb": "2.37.0" }, "dependencies": { - "@sentry-internal/replay": "10.20.0", - "@sentry/core": "10.20.0" + "@sentry-internal/replay": "10.21.0", + "@sentry/core": "10.21.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index ba9f70516b01..57bc676a7869 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay", - "version": "10.20.0", + "version": "10.21.0", "description": "User replays for Sentry", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -81,7 +81,7 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@babel/core": "^7.27.7", - "@sentry-internal/replay-worker": "10.20.0", + "@sentry-internal/replay-worker": "10.21.0", "@sentry-internal/rrweb": "2.37.0", "@sentry-internal/rrweb-snapshot": "2.37.0", "fflate": "0.8.2", @@ -90,8 +90,8 @@ "node-fetch": "^2.6.7" }, "dependencies": { - "@sentry-internal/browser-utils": "10.20.0", - "@sentry/core": "10.20.0" + "@sentry-internal/browser-utils": "10.21.0", + "@sentry/core": "10.21.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index 3e02a1ef7fd7..1fca13cf84e4 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-worker", - "version": "10.20.0", + "version": "10.21.0", "description": "Worker for @sentry-internal/replay", "main": "build/esm/index.js", "module": "build/esm/index.js", diff --git a/packages/solid/package.json b/packages/solid/package.json index 74851199d7d7..dae956cdd977 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solid", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Solid", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solid", @@ -54,8 +54,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.20.0", - "@sentry/core": "10.20.0" + "@sentry/browser": "10.21.0", + "@sentry/core": "10.21.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 09cd15891644..775b7a3c08e9 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solidstart", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Solid Start", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solidstart", @@ -66,9 +66,9 @@ } }, "dependencies": { - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", - "@sentry/solid": "10.20.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", + "@sentry/solid": "10.21.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/svelte/package.json b/packages/svelte/package.json index e65fa84fd435..6114b4c2f4a1 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/svelte", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Svelte", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/svelte", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.20.0", - "@sentry/core": "10.20.0", + "@sentry/browser": "10.21.0", + "@sentry/core": "10.21.0", "magic-string": "^0.30.0" }, "peerDependencies": { diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index b0fb07508ef7..ed8f6ddb40aa 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/sveltekit", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for SvelteKit", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/sveltekit", @@ -48,10 +48,10 @@ }, "dependencies": { "@babel/parser": "7.26.9", - "@sentry/cloudflare": "10.20.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", - "@sentry/svelte": "10.20.0", + "@sentry/cloudflare": "10.21.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", + "@sentry/svelte": "10.21.0", "@sentry/vite-plugin": "^4.1.0", "magic-string": "0.30.7", "recast": "0.23.11", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 71ae6f8c4e53..b5f9ab645b5f 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart-react", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for TanStack Start React", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart-react", @@ -52,10 +52,10 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry-internal/browser-utils": "10.20.0", - "@sentry/core": "10.20.0", - "@sentry/node": "10.20.0", - "@sentry/react": "10.20.0" + "@sentry-internal/browser-utils": "10.21.0", + "@sentry/core": "10.21.0", + "@sentry/node": "10.21.0", + "@sentry/react": "10.21.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/tanstackstart/package.json b/packages/tanstackstart/package.json index b03139402bdb..0e263b47c152 100644 --- a/packages/tanstackstart/package.json +++ b/packages/tanstackstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart", - "version": "10.20.0", + "version": "10.21.0", "description": "Utilities for the Sentry TanStack Start SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart", diff --git a/packages/types/package.json b/packages/types/package.json index e393534729c7..65e50db67973 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/types", - "version": "10.20.0", + "version": "10.21.0", "description": "Types for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/types", @@ -57,7 +57,7 @@ "yalc:publish": "yalc publish --push --sig" }, "dependencies": { - "@sentry/core": "10.20.0" + "@sentry/core": "10.21.0" }, "volta": { "extends": "../../package.json" diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 430842c16d09..02ce8b6b2e75 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/typescript", - "version": "10.20.0", + "version": "10.21.0", "description": "Typescript configuration used at Sentry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/typescript", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 94ce4856d199..9abae213b4e7 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vercel-edge", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for the Vercel Edge Runtime", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vercel-edge", @@ -41,14 +41,14 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/resources": "^2.1.0", - "@sentry/core": "10.20.0" + "@sentry/core": "10.21.0" }, "devDependencies": { "@edge-runtime/types": "3.0.1", "@opentelemetry/core": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/opentelemetry": "10.20.0" + "@sentry/opentelemetry": "10.21.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/vue/package.json b/packages/vue/package.json index ba35ef861b38..d51e48074dc8 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vue", - "version": "10.20.0", + "version": "10.21.0", "description": "Official Sentry SDK for Vue.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vue", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.20.0", - "@sentry/core": "10.20.0" + "@sentry/browser": "10.21.0", + "@sentry/core": "10.21.0" }, "peerDependencies": { "pinia": "2.x || 3.x", diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 35515307262d..1d4bdd213251 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/wasm", - "version": "10.20.0", + "version": "10.21.0", "description": "Support for WASM.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/wasm", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.20.0", - "@sentry/core": "10.20.0" + "@sentry/browser": "10.21.0", + "@sentry/core": "10.21.0" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", From 718a3eab491068a32c2b8d2eb2794699330a8336 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Tue, 21 Oct 2025 18:08:56 +0200 Subject: [PATCH 047/190] feat(core): Truncate request messages in AI integrations (#17921) Fixes: https://github.com/getsentry/sentry-javascript/issues/17809 Implements message truncation logic that drops oldest messages first until the payload fits within the 20KB limit. If a single message exceeds the limit, its content is truncated from the end. Supports OpenAI/Anthropic ({ role, content }) and Google GenAI ({ role, parts: [{ text }] }) message formats. --- .../anthropic/scenario-message-truncation.mjs | 71 +++++ .../suites/tracing/anthropic/test.ts | 36 +++ .../scenario-message-truncation.mjs | 69 ++++ .../suites/tracing/google-genai/test.ts | 38 +++ .../openai/scenario-message-truncation.mjs | 69 ++++ .../suites/tracing/openai/test.ts | 40 ++- .../core/src/utils/ai/messageTruncation.ts | 296 ++++++++++++++++++ packages/core/src/utils/ai/utils.ts | 21 ++ packages/core/src/utils/anthropic-ai/index.ts | 34 +- packages/core/src/utils/anthropic-ai/utils.ts | 22 +- packages/core/src/utils/google-genai/index.ts | 15 +- packages/core/src/utils/openai/index.ts | 7 +- packages/core/src/utils/vercel-ai/index.ts | 4 +- 13 files changed, 691 insertions(+), 31 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs create mode 100644 packages/core/src/utils/ai/messageTruncation.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs new file mode 100644 index 000000000000..21821cdc5aae --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-message-truncation.mjs @@ -0,0 +1,71 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + this.baseURL = config.baseURL; + + // Create messages object with create method + this.messages = { + create: this._messagesCreate.bind(this), + }; + } + + /** + * Create a mock message + */ + async _messagesCreate(params) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + id: 'msg-truncation-test', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Response to truncated messages', + }, + ], + model: params.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ + apiKey: 'mock-api-key', + }); + + const client = instrumentAnthropicAiClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.messages.create({ + model: 'claude-3-haiku-20240307', + max_tokens: 100, + messages: [ + { role: 'user', content: largeContent1 }, + { role: 'assistant', content: largeContent2 }, + { role: 'user', content: largeContent3 }, + ], + temperature: 0.7, + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index c05db16fc251..57e788b721d1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -497,4 +497,40 @@ describe('Anthropic integration', () => { await createRunner().ignore('event').expect({ transaction: EXPECTED_ERROR_SPANS }).start().completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + // Messages should be present (truncation happened) and should be a JSON array + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs new file mode 100644 index 000000000000..bb24b6835db2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-message-truncation.mjs @@ -0,0 +1,69 @@ +import { instrumentGoogleGenAIClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockGoogleGenerativeAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.models = { + generateContent: this._generateContent.bind(this), + }; + } + + async _generateContent() { + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + response: { + text: () => 'Response to truncated messages', + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 15, + totalTokenCount: 25, + }, + candidates: [ + { + content: { + parts: [{ text: 'Response to truncated messages' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockGoogleGenerativeAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentGoogleGenAIClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.models.generateContent({ + model: 'gemini-1.5-flash', + config: { + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 100, + }, + contents: [ + { role: 'user', parts: [{ text: largeContent1 }] }, + { role: 'model', parts: [{ text: largeContent2 }] }, + { role: 'user', parts: [{ text: largeContent3 }] }, + ], + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 92d669c7e10f..921f94e78765 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -486,4 +486,42 @@ describe('Google GenAI integration', () => { .completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.google_genai', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'gemini-1.5-flash', + // Messages should be present (truncation happened) and should be a JSON array with parts + 'gen_ai.request.messages': expect.stringMatching( + /^\[\{"role":"user","parts":\[\{"text":"C+"\}\]\}\]$/, + ), + }), + description: 'models gemini-1.5-flash', + op: 'gen_ai.models', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs new file mode 100644 index 000000000000..5623d3763657 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation.mjs @@ -0,0 +1,69 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.chat = { + completions: { + create: async params => { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + id: 'chatcmpl-truncation-test', + object: 'chat.completion', + created: 1677652288, + model: params.model, + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Response to truncated messages', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 15, + total_tokens: 25, + }, + }; + }, + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // Create 3 large messages where: + // - First 2 messages are very large (will be dropped) + // - Last message is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.chat.completions.create({ + model: 'gpt-3.5-turbo', + messages: [ + { role: 'system', content: largeContent1 }, + { role: 'user', content: largeContent2 }, + { role: 'user', content: largeContent3 }, + ], + temperature: 0.7, + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index c0c0b79e95f7..8c788834f126 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -187,7 +187,7 @@ describe('OpenAI integration', () => { 'sentry.origin': 'auto.ai.openai', 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-3.5-turbo', - 'gen_ai.request.messages': '"Translate this to French: Hello"', + 'gen_ai.request.messages': 'Translate this to French: Hello', 'gen_ai.response.text': 'Response to: Translate this to French: Hello', 'gen_ai.response.finish_reasons': '["completed"]', 'gen_ai.response.model': 'gpt-3.5-turbo', @@ -261,7 +261,7 @@ describe('OpenAI integration', () => { 'gen_ai.system': 'openai', 'gen_ai.request.model': 'gpt-4', 'gen_ai.request.stream': true, - 'gen_ai.request.messages': '"Test streaming responses API"', + 'gen_ai.request.messages': 'Test streaming responses API', 'gen_ai.response.text': 'Streaming response to: Test streaming responses APITest streaming responses API', 'gen_ai.response.finish_reasons': '["in_progress","completed"]', 'gen_ai.response.id': 'resp_stream_456', @@ -397,4 +397,40 @@ describe('OpenAI integration', () => { .completed(); }); }); + + createEsmAndCjsTests( + __dirname, + 'scenario-message-truncation.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + // Messages should be present (truncation happened) and should be a JSON array of a single index + 'gen_ai.request.messages': expect.stringMatching(/^\[\{"role":"user","content":"C+"\}\]$/), + }), + description: 'chat gpt-3.5-turbo', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/packages/core/src/utils/ai/messageTruncation.ts b/packages/core/src/utils/ai/messageTruncation.ts new file mode 100644 index 000000000000..64d186f927b8 --- /dev/null +++ b/packages/core/src/utils/ai/messageTruncation.ts @@ -0,0 +1,296 @@ +/** + * Default maximum size in bytes for GenAI messages. + * Messages exceeding this limit will be truncated. + */ +export const DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT = 20000; + +/** + * Message format used by OpenAI and Anthropic APIs. + */ +type ContentMessage = { + [key: string]: unknown; + content: string; +}; + +/** + * Message format used by Google GenAI API. + * Parts can be strings or objects with a text property. + */ +type PartsMessage = { + [key: string]: unknown; + parts: Array; +}; + +/** + * A part in a Google GenAI message that contains text. + */ +type TextPart = string | { text: string }; + +/** + * Calculate the UTF-8 byte length of a string. + */ +const utf8Bytes = (text: string): number => { + return new TextEncoder().encode(text).length; +}; + +/** + * Calculate the UTF-8 byte length of a value's JSON representation. + */ +const jsonBytes = (value: unknown): number => { + return utf8Bytes(JSON.stringify(value)); +}; + +/** + * Truncate a string to fit within maxBytes when encoded as UTF-8. + * Uses binary search for efficiency with multi-byte characters. + * + * @param text - The string to truncate + * @param maxBytes - Maximum byte length (UTF-8 encoded) + * @returns Truncated string that fits within maxBytes + */ +function truncateTextByBytes(text: string, maxBytes: number): string { + if (utf8Bytes(text) <= maxBytes) { + return text; + } + + let low = 0; + let high = text.length; + let bestFit = ''; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const candidate = text.slice(0, mid); + const byteSize = utf8Bytes(candidate); + + if (byteSize <= maxBytes) { + bestFit = candidate; + low = mid + 1; + } else { + high = mid - 1; + } + } + + return bestFit; +} + +/** + * Extract text content from a Google GenAI message part. + * Parts are either plain strings or objects with a text property. + * + * @returns The text content + */ +function getPartText(part: TextPart): string { + if (typeof part === 'string') { + return part; + } + return part.text; +} + +/** + * Create a new part with updated text content while preserving the original structure. + * + * @param part - Original part (string or object) + * @param text - New text content + * @returns New part with updated text + */ +function withPartText(part: TextPart, text: string): TextPart { + if (typeof part === 'string') { + return text; + } + return { ...part, text }; +} + +/** + * Check if a message has the OpenAI/Anthropic content format. + */ +function isContentMessage(message: unknown): message is ContentMessage { + return ( + message !== null && + typeof message === 'object' && + 'content' in message && + typeof (message as ContentMessage).content === 'string' + ); +} + +/** + * Check if a message has the Google GenAI parts format. + */ +function isPartsMessage(message: unknown): message is PartsMessage { + return ( + message !== null && + typeof message === 'object' && + 'parts' in message && + Array.isArray((message as PartsMessage).parts) && + (message as PartsMessage).parts.length > 0 + ); +} + +/** + * Truncate a message with `content: string` format (OpenAI/Anthropic). + * + * @param message - Message with content property + * @param maxBytes - Maximum byte limit + * @returns Array with truncated message, or empty array if it doesn't fit + */ +function truncateContentMessage(message: ContentMessage, maxBytes: number): unknown[] { + // Calculate overhead (message structure without content) + const emptyMessage = { ...message, content: '' }; + const overhead = jsonBytes(emptyMessage); + const availableForContent = maxBytes - overhead; + + if (availableForContent <= 0) { + return []; + } + + const truncatedContent = truncateTextByBytes(message.content, availableForContent); + return [{ ...message, content: truncatedContent }]; +} + +/** + * Truncate a message with `parts: [...]` format (Google GenAI). + * Keeps as many complete parts as possible, only truncating the first part if needed. + * + * @param message - Message with parts array + * @param maxBytes - Maximum byte limit + * @returns Array with truncated message, or empty array if it doesn't fit + */ +function truncatePartsMessage(message: PartsMessage, maxBytes: number): unknown[] { + const { parts } = message; + + // Calculate overhead by creating empty text parts + const emptyParts = parts.map(part => withPartText(part, '')); + const overhead = jsonBytes({ ...message, parts: emptyParts }); + let remainingBytes = maxBytes - overhead; + + if (remainingBytes <= 0) { + return []; + } + + // Include parts until we run out of space + const includedParts: TextPart[] = []; + + for (const part of parts) { + const text = getPartText(part); + const textSize = utf8Bytes(text); + + if (textSize <= remainingBytes) { + // Part fits: include it as-is + includedParts.push(part); + remainingBytes -= textSize; + } else if (includedParts.length === 0) { + // First part doesn't fit: truncate it + const truncated = truncateTextByBytes(text, remainingBytes); + if (truncated) { + includedParts.push(withPartText(part, truncated)); + } + break; + } else { + // Subsequent part doesn't fit: stop here + break; + } + } + + return includedParts.length > 0 ? [{ ...message, parts: includedParts }] : []; +} + +/** + * Truncate a single message to fit within maxBytes. + * + * Supports two message formats: + * - OpenAI/Anthropic: `{ ..., content: string }` + * - Google GenAI: `{ ..., parts: Array }` + * + * @param message - The message to truncate + * @param maxBytes - Maximum byte limit for the message + * @returns Array containing the truncated message, or empty array if truncation fails + */ +function truncateSingleMessage(message: unknown, maxBytes: number): unknown[] { + if (!message || typeof message !== 'object') { + return []; + } + + if (isContentMessage(message)) { + return truncateContentMessage(message, maxBytes); + } + + if (isPartsMessage(message)) { + return truncatePartsMessage(message, maxBytes); + } + + // Unknown message format: cannot truncate safely + return []; +} + +/** + * Truncate an array of messages to fit within a byte limit. + * + * Strategy: + * - Keeps the newest messages (from the end of the array) + * - Uses O(n) algorithm: precompute sizes once, then find largest suffix under budget + * - If no complete messages fit, attempts to truncate the newest single message + * + * @param messages - Array of messages to truncate + * @param maxBytes - Maximum total byte limit for all messages + * @returns Truncated array of messages + * + * @example + * ```ts + * const messages = [msg1, msg2, msg3, msg4]; // newest is msg4 + * const truncated = truncateMessagesByBytes(messages, 10000); + * // Returns [msg3, msg4] if they fit, or [msg4] if only it fits, etc. + * ``` + */ +export function truncateMessagesByBytes(messages: unknown[], maxBytes: number): unknown[] { + // Early return for empty or invalid input + if (!Array.isArray(messages) || messages.length === 0) { + return messages; + } + + // Fast path: if all messages fit, return as-is + const totalBytes = jsonBytes(messages); + if (totalBytes <= maxBytes) { + return messages; + } + + // Precompute each message's JSON size once for efficiency + const messageSizes = messages.map(jsonBytes); + + // Find the largest suffix (newest messages) that fits within the budget + let bytesUsed = 0; + let startIndex = messages.length; // Index where the kept suffix starts + + for (let i = messages.length - 1; i >= 0; i--) { + const messageSize = messageSizes[i]; + + if (messageSize && bytesUsed + messageSize > maxBytes) { + // Adding this message would exceed the budget + break; + } + + if (messageSize) { + bytesUsed += messageSize; + } + startIndex = i; + } + + // If no complete messages fit, try truncating just the newest message + if (startIndex === messages.length) { + const newestMessage = messages[messages.length - 1]; + return truncateSingleMessage(newestMessage, maxBytes); + } + + // Return the suffix that fits + return messages.slice(startIndex); +} + +/** + * Truncate GenAI messages using the default byte limit. + * + * Convenience wrapper around `truncateMessagesByBytes` with the default limit. + * + * @param messages - Array of messages to truncate + * @returns Truncated array of messages + */ +export function truncateGenAiMessages(messages: unknown[]): unknown[] { + return truncateMessagesByBytes(messages, DEFAULT_GEN_AI_MESSAGES_BYTE_LIMIT); +} diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/utils/ai/utils.ts index ecb46d5f0d0d..00e147a16e5f 100644 --- a/packages/core/src/utils/ai/utils.ts +++ b/packages/core/src/utils/ai/utils.ts @@ -7,6 +7,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from './gen-ai-attributes'; +import { truncateGenAiMessages } from './messageTruncation'; /** * Maps AI method paths to Sentry operation name */ @@ -84,3 +85,23 @@ export function setTokenUsageAttributes( }); } } + +/** + * Get the truncated JSON string for a string or array of strings. + * + * @param value - The string or array of strings to truncate + * @returns The truncated JSON string + */ +export function getTruncatedJsonString(value: T | T[]): string { + if (typeof value === 'string') { + // Some values are already JSON strings, so we don't need to duplicate the JSON parsing + return value; + } + if (Array.isArray(value)) { + // truncateGenAiMessages returns an array of strings, so we need to stringify it + const truncatedMessages = truncateGenAiMessages(value); + return JSON.stringify(truncatedMessages); + } + // value is an object, so we need to stringify it + return JSON.stringify(value); +} diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index 8e77dd76b34e..d81741668be9 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -23,7 +23,13 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; +import { + buildMethodPath, + getFinalOperationName, + getSpanOperation, + getTruncatedJsonString, + setTokenUsageAttributes, +} from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; import type { @@ -33,7 +39,7 @@ import type { AnthropicAiStreamingEvent, ContentBlock, } from './types'; -import { shouldInstrument } from './utils'; +import { handleResponseError, shouldInstrument } from './utils'; /** * Extract request attributes from method arguments @@ -77,33 +83,19 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record): void { if ('messages' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + const truncatedInput = getTruncatedJsonString(params.input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); } + if ('prompt' in params) { span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); } } -/** - * Capture error information from the response - * @see https://docs.anthropic.com/en/api/errors#error-shapes - */ -function handleResponseError(span: Span, response: AnthropicAiResponse): void { - if (response.error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'unknown_error' }); - - captureException(response.error, { - mechanism: { - handled: false, - type: 'auto.ai.anthropic.anthropic_error', - }, - }); - } -} - /** * Add content attributes when recordOutputs is enabled */ diff --git a/packages/core/src/utils/anthropic-ai/utils.ts b/packages/core/src/utils/anthropic-ai/utils.ts index 299d20170d6c..bce96aa68bcc 100644 --- a/packages/core/src/utils/anthropic-ai/utils.ts +++ b/packages/core/src/utils/anthropic-ai/utils.ts @@ -1,5 +1,8 @@ +import { captureException } from '../../exports'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types-hoist/span'; import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; -import type { AnthropicAiInstrumentedMethod } from './types'; +import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types'; /** * Check if a method path should be instrumented @@ -7,3 +10,20 @@ import type { AnthropicAiInstrumentedMethod } from './types'; export function shouldInstrument(methodPath: string): methodPath is AnthropicAiInstrumentedMethod { return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); } + +/** + * Capture error information from the response + * @see https://docs.anthropic.com/en/api/errors#error-shapes + */ +export function handleResponseError(span: Span, response: AnthropicAiResponse): void { + if (response.error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: response.error.type || 'unknown_error' }); + + captureException(response.error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic.anthropic_error', + }, + }); + } +} diff --git a/packages/core/src/utils/google-genai/index.ts b/packages/core/src/utils/google-genai/index.ts index 20e6e2a53606..9639b1255d29 100644 --- a/packages/core/src/utils/google-genai/index.ts +++ b/packages/core/src/utils/google-genai/index.ts @@ -22,7 +22,7 @@ import { GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { buildMethodPath, getFinalOperationName, getSpanOperation } from '../ai/utils'; +import { buildMethodPath, getFinalOperationName, getSpanOperation, getTruncatedJsonString } from '../ai/utils'; import { handleCallbackErrors } from '../handleCallbackErrors'; import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; @@ -136,17 +136,24 @@ function extractRequestAttributes( function addPrivateRequestAttributes(span: Span, params: Record): void { // For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[] if ('contents' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.contents) }); + const contents = params.contents; + // For models.generateContent: ContentListUnion: Content | Content[] | PartUnion | PartUnion[] + const truncatedContents = getTruncatedJsonString(contents); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedContents }); } // For chat.sendMessage: message can be string or Part[] if ('message' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.message) }); + const message = params.message; + const truncatedMessage = getTruncatedJsonString(message); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessage }); } // For chats.create: history contains the conversation history if ('history' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.history) }); + const history = params.history; + const truncatedHistory = getTruncatedJsonString(history); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedHistory }); } } diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index 4ecfad625062..bb099199772c 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -19,6 +19,7 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { OPENAI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { @@ -191,10 +192,12 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. function addRequestAttributes(span: Span, params: Record): void { if ('messages' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + const truncatedMessages = getTruncatedJsonString(params.messages); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedMessages }); } if ('input' in params) { - span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + const truncatedInput = getTruncatedJsonString(params.input); + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: truncatedInput }); } } diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 8f353e88d394..747a3c105449 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -6,6 +6,7 @@ import { GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import { getTruncatedJsonString } from '../ai/utils'; import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; @@ -196,7 +197,8 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute } if (attributes[AI_PROMPT_ATTRIBUTE]) { - span.setAttribute('gen_ai.prompt', attributes[AI_PROMPT_ATTRIBUTE]); + const truncatedPrompt = getTruncatedJsonString(attributes[AI_PROMPT_ATTRIBUTE] as string | string[]); + span.setAttribute('gen_ai.prompt', truncatedPrompt); } if (attributes[AI_MODEL_ID_ATTRIBUTE] && !attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]) { span.setAttribute(GEN_AI_RESPONSE_MODEL_ATTRIBUTE, attributes[AI_MODEL_ID_ATTRIBUTE]); From cefcdbc9d1780b2acd85b8263a7f5da9a4183eb8 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 21 Oct 2025 13:28:18 -0400 Subject: [PATCH 048/190] chore: Upgrade madge to v8 (#17957) No breaking changes that affect us: https://github.com/pahen/madge/blob/master/CHANGELOG.md#v800 Should reduce our total dependency count. --- package.json | 2 +- yarn.lock | 316 ++++++++++++++++----------------------------------- 2 files changed, 102 insertions(+), 216 deletions(-) diff --git a/package.json b/package.json index 0c3d47a3c7b3..1fd6eb062564 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "eslint": "7.32.0", "jsdom": "^21.1.2", "lerna": "7.1.1", - "madge": "7.0.0", + "madge": "8.0.0", "nodemon": "^3.1.10", "npm-run-all2": "^6.2.0", "prettier": "^3.6.2", diff --git a/yarn.lock b/yarn.lock index c0bc7ba27923..4a6077f16b4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2873,14 +2873,6 @@ "@deno/shim-deno-test" "^0.5.0" which "^4.0.0" -"@dependents/detective-less@^4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@dependents/detective-less/-/detective-less-4.1.0.tgz#4a979ee7a6a79eb33602862d6a1263e30f98002e" - integrity sha512-KrkT6qO5NxqNfy68sBl6CTSoJ4SNDIS5iQArkibhlbGU4LaDukZ3q2HIkh8aUKDio6o4itU4xDR7t82Y2eP1Bg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^6.0.1" - "@dependents/detective-less@^5.0.1": version "5.0.1" resolved "https://registry.yarnpkg.com/@dependents/detective-less/-/detective-less-5.0.1.tgz#e6c5b502f0d26a81da4170c1ccd848a6eaa68470" @@ -8082,6 +8074,33 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== +"@ts-graphviz/adapter@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@ts-graphviz/adapter/-/adapter-2.0.6.tgz#18d5a42304dca7ffff760fcaf311a3148ef4a3bd" + integrity sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q== + dependencies: + "@ts-graphviz/common" "^2.1.5" + +"@ts-graphviz/ast@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@ts-graphviz/ast/-/ast-2.0.7.tgz#4ec33492e4b4e998d4632030e97a9f7e149afb86" + integrity sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw== + dependencies: + "@ts-graphviz/common" "^2.1.5" + +"@ts-graphviz/common@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@ts-graphviz/common/-/common-2.1.5.tgz#a256dfaea009a5b147d8f73f25e57fb44f6462a2" + integrity sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg== + +"@ts-graphviz/core@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@ts-graphviz/core/-/core-2.0.7.tgz#2185e390990038b267a2341c3db1cef3680bbee8" + integrity sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg== + dependencies: + "@ts-graphviz/ast" "^2.0.7" + "@ts-graphviz/common" "^2.1.5" + "@tsconfig/node10@^1.0.7": version "1.0.8" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9" @@ -9126,11 +9145,6 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.48.0.tgz#d725da8dfcff320aab2ac6f65c97b0df30058449" integrity sha512-UTe67B0Ypius0fnEE518NB2N8gGutIlTojeTg4nt0GQvikReVkurqxd2LvYa9q9M5MQ6rtpNyWTBxdscw40Xhw== -"@typescript-eslint/types@5.62.0": - version "5.62.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" - integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== - "@typescript-eslint/types@6.7.4": version "6.7.4" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.7.4.tgz#5d358484d2be986980c039de68e9f1eb62ea7897" @@ -9167,19 +9181,6 @@ semver "^7.5.4" ts-api-utils "^1.0.1" -"@typescript-eslint/typescript-estree@^5.62.0": - version "5.62.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" - integrity sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA== - dependencies: - "@typescript-eslint/types" "5.62.0" - "@typescript-eslint/visitor-keys" "5.62.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - "@typescript-eslint/typescript-estree@^8.23.0": version "8.35.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz#86141e6c55b75bc1eaecc0781bd39704de14e52a" @@ -9231,14 +9232,6 @@ "@typescript-eslint/types" "5.48.0" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@5.62.0": - version "5.62.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" - integrity sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw== - dependencies: - "@typescript-eslint/types" "5.62.0" - eslint-visitor-keys "^3.3.0" - "@typescript-eslint/visitor-keys@6.7.4": version "6.7.4" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.7.4.tgz#80dfecf820fc67574012375859085f91a4dff043" @@ -10853,11 +10846,6 @@ ast-kit@^1.0.1, ast-kit@^1.1.0: "@babel/parser" "^7.25.6" pathe "^1.1.2" -ast-module-types@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ast-module-types/-/ast-module-types-5.0.0.tgz#32b2b05c56067ff38e95df66f11d6afd6c9ba16b" - integrity sha512-JvqziE0Wc0rXQfma0HZC/aY7URXHFuZV84fJRtP8u+lhp0JYCNd5wJzVXP45t0PH0Mej3ynlzvdyITYIu0G4LQ== - ast-module-types@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/ast-module-types/-/ast-module-types-6.0.1.tgz#4b4ca0251c57b815bab62604dcb22f8c903e2523" @@ -14184,15 +14172,15 @@ dependency-graph@^0.11.0: resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27" integrity sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg== -dependency-tree@^10.0.9: - version "10.0.9" - resolved "https://registry.yarnpkg.com/dependency-tree/-/dependency-tree-10.0.9.tgz#0c6c0dbeb0c5ec2cf83bf755f30e9cb12e7b4ac7" - integrity sha512-dwc59FRIsht+HfnTVM0BCjJaEWxdq2YAvEDy4/Hn6CwS3CBWMtFnL3aZGAkQn3XCYxk/YcTDE4jX2Q7bFTwCjA== +dependency-tree@^11.0.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/dependency-tree/-/dependency-tree-11.2.0.tgz#ae764155b2903267181def4b20be49b1fd76da5e" + integrity sha512-+C1H3mXhcvMCeu5i2Jpg9dc0N29TWTuT6vJD7mHLAfVmAbo9zW8NlkvQ1tYd3PDMab0IRQM0ccoyX68EZtx9xw== dependencies: - commander "^10.0.1" - filing-cabinet "^4.1.6" - precinct "^11.0.5" - typescript "^5.0.4" + commander "^12.1.0" + filing-cabinet "^5.0.3" + precinct "^12.2.0" + typescript "^5.8.3" deprecation@^2.0.0, deprecation@^2.3.1: version "2.3.1" @@ -14249,16 +14237,6 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== -detective-amd@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/detective-amd/-/detective-amd-5.0.2.tgz#579900f301c160efe037a6377ec7e937434b2793" - integrity sha512-XFd/VEQ76HSpym80zxM68ieB77unNuoMwopU2TFT/ErUk5n4KvUTwW4beafAVUugrjV48l4BmmR0rh2MglBaiA== - dependencies: - ast-module-types "^5.0.0" - escodegen "^2.0.0" - get-amd-module-type "^5.0.1" - node-source-walk "^6.0.1" - detective-amd@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/detective-amd/-/detective-amd-6.0.1.tgz#71eb13b5d9b17222d7b4de3fb89a8e684d8b9a23" @@ -14269,14 +14247,6 @@ detective-amd@^6.0.1: get-amd-module-type "^6.0.1" node-source-walk "^7.0.1" -detective-cjs@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/detective-cjs/-/detective-cjs-5.0.1.tgz#836ad51c6de4863efc7c419ec243694f760ff8b2" - integrity sha512-6nTvAZtpomyz/2pmEmGX1sXNjaqgMplhQkskq2MLrar0ZAIkHMrDhLXkRiK2mvbu9wSWr0V5/IfiTrZqAQMrmQ== - dependencies: - ast-module-types "^5.0.0" - node-source-walk "^6.0.0" - detective-cjs@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/detective-cjs/-/detective-cjs-6.0.1.tgz#4fb81a67337630811409abb2148b2b622cacbdcd" @@ -14285,13 +14255,6 @@ detective-cjs@^6.0.1: ast-module-types "^6.0.1" node-source-walk "^7.0.1" -detective-es6@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/detective-es6/-/detective-es6-4.0.1.tgz#38d5d49a6d966e992ef8f2d9bffcfe861a58a88a" - integrity sha512-k3Z5tB4LQ8UVHkuMrFOlvb3GgFWdJ9NqAa2YLUU/jTaWJIm+JJnEh4PsMc+6dfT223Y8ACKOaC0qcj7diIhBKw== - dependencies: - node-source-walk "^6.0.1" - detective-es6@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-es6/-/detective-es6-5.0.1.tgz#f0c026bc9b767a243e57ef282f4343fcf3b8ec4e" @@ -14299,15 +14262,6 @@ detective-es6@^5.0.1: dependencies: node-source-walk "^7.0.1" -detective-postcss@^6.1.3: - version "6.1.3" - resolved "https://registry.yarnpkg.com/detective-postcss/-/detective-postcss-6.1.3.tgz#51a2d4419327ad85d0af071c7054c79fafca7e73" - integrity sha512-7BRVvE5pPEvk2ukUWNQ+H2XOq43xENWbH0LcdCE14mwgTBEAMoAx+Fc1rdp76SmyZ4Sp48HlV7VedUnP6GA1Tw== - dependencies: - is-url "^1.2.4" - postcss "^8.4.23" - postcss-values-parser "^6.0.2" - detective-postcss@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/detective-postcss/-/detective-postcss-7.0.1.tgz#f5822d8988339fb56851fcdb079d51fbcff114db" @@ -14316,14 +14270,6 @@ detective-postcss@^7.0.1: is-url "^1.2.4" postcss-values-parser "^6.0.2" -detective-sass@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/detective-sass/-/detective-sass-5.0.3.tgz#63e54bc9b32f4bdbd9d5002308f9592a3d3a508f" - integrity sha512-YsYT2WuA8YIafp2RVF5CEfGhhyIVdPzlwQgxSjK+TUm3JoHP+Tcorbk3SfG0cNZ7D7+cYWa0ZBcvOaR0O8+LlA== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^6.0.1" - detective-sass@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/detective-sass/-/detective-sass-6.0.1.tgz#fcf5aa51bebf7b721807be418418470ee2409f8a" @@ -14332,14 +14278,6 @@ detective-sass@^6.0.1: gonzales-pe "^4.3.0" node-source-walk "^7.0.1" -detective-scss@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-4.0.3.tgz#79758baa0158f72bfc4481eb7e21cc3b5f1ea6eb" - integrity sha512-VYI6cHcD0fLokwqqPFFtDQhhSnlFWvU614J42eY6G0s8c+MBhi9QAWycLwIOGxlmD8I/XvGSOUV1kIDhJ70ZPg== - dependencies: - gonzales-pe "^4.3.0" - node-source-walk "^6.0.1" - detective-scss@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-scss/-/detective-scss-5.0.1.tgz#6a7f792dc9c0e8cfc0d252a50ba26a6df12596a7" @@ -14348,26 +14286,11 @@ detective-scss@^5.0.1: gonzales-pe "^4.3.0" node-source-walk "^7.0.1" -detective-stylus@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/detective-stylus/-/detective-stylus-4.0.0.tgz#ce97b6499becdc291de7b3c11df8c352c1eee46e" - integrity sha512-TfPotjhszKLgFBzBhTOxNHDsutIxx9GTWjrL5Wh7Qx/ydxKhwUrlSFeLIn+ZaHPF+h0siVBkAQSuy6CADyTxgQ== - detective-stylus@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/detective-stylus/-/detective-stylus-5.0.1.tgz#57d54a0b405305ee16655e42008b38a827a9f179" integrity sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA== -detective-typescript@^11.1.0: - version "11.2.0" - resolved "https://registry.yarnpkg.com/detective-typescript/-/detective-typescript-11.2.0.tgz#5b1450b518cb84b6cfb98ea72d5edd9660668e1b" - integrity sha512-ARFxjzizOhPqs1fYC/2NMC3N4jrQ6HvVflnXBTRqNEqJuXwyKLRr9CrJwkRcV/SnZt1sNXgsF6FPm0x57Tq0rw== - dependencies: - "@typescript-eslint/typescript-estree" "^5.62.0" - ast-module-types "^5.0.0" - node-source-walk "^6.0.2" - typescript "^5.4.4" - detective-typescript@^14.0.0: version "14.0.0" resolved "https://registry.yarnpkg.com/detective-typescript/-/detective-typescript-14.0.0.tgz#3cf429652eb7d7d2be2c050ac47af957a559527d" @@ -15324,10 +15247,10 @@ engine.io@~6.6.0: engine.io-parser "~5.2.1" ws "~8.17.1" -enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.1: - version "5.17.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" - integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== +enhanced-resolve@^5.10.0, enhanced-resolve@^5.14.1, enhanced-resolve@^5.17.1, enhanced-resolve@^5.18.0: + version "5.18.3" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz#9b5f4c5c076b8787c78fe540392ce76a88855b44" + integrity sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -17007,23 +16930,22 @@ filesize@^10.0.5: resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.6.tgz#31194da825ac58689c0bce3948f33ce83aabd361" integrity sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w== -filing-cabinet@^4.1.6: - version "4.2.0" - resolved "https://registry.yarnpkg.com/filing-cabinet/-/filing-cabinet-4.2.0.tgz#bd81241edce6e0c051882bef7b69ffa4c017baf9" - integrity sha512-YZ21ryzRcyqxpyKggdYSoXx//d3sCJzM3lsYoaeg/FyXdADGJrUl+BW1KIglaVLJN5BBcMtWylkygY8zBp2MrQ== +filing-cabinet@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/filing-cabinet/-/filing-cabinet-5.0.3.tgz#e5ab960958653ee7fe70d5d99b3b88c342ce7907" + integrity sha512-PlPcMwVWg60NQkhvfoxZs4wEHjhlOO/y7OAm4sKM60o1Z9nttRY4mcdQxp/iZ+kg/Vv6Hw1OAaTbYVM9DA9pYg== dependencies: app-module-path "^2.2.0" - commander "^10.0.1" - enhanced-resolve "^5.14.1" - is-relative-path "^1.0.2" - module-definition "^5.0.1" - module-lookup-amd "^8.0.5" - resolve "^1.22.3" - resolve-dependency-path "^3.0.2" - sass-lookup "^5.0.1" - stylus-lookup "^5.0.1" + commander "^12.1.0" + enhanced-resolve "^5.18.0" + module-definition "^6.0.1" + module-lookup-amd "^9.0.3" + resolve "^1.22.10" + resolve-dependency-path "^4.0.1" + sass-lookup "^6.1.0" + stylus-lookup "^6.1.0" tsconfig-paths "^4.2.0" - typescript "^5.0.4" + typescript "^5.7.3" fill-range@^4.0.0: version "4.0.0" @@ -17610,14 +17532,6 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -get-amd-module-type@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/get-amd-module-type/-/get-amd-module-type-5.0.1.tgz#bef38ea3674e1aa1bda9c59c8b0da598582f73f2" - integrity sha512-jb65zDeHyDjFR1loOVk0HQGM5WNwoGB8aLWy3LKCieMKol0/ProHkhO2X1JxojuN10vbz1qNn09MJ7tNp7qMzw== - dependencies: - ast-module-types "^5.0.0" - node-source-walk "^6.0.1" - get-amd-module-type@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-amd-module-type/-/get-amd-module-type-6.0.1.tgz#191f479ae8706c246b52bf402fbe1bb0965d9f1e" @@ -19689,11 +19603,6 @@ is-regexp@^1.0.0: resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk= -is-relative-path@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-relative-path/-/is-relative-path-1.0.2.tgz#091b46a0d67c1ed0fe85f1f8cfdde006bb251d46" - integrity sha1-CRtGoNZ8HtD+hfH4z93gBrslHUY= - is-set@^2.0.2, is-set@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" @@ -21250,23 +21159,22 @@ lz-string@^1.4.4, lz-string@^1.5.0: resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== -madge@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/madge/-/madge-7.0.0.tgz#64b1762033b0f969caa7e5853004b6850e8430bb" - integrity sha512-x9eHkBWoCJ2B8yGesWf8LRucarkbH5P3lazqgvmxe4xn5U2Meyfu906iG9mBB1RnY/f4D+gtELWdiz1k6+jAZA== +madge@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/madge/-/madge-8.0.0.tgz#cca4ab66fb388e7b6bf43c1f78dcaab3cad30f50" + integrity sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw== dependencies: chalk "^4.1.2" commander "^7.2.0" commondir "^1.0.1" debug "^4.3.4" - dependency-tree "^10.0.9" + dependency-tree "^11.0.0" ora "^5.4.1" pluralize "^8.0.0" - precinct "^11.0.5" pretty-ms "^7.0.1" rc "^1.2.8" stream-to-array "^2.3.0" - ts-graphviz "^1.8.1" + ts-graphviz "^2.1.2" walkdir "^0.4.1" magic-regexp@^0.8.0: @@ -22477,14 +22385,6 @@ modify-values@^1.0.1: resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -module-definition@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/module-definition/-/module-definition-5.0.1.tgz#62d1194e5d5ea6176b7dc7730f818f466aefa32f" - integrity sha512-kvw3B4G19IXk+BOXnYq/D/VeO9qfHaapMeuS7w7sNUqmGaA6hywdFHMi+VWeR9wUScXM7XjoryTffCZ5B0/8IA== - dependencies: - ast-module-types "^5.0.0" - node-source-walk "^6.0.1" - module-definition@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/module-definition/-/module-definition-6.0.1.tgz#47e73144cc5a9aa31f3380166fddf8e962ccb2e4" @@ -22498,14 +22398,14 @@ module-details-from-path@^1.0.3, module-details-from-path@^1.0.4: resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.4.tgz#b662fdcd93f6c83d3f25289da0ce81c8d9685b94" integrity sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w== -module-lookup-amd@^8.0.5: - version "8.0.5" - resolved "https://registry.yarnpkg.com/module-lookup-amd/-/module-lookup-amd-8.0.5.tgz#aaeea41979105b49339380ca3f7d573db78c32a5" - integrity sha512-vc3rYLjDo5Frjox8NZpiyLXsNWJ5BWshztc/5KSOMzpg9k5cHH652YsJ7VKKmtM4SvaxuE9RkrYGhiSjH3Ehow== +module-lookup-amd@^9.0.3: + version "9.0.5" + resolved "https://registry.yarnpkg.com/module-lookup-amd/-/module-lookup-amd-9.0.5.tgz#2563ba8e4f9dbcda914eac3ba4dc3ad8af80eb7d" + integrity sha512-Rs5FVpVcBYRHPLuhHOjgbRhosaQYLtEo3JIeDIbmNo7mSssi1CTzwMh8v36gAzpbzLGXI9wB/yHh+5+3fY1QVw== dependencies: - commander "^10.0.1" + commander "^12.1.0" glob "^7.2.3" - requirejs "^2.3.6" + requirejs "^2.3.7" requirejs-config-file "^4.0.0" moment@~2.30.1: @@ -23192,13 +23092,6 @@ node-schedule@^2.1.1: long-timeout "0.1.1" sorted-array-functions "^1.3.0" -node-source-walk@^6.0.0, node-source-walk@^6.0.1, node-source-walk@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/node-source-walk/-/node-source-walk-6.0.2.tgz#ba81bc4bc0f6f05559b084bea10be84c3f87f211" - integrity sha512-jn9vOIK/nfqoFCcpK89/VCVaLg1IHE6UVfDOzvqmANaJ/rWCTEdH8RZ1V278nv2jr36BJdyQXIAavBLXpzdlag== - dependencies: - "@babel/parser" "^7.21.8" - node-source-walk@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/node-source-walk/-/node-source-walk-7.0.1.tgz#3e4ab8d065377228fd038af7b2d4fb58f61defd3" @@ -25491,7 +25384,7 @@ postcss@8.4.31: picocolors "^1.0.0" source-map-js "^1.0.2" -postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.18, postcss@^8.4.23, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.3, postcss@^8.5.6: +postcss@^8.1.10, postcss@^8.2.14, postcss@^8.2.15, postcss@^8.3.7, postcss@^8.4.18, postcss@^8.4.27, postcss@^8.4.39, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.7, postcss@^8.4.8, postcss@^8.5.1, postcss@^8.5.3, postcss@^8.5.6: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -25550,25 +25443,7 @@ prebuild-install@^7.1.1: tar-fs "^2.0.0" tunnel-agent "^0.6.0" -precinct@^11.0.5: - version "11.0.5" - resolved "https://registry.yarnpkg.com/precinct/-/precinct-11.0.5.tgz#3e15b3486670806f18addb54b8533e23596399ff" - integrity sha512-oHSWLC8cL/0znFhvln26D14KfCQFFn4KOLSw6hmLhd+LQ2SKt9Ljm89but76Pc7flM9Ty1TnXyrA2u16MfRV3w== - dependencies: - "@dependents/detective-less" "^4.1.0" - commander "^10.0.1" - detective-amd "^5.0.2" - detective-cjs "^5.0.1" - detective-es6 "^4.0.1" - detective-postcss "^6.1.3" - detective-sass "^5.0.3" - detective-scss "^4.0.3" - detective-stylus "^4.0.0" - detective-typescript "^11.1.0" - module-definition "^5.0.1" - node-source-walk "^6.0.2" - -precinct@^12.0.0: +precinct@^12.0.0, precinct@^12.2.0: version "12.2.0" resolved "https://registry.yarnpkg.com/precinct/-/precinct-12.2.0.tgz#6ab18f48034cc534f2c8fedb318f19a11bcd171b" integrity sha512-NFBMuwIfaJ4SocE9YXPU/n4AcNSoFMVFjP72nvl3cx69j/ke61/hPOWFREVxLkFhhEGnA8ZuVfTqJBa+PK3b5w== @@ -26728,7 +26603,7 @@ requirejs-config-file@^4.0.0: esprima "^4.0.0" stringify-object "^3.2.1" -requirejs@^2.3.6: +requirejs@^2.3.7: version "2.3.7" resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.7.tgz#0b22032e51a967900e0ae9f32762c23a87036bd0" integrity sha512-DouTG8T1WanGok6Qjg2SXuCMzszOo0eHeH9hDZ5Y4x8Je+9JB38HdTLT4/VA8OaUhBa0JPVHJ0pyBkM1z+pDsw== @@ -26755,10 +26630,10 @@ resolve-cwd@^3.0.0: dependencies: resolve-from "^5.0.0" -resolve-dependency-path@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/resolve-dependency-path/-/resolve-dependency-path-3.0.2.tgz#012816717bcbe8b846835da11af9d2beb5acef50" - integrity sha512-Tz7zfjhLfsvR39ADOSk9us4421J/1ztVBo4rWUkF38hgHK5m0OCZ3NxFVpqHRkjctnwVa15igEUHFJp8MCS7vA== +resolve-dependency-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/resolve-dependency-path/-/resolve-dependency-path-4.0.1.tgz#1b9d43e5b62384301e26d040b9fce61ee5db60bd" + integrity sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ== resolve-dir@^1.0.0, resolve-dir@^1.0.1: version "1.0.1" @@ -26861,7 +26736,7 @@ resolve@1.22.8: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.22.3, resolve@^1.22.4, resolve@^1.22.6, resolve@^1.22.8, resolve@^1.4.0, resolve@^1.5.0: +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.1, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.17.0, resolve@^1.19.0, resolve@^1.20.0, resolve@^1.22.0, resolve@^1.22.1, resolve@^1.22.10, resolve@^1.22.4, resolve@^1.22.6, resolve@^1.22.8, resolve@^1.4.0, resolve@^1.5.0: version "1.22.10" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== @@ -27310,12 +27185,13 @@ sass-loader@13.0.2: klona "^2.0.4" neo-async "^2.6.2" -sass-lookup@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/sass-lookup/-/sass-lookup-5.0.1.tgz#1f01d7ff21e09d8c9dcf8d05b3fca28f2f96e6ed" - integrity sha512-t0X5PaizPc2H4+rCwszAqHZRtr4bugo4pgiCvrBFvIX0XFxnr29g77LJcpyj9A0DcKf7gXMLcgvRjsonYI6x4g== +sass-lookup@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/sass-lookup/-/sass-lookup-6.1.0.tgz#a13b1f31dd44d2b4bcd55ba8f72763db4d95bd7c" + integrity sha512-Zx+lVyoWqXZxHuYWlTA17Z5sczJ6braNT2C7rmClw+c4E7r/n911Zwss3h1uHI9reR5AgHZyNHF7c2+VIp5AUA== dependencies: - commander "^10.0.1" + commander "^12.1.0" + enhanced-resolve "^5.18.0" sass@1.54.4: version "1.54.4" @@ -28777,12 +28653,12 @@ stylus-loader@7.0.0: klona "^2.0.5" normalize-path "^3.0.0" -stylus-lookup@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/stylus-lookup/-/stylus-lookup-5.0.1.tgz#3c4d116c3b1e8e1a8169c0d9cd20e608595560f4" - integrity sha512-tLtJEd5AGvnVy4f9UHQMw4bkJJtaAcmo54N+ovQBjDY3DuWyK9Eltxzr5+KG0q4ew6v2EHyuWWNnHeiw/Eo7rQ== +stylus-lookup@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/stylus-lookup/-/stylus-lookup-6.1.0.tgz#f0fe88a885b830dc7520f51dd0a7e59e5d3307b4" + integrity sha512-5QSwgxAzXPMN+yugy61C60PhoANdItfdjSEZR8siFwz7yL9jTmV0UBKDCfn3K8GkGB4g0Y9py7vTCX8rFu4/pQ== dependencies: - commander "^10.0.1" + commander "^12.1.0" stylus@0.59.0, stylus@^0.59.0: version "0.59.0" @@ -29574,10 +29450,15 @@ ts-api-utils@^2.1.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== -ts-graphviz@^1.8.1: - version "1.8.2" - resolved "https://registry.yarnpkg.com/ts-graphviz/-/ts-graphviz-1.8.2.tgz#6c4768d05f8a36e37abe34855ffe89a4c4bd96cc" - integrity sha512-5YhbFoHmjxa7pgQLkB07MtGnGJ/yhvjmc9uhsnDBEICME6gkPf83SBwLDQqGDoCa3XzUMWLk1AU2Wn1u1naDtA== +ts-graphviz@^2.1.2: + version "2.1.6" + resolved "https://registry.yarnpkg.com/ts-graphviz/-/ts-graphviz-2.1.6.tgz#007fcb42b4e8c55d26543ece9e86395bd3c3cfd6" + integrity sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw== + dependencies: + "@ts-graphviz/adapter" "^2.0.6" + "@ts-graphviz/ast" "^2.0.7" + "@ts-graphviz/common" "^2.1.5" + "@ts-graphviz/core" "^2.0.7" ts-interface-checker@^0.1.9: version "0.1.13" @@ -29811,10 +29692,10 @@ typescript@4.6.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== -"typescript@>=3 < 6", typescript@^5.0.4, typescript@^5.4.4, typescript@^5.7.3, typescript@~5.8.0: - version "5.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" - integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== +"typescript@>=3 < 6", typescript@^5.7.3, typescript@^5.8.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== typescript@^3.9: version "3.9.10" @@ -29826,6 +29707,11 @@ typescript@next: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.0-dev.20230530.tgz#4251ade97a9d8a86850c4d5c3c4f3e1cb2ccf52c" integrity sha512-bIoMajCZWzLB+pWwncaba/hZc6dRnw7x8T/fenOnP9gYQB/gc4xdm48AXp5SH5I/PvvSeZ/dXkUMtc8s8BiDZw== +typescript@~5.8.0: + version "5.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" From 7333e18f5e8abc856a04d37f24ec6019c93f6853 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 22 Oct 2025 15:11:27 +0200 Subject: [PATCH 049/190] feat(core): Instrument LangChain AI (#17955) This PR adds automatic instrumentation for LangChain chat clients in Node SDK, we cover most used providers mentioned in https://python.langchain.com/docs/integrations/chat/. **What's added?** TLDR; a [LangChain Callback Handler ](https://js.langchain.com/docs/concepts/callbacks/) that: - Creates a stateful callback handler that tracks LangChain lifecycle events - Handles LLM/Chat Model events (start, end, error, streaming) - Handles Chain events (start, end, error) - Handles Tool events (start, end, error) - Extracts and normalizes request/respo **How it works?** 1. **Module Patching**: When a LangChain provider package is loaded (e.g., `@langchain/anthropic`), the instrumentation: - Finds the chat model class (e.g., `ChatAnthropic`) - Wraps the `invoke`, `stream`, and `batch` methods on the prototype - Uses a Proxy to intercept method calls 2. **Callback Injection**: When a LangChain method is called: - The wrapper intercepts the call - Augments the `options.callbacks` array with Sentry's callback handler - Calls the original method with the augmented callbacks The integration is **enabled by default** when you initialize Sentry in Node.js: ```javascript import * as Sentry from '@sentry/node'; import { ChatAnthropic } from '@langchain/anthropic'; Sentry.init({ dsn: 'your-dsn', tracesSampleRate: 1.0, sendDefaultPii: true, // Enable to record inputs/outputs }); // LangChain calls are automatically instrumented const model = new ChatAnthropic({ model: 'claude-3-5-sonnet-20241022', }); await model.invoke('What is the capital of France?'); ``` You can configure what data is recorded: ```javascript Sentry.init({ integrations: [ Sentry.langChainIntegration({ recordInputs: true, // Record prompts/messages recordOutputs: true, // Record responses }) ], }); ``` Note: We need to disable integrations for AI providers that LangChain use to avoid duplicate spans, this will be handled in a follow up PR. --- .size-limit.js | 2 +- .../node-integration-tests/package.json | 2 + .../tracing/langchain/instrument-with-pii.mjs | 19 + .../suites/tracing/langchain/instrument.mjs | 19 + .../tracing/langchain/scenario-tools.mjs | 90 ++++ .../suites/tracing/langchain/scenario.mjs | 110 +++++ .../suites/tracing/langchain/test.ts | 197 ++++++++ packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/core/src/index.ts | 3 + .../core/src/utils/ai/gen-ai-attributes.ts | 15 + .../core/src/utils/langchain/constants.ts | 11 + packages/core/src/utils/langchain/index.ts | 321 +++++++++++++ packages/core/src/utils/langchain/types.ts | 208 +++++++++ packages/core/src/utils/langchain/utils.ts | 424 ++++++++++++++++++ packages/google-cloud-serverless/src/index.ts | 2 + packages/node/src/index.ts | 2 + .../node/src/integrations/tracing/index.ts | 3 + .../integrations/tracing/langchain/index.ts | 107 +++++ .../tracing/langchain/instrumentation.ts | 214 +++++++++ yarn.lock | 114 ++++- 22 files changed, 1851 insertions(+), 18 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/test.ts create mode 100644 packages/core/src/utils/langchain/constants.ts create mode 100644 packages/core/src/utils/langchain/index.ts create mode 100644 packages/core/src/utils/langchain/types.ts create mode 100644 packages/core/src/utils/langchain/utils.ts create mode 100644 packages/node/src/integrations/tracing/langchain/index.ts create mode 100644 packages/node/src/integrations/tracing/langchain/instrumentation.ts diff --git a/.size-limit.js b/.size-limit.js index 9cebd30285e4..269ce49b1cc1 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '156 KB', + limit: '157 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 5f2ab2023405..b4fd1c3b4125 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -27,6 +27,8 @@ "@aws-sdk/client-s3": "^3.552.0", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", + "@langchain/anthropic": "^0.3.10", + "@langchain/core": "^0.3.28", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", "@nestjs/common": "^11", diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs new file mode 100644 index 000000000000..85b2a963d977 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument-with-pii.mjs @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + // Filter out Anthropic integration to avoid duplicate spans with LangChain + integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1/messages')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs new file mode 100644 index 000000000000..524d19f4b995 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/instrument.mjs @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + // Filter out Anthropic integration to avoid duplicate spans with LangChain + integrations: integrations => integrations.filter(integration => integration.name !== 'Anthropic_AI'), + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/v1/messages')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs new file mode 100644 index 000000000000..256ee4568884 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario-tools.mjs @@ -0,0 +1,90 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + const model = req.body.model; + + // Simulate tool call response + res.json({ + id: 'msg_tool_test_123', + type: 'message', + role: 'assistant', + model: model, + content: [ + { + type: 'text', + text: 'Let me check the weather for you.', + }, + { + type: 'tool_use', + id: 'toolu_01A09q90qw90lq917835lq9', + name: 'get_weather', + input: { location: 'San Francisco, CA' }, + }, + { + type: 'text', + text: 'The weather looks great!', + }, + ], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 20, + output_tokens: 30, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const model = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 150, + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + await model.invoke('What is the weather in San Francisco?', { + tools: [ + { + name: 'get_weather', + description: 'Get the current weather in a given location', + input_schema: { + type: 'object', + properties: { + location: { + type: 'string', + description: 'The city and state, e.g. San Francisco, CA', + }, + }, + required: ['location'], + }, + }, + ], + }); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs new file mode 100644 index 000000000000..2c60e55ff77e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/scenario.mjs @@ -0,0 +1,110 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + const model = req.body.model; + + if (model === 'error-model') { + res + .status(400) + .set('request-id', 'mock-request-123') + .json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: 'Model not found', + }, + }); + return; + } + + // Simulate basic response + res.json({ + id: 'msg_test123', + type: 'message', + role: 'assistant', + content: [ + { + type: 'text', + text: 'Mock response from Anthropic!', + }, + ], + model: model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + // Test 1: Basic chat model invocation + const model1 = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 100, + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + await model1.invoke('Tell me a joke'); + + // Test 2: Chat with different model + const model2 = new ChatAnthropic({ + model: 'claude-3-opus-20240229', + temperature: 0.9, + topP: 0.95, + maxTokens: 200, + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + await model2.invoke([ + { role: 'system', content: 'You are a helpful assistant' }, + { role: 'user', content: 'What is the capital of France?' }, + ]); + + // Test 3: Error handling + const errorModel = new ChatAnthropic({ + model: 'error-model', + apiKey: 'mock-api-key', + clientOptions: { + baseURL: baseUrl, + }, + }); + + try { + await errorModel.invoke('This will fail'); + } catch { + // Expected error + } + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts new file mode 100644 index 000000000000..e3738b61b7a7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -0,0 +1,197 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('LangChain integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chat model with claude-3-5-sonnet + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Second span - chat model with claude-3-opus + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-opus-20240229', + 'gen_ai.request.temperature': 0.9, + 'gen_ai.request.top_p': 0.95, + 'gen_ai.request.max_tokens': 200, + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + }), + description: 'chat claude-3-opus-20240229', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Third span - error handling + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + }), + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'unknown_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - chat model with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response when recordOutputs: true + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Second span - chat model with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-opus-20240229', + 'gen_ai.request.temperature': 0.9, + 'gen_ai.request.top_p': 0.95, + 'gen_ai.request.max_tokens': 200, + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response when recordOutputs: true + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': expect.any(String), + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + description: 'chat claude-3-opus-20240229', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + // Third span - error handling with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + }), + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'unknown_error', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates langchain related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates langchain related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .start() + .completed(); + }); + }); + + const EXPECTED_TRANSACTION_TOOL_CALLS = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 150, + 'gen_ai.usage.input_tokens': 20, + 'gen_ai.usage.output_tokens': 30, + 'gen_ai.usage.total_tokens': 50, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': expect.any(String), + 'gen_ai.response.stop_reason': 'tool_use', + 'gen_ai.response.tool_calls': expect.any(String), + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-tools.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates langchain spans with tool calls', async () => { + await createRunner().ignore('event').expect({ transaction: EXPECTED_TRANSACTION_TOOL_CALLS }).start().completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 15158bdbb7bc..69ca79e04a17 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -31,6 +31,7 @@ export { contextLinesIntegration, continueTrace, createGetModuleFromFilename, + createLangChainCallbackHandler, createTransport, cron, dataloaderIntegration, @@ -93,6 +94,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, parameterize, pinoIntegration, postgresIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 5ff30f069486..da0393d9b0e9 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -42,6 +42,7 @@ export { close, getSentryRelease, createGetModuleFromFilename, + createLangChainCallbackHandler, httpHeadersToSpanAttributes, winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation @@ -56,6 +57,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 5ec1568229e4..33af15790191 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -62,6 +62,7 @@ export { close, getSentryRelease, createGetModuleFromFilename, + createLangChainCallbackHandler, httpHeadersToSpanAttributes, winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation @@ -76,6 +77,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a6c5c2e17d3..f3b29009b9ce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -144,6 +144,9 @@ export { ANTHROPIC_AI_INTEGRATION_NAME } from './utils/anthropic-ai/constants'; export { instrumentGoogleGenAIClient } from './utils/google-genai'; export { GOOGLE_GENAI_INTEGRATION_NAME } from './utils/google-genai/constants'; export type { GoogleGenAIResponse } from './utils/google-genai/types'; +export { createLangChainCallbackHandler } from './utils/langchain'; +export { LANGCHAIN_INTEGRATION_NAME } from './utils/langchain/constants'; +export type { LangChainOptions, LangChainIntegration } from './utils/langchain/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; export type { AnthropicAiClient, diff --git a/packages/core/src/utils/ai/gen-ai-attributes.ts b/packages/core/src/utils/ai/gen-ai-attributes.ts index d55851927cb6..84efb21c1822 100644 --- a/packages/core/src/utils/ai/gen-ai-attributes.ts +++ b/packages/core/src/utils/ai/gen-ai-attributes.ts @@ -80,6 +80,11 @@ export const GEN_AI_RESPONSE_MODEL_ATTRIBUTE = 'gen_ai.response.model'; */ export const GEN_AI_RESPONSE_ID_ATTRIBUTE = 'gen_ai.response.id'; +/** + * The reason why the model stopped generating tokens + */ +export const GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE = 'gen_ai.response.stop_reason'; + /** * The number of tokens used in the prompt */ @@ -129,6 +134,16 @@ export const GEN_AI_RESPONSE_STREAMING_ATTRIBUTE = 'gen_ai.response.streaming'; */ export const GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE = 'gen_ai.response.tool_calls'; +/** + * The number of cache creation input tokens used + */ +export const GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE = 'gen_ai.usage.cache_creation_input_tokens'; + +/** + * The number of cache read input tokens used + */ +export const GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE = 'gen_ai.usage.cache_read_input_tokens'; + /** * The number of cache write input tokens used */ diff --git a/packages/core/src/utils/langchain/constants.ts b/packages/core/src/utils/langchain/constants.ts new file mode 100644 index 000000000000..ead9bed623ad --- /dev/null +++ b/packages/core/src/utils/langchain/constants.ts @@ -0,0 +1,11 @@ +export const LANGCHAIN_INTEGRATION_NAME = 'LangChain'; +export const LANGCHAIN_ORIGIN = 'auto.ai.langchain'; + +export const ROLE_MAP: Record = { + human: 'user', + ai: 'assistant', + assistant: 'assistant', + system: 'system', + function: 'function', + tool: 'tool', +}; diff --git a/packages/core/src/utils/langchain/index.ts b/packages/core/src/utils/langchain/index.ts new file mode 100644 index 000000000000..1930be794be5 --- /dev/null +++ b/packages/core/src/utils/langchain/index.ts @@ -0,0 +1,321 @@ +import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { startSpanManual } from '../../tracing/trace'; +import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE } from '../ai/gen-ai-attributes'; +import { LANGCHAIN_ORIGIN } from './constants'; +import type { + LangChainCallbackHandler, + LangChainLLMResult, + LangChainMessage, + LangChainOptions, + LangChainSerialized, +} from './types'; +import { + extractChatModelRequestAttributes, + extractLLMRequestAttributes, + extractLlmResponseAttributes, + getInvocationParams, +} from './utils'; + +/** + * Creates a Sentry callback handler for LangChain + * Returns a plain object that LangChain will call via duck-typing + * + * This is a stateful handler that tracks spans across multiple LangChain executions. + */ +export function createLangChainCallbackHandler(options: LangChainOptions = {}): LangChainCallbackHandler { + const recordInputs = options.recordInputs ?? false; + const recordOutputs = options.recordOutputs ?? false; + + // Internal state - single instance tracks all spans + const spanMap = new Map(); + + /** + * Exit a span and clean up + */ + const exitSpan = (runId: string): void => { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.end(); + spanMap.delete(runId); + } + }; + + /** + * Handler for LLM Start + * This handler will be called by LangChain's callback handler when an LLM event is detected. + */ + const handler: LangChainCallbackHandler = { + // Required LangChain BaseCallbackHandler properties + lc_serializable: false, + lc_namespace: ['langchain_core', 'callbacks', 'sentry'], + lc_secrets: undefined, + lc_attributes: undefined, + lc_aliases: undefined, + lc_serializable_keys: undefined, + lc_id: ['langchain_core', 'callbacks', 'sentry'], + lc_kwargs: {}, + name: 'SentryCallbackHandler', + + // BaseCallbackHandlerInput boolean flags + ignoreLLM: false, + ignoreChain: false, + ignoreAgent: false, + ignoreRetriever: false, + ignoreCustomEvent: false, + raiseError: false, + awaitHandlers: true, + + handleLLMStart( + llm: unknown, + prompts: string[], + runId: string, + _parentRunId?: string, + _extraParams?: Record, + tags?: string[], + metadata?: Record, + _runName?: string, + ) { + const invocationParams = getInvocationParams(tags); + const attributes = extractLLMRequestAttributes( + llm as LangChainSerialized, + prompts, + recordInputs, + invocationParams, + metadata, + ); + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; + const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; + + startSpanManual( + { + name: `${operationName} ${modelName}`, + op: 'gen_ai.pipeline', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.pipeline', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // Chat Model Start Handler + handleChatModelStart( + llm: unknown, + messages: unknown, + runId: string, + _parentRunId?: string, + _extraParams?: Record, + tags?: string[], + metadata?: Record, + _runName?: string, + ) { + const invocationParams = getInvocationParams(tags); + const attributes = extractChatModelRequestAttributes( + llm as LangChainSerialized, + messages as LangChainMessage[][], + recordInputs, + invocationParams, + metadata, + ); + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]; + const operationName = attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]; + + startSpanManual( + { + name: `${operationName} ${modelName}`, + op: 'gen_ai.chat', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.chat', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // LLM End Handler - note: handleLLMEnd with capital LLM (used by both LLMs and chat models!) + handleLLMEnd( + output: unknown, + runId: string, + _parentRunId?: string, + _tags?: string[], + _extraParams?: Record, + ) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + const attributes = extractLlmResponseAttributes(output as LangChainLLMResult, recordOutputs); + if (attributes) { + span.setAttributes(attributes); + } + exitSpan(runId); + } + }, + + // LLM Error Handler - note: handleLLMError with capital LLM + handleLLMError(error: Error, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'llm_error' }); + exitSpan(runId); + } + + captureException(error, { + mechanism: { + handled: false, + type: `${LANGCHAIN_ORIGIN}.llm_error_handler`, + }, + }); + }, + + // Chain Start Handler + handleChainStart(chain: { name?: string }, inputs: Record, runId: string, _parentRunId?: string) { + const chainName = chain.name || 'unknown_chain'; + const attributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langchain', + 'langchain.chain.name': chainName, + }; + + // Add inputs if recordInputs is enabled + if (recordInputs) { + attributes['langchain.chain.inputs'] = JSON.stringify(inputs); + } + + startSpanManual( + { + name: `chain ${chainName}`, + op: 'gen_ai.invoke_agent', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.invoke_agent', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // Chain End Handler + handleChainEnd(outputs: unknown, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + // Add outputs if recordOutputs is enabled + if (recordOutputs) { + span.setAttributes({ + 'langchain.chain.outputs': JSON.stringify(outputs), + }); + } + exitSpan(runId); + } + }, + + // Chain Error Handler + handleChainError(error: Error, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'chain_error' }); + exitSpan(runId); + } + + captureException(error, { + mechanism: { + handled: false, + type: `${LANGCHAIN_ORIGIN}.chain_error_handler`, + }, + }); + }, + + // Tool Start Handler + handleToolStart(tool: { name?: string }, input: string, runId: string, _parentRunId?: string) { + const toolName = tool.name || 'unknown_tool'; + const attributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, + 'gen_ai.tool.name': toolName, + }; + + // Add input if recordInputs is enabled + if (recordInputs) { + attributes['gen_ai.tool.input'] = input; + } + + startSpanManual( + { + name: `execute_tool ${toolName}`, + op: 'gen_ai.execute_tool', + attributes: { + ...attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.execute_tool', + }, + }, + span => { + spanMap.set(runId, span); + return span; + }, + ); + }, + + // Tool End Handler + handleToolEnd(output: unknown, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + // Add output if recordOutputs is enabled + if (recordOutputs) { + span.setAttributes({ + 'gen_ai.tool.output': JSON.stringify(output), + }); + } + exitSpan(runId); + } + }, + + // Tool Error Handler + handleToolError(error: Error, runId: string) { + const span = spanMap.get(runId); + if (span?.isRecording()) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'tool_error' }); + exitSpan(runId); + } + + captureException(error, { + mechanism: { + handled: false, + type: `${LANGCHAIN_ORIGIN}.tool_error_handler`, + }, + }); + }, + + // LangChain BaseCallbackHandler required methods + copy() { + return handler; + }, + + toJSON() { + return { + lc: 1, + type: 'not_implemented', + id: handler.lc_id, + }; + }, + + toJSONNotImplemented() { + return { + lc: 1, + type: 'not_implemented', + id: handler.lc_id, + }; + }, + }; + + return handler; +} diff --git a/packages/core/src/utils/langchain/types.ts b/packages/core/src/utils/langchain/types.ts new file mode 100644 index 000000000000..e08542eefd60 --- /dev/null +++ b/packages/core/src/utils/langchain/types.ts @@ -0,0 +1,208 @@ +/** + * Options for LangChain integration + */ +export interface LangChainOptions { + /** + * Whether to record input messages/prompts + * @default false (respects sendDefaultPii option) + */ + recordInputs?: boolean; + + /** + * Whether to record output text and responses + * @default false (respects sendDefaultPii option) + */ + recordOutputs?: boolean; +} + +/** + * LangChain Serialized type (compatible with @langchain/core) + * Uses general types to be compatible with LangChain's Serialized interface. + * This is a flexible interface that accepts any serialized LangChain object. + */ +export interface LangChainSerialized { + [key: string]: unknown; + lc?: number; + type?: string; + id?: string[]; + name?: string; + graph?: Record; + kwargs?: Record; +} + +/** + * LangChain message structure + * Supports both regular messages and LangChain serialized format + */ +export interface LangChainMessage { + [key: string]: unknown; + type?: string; + content?: string; + message?: { + content?: unknown[]; + type?: string; + }; + role?: string; + additional_kwargs?: Record; + // LangChain serialized format + lc?: number; + id?: string[]; + kwargs?: { + [key: string]: unknown; + content?: string; + additional_kwargs?: Record; + response_metadata?: Record; + }; +} + +/** + * LangChain LLM result structure + */ +export interface LangChainLLMResult { + [key: string]: unknown; + generations: Array< + Array<{ + text?: string; + message?: LangChainMessage; + generation_info?: { + [key: string]: unknown; + + finish_reason?: string; + logprobs?: unknown; + }; + }> + >; + llmOutput?: { + [key: string]: unknown; + tokenUsage?: { + completionTokens?: number; + promptTokens?: number; + totalTokens?: number; + }; + model_name?: string; + }; +} + +/** + * Integration interface for type safety + */ +export interface LangChainIntegration { + name: string; + options: LangChainOptions; +} + +/** + * LangChain callback handler interface + * Compatible with both BaseCallbackHandlerMethodsClass and BaseCallbackHandler from @langchain/core + * Uses general types and index signature for maximum compatibility across LangChain versions + */ +export interface LangChainCallbackHandler { + // Allow any additional properties for full compatibility + [key: string]: unknown; + + // LangChain BaseCallbackHandler class properties (matching the class interface exactly) + lc_serializable: boolean; + lc_namespace: ['langchain_core', 'callbacks', string]; + lc_secrets: { [key: string]: string } | undefined; + lc_attributes: { [key: string]: string } | undefined; + lc_aliases: { [key: string]: string } | undefined; + lc_serializable_keys: string[] | undefined; + lc_id: string[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + lc_kwargs: { [key: string]: any }; + name: string; + + // BaseCallbackHandlerInput properties (required boolean flags) + ignoreLLM: boolean; + ignoreChain: boolean; + ignoreAgent: boolean; + ignoreRetriever: boolean; + ignoreCustomEvent: boolean; + raiseError: boolean; + awaitHandlers: boolean; + + // Callback handler methods (properties with function signatures) + // Using 'any' for parameters and return types to match LangChain's BaseCallbackHandler exactly + handleLLMStart?: ( + llm: unknown, + prompts: string[], + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[], + metadata?: Record, + runName?: string, + ) => Promise | unknown; + handleChatModelStart?: ( + llm: unknown, + messages: unknown, + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[], + metadata?: Record, + runName?: string, + ) => Promise | unknown; + handleLLMNewToken?: ( + token: string, + idx: unknown, + runId: string, + parentRunId?: string, + tags?: string[], + fields?: unknown, + ) => Promise | unknown; + handleLLMEnd?: ( + output: unknown, + runId: string, + parentRunId?: string, + tags?: string[], + extraParams?: Record, + ) => Promise | unknown; + handleLLMError?: ( + error: Error, + runId: string, + parentRunId?: string, + tags?: string[], + extraParams?: Record, + ) => Promise | unknown; + handleChainStart?: ( + chain: { name?: string }, + inputs: Record, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + runType?: string, + runName?: string, + ) => Promise | unknown; + handleChainEnd?: ( + outputs: unknown, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) => Promise | unknown; + handleChainError?: ( + error: Error, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) => Promise | unknown; + handleToolStart?: ( + tool: { name?: string }, + input: string, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + runName?: string, + ) => Promise | unknown; + handleToolEnd?: (output: unknown, runId: string, parentRunId?: string, tags?: string[]) => Promise | unknown; + handleToolError?: (error: Error, runId: string, parentRunId?: string, tags?: string[]) => Promise | unknown; + + // LangChain class methods (required for BaseCallbackHandler compatibility) + copy(): unknown; + toJSON(): Record; + toJSONNotImplemented(): unknown; +} diff --git a/packages/core/src/utils/langchain/utils.ts b/packages/core/src/utils/langchain/utils.ts new file mode 100644 index 000000000000..8464e71aecb0 --- /dev/null +++ b/packages/core/src/utils/langchain/utils.ts @@ -0,0 +1,424 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import type { SpanAttributeValue } from '../../types-hoist/span'; +import { + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants'; +import type { LangChainLLMResult, LangChainMessage, LangChainSerialized } from './types'; + +/** + * Assigns an attribute only when the value is neither `undefined` nor `null`. + * + * We keep this tiny helper because call sites are repetitive and easy to miswrite. + * It also preserves falsy-but-valid values like `0` and `""`. + */ +const setIfDefined = (target: Record, key: string, value: unknown): void => { + if (value != null) target[key] = value as SpanAttributeValue; +}; + +/** + * Like `setIfDefined`, but converts the value with `Number()` and skips only when the + * result is `NaN`. This ensures numeric 0 makes it through (unlike truthy checks). + */ +const setNumberIfDefined = (target: Record, key: string, value: unknown): void => { + const n = Number(value); + if (!Number.isNaN(n)) target[key] = n; +}; + +/** + * Converts a value to a string. Avoids double-quoted JSON strings where a plain + * string is desired, but still handles objects/arrays safely. + */ +function asString(v: unknown): string { + if (typeof v === 'string') return v; + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +/** + * Normalizes a single role token to our canonical set. + * + * @param role Incoming role value (free-form, any casing) + * @returns Canonical role: 'user' | 'assistant' | 'system' | 'function' | 'tool' | + */ +function normalizeMessageRole(role: string): string { + const normalized = role.toLowerCase(); + return ROLE_MAP[normalized] ?? normalized; +} + +/** + * Infers a role from a LangChain message constructor name. + * + * Checks for substrings like "System", "Human", "AI", etc. + */ +function normalizeRoleNameFromCtor(name: string): string { + if (name.includes('System')) return 'system'; + if (name.includes('Human')) return 'user'; + if (name.includes('AI') || name.includes('Assistant')) return 'assistant'; + if (name.includes('Function')) return 'function'; + if (name.includes('Tool')) return 'tool'; + return 'user'; +} + +/** + * Returns invocation params from a LangChain `tags` object. + * + * LangChain often passes runtime parameters (model, temperature, etc.) via the + * `tags.invocation_params` bag. If `tags` is an array (LangChain sometimes uses + * string tags), we return `undefined`. + * + * @param tags LangChain tags (string[] or record) + * @returns The `invocation_params` object, if present + */ +export function getInvocationParams(tags?: string[] | Record): Record | undefined { + if (!tags || Array.isArray(tags)) return undefined; + return tags.invocation_params as Record | undefined; +} + +/** + * Normalizes a heterogeneous set of LangChain messages to `{ role, content }`. + * + * Why so many branches? LangChain messages can arrive in several shapes: + * - Message classes with `_getType()` (most reliable) + * - Classes with meaningful constructor names (e.g. `SystemMessage`) + * - Plain objects with `type`, or `{ role, content }` + * - Serialized format with `{ lc: 1, id: [...], kwargs: { content } }` + * We preserve the prioritization to minimize behavioral drift. + * + * @param messages Mixed LangChain messages + * @returns Array of normalized `{ role, content }` + */ +export function normalizeLangChainMessages(messages: LangChainMessage[]): Array<{ role: string; content: string }> { + return messages.map(message => { + // 1) Prefer _getType() when present + const maybeGetType = (message as { _getType?: () => string })._getType; + if (typeof maybeGetType === 'function') { + const messageType = maybeGetType.call(message); + return { + role: normalizeMessageRole(messageType), + content: asString(message.content), + }; + } + + // 2) Then try constructor name (SystemMessage / HumanMessage / ...) + const ctor = (message as { constructor?: { name?: string } }).constructor?.name; + if (ctor) { + return { + role: normalizeMessageRole(normalizeRoleNameFromCtor(ctor)), + content: asString(message.content), + }; + } + + // 3) Then objects with `type` + if (message.type) { + const role = String(message.type).toLowerCase(); + return { + role: normalizeMessageRole(role), + content: asString(message.content), + }; + } + + // 4) Then objects with `{ role, content }` + if (message.role) { + return { + role: normalizeMessageRole(String(message.role)), + content: asString(message.content), + }; + } + + // 5) Serialized LangChain format (lc: 1) + if (message.lc === 1 && message.kwargs) { + const id = message.id; + const messageType = Array.isArray(id) && id.length > 0 ? id[id.length - 1] : ''; + const role = typeof messageType === 'string' ? normalizeRoleNameFromCtor(messageType) : 'user'; + + return { + role: normalizeMessageRole(role), + content: asString(message.kwargs?.content), + }; + } + + // 6) Fallback: treat as user text + return { + role: 'user', + content: asString(message.content), + }; + }); +} + +/** + * Extracts request attributes common to both LLM and ChatModel invocations. + * + * Source precedence: + * 1) `invocationParams` (highest) + * 2) `langSmithMetadata` + * + * Numeric values are set even when 0 (e.g. `temperature: 0`), but skipped if `NaN`. + */ +function extractCommonRequestAttributes( + serialized: LangChainSerialized, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + const attrs: Record = {}; + + // Get kwargs if available (from constructor type) + const kwargs = 'kwargs' in serialized ? serialized.kwargs : undefined; + + const temperature = invocationParams?.temperature ?? langSmithMetadata?.ls_temperature ?? kwargs?.temperature; + setNumberIfDefined(attrs, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, temperature); + + const maxTokens = invocationParams?.max_tokens ?? langSmithMetadata?.ls_max_tokens ?? kwargs?.max_tokens; + setNumberIfDefined(attrs, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, maxTokens); + + const topP = invocationParams?.top_p ?? kwargs?.top_p; + setNumberIfDefined(attrs, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, topP); + + const frequencyPenalty = invocationParams?.frequency_penalty; + setNumberIfDefined(attrs, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, frequencyPenalty); + + const presencePenalty = invocationParams?.presence_penalty; + setNumberIfDefined(attrs, GEN_AI_REQUEST_PRESENCE_PENALTY_ATTRIBUTE, presencePenalty); + + // LangChain uses `stream`. We only set the attribute if the key actually exists + // (some callbacks report `false` even on streamed requests, this stems from LangChain's callback handler). + if (invocationParams && 'stream' in invocationParams) { + setIfDefined(attrs, GEN_AI_REQUEST_STREAM_ATTRIBUTE, Boolean(invocationParams.stream)); + } + + return attrs; +} + +/** + * Small helper to assemble boilerplate attributes shared by both request extractors. + */ +function baseRequestAttributes( + system: unknown, + modelName: unknown, + operation: 'pipeline' | 'chat', + serialized: LangChainSerialized, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + return { + [GEN_AI_SYSTEM_ATTRIBUTE]: asString(system ?? 'langchain'), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: operation, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: asString(modelName), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, + ...extractCommonRequestAttributes(serialized, invocationParams, langSmithMetadata), + }; +} + +/** + * Extracts attributes for plain LLM invocations (string prompts). + * + * - Operation is tagged as `pipeline` to distinguish from chat-style invocations. + * - When `recordInputs` is true, string prompts are wrapped into `{role:"user"}` + * messages to align with the chat schema used elsewhere. + */ +export function extractLLMRequestAttributes( + llm: LangChainSerialized, + prompts: string[], + recordInputs: boolean, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + const system = langSmithMetadata?.ls_provider; + const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown'; + + const attrs = baseRequestAttributes(system, modelName, 'pipeline', llm, invocationParams, langSmithMetadata); + + if (recordInputs && Array.isArray(prompts) && prompts.length > 0) { + const messages = prompts.map(p => ({ role: 'user', content: p })); + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(messages)); + } + + return attrs; +} + +/** + * Extracts attributes for ChatModel invocations (array-of-arrays of messages). + * + * - Operation is tagged as `chat`. + * - We flatten LangChain's `LangChainMessage[][]` and normalize shapes into a + * consistent `{ role, content }` array when `recordInputs` is true. + * - Provider system value falls back to `serialized.id?.[2]`. + */ +export function extractChatModelRequestAttributes( + llm: LangChainSerialized, + langChainMessages: LangChainMessage[][], + recordInputs: boolean, + invocationParams?: Record, + langSmithMetadata?: Record, +): Record { + const system = langSmithMetadata?.ls_provider ?? llm.id?.[2]; + const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown'; + + const attrs = baseRequestAttributes(system, modelName, 'chat', llm, invocationParams, langSmithMetadata); + + if (recordInputs && Array.isArray(langChainMessages) && langChainMessages.length > 0) { + const normalized = normalizeLangChainMessages(langChainMessages.flat()); + setIfDefined(attrs, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, asString(normalized)); + } + + return attrs; +} + +/** + * Scans generations for Anthropic-style `tool_use` items and records them. + * + * LangChain represents some provider messages (e.g., Anthropic) with a `message.content` + * array that may include objects `{ type: 'tool_use', ... }`. We collect and attach + * them as a JSON array on `gen_ai.response.tool_calls` for downstream consumers. + */ +function addToolCallsAttributes(generations: LangChainMessage[][], attrs: Record): void { + const toolCalls: unknown[] = []; + const flatGenerations = generations.flat(); + + for (const gen of flatGenerations) { + const content = gen.message?.content; + if (Array.isArray(content)) { + for (const item of content) { + const t = item as { type: string }; + if (t.type === 'tool_use') toolCalls.push(t); + } + } + } + + if (toolCalls.length > 0) { + setIfDefined(attrs, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, asString(toolCalls)); + } +} + +/** + * Adds token usage attributes, supporting both OpenAI (`tokenUsage`) and Anthropic (`usage`) formats. + * - Preserve zero values (0 tokens) by avoiding truthy checks. + * - Compute a total for Anthropic when not explicitly provided. + * - Include cache token metrics when present. + */ +function addTokenUsageAttributes( + llmOutput: LangChainLLMResult['llmOutput'], + attrs: Record, +): void { + if (!llmOutput) return; + + const tokenUsage = llmOutput.tokenUsage as + | { promptTokens?: number; completionTokens?: number; totalTokens?: number } + | undefined; + const anthropicUsage = llmOutput.usage as + | { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + } + | undefined; + + if (tokenUsage) { + setNumberIfDefined(attrs, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, tokenUsage.promptTokens); + setNumberIfDefined(attrs, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, tokenUsage.completionTokens); + setNumberIfDefined(attrs, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, tokenUsage.totalTokens); + } else if (anthropicUsage) { + setNumberIfDefined(attrs, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, anthropicUsage.input_tokens); + setNumberIfDefined(attrs, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, anthropicUsage.output_tokens); + + // Compute total when not provided by the provider. + const input = Number(anthropicUsage.input_tokens); + const output = Number(anthropicUsage.output_tokens); + const total = (Number.isNaN(input) ? 0 : input) + (Number.isNaN(output) ? 0 : output); + if (total > 0) setNumberIfDefined(attrs, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, total); + + // Extra Anthropic cache metrics (present only when caching is enabled) + if (anthropicUsage.cache_creation_input_tokens !== undefined) + setNumberIfDefined( + attrs, + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS_ATTRIBUTE, + anthropicUsage.cache_creation_input_tokens, + ); + if (anthropicUsage.cache_read_input_tokens !== undefined) + setNumberIfDefined(attrs, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_ATTRIBUTE, anthropicUsage.cache_read_input_tokens); + } +} + +/** + * Extracts response-related attributes based on a `LangChainLLMResult`. + * + * - Records finish reasons when present on generations (e.g., OpenAI) + * - When `recordOutputs` is true, captures textual response content and any + * tool calls. + * - Also propagates model name (`model_name` or `model`), response `id`, and + * `stop_reason` (for providers that use it). + */ +export function extractLlmResponseAttributes( + llmResult: LangChainLLMResult, + recordOutputs: boolean, +): Record | undefined { + if (!llmResult) return; + + const attrs: Record = {}; + + if (Array.isArray(llmResult.generations)) { + const finishReasons = llmResult.generations + .flat() + .map(g => g.generation_info?.finish_reason) + .filter((r): r is string => typeof r === 'string'); + + if (finishReasons.length > 0) { + setIfDefined(attrs, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, asString(finishReasons)); + } + + // Tool calls metadata (names, IDs) are not PII, so capture them regardless of recordOutputs + addToolCallsAttributes(llmResult.generations as LangChainMessage[][], attrs); + + if (recordOutputs) { + const texts = llmResult.generations + .flat() + .map(gen => gen.text ?? gen.message?.content) + .filter(t => typeof t === 'string'); + + if (texts.length > 0) { + setIfDefined(attrs, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, asString(texts)); + } + } + } + + addTokenUsageAttributes(llmResult.llmOutput, attrs); + + const llmOutput = llmResult.llmOutput as { model_name?: string; model?: string; id?: string; stop_reason?: string }; + // Provider model identifier: `model_name` (OpenAI-style) or `model` (others) + const modelName = llmOutput?.model_name ?? llmOutput?.model; + if (modelName) setIfDefined(attrs, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, modelName); + + if (llmOutput?.id) { + setIfDefined(attrs, GEN_AI_RESPONSE_ID_ATTRIBUTE, llmOutput.id); + } + + if (llmOutput?.stop_reason) { + setIfDefined(attrs, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, asString(llmOutput.stop_reason)); + } + + return attrs; +} diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index db52cf357a16..02e55c45a7ba 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -42,6 +42,7 @@ export { close, getSentryRelease, createGetModuleFromFilename, + createLangChainCallbackHandler, httpHeadersToSpanAttributes, winterCGHeadersToDict, // eslint-disable-next-line deprecation/deprecation @@ -56,6 +57,7 @@ export { onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, openAIIntegration, + langChainIntegration, modulesIntegration, contextLinesIntegration, nodeContextIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index b599351b5124..e469fd75d2d2 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -27,6 +27,7 @@ export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { openAIIntegration } from './integrations/tracing/openai'; export { anthropicAIIntegration } from './integrations/tracing/anthropic-ai'; export { googleGenAIIntegration } from './integrations/tracing/google-genai'; +export { langChainIntegration } from './integrations/tracing/langchain'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler, @@ -134,6 +135,7 @@ export { consoleIntegration, wrapMcpServerWithSentry, featureFlagsIntegration, + createLangChainCallbackHandler, } from '@sentry/core'; export type { diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index dd9d9ac8df2b..2782d7907349 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -13,6 +13,7 @@ import { hapiIntegration, instrumentHapi } from './hapi'; import { honoIntegration, instrumentHono } from './hono'; import { instrumentKafka, kafkaIntegration } from './kafka'; import { instrumentKoa, koaIntegration } from './koa'; +import { instrumentLangChain, langChainIntegration } from './langchain'; import { instrumentLruMemoizer, lruMemoizerIntegration } from './lrumemoizer'; import { instrumentMongo, mongoIntegration } from './mongo'; import { instrumentMongoose, mongooseIntegration } from './mongoose'; @@ -56,6 +57,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { firebaseIntegration(), anthropicAIIntegration(), googleGenAIIntegration(), + langChainIntegration(), ]; } @@ -93,5 +95,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentFirebase, instrumentAnthropicAi, instrumentGoogleGenAI, + instrumentLangChain, ]; } diff --git a/packages/node/src/integrations/tracing/langchain/index.ts b/packages/node/src/integrations/tracing/langchain/index.ts new file mode 100644 index 000000000000..e575691b930f --- /dev/null +++ b/packages/node/src/integrations/tracing/langchain/index.ts @@ -0,0 +1,107 @@ +import type { IntegrationFn, LangChainOptions } from '@sentry/core'; +import { defineIntegration, LANGCHAIN_INTEGRATION_NAME } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryLangChainInstrumentation } from './instrumentation'; + +export const instrumentLangChain = generateInstrumentOnce( + LANGCHAIN_INTEGRATION_NAME, + options => new SentryLangChainInstrumentation(options), +); + +const _langChainIntegration = ((options: LangChainOptions = {}) => { + return { + name: LANGCHAIN_INTEGRATION_NAME, + setupOnce() { + instrumentLangChain(options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for LangChain. + * + * This integration is enabled by default. + * + * When configured, this integration automatically instruments LangChain runnable instances + * to capture telemetry data by injecting Sentry callback handlers into all LangChain calls. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * import { ChatOpenAI } from '@langchain/openai'; + * + * Sentry.init({ + * integrations: [Sentry.langChainIntegration()], + * sendDefaultPii: true, // Enable to record inputs/outputs + * }); + * + * // LangChain calls are automatically instrumented + * const model = new ChatOpenAI(); + * await model.invoke("What is the capital of France?"); + * ``` + * + * ## Manual Callback Handler + * + * You can also manually add the Sentry callback handler alongside other callbacks: + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * import { ChatOpenAI } from '@langchain/openai'; + * + * const sentryHandler = Sentry.createLangChainCallbackHandler({ + * recordInputs: true, + * recordOutputs: true + * }); + * + * const model = new ChatOpenAI(); + * await model.invoke( + * "What is the capital of France?", + * { callbacks: [sentryHandler, myOtherCallback] } + * ); + * ``` + * + * ## Options + * + * - `recordInputs`: Whether to record input messages/prompts (default: respects `sendDefaultPii` client option) + * - `recordOutputs`: Whether to record response text (default: respects `sendDefaultPii` client option) + * + * ### Default Behavior + * + * By default, the integration will: + * - Record inputs and outputs ONLY if `sendDefaultPii` is set to `true` in your Sentry client options + * - Otherwise, inputs and outputs are NOT recorded unless explicitly enabled + * + * @example + * ```javascript + * // Record inputs and outputs when sendDefaultPii is false + * Sentry.init({ + * integrations: [ + * Sentry.langChainIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Never record inputs/outputs regardless of sendDefaultPii + * Sentry.init({ + * sendDefaultPii: true, + * integrations: [ + * Sentry.langChainIntegration({ + * recordInputs: false, + * recordOutputs: false + * }) + * ], + * }); + * ``` + * + * ## Supported Events + * + * The integration captures the following LangChain lifecycle events: + * - LLM/Chat Model: start, end, error + * - Chain: start, end, error + * - Tool: start, end, error + * + */ +export const langChainIntegration = defineIntegration(_langChainIntegration); diff --git a/packages/node/src/integrations/tracing/langchain/instrumentation.ts b/packages/node/src/integrations/tracing/langchain/instrumentation.ts new file mode 100644 index 000000000000..f171a2dfb022 --- /dev/null +++ b/packages/node/src/integrations/tracing/langchain/instrumentation.ts @@ -0,0 +1,214 @@ +import { + type InstrumentationConfig, + type InstrumentationModuleDefinition, + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, +} from '@opentelemetry/instrumentation'; +import type { LangChainOptions } from '@sentry/core'; +import { createLangChainCallbackHandler, getClient, SDK_VERSION } from '@sentry/core'; + +const supportedVersions = ['>=0.1.0 <1.0.0']; + +type LangChainInstrumentationOptions = InstrumentationConfig & LangChainOptions; + +/** + * Represents the patched shape of LangChain provider package exports + */ +interface PatchedLangChainExports { + [key: string]: unknown; +} + +/** + * Augments a callback handler list with Sentry's handler if not already present + */ +function augmentCallbackHandlers(handlers: unknown, sentryHandler: unknown): unknown { + // Handle null/undefined - return array with just our handler + if (!handlers) { + return [sentryHandler]; + } + + // If handlers is already an array + if (Array.isArray(handlers)) { + // Check if our handler is already in the list + if (handlers.includes(sentryHandler)) { + return handlers; + } + // Add our handler to the list + return [...handlers, sentryHandler]; + } + + // If it's a single handler object, convert to array + if (typeof handlers === 'object') { + return [handlers, sentryHandler]; + } + + // Unknown type - return original + return handlers; +} + +/** + * Wraps Runnable methods (invoke, stream, batch) to inject Sentry callbacks at request time + * Uses a Proxy to intercept method calls and augment the options.callbacks + */ +function wrapRunnableMethod( + originalMethod: (...args: unknown[]) => unknown, + sentryHandler: unknown, + _methodName: string, +): (...args: unknown[]) => unknown { + return new Proxy(originalMethod, { + apply(target, thisArg, args: unknown[]): unknown { + // LangChain Runnable method signatures: + // invoke(input, options?) - options contains callbacks + // stream(input, options?) - options contains callbacks + // batch(inputs, options?) - options contains callbacks + + // Options is typically the second argument + const optionsIndex = 1; + let options = args[optionsIndex] as Record | undefined; + + // If options don't exist or aren't an object, create them + if (!options || typeof options !== 'object' || Array.isArray(options)) { + options = {}; + args[optionsIndex] = options; + } + + // Inject our callback handler into options.callbacks (request time callbacks) + const existingCallbacks = options.callbacks; + const augmentedCallbacks = augmentCallbackHandlers(existingCallbacks, sentryHandler); + options.callbacks = augmentedCallbacks; + + // Call original method with augmented options + return Reflect.apply(target, thisArg, args); + }, + }) as (...args: unknown[]) => unknown; +} + +/** + * Sentry LangChain instrumentation using OpenTelemetry. + */ +export class SentryLangChainInstrumentation extends InstrumentationBase { + public constructor(config: LangChainInstrumentationOptions = {}) { + super('@sentry/instrumentation-langchain', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the modules to be patched. + * We patch the BaseChatModel class methods to inject callbacks + * + * We hook into provider packages (@langchain/anthropic, @langchain/openai, etc.) + * because @langchain/core is often bundled and not loaded as a separate module + */ + public init(): InstrumentationModuleDefinition | InstrumentationModuleDefinition[] { + const modules: InstrumentationModuleDefinition[] = []; + + // Hook into common LangChain provider packages + const providerPackages = [ + '@langchain/anthropic', + '@langchain/openai', + '@langchain/google-genai', + '@langchain/mistralai', + '@langchain/google-vertexai', + '@langchain/groq', + ]; + + for (const packageName of providerPackages) { + // In CJS, LangChain packages re-export from dist/index.cjs files. + // Patching only the root module sometimes misses the real implementation or + // gets overwritten when that file is loaded. We add a file-level patch so that + // _patch runs again on the concrete implementation + modules.push( + new InstrumentationNodeModuleDefinition( + packageName, + supportedVersions, + this._patch.bind(this), + exports => exports, + [ + new InstrumentationNodeModuleFile( + `${packageName}/dist/index.cjs`, + supportedVersions, + this._patch.bind(this), + exports => exports, + ), + ], + ), + ); + } + + return modules; + } + + /** + * Core patch logic - patches chat model methods to inject Sentry callbacks + * This is called when a LangChain provider package is loaded + */ + private _patch(exports: PatchedLangChainExports): PatchedLangChainExports | void { + const client = getClient(); + const defaultPii = Boolean(client?.getOptions().sendDefaultPii); + + const config = this.getConfig(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordInputs = config?.recordInputs ?? defaultPii; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const recordOutputs = config?.recordOutputs ?? defaultPii; + + // Create a shared handler instance + const sentryHandler = createLangChainCallbackHandler({ + recordInputs, + recordOutputs, + }); + + // Patch Runnable methods to inject callbacks at request time + // This directly manipulates options.callbacks that LangChain uses + this._patchRunnableMethods(exports, sentryHandler); + + return exports; + } + + /** + * Patches chat model methods (invoke, stream, batch) to inject Sentry callbacks + * Finds a chat model class from the provider package exports and patches its prototype methods + */ + private _patchRunnableMethods(exports: PatchedLangChainExports, sentryHandler: unknown): void { + // Known chat model class names for each provider + const knownChatModelNames = [ + 'ChatAnthropic', + 'ChatOpenAI', + 'ChatGoogleGenerativeAI', + 'ChatMistralAI', + 'ChatVertexAI', + 'ChatGroq', + ]; + + // Find a chat model class in the exports by checking known class names + const chatModelClass = Object.values(exports).find(exp => { + if (typeof exp !== 'function') { + return false; + } + return knownChatModelNames.includes(exp.name); + }) as { prototype: unknown; name: string } | undefined; + + if (!chatModelClass) { + return; + } + + // Patch directly on chatModelClass.prototype + const targetProto = chatModelClass.prototype as Record; + + // Patch the methods (invoke, stream, batch) + // All chat model instances will inherit these patched methods + const methodsToPatch = ['invoke', 'stream', 'batch'] as const; + + for (const methodName of methodsToPatch) { + const method = targetProto[methodName]; + if (typeof method === 'function') { + targetProto[methodName] = wrapRunnableMethod( + method as (...args: unknown[]) => unknown, + sentryHandler, + methodName, + ); + } + } + } +} diff --git a/yarn.lock b/yarn.lock index 4a6077f16b4b..7a38d3f22cd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -335,6 +335,13 @@ dependencies: json-schema-to-ts "^3.1.1" +"@anthropic-ai/sdk@^0.65.0": + version "0.65.0" + resolved "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.65.0.tgz#3f464fe2029eacf8e7e7fb8197579d00c8ca7502" + integrity sha512-zIdPOcrCVEI8t3Di40nH4z9EoeyGZfXbYSvWdDLsB/KkaSYMnEgC7gmcgWu83g2NTn1ZTpbMvpdttWDGGIk6zw== + dependencies: + json-schema-to-ts "^3.1.1" + "@apm-js-collab/code-transformer@^0.8.0", "@apm-js-collab/code-transformer@^0.8.2": version "0.8.2" resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz#a3160f16d1c4df9cb81303527287ad18d00994d1" @@ -2678,6 +2685,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== +"@cfworker/json-schema@^4.0.2": + version "4.1.1" + resolved "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz#4a2a3947ee9fa7b7c24be981422831b8674c3be6" + integrity sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og== + "@cloudflare/kv-asset-handler@0.4.0", "@cloudflare/kv-asset-handler@^0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz#a8588c6a2e89bb3e87fb449295a901c9f6d3e1bf" @@ -4888,6 +4900,32 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@langchain/anthropic@^0.3.10": + version "0.3.31" + resolved "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-0.3.31.tgz#80bc2464ab98cfb8df0de50cf219d92cfe5934e1" + integrity sha512-XyjwE1mA1I6sirSlVZtI6tyv7nH3+b8F5IFDi9WNKA8+SidJ0o3cP90TxrK7x1sSLmdj+su3f8s2hOusw6xpaw== + dependencies: + "@anthropic-ai/sdk" "^0.65.0" + fast-xml-parser "^4.4.1" + +"@langchain/core@^0.3.28": + version "0.3.78" + resolved "https://registry.npmjs.org/@langchain/core/-/core-0.3.78.tgz#40e69fba6688858edbcab4473358ec7affc685fd" + integrity sha512-Nn0x9erQlK3zgtRU1Z8NUjLuyW0gzdclMsvLQ6wwLeDqV91pE+YKl6uQb+L2NUDs4F0N7c2Zncgz46HxrvPzuA== + dependencies: + "@cfworker/json-schema" "^4.0.2" + ansi-styles "^5.0.0" + camelcase "6" + decamelize "1.2.0" + js-tiktoken "^1.0.12" + langsmith "^0.3.67" + mustache "^4.2.0" + p-queue "^6.6.2" + p-retry "4" + uuid "^10.0.0" + zod "^3.25.32" + zod-to-json-schema "^3.22.3" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" @@ -9037,6 +9075,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.8.tgz#bb197b9639aa1a04cf464a617fe800cccd92ad5c" integrity sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw== +"@types/uuid@^10.0.0": + version "10.0.0" + resolved "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" + integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== + "@types/webidl-conversions@*": version "7.0.3" resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" @@ -11414,7 +11457,7 @@ base64-arraybuffer@^1.0.1: resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== -base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1: +base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -12488,16 +12531,16 @@ camelcase@5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42" integrity sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA== +camelcase@6, camelcase@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - camelcase@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-7.0.1.tgz#f02e50af9fd7782bc8b88a3558c32fd3a388f048" @@ -13220,6 +13263,13 @@ console-control-strings@^1.1.0: resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= +console-table-printer@^2.12.1: + version "2.14.6" + resolved "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.14.6.tgz#edfe0bf311fa2701922ed509443145ab51e06436" + integrity sha512-MCBl5HNVaFuuHW6FGbL/4fB7N/ormCy+tQ+sxTrF6QtSbSNETvPuOVbkJBhzDgYhvjWGrTma4eYJa37ZuoQsPw== + dependencies: + simple-wcswidth "^1.0.1" + console-ui@^3.0.4, console-ui@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/console-ui/-/console-ui-3.1.2.tgz#51aef616ff02013c85ccee6a6d77ef7a94202e7a" @@ -13936,7 +13986,7 @@ decamelize-keys@^1.1.0: decamelize "^1.1.0" map-obj "^1.0.0" -decamelize@^1.1.0: +decamelize@1.2.0, decamelize@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= @@ -19988,6 +20038,13 @@ js-string-escape@^1.0.1: resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" integrity sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8= +js-tiktoken@^1.0.12: + version "1.0.21" + resolved "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz#368a9957591a30a62997dd0c4cf30866f00f8221" + integrity sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g== + dependencies: + base64-js "^1.5.1" + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -20392,6 +20449,19 @@ lambda-local@^2.2.0: dotenv "^16.3.1" winston "^3.10.0" +langsmith@^0.3.67: + version "0.3.74" + resolved "https://registry.npmjs.org/langsmith/-/langsmith-0.3.74.tgz#014d31a9ff7530b54f0d797502abd512ce8fb6fb" + integrity sha512-ZuW3Qawz8w88XcuCRH91yTp6lsdGuwzRqZ5J0Hf5q/AjMz7DwcSv0MkE6V5W+8hFMI850QZN2Wlxwm3R9lHlZg== + dependencies: + "@types/uuid" "^10.0.0" + chalk "^4.1.2" + console-table-printer "^2.12.1" + p-queue "^6.6.2" + p-retry "4" + semver "^7.6.3" + uuid "^10.0.0" + language-subtag-registry@~0.3.2: version "0.3.22" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d" @@ -24066,7 +24136,7 @@ p-pipe@3.1.0: resolved "https://registry.npmjs.org/p-pipe/-/p-pipe-3.1.0.tgz#48b57c922aa2e1af6a6404cb7c6bf0eb9cc8e60e" integrity sha512-08pj8ATpzMR0Y80x50yJHn37NF6vjrqHutASaX5LiH5npS9XPvrUmscd9MF5R4fuYRHOxQR1FfMIlF7AzwoPqw== -p-queue@6.6.2: +p-queue@6.6.2, p-queue@^6.6.2: version "6.6.2" resolved "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== @@ -24087,7 +24157,7 @@ p-reduce@2.1.0, p-reduce@^2.0.0, p-reduce@^2.1.0: resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" integrity sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw== -p-retry@^4.5.0: +p-retry@4, p-retry@^4.5.0: version "4.6.2" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== @@ -27720,6 +27790,11 @@ simple-update-notifier@^2.0.0: dependencies: semver "^7.5.3" +simple-wcswidth@^1.0.1: + version "1.1.2" + resolved "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz#66722f37629d5203f9b47c5477b1225b85d6525b" + integrity sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw== + sinon@19.0.2: version "19.0.2" resolved "https://registry.yarnpkg.com/sinon/-/sinon-19.0.2.tgz#944cf771d22236aa84fc1ab70ce5bffc3a215dad" @@ -30441,6 +30516,11 @@ uuid@8.3.2, uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== +uuid@^10.0.0: + version "10.0.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" @@ -31856,20 +31936,20 @@ zip-stream@^6.0.1: compress-commons "^6.0.2" readable-stream "^4.0.0" -zod-to-json-schema@^3.24.1: - version "3.24.5" - resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz#d1095440b147fb7c2093812a53c54df8d5df50a3" - integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== +zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.24.1: + version "3.24.6" + resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz#5920f020c4d2647edfbb954fa036082b92c9e12d" + integrity sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg== zod@3.22.3: version "3.22.3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060" integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug== -zod@^3.22.2, zod@^3.22.4, zod@^3.23.8, zod@^3.24.1: - version "3.25.75" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.75.tgz#8ff9be2fbbcb381a9236f9f74a8879ca29dcc504" - integrity sha512-OhpzAmVzabPOL6C3A3gpAifqr9MqihV/Msx3gor2b2kviCgcb+HM9SEOpMWwwNp9MRunWnhtAKUoo0AHhjyPPg== +zod@^3.22.2, zod@^3.22.4, zod@^3.23.8, zod@^3.24.1, zod@^3.25.32: + version "3.25.76" + resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== zone.js@^0.12.0: version "0.12.0" From f895f09b529f743b80cdfcea5e11d7c26a9384b7 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 22 Oct 2025 15:21:24 +0200 Subject: [PATCH 050/190] fix(node): Pino child loggers (#17934) This PR: - Includes bindings from child loggers as attributes - Tests that track/untrack setting is propagated to child loggers --- .../suites/pino/scenario-track.mjs | 13 +++++- .../suites/pino/scenario.mjs | 3 +- .../suites/pino/test.ts | 44 +++++++++---------- packages/node-core/src/integrations/pino.ts | 38 ++++++++++------ 4 files changed, 60 insertions(+), 38 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs index 2e968444a74f..e55f11f9c00c 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs @@ -17,7 +17,18 @@ Sentry.withIsolationScope(() => { setTimeout(() => { Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'later' }, () => { - logger.error(new Error('oh no')); + // This child should be captured as we marked the parent logger to be tracked + const child = logger.child({ module: 'authentication' }); + child.error(new Error('oh no')); + + // This child should be ignored + const child2 = logger.child({ module: 'authentication.v2' }); + Sentry.pinoIntegration.untrackLogger(child2); + child2.error(new Error('oh no v2')); + + // This should also be ignored as the parent is ignored + const child3 = child2.child({ module: 'authentication.v3' }); + child3.error(new Error('oh no v3')); }); }); }, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs index beb080ac3c42..55966552a07f 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -17,7 +17,8 @@ Sentry.withIsolationScope(() => { setTimeout(() => { Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'later' }, () => { - logger.error(new Error('oh no')); + const child = logger.child({ module: 'authentication' }); + child.error(new Error('oh no')); }); }); }, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index 1982c8d686fc..f9cdb143ddff 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -45,7 +45,6 @@ conditionalTest({ min: 20 })('Pino integration', () => { function: '?', in_app: true, module: 'scenario', - context_line: " logger.error(new Error('oh no'));", }), ]), }, @@ -63,8 +62,8 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'hello world', trace_id: expect.any(String), severity_number: 9, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { @@ -74,7 +73,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, { timestamp: expect.any(Number), @@ -82,14 +81,14 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'oh no', trace_id: expect.any(String), severity_number: 17, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, + module: { value: 'authentication', 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' }, - }), + }, }, ], }, @@ -139,8 +138,8 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'hello world', trace_id: expect.any(String), severity_number: 9, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, 'pino.logger.level': { value: 30, type: 'integer' }, user: { value: 'user-id', type: 'string' }, something: { @@ -150,7 +149,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, - }), + }, }, { timestamp: expect.any(Number), @@ -158,14 +157,13 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'oh no', trace_id: expect.any(String), severity_number: 17, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + 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' }, - }), + }, }, ], }, @@ -189,18 +187,19 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'hello world', trace_id: expect.any(String), severity_number: 9, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + 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"}', }, + msg: { value: 'hello world', 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' }, - }), + }, }, { timestamp: expect.any(Number), @@ -208,14 +207,15 @@ conditionalTest({ min: 20 })('Pino integration', () => { body: 'oh no', trace_id: expect.any(String), severity_number: 17, - attributes: expect.objectContaining({ - 'pino.logger.name': { value: 'myapp', type: 'string' }, + attributes: { + name: { value: 'myapp', type: 'string' }, + module: { value: 'authentication', type: 'string' }, + msg: { value: 'oh no', 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 dfc51d5022ff..7c3e0c2c813f 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -81,9 +81,23 @@ type DeepPartial = { [P in keyof T]?: T[P] extends object ? Partial : T[P]; }; +type PinoResult = { + level?: string; + time?: string; + pid?: number; + hostname?: string; + err?: Error; +} & Record; + +function stripIgnoredFields(result: PinoResult): PinoResult { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { level, time, pid, hostname, err, ...rest } = result; + return rest; +} + const _pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { const options: PinoOptions = { - autoInstrument: userOptions.autoInstrument === false ? userOptions.autoInstrument : DEFAULT_OPTIONS.autoInstrument, + autoInstrument: userOptions.autoInstrument !== false, error: { ...DEFAULT_OPTIONS.error, ...userOptions.error }, log: { ...DEFAULT_OPTIONS.log, ...userOptions.log }, }; @@ -112,27 +126,23 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial = { - ...obj, + ...resultObj, '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 }); } @@ -153,8 +163,8 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial { const { self, arguments: args, result } = data as { self: Pino; arguments: PinoHookArgs; result: string }; - onPinoStart(self, args, result); + onPinoStart(self, args, JSON.parse(result)); }); integratedChannel.end.subscribe(data => { @@ -174,7 +184,7 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial Date: Wed, 22 Oct 2025 15:42:41 +0200 Subject: [PATCH 051/190] test(hono): Fix hono e2e tests (#18000) ATM there are failing Hono E2E tests (e.g. [here](https://github.com/getsentry/sentry-javascript/actions/runs/18714106732/job/53370082672?pr=17998)), which print out following: ```sh Error: src/index.ts(20,3): error TS2584: Cannot find name 'console'. Do you need to change your target library? Try changing the 'lib' compiler option to include 'dom'. ``` The reason was, that the types were not there yet. I just wonder why it was working before. In follow up PRs I will try to update Cloudflare tests with its integrations. --- .../e2e-tests/test-applications/cloudflare-hono/package.json | 1 + .../e2e-tests/test-applications/cloudflare-hono/tsconfig.json | 1 + 2 files changed, 2 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index a8fe024e9405..b005398a5faf 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", "@cloudflare/workers-types": "^4.20250521.0", + "typescript": "^5.9.3", "vitest": "3.1.0", "wrangler": "4.22.0" }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json index e5d6f2b66f33..3c1c64b66cb8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/tsconfig.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "lib": ["ESNext"], "jsx": "react-jsx", + "types": ["@cloudflare/workers-types/experimental"], "jsxImportSource": "hono/jsx" }, "include": ["src/**/*"], From e05acdd5099e5c4dd2190452c96284460239177c Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 22 Oct 2025 16:19:48 +0200 Subject: [PATCH 052/190] fix(node): Pino capture serialized `err` (#17999) --- dev-packages/node-integration-tests/suites/pino/test.ts | 5 +++++ packages/node-core/src/integrations/pino.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index f9cdb143ddff..19fb5d80387a 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -84,6 +84,8 @@ conditionalTest({ min: 20 })('Pino integration', () => { attributes: { name: { value: 'myapp', type: 'string' }, module: { value: 'authentication', type: 'string' }, + msg: { value: 'oh no', type: 'string' }, + err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, @@ -159,6 +161,8 @@ conditionalTest({ min: 20 })('Pino integration', () => { severity_number: 17, attributes: { name: { value: 'myapp', type: 'string' }, + msg: { value: 'oh no', type: 'string' }, + err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, @@ -211,6 +215,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { name: { value: 'myapp', type: 'string' }, module: { value: 'authentication', type: 'string' }, msg: { value: 'oh no', type: 'string' }, + err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index 7c3e0c2c813f..68c17ded1abe 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -86,12 +86,11 @@ type PinoResult = { time?: string; pid?: number; hostname?: string; - err?: Error; } & Record; function stripIgnoredFields(result: PinoResult): PinoResult { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { level, time, pid, hostname, err, ...rest } = result; + const { level, time, pid, hostname, ...rest } = result; return rest; } From 39f85b39b1c60aaf9b1c25e9ecf688c3c1903484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 23 Oct 2025 10:08:41 +0200 Subject: [PATCH 053/190] feat: Align sentry origin with documentation (#17998) There were 2 major changes: - `auto.console.logging` -> `auto.log.console` - `auto.logging.*` -> `auto.log.*` This can go in already, I am just not sure if this should be a breaking change or a minor bump, since theoretically dashboards or bookmarked searches/groupings would be failing. (closes #17900) --- .../public-api/logger/integration/test.ts | 30 ++++++------ .../suites/winston/test.ts | 18 ++++---- .../suites/consola/test.ts | 46 +++++++++---------- .../suites/pino/test.ts | 12 ++--- .../suites/winston/test.ts | 18 ++++---- packages/core/src/integrations/consola.ts | 2 +- packages/core/src/logs/console-integration.ts | 2 +- .../test/lib/integrations/consola.test.ts | 20 ++++---- packages/node-core/src/integrations/pino.ts | 2 +- .../node-core/src/integrations/winston.ts | 2 +- 10 files changed, 76 insertions(+), 76 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 442800456f9b..dd4bd7e8ebc3 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -30,7 +30,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.trace 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.trace {} {}', type: 'string' }, @@ -45,7 +45,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.debug 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.debug {} {}', type: 'string' }, @@ -60,7 +60,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.log 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.log {} {}', type: 'string' }, @@ -75,7 +75,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.info 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.info {} {}', type: 'string' }, @@ -90,7 +90,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.warn 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.warn {} {}', type: 'string' }, @@ -105,7 +105,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'console.error 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'console.error {} {}', type: 'string' }, @@ -120,7 +120,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Assertion failed: console.assert 123 false', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -132,7 +132,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Object: {"key":"value","nested":{"prop":123}}', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Object: {}', type: 'string' }, @@ -146,7 +146,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Array: [1,2,3,"string"]', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Array: {}', type: 'string' }, @@ -160,7 +160,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Mixed: prefix {"obj":true} [4,5,6] suffix', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'Mixed: {} {} {} {}', type: 'string' }, @@ -177,7 +177,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: '', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -189,7 +189,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'String substitution %s %d test 42', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -201,7 +201,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'Object substitution %o {"key":"value"}', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, }, @@ -213,7 +213,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'first 0 1 2', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'first {} {} {}', type: 'string' }, @@ -229,7 +229,7 @@ sentryTest('should capture console object calls', async ({ getLocalTestUrl, page trace_id: expect.any(String), body: 'hello true null undefined', attributes: { - 'sentry.origin': { value: 'auto.console.logging', type: 'string' }, + 'sentry.origin': { value: 'auto.log.console', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.browser', type: 'string' }, 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, 'sentry.message.template': { value: 'hello {} {} {}', type: 'string' }, diff --git a/dev-packages/node-core-integration-tests/suites/winston/test.ts b/dev-packages/node-core-integration-tests/suites/winston/test.ts index 034210f8690b..777b1149c871 100644 --- a/dev-packages/node-core-integration-tests/suites/winston/test.ts +++ b/dev-packages/node-core-integration-tests/suites/winston/test.ts @@ -18,7 +18,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -33,7 +33,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -62,7 +62,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -77,7 +77,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -92,7 +92,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -107,7 +107,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -136,7 +136,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -151,7 +151,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -166,7 +166,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, diff --git a/dev-packages/node-integration-tests/suites/consola/test.ts b/dev-packages/node-integration-tests/suites/consola/test.ts index cf396e319c51..2ee47a17dd20 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -18,7 +18,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -35,7 +35,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -52,7 +52,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -83,7 +83,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -100,7 +100,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -117,7 +117,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -135,7 +135,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -152,7 +152,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -169,7 +169,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -186,7 +186,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -203,7 +203,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -220,7 +220,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -236,7 +236,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -253,7 +253,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -283,7 +283,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -300,7 +300,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -342,7 +342,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -360,7 +360,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -392,7 +392,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -410,7 +410,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -440,7 +440,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -457,7 +457,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -474,7 +474,7 @@ describe('consola integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.consola', type: 'string' }, + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index 19fb5d80387a..a2ec57b57e56 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -70,7 +70,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { type: 'string', value: '{"more":3,"complex":"nope"}', }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -87,7 +87,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { msg: { value: 'oh no', type: 'string' }, err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -148,7 +148,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { type: 'string', value: '{"more":3,"complex":"nope"}', }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -164,7 +164,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { msg: { value: 'oh no', type: 'string' }, err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -200,7 +200,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { value: '{"more":3,"complex":"nope"}', }, msg: { value: 'hello world', type: 'string' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, @@ -217,7 +217,7 @@ conditionalTest({ min: 20 })('Pino integration', () => { msg: { value: 'oh no', type: 'string' }, err: { value: expect.any(String), type: 'string' }, 'pino.logger.level': { value: 50, type: 'integer' }, - 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.origin': { value: 'auto.log.pino', type: 'string' }, 'sentry.release': { value: '1.0', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, }, diff --git a/dev-packages/node-integration-tests/suites/winston/test.ts b/dev-packages/node-integration-tests/suites/winston/test.ts index 034210f8690b..777b1149c871 100644 --- a/dev-packages/node-integration-tests/suites/winston/test.ts +++ b/dev-packages/node-integration-tests/suites/winston/test.ts @@ -18,7 +18,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -33,7 +33,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -62,7 +62,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -77,7 +77,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -92,7 +92,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -107,7 +107,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -136,7 +136,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -151,7 +151,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, @@ -166,7 +166,7 @@ describe('winston integration', () => { severity_number: expect.any(Number), trace_id: expect.any(String), attributes: { - 'sentry.origin': { value: 'auto.logging.winston', type: 'string' }, + 'sentry.origin': { value: 'auto.log.winston', type: 'string' }, 'sentry.release': { value: '1.0.0', type: 'string' }, 'sentry.environment': { value: 'test', type: 'string' }, 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index 1caa7d2f212f..4781b253b161 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -217,7 +217,7 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con const message = messageParts.join(' '); // Build attributes - attributes['sentry.origin'] = 'auto.logging.consola'; + attributes['sentry.origin'] = 'auto.log.consola'; if (tag) { attributes['consola.tag'] = tag; diff --git a/packages/core/src/logs/console-integration.ts b/packages/core/src/logs/console-integration.ts index bf49c745e788..ccf14e3ebf48 100644 --- a/packages/core/src/logs/console-integration.ts +++ b/packages/core/src/logs/console-integration.ts @@ -16,7 +16,7 @@ interface CaptureConsoleOptions { const INTEGRATION_NAME = 'ConsoleLogs'; const DEFAULT_ATTRIBUTES = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.console.logging', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.log.console', }; const _consoleLoggingIntegration = ((options: Partial = {}) => { diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index a5c68184e03b..a32f073eeb75 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -78,7 +78,7 @@ describe('createConsolaReporter', () => { level: 'error', message: 'This is an error', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.tag': 'test', 'consola.type': 'error', 'consola.level': 0, @@ -98,7 +98,7 @@ describe('createConsolaReporter', () => { level: 'warn', message: 'This is a warning', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'warn', }, }); @@ -116,7 +116,7 @@ describe('createConsolaReporter', () => { level: 'info', message: 'This is info', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', }, }); @@ -134,7 +134,7 @@ describe('createConsolaReporter', () => { level: 'debug', message: 'Debug message', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'debug', }, }); @@ -152,7 +152,7 @@ describe('createConsolaReporter', () => { level: 'trace', message: 'Trace message', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'trace', }, }); @@ -170,7 +170,7 @@ describe('createConsolaReporter', () => { level: 'fatal', message: 'Fatal error', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'fatal', }, }); @@ -189,7 +189,7 @@ describe('createConsolaReporter', () => { level: 'info', message: 'Hello world 123 {"key":"value"}', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', }, }); @@ -210,7 +210,7 @@ describe('createConsolaReporter', () => { level: 'info', message: 'Message {"self":"[Circular ~]"}', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', }, }); @@ -228,7 +228,7 @@ describe('createConsolaReporter', () => { level: 'fatal', message: 'Fatal message', attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.level': 0, }, }); @@ -257,7 +257,7 @@ describe('createConsolaReporter', () => { level: expectedLevel, message: `Test ${type} message`, attributes: { - 'sentry.origin': 'auto.logging.consola', + 'sentry.origin': 'auto.log.consola', 'consola.type': type, }, }); diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index 68c17ded1abe..21eeff64769e 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -138,7 +138,7 @@ const _pinoIntegration = defineIntegration((userOptions: DeepPartial = { ...resultObj, - 'sentry.origin': 'auto.logging.pino', + 'sentry.origin': 'auto.log.pino', 'pino.logger.level': levelNumber, }; diff --git a/packages/node-core/src/integrations/winston.ts b/packages/node-core/src/integrations/winston.ts index 63e208920914..bea0fa584bf7 100644 --- a/packages/node-core/src/integrations/winston.ts +++ b/packages/node-core/src/integrations/winston.ts @@ -89,7 +89,7 @@ export function createSentryWinstonTransport Date: Thu, 23 Oct 2025 10:30:15 +0200 Subject: [PATCH 054/190] feat(node): Pass requestHook and responseHook option to OTel (#17996) This adds two new options into the `nativeNodeFetchIntegration` - the only thing it does is passing the two new options directly into the OTel instrumentation. Since this is OTel related, this is only accessible within the `node` SDK. The documentation will be then updated for the fetch integration ([it seems](https://docs.sentry.io/platforms/javascript/guides/node/configuration/integrations/nodefetch/) that also the `spans` are missing) (closes #17953) --- .../fetch-forward-request-hook/scenario.ts | 26 +++++++++ .../fetch-forward-request-hook/test.ts | 58 +++++++++++++++++++ packages/node/src/integrations/node-fetch.ts | 4 +- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts new file mode 100644 index 000000000000..0843830321c4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/scenario.ts @@ -0,0 +1,26 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [ + Sentry.nativeNodeFetchIntegration({ + requestHook: (span, req) => { + span.setAttribute('sentry.request.hook', req.path); + }, + responseHook: (span, { response, request }) => { + span.setAttribute('sentry.response.hook.path', request.path); + span.setAttribute('sentry.response.hook.status_code', response.statusCode); + }, + }), + ], +}); + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_transaction' }, async () => { + await fetch(`${process.env.SERVER_URL}/api/v0`); + await fetch(`${process.env.SERVER_URL}/api/v1`); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts new file mode 100644 index 000000000000..8d0a35a43d05 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-forward-request-hook/test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'vitest'; +import { createRunner } from '../../../../utils/runner'; +import { createTestServer } from '../../../../utils/server'; + +test('adds requestHook and responseHook attributes to spans of outgoing fetch requests', async () => { + expect.assertions(3); + + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/v0', () => { + // Just ensure we're called + expect(true).toBe(true); + }) + .get( + '/api/v1', + () => { + // Just ensure we're called + expect(true).toBe(true); + }, + 404, + ) + .start(); + + await createRunner(__dirname, 'scenario.ts') + .withEnv({ SERVER_URL }) + .expect({ + transaction: { + transaction: 'test_transaction', + spans: [ + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v0/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'ok', + data: expect.objectContaining({ + 'sentry.request.hook': '/api/v0', + 'sentry.response.hook.path': '/api/v0', + 'sentry.response.hook.status_code': 200, + }), + }), + expect.objectContaining({ + description: expect.stringMatching(/GET .*\/api\/v1/), + op: 'http.client', + origin: 'auto.http.otel.node_fetch', + status: 'not_found', + data: expect.objectContaining({ + 'sentry.request.hook': '/api/v1', + 'sentry.response.hook.path': '/api/v1', + 'sentry.response.hook.status_code': 404, + 'http.response.status_code': 404, + }), + }), + ], + }, + }) + .start() + .completed(); + closeTestServer(); +}); diff --git a/packages/node/src/integrations/node-fetch.ts b/packages/node/src/integrations/node-fetch.ts index 437806e16dbc..6da9fd628bac 100644 --- a/packages/node/src/integrations/node-fetch.ts +++ b/packages/node/src/integrations/node-fetch.ts @@ -8,7 +8,7 @@ import type { NodeClientOptions } from '../types'; const INTEGRATION_NAME = 'NodeFetch'; -interface NodeFetchOptions { +interface NodeFetchOptions extends Pick { /** * Whether breadcrumbs should be recorded for requests. * Defaults to true @@ -106,6 +106,8 @@ function getConfigWithDefaults(options: Partial = {}): UndiciI [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch', }; }, + requestHook: options.requestHook, + responseHook: options.responseHook, } satisfies UndiciInstrumentationConfig; return instrumentationConfig; From 152b9d4b573e5143fa674db4d15f98ee56685477 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 23 Oct 2025 13:22:22 +0200 Subject: [PATCH 055/190] feat(nextjs): Support node runtime on proxy files (#17995) [Next 16 was released](https://github.com/vercel/next.js/releases/tag/v16.0.0) With that proxy files run per default on nodejs. This PR - Updates the tests to run on next 16 (non-beta) - Adds support for handling middleware transactions in the node part of the sdk --- .../test-applications/nextjs-16/package.json | 13 +++- .../nextjs-16/tests/middleware.test.ts | 65 +++++++++++-------- .../nextjs/src/common/nextSpanAttributes.ts | 3 + packages/nextjs/src/server/index.ts | 39 ++++++++--- 4 files changed, 84 insertions(+), 36 deletions(-) create mode 100644 packages/nextjs/src/common/nextSpanAttributes.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 2da23b152807..af9f306f017d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -16,6 +16,8 @@ "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" @@ -25,7 +27,7 @@ "@sentry/core": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^1", - "next": "16.0.0-beta.0", + "next": "16.0.0", "react": "19.1.0", "react-dom": "19.1.0", "require-in-the-middle": "^7", @@ -50,6 +52,15 @@ "build-command": "pnpm test:build-webpack", "label": "nextjs-16 (webpack)", "assert-command": "pnpm test:assert-webpack" + }, + { + "build-command": "pnpm test:build-latest-webpack", + "label": "nextjs-16 (latest, webpack)", + "assert-command": "pnpm test:assert-webpack" + }, + { + "build-command": "pnpm test:build-latest", + "label": "nextjs-16 (latest, turbopack)" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index 4ed289eb6215..aa4611fb7afc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; +import { isDevMode } from './isDevMode'; test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { @@ -13,8 +14,8 @@ test('Should create a transaction for middleware', async ({ request }) => { expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - expect(middlewareTransaction.transaction_info?.source).toBe('url'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); // Assert that isolation scope works properly expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); @@ -22,6 +23,7 @@ test('Should create a transaction for middleware', async ({ request }) => { }); test('Faulty middlewares', async ({ request }) => { + test.skip(isDevMode, 'Throwing crashes the dev server atm'); // https://github.com/vercel/next.js/issues/85261 const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { return transactionEvent?.transaction === 'middleware GET'; }); @@ -36,27 +38,29 @@ test('Faulty middlewares', async ({ request }) => { await test.step('should record transactions', async () => { const middlewareTransaction = await middlewareTransactionPromise; - expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error'); + expect(middlewareTransaction.contexts?.trace?.status).toBe('internal_error'); expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); - expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); - expect(middlewareTransaction.transaction_info?.source).toBe('url'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('node'); + expect(middlewareTransaction.transaction_info?.source).toBe('route'); }); - await test.step('should record exceptions', async () => { - const errorEvent = await errorEventPromise; - - // Assert that isolation scope works properly - expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); - expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect([ - 'middleware GET', // non-otel webpack versions - '/middleware', // middleware file - '/proxy', // proxy file - ]).toContain(errorEvent.transaction); - }); + // TODO: proxy errors currently not reported via onRequestError + // await test.step('should record exceptions', async () => { + // const errorEvent = await errorEventPromise; + + // // Assert that isolation scope works properly + // expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + // expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // expect([ + // 'middleware GET', // non-otel webpack versions + // '/middleware', // middleware file + // '/proxy', // proxy file + // ]).toContain(errorEvent.transaction); + // }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + test.skip(isDevMode, 'The fetch requests ends up in a separate tx in dev atm'); const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { return ( transactionEvent?.transaction === 'middleware GET' && @@ -74,18 +78,26 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru expect.arrayContaining([ { data: { - 'http.method': 'GET', + 'http.request.method': 'GET', + 'http.request.method_original': 'GET', 'http.response.status_code': 200, - type: 'fetch', - url: 'http://localhost:3030/', - 'http.url': 'http://localhost:3030/', - 'server.address': 'localhost:3030', + 'network.peer.address': '::1', + 'network.peer.port': 3030, + 'otel.kind': 'CLIENT', 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.wintercg_fetch', + 'sentry.origin': 'auto.http.otel.node_fetch', + 'server.address': 'localhost', + 'server.port': 3030, + url: 'http://localhost:3030/', + 'url.full': 'http://localhost:3030/', + 'url.path': '/', + 'url.query': '', + 'url.scheme': 'http', + 'user_agent.original': 'node', }, description: 'GET http://localhost:3030/', op: 'http.client', - origin: 'auto.http.wintercg_fetch', + origin: 'auto.http.otel.node_fetch', parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), span_id: expect.stringMatching(/[a-f0-9]{16}/), start_timestamp: expect.any(Number), @@ -95,11 +107,12 @@ test('Should trace outgoing fetch requests inside middleware and create breadcru }, ]), ); + expect(middlewareTransaction.breadcrumbs).toEqual( expect.arrayContaining([ { - category: 'fetch', - data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' }, + category: 'http', + data: { 'http.method': 'GET', status_code: 200, url: 'http://localhost:3030/' }, timestamp: expect.any(Number), type: 'http', }, diff --git a/packages/nextjs/src/common/nextSpanAttributes.ts b/packages/nextjs/src/common/nextSpanAttributes.ts new file mode 100644 index 000000000000..8b9f4a9d1374 --- /dev/null +++ b/packages/nextjs/src/common/nextSpanAttributes.ts @@ -0,0 +1,3 @@ +export const ATTR_NEXT_SPAN_TYPE = 'next.span_type'; +export const ATTR_NEXT_SPAN_NAME = 'next.span_name'; +export const ATTR_NEXT_ROUTE = 'next.route'; diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 5ce23e6a9460..aa6210c2ff6a 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -31,6 +31,7 @@ import { getScopesFromContext } from '@sentry/opentelemetry'; import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getVercelEnv } from '../common/getVercelEnv'; +import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL, TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, @@ -169,7 +170,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. - if (typeof spanAttributes?.['next.route'] === 'string') { + if (typeof spanAttributes?.[ATTR_NEXT_ROUTE] === 'string') { const rootSpanAttributes = spanToJSON(rootSpan).data; // Only hoist the http.route attribute if the transaction doesn't already have it if ( @@ -177,17 +178,27 @@ export function init(options: NodeOptions): NodeClient | undefined { (rootSpanAttributes?.[ATTR_HTTP_REQUEST_METHOD] || rootSpanAttributes?.[SEMATTRS_HTTP_METHOD]) && !rootSpanAttributes?.[ATTR_HTTP_ROUTE] ) { - const route = spanAttributes['next.route'].replace(/\/route$/, ''); + const route = spanAttributes[ATTR_NEXT_ROUTE].replace(/\/route$/, ''); rootSpan.updateName(route); rootSpan.setAttribute(ATTR_HTTP_ROUTE, route); // Preserving the original attribute despite internally not depending on it - rootSpan.setAttribute('next.route', route); + rootSpan.setAttribute(ATTR_NEXT_ROUTE, route); } } + if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute') { + const middlewareName = spanAttributes[ATTR_NEXT_SPAN_NAME]; + if (typeof middlewareName === 'string') { + rootSpan.updateName(middlewareName); + rootSpan.setAttribute(ATTR_HTTP_ROUTE, middlewareName); + rootSpan.setAttribute(ATTR_NEXT_SPAN_NAME, middlewareName); + } + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); + } + // We want to skip span data inference for any spans generated by Next.js. Reason being that Next.js emits spans // with patterns (e.g. http.server spans) that will produce confusing data. - if (spanAttributes?.['next.span_type'] !== undefined) { + if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] !== undefined) { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); } @@ -197,7 +208,7 @@ export function init(options: NodeOptions): NodeClient | undefined { } // We want to fork the isolation scope for incoming requests - if (spanAttributes?.['next.span_type'] === 'BaseServer.handleRequest' && isRootSpan) { + if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' && isRootSpan) { const scopes = getCapturedScopesOnSpan(span); const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); @@ -320,7 +331,7 @@ export function init(options: NodeOptions): NodeClient | undefined { // Enhance route handler transactions if ( event.type === 'transaction' && - event.contexts?.trace?.data?.['next.span_type'] === 'BaseServer.handleRequest' + event.contexts?.trace?.data?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest' ) { event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; event.contexts.trace.op = 'http.server'; @@ -333,14 +344,15 @@ export function init(options: NodeOptions): NodeClient | undefined { const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD]; // eslint-disable-next-line deprecation/deprecation const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET]; - const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data['next.route']; + const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data[ATTR_NEXT_ROUTE]; + const spanName = event.contexts.trace.data[ATTR_NEXT_SPAN_NAME]; - if (typeof method === 'string' && typeof route === 'string') { + if (typeof method === 'string' && typeof route === 'string' && !route.startsWith('middleware')) { const cleanRoute = route.replace(/\/route$/, ''); event.transaction = `${method} ${cleanRoute}`; event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route'; // Preserve next.route in case it did not get hoisted - event.contexts.trace.data['next.route'] = cleanRoute; + event.contexts.trace.data[ATTR_NEXT_ROUTE] = cleanRoute; } // backfill transaction name for pages that would otherwise contain unparameterized routes @@ -348,6 +360,15 @@ export function init(options: NodeOptions): NodeClient | undefined { event.transaction = `${method} ${event.contexts.trace.data[TRANSACTION_ATTR_SENTRY_ROUTE_BACKFILL]}`; } + const middlewareMatch = + typeof spanName === 'string' && spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + + if (middlewareMatch) { + const normalizedName = `middleware ${middlewareMatch[1]}`; + event.transaction = normalizedName; + event.contexts.trace.op = 'http.server.middleware'; + } + // Next.js overrides transaction names for page loads that throw an error // but we want to keep the original target name if (event.transaction === 'GET /_error' && target) { From 43b383c63d79147b0ca8c0109c6b1c4abf64484c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 23 Oct 2025 13:53:48 +0200 Subject: [PATCH 056/190] feat(firebase): Instrument cloud functions for firebase v2 (#17952) Closes: #17861 This adds - instrumentation of Cloud Functions for Firebase (v2) along side the Firestore integration. It can be used with the `Sentry.firebaseIntegration()` (this is atm not documented in the docs and got added in #16719, but will be added right after this has been merged. See https://github.com/getsentry/sentry-docs/issues/15247). - The test app for Firebase has been rewritten and updated since it requires a little special setup.
Supported functions
  • onRequest
  • onCall
  • onDocumentCreated
  • onDocumentUpdated
  • onDocumentDeleted
  • onDocumentWritten
  • onDocumentCreatedWithAuthContext
  • onDocumentUpdatedWithAuthContext
  • onDocumentDeletedWithAuthContext
  • onDocumentWrittenWithAuthContext
  • onSchedule
  • onObjectFinalized
  • onObjectArchived
  • onObjectDeleted
  • onObjectMetadataUpdated
Bear in mind that the OTel attributes for FaaS are still in [Development](https://opentelemetry.io/docs/specs/semconv/faas/faas-spans/) and could change or be removed over time (not sure if we should then even add them in here at this point in time). --- .size-limit.js | 2 +- .../node-firebase/.firebaserc | 5 - .../test-applications/node-firebase/README.md | 80 +++-- .../node-firebase/firebase.json | 9 +- .../node-firebase/firestore-app/package.json | 20 ++ .../{ => firestore-app}/src/app.ts | 0 .../{ => firestore-app}/src/init.ts | 0 .../node-firebase/firestore-app/tsconfig.json | 8 + .../node-firebase/functions/package.json | 19 ++ .../node-firebase/functions/src/index.ts | 50 ++++ .../node-firebase/functions/src/init.ts | 10 + .../node-firebase/functions/tsconfig.json | 8 + .../node-firebase/package.json | 26 +- .../node-firebase/pnpm-workspace.yaml | 3 + .../node-firebase/tests/functions.test.ts | 150 ++++++++++ .../node-firebase/tsconfig.build.json | 4 - .../node-firebase/tsconfig.json | 6 +- .../integrations/tracing/firebase/firebase.ts | 20 +- .../firebase/otel/firebaseInstrumentation.ts | 3 + .../firebase/otel/patches/functions.ts | 276 ++++++++++++++++++ .../tracing/firebase/otel/types.ts | 48 +++ 21 files changed, 667 insertions(+), 80 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json rename dev-packages/e2e-tests/test-applications/node-firebase/{ => firestore-app}/src/app.ts (100%) rename dev-packages/e2e-tests/test-applications/node-firebase/{ => firestore-app}/src/init.ts (100%) create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml create mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json create mode 100644 packages/node/src/integrations/tracing/firebase/otel/patches/functions.ts diff --git a/.size-limit.js b/.size-limit.js index 269ce49b1cc1..7106f2e29b03 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -240,7 +240,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '157 KB', + limit: '158 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc b/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc deleted file mode 100644 index 47e4665f6905..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-firebase/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "sentry-firebase-e2e-test-f4ed3" - } -} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/README.md b/dev-packages/e2e-tests/test-applications/node-firebase/README.md index e44ee12f5268..bd91bd5a872a 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/README.md +++ b/dev-packages/e2e-tests/test-applications/node-firebase/README.md @@ -1,64 +1,50 @@ -## Assuming you already have installed docker desktop or orbstack etc. or any other docker software +
+ + Firebase + +
-### Enabling / authorising firebase emulator through docker +## Description -1. Run the docker +[Firebase](https://firebase.google.com/) starter repository with Cloud Functions for Firebase and Firestore. -```bash -pnpm docker -``` - -2. In new tab, enter the docker container by simply running +## Project setup -```bash -docker exec -it sentry-firebase bash +```sh +$ pnpm install ``` -3. Now inside docker container run +## Compile and run the project -```bash -firebase login +```sh +$ pnpm dev # builds the functions and firestore app +$ pnpm emulate +$ pnpm start # run the firestore app ``` -4. You should now see a long link to authenticate with google account, copy the link and open it using your browser -5. Choose the account you want to authenticate with -6. Once you do this you should be able to see something like "Firebase CLI Login Successful" -7. And inside docker container you should see something like "Success! Logged in as " -8. Now you can exit docker container - -```bash -exit -``` +## Run tests -9. Switch back to previous tab, stop the docker container (ctrl+c). -10. You should now be able to run the test, as you have correctly authenticated the firebase emulator +Either run the tests directly: -### Preparing data for CLI - -1. Please authorize the docker first - see the previous section -2. Once you do that you can generate .env file locally, to do that just run - -```bash -npm run createEnvFromConfig +```sh +$ pnpm test:build +$ pnpm test:assert ``` -3. It will create a new file called ".env" inside folder "docker" -4. View the file. There will be 2 params CONFIG_FIREBASE_TOOLS and CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS. -5. Now inside the CLI create a new variable under the name CONFIG_FIREBASE_TOOLS and - CONFIG_UPDATE_NOTIFIER_FIREBASE_TOOLS - take values from mentioned .env file -6. File .env is ignored to avoid situation when developer after authorizing firebase with private account will - accidently push the tokens to github. -7. But if we want the users to still have some default to be used for authorisation (on their local development) it will - be enough to commit this file, we just have to authorize it with some "special" account. +Or run develop while running the tests directly against the emulator. Start each script in a separate terminal: -**Some explanation towards environment settings, the environment variable defined directly in "environments" takes -precedence over .env file, that means it will be safe to define it in CLI and still keeps the .env file.** +```sh +$ pnpm dev +$ pnpm emulate +$ pnpm test --ui +``` -### Scripts - helpers +The tests will run against the Firebase Emulator Suite. -- createEnvFromConfig - it will use the firebase docker authentication and create .env file which will be used then by - docker whenever you run emulator -- createConfigFromEnv - it will use '.env' file in docker folder to create .config for the firebase to be used to - authenticate whenever you run docker, Docker by default loads .env file itself +## Resources -Use these scripts when testing and updating the environment settings on CLI +- [Firebase](https://firebase.google.com/) +- [Firebase Emulator Suite](https://firebase.google.com/docs/emulator-suite) +- [Firebase SDK](https://firebase.google.com/docs/sdk) +- [Firebase Functions](https://firebase.google.com/docs/functions) +- [Firestore](https://firebase.google.com/docs/firestore) diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json index 05203f1d6567..eb1b42b8aa9c 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firebase.json @@ -16,5 +16,12 @@ "enabled": true }, "singleProjectMode": true - } + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log", "*.local"] + } + ] } diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json new file mode 100644 index 000000000000..b5d19993bdae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/package.json @@ -0,0 +1,20 @@ +{ + "name": "firestore-app", + "private": true, + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch", + "start": "node ./dist/app.js" + }, + "dependencies": { + "@firebase/app": "^0.13.1", + "@sentry/node": "latest || *", + "express": "^4.18.2", + "firebase": "^12.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.13", + "@types/node": "^22.13.14", + "typescript": "5.9.3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/app.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/node-firebase/src/app.ts rename to dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/app.ts diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/node-firebase/src/init.ts rename to dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/src/init.ts diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json new file mode 100644 index 000000000000..ee180965030d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore-app/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json new file mode 100644 index 000000000000..c3be318b8c38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/package.json @@ -0,0 +1,19 @@ +{ + "name": "functions", + "scripts": { + "build": "tsc", + "dev": "tsc --build --watch" + }, + "engines": { + "node": "20" + }, + "main": "dist/index.js", + "dependencies": { + "firebase-admin": "^12.6.0", + "firebase-functions": "^6.0.1", + "@sentry/node": "latest || *" + }, + "devDependencies": { + "typescript": "5.9.3" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts new file mode 100644 index 000000000000..6a3df6f4a61a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/index.ts @@ -0,0 +1,50 @@ +import './init'; + +import { onDocumentCreated, onDocumentCreatedWithAuthContext } from 'firebase-functions/firestore'; +import { onRequest } from 'firebase-functions/https'; +import * as logger from 'firebase-functions/logger'; +import { setGlobalOptions } from 'firebase-functions/options'; +import * as admin from 'firebase-admin'; + +setGlobalOptions({ region: 'default' }); + +admin.initializeApp(); + +const db = admin.firestore(); + +export const helloWorld = onRequest(async (request, response) => { + logger.info('Hello logs!', { structuredData: true }); + + response.send('Hello from Firebase!'); +}); + +export const unhandeledError = onRequest(async (request, response) => { + throw new Error('There is an error!'); +}); + +export const onCallSomething = onRequest(async (request, response) => { + const data = { + name: request.body?.name || 'Sample Document', + timestamp: performance.now(), + description: request.body?.description || 'Created via Cloud Function', + }; + + await db.collection('documents').add(data); + + logger.info('Create document!', { structuredData: true }); + + response.send({ message: 'Document created!' }); +}); + +export const onDocumentCreate = onDocumentCreated('documents/{documentId}', async event => { + const documentId = event.params.documentId; + + await db.collection('documents').doc(documentId).update({ + processed: true, + processedAt: new Date(), + }); +}); + +export const onDocumentCreateWithAuthContext = onDocumentCreatedWithAuthContext('documents/{documentId}', async () => { + // noop +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts new file mode 100644 index 000000000000..c3b4a642375a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/src/init.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + integrations: [Sentry.firebaseIntegration()], + defaultIntegrations: false, + tunnel: `http://localhost:3031/`, // proxy server +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json new file mode 100644 index 000000000000..ee180965030d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/functions/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/package.json b/dev-packages/e2e-tests/test-applications/node-firebase/package.json index 0a23fbbeef92..41eb0ce085d4 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/package.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/package.json @@ -3,34 +3,26 @@ "version": "0.0.1", "private": true, "scripts": { - "build": "tsc", - "dev": "tsc --build --watch", + "build": "pnpm run -r build", + "dev": "pnpm run -r dev", "proxy": "node start-event-proxy.mjs", - "emulate": "firebase emulators:start &", - "start": "node ./dist/app.js", + "emulate": "firebase emulators:start --project demo-functions", + "start": "pnpm run -r start", "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", + "clean": "npx rimraf node_modules **/node_modules pnpm-lock.yaml **/dist *-debug.log test-results", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm firebase emulators:exec 'pnpm test'" + "test:assert": "pnpm firebase emulators:exec --project demo-functions 'pnpm test'" }, "dependencies": { - "@firebase/app": "^0.13.1", - "@sentry/node": "latest || *", - "@sentry/core": "latest || *", - "@sentry/opentelemetry": "latest || *", - "@types/node": "^18.19.1", + "@types/node": "^22.13.14", "dotenv": "^16.4.5", - "express": "^4.18.2", - "firebase": "^12.0.0", - "firebase-admin": "^12.0.0", "tsconfig-paths": "^4.2.0", - "typescript": "4.9.5" + "typescript": "5.9.3" }, "devDependencies": { "@playwright/test": "~1.53.2", "@sentry-internal/test-utils": "link:../../../test-utils", - "@types/express": "^4.17.13", - "firebase-tools": "^12.0.0" + "firebase-tools": "^14.20.0" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml new file mode 100644 index 000000000000..8a5eb172e019 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'functions' + - 'firestore-app' diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts new file mode 100644 index 000000000000..2600b8bc1ec5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tests/functions.test.ts @@ -0,0 +1,150 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('should only call the function once without any extra calls', async () => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'firebase.function.http.request'; + }); + + await fetch(`http://localhost:5001/demo-functions/default/helloWorld`); + + const transactionEvent = await serverTransactionPromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts).toEqual( + expect.objectContaining({ + trace: expect.objectContaining({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'helloWorld', + 'faas.provider': 'firebase', + 'faas.trigger': 'http.request', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.request', + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: 'http.request', + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }), + }), + ); +}); + +test('should send failed transaction when the function fails', async () => { + const errorEventPromise = waitForError('node-firebase', () => true); + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return !!span.transaction; + }); + + await fetch(`http://localhost:5001/demo-functions/default/unhandeledError`); + + const transactionEvent = await serverTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts?.trace?.trace_id).toEqual(errorEvent.contexts?.trace?.trace_id); + expect(errorEvent).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'There is an error!', + mechanism: { + type: 'auto.firebase.otel.functions', + handled: false, + }, + }, + ], + }, + }); +}); + +test('should create a document and trigger onDocumentCreated and another with authContext', async () => { + const serverTransactionPromise = waitForTransaction('node-firebase', span => { + return span.transaction === 'firebase.function.http.request'; + }); + + const serverTransactionOnDocumentCreatePromise = waitForTransaction('node-firebase', span => { + return ( + span.transaction === 'firebase.function.firestore.document.created' && + span.contexts?.trace?.data?.['faas.name'] === 'onDocumentCreate' + ); + }); + + const serverTransactionOnDocumentWithAuthContextCreatePromise = waitForTransaction('node-firebase', span => { + return ( + span.transaction === 'firebase.function.firestore.document.created' && + span.contexts?.trace?.data?.['faas.name'] === 'onDocumentCreateWithAuthContext' + ); + }); + + await fetch(`http://localhost:5001/demo-functions/default/onCallSomething`); + + const transactionEvent = await serverTransactionPromise; + const transactionEventOnDocumentCreate = await serverTransactionOnDocumentCreatePromise; + const transactionEventOnDocumentWithAuthContextCreate = await serverTransactionOnDocumentWithAuthContextCreatePromise; + + expect(transactionEvent.transaction).toEqual('firebase.function.http.request'); + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onCallSomething', + 'faas.provider': 'firebase', + 'faas.trigger': 'http.request', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.request', + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: 'http.request', + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEvent.spans).toHaveLength(3); + expect(transactionEventOnDocumentCreate.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onDocumentCreate', + 'faas.provider': 'firebase', + 'faas.trigger': 'firestore.document.created', + 'otel.kind': 'SERVER', + 'sentry.op': expect.any(String), + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: expect.any(String), + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEventOnDocumentCreate.spans).toHaveLength(2); + expect(transactionEventOnDocumentWithAuthContextCreate.contexts?.trace).toEqual({ + data: { + 'cloud.project_id': 'demo-functions', + 'faas.name': 'onDocumentCreateWithAuthContext', + 'faas.provider': 'firebase', + 'faas.trigger': 'firestore.document.created', + 'otel.kind': 'SERVER', + 'sentry.op': expect.any(String), + 'sentry.origin': 'auto.firebase.otel.functions', + 'sentry.sample_rate': expect.any(Number), + 'sentry.source': 'route', + }, + op: expect.any(String), + origin: 'auto.firebase.otel.functions', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + }); + expect(transactionEventOnDocumentWithAuthContextCreate.spans).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json deleted file mode 100644 index 26c30d4eddf2..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist"] -} diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json index 8cb64e989ed9..881847032511 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/node-firebase/tsconfig.json @@ -3,8 +3,6 @@ "types": ["node"], "esModuleInterop": true, "lib": ["es2018"], - "strict": true, - "outDir": "dist" - }, - "include": ["src/**/*.ts"] + "strict": true + } } diff --git a/packages/node/src/integrations/tracing/firebase/firebase.ts b/packages/node/src/integrations/tracing/firebase/firebase.ts index 649a7089289b..ceb521d54fa3 100644 --- a/packages/node/src/integrations/tracing/firebase/firebase.ts +++ b/packages/node/src/integrations/tracing/firebase/firebase.ts @@ -1,5 +1,5 @@ import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; +import { captureException, defineIntegration, flush, SEMANTIC_ATTRIBUTE_SENTRY_OP } from '@sentry/core'; import { addOriginToSpan, generateInstrumentOnce } from '@sentry/node-core'; import { type FirebaseInstrumentationConfig, FirebaseInstrumentation } from './otel'; @@ -11,6 +11,24 @@ const config: FirebaseInstrumentationConfig = { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'db.query'); }, + functions: { + requestHook: span => { + addOriginToSpan(span, 'auto.firebase.otel.functions'); + + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.request'); + }, + errorHook: async (_, error) => { + if (error) { + captureException(error, { + mechanism: { + type: 'auto.firebase.otel.functions', + handled: false, + }, + }); + await flush(2000); + } + }, + }, }; export const instrumentFirebase = generateInstrumentOnce(INTEGRATION_NAME, () => new FirebaseInstrumentation(config)); diff --git a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts index ad67ea701079..724005e6f9ed 100644 --- a/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts +++ b/packages/node/src/integrations/tracing/firebase/otel/firebaseInstrumentation.ts @@ -1,10 +1,12 @@ import { type InstrumentationNodeModuleDefinition, InstrumentationBase } from '@opentelemetry/instrumentation'; import { SDK_VERSION } from '@sentry/core'; import { patchFirestore } from './patches/firestore'; +import { patchFunctions } from './patches/functions'; import type { FirebaseInstrumentationConfig } from './types'; const DefaultFirebaseInstrumentationConfig: FirebaseInstrumentationConfig = {}; const firestoreSupportedVersions = ['>=3.0.0 <5']; // firebase 9+ +const functionsSupportedVersions = ['>=6.0.0 <7']; // firebase-functions v2 /** * Instrumentation for Firebase services, specifically Firestore. @@ -31,6 +33,7 @@ export class FirebaseInstrumentation extends InstrumentationBase {}; + let responseHook: ResponseHook = () => {}; + const errorHook = config.functions?.errorHook; + const configRequestHook = config.functions?.requestHook; + const configResponseHook = config.functions?.responseHook; + + if (typeof configResponseHook === 'function') { + responseHook = (span: Span, err: unknown) => { + safeExecuteInTheMiddle( + () => configResponseHook(span, err), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + if (typeof configRequestHook === 'function') { + requestHook = (span: Span) => { + safeExecuteInTheMiddle( + () => configRequestHook(span), + error => { + if (!error) { + return; + } + diag.error(error?.message); + }, + true, + ); + }; + } + + const moduleFunctionsCJS = new InstrumentationNodeModuleDefinition('firebase-functions', functionsSupportedVersions); + const modulesToInstrument = [ + { name: 'firebase-functions/lib/v2/providers/https.js', triggerType: 'function' }, + { name: 'firebase-functions/lib/v2/providers/firestore.js', triggerType: 'firestore' }, + { name: 'firebase-functions/lib/v2/providers/scheduler.js', triggerType: 'scheduler' }, + { name: 'firebase-functions/lib/v2/storage.js', triggerType: 'storage' }, + ] as const; + + modulesToInstrument.forEach(({ name, triggerType }) => { + moduleFunctionsCJS.files.push( + new InstrumentationNodeModuleFile( + name, + functionsSupportedVersions, + moduleExports => + wrapCommonFunctions( + moduleExports, + wrap, + unwrap, + tracer, + { requestHook, responseHook, errorHook }, + triggerType, + ), + moduleExports => unwrapCommonFunctions(moduleExports, unwrap), + ), + ); + }); + + return moduleFunctionsCJS; +} + +/** + * Patches Cloud Functions for Firebase (v2) to add OpenTelemetry instrumentation + * + * @param tracer - Opentelemetry Tracer + * @param functionsConfig - Firebase instrumentation config + * @param triggerType - Type of trigger + * @returns A function that patches the function + */ +export function patchV2Functions( + tracer: Tracer, + functionsConfig: FirebaseInstrumentationConfig['functions'], + triggerType: string, +): (original: T) => (...args: OverloadedParameters) => ReturnType { + return function v2FunctionsWrapper(original: T): (...args: OverloadedParameters) => ReturnType { + return function (this: FirebaseInstrumentation, ...args: OverloadedParameters): ReturnType { + const handler = typeof args[0] === 'function' ? args[0] : args[1]; + const documentOrOptions = typeof args[0] === 'function' ? undefined : args[0]; + + if (!handler) { + return original.call(this, ...args); + } + + const wrappedHandler = async function (this: unknown, ...handlerArgs: unknown[]): Promise { + const functionName = process.env.FUNCTION_TARGET || process.env.K_SERVICE || 'unknown'; + const span = tracer.startSpan(`firebase.function.${triggerType}`, { + kind: SpanKind.SERVER, + }); + + const attributes: SpanAttributes = { + 'faas.name': functionName, + 'faas.trigger': triggerType, + 'faas.provider': 'firebase', + }; + + if (process.env.GCLOUD_PROJECT) { + attributes['cloud.project_id'] = process.env.GCLOUD_PROJECT; + } + + if (process.env.EVENTARC_CLOUD_EVENT_SOURCE) { + attributes['cloud.event_source'] = process.env.EVENTARC_CLOUD_EVENT_SOURCE; + } + + span.setAttributes(attributes); + functionsConfig?.requestHook?.(span); + + // Can be changed to safeExecuteInTheMiddleAsync once following is merged and released + // https://github.com/open-telemetry/opentelemetry-js/pull/6032 + return context.with(trace.setSpan(context.active(), span), async () => { + let error: Error | undefined; + let result: T | undefined; + + try { + result = await handler.apply(this, handlerArgs); + } catch (e) { + error = e as Error; + } + + functionsConfig?.responseHook?.(span, error); + + if (error) { + span.recordException(error); + } + + span.end(); + + if (error) { + await functionsConfig?.errorHook?.(span, error); + throw error; + } + + return result; + }); + }; + + if (documentOrOptions) { + return original.call(this, documentOrOptions, wrappedHandler); + } else { + return original.call(this, wrappedHandler); + } + }; + }; +} + +function wrapCommonFunctions( + moduleExports: AvailableFirebaseFunctions, + wrap: InstrumentationBase['_wrap'], + unwrap: InstrumentationBase['_unwrap'], + tracer: Tracer, + functionsConfig: FirebaseInstrumentationConfig['functions'], + triggerType: 'function' | 'firestore' | 'scheduler' | 'storage', +): AvailableFirebaseFunctions { + unwrapCommonFunctions(moduleExports, unwrap); + + switch (triggerType) { + case 'function': + wrap(moduleExports, 'onRequest', patchV2Functions(tracer, functionsConfig, 'http.request')); + wrap(moduleExports, 'onCall', patchV2Functions(tracer, functionsConfig, 'http.call')); + break; + + case 'firestore': + wrap(moduleExports, 'onDocumentCreated', patchV2Functions(tracer, functionsConfig, 'firestore.document.created')); + wrap(moduleExports, 'onDocumentUpdated', patchV2Functions(tracer, functionsConfig, 'firestore.document.updated')); + wrap(moduleExports, 'onDocumentDeleted', patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted')); + wrap(moduleExports, 'onDocumentWritten', patchV2Functions(tracer, functionsConfig, 'firestore.document.written')); + wrap( + moduleExports, + 'onDocumentCreatedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.created'), + ); + wrap( + moduleExports, + 'onDocumentUpdatedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.updated'), + ); + + wrap( + moduleExports, + 'onDocumentDeletedWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.deleted'), + ); + + wrap( + moduleExports, + 'onDocumentWrittenWithAuthContext', + patchV2Functions(tracer, functionsConfig, 'firestore.document.written'), + ); + break; + + case 'scheduler': + wrap(moduleExports, 'onSchedule', patchV2Functions(tracer, functionsConfig, 'scheduler.scheduled')); + break; + + case 'storage': + wrap(moduleExports, 'onObjectFinalized', patchV2Functions(tracer, functionsConfig, 'storage.object.finalized')); + wrap(moduleExports, 'onObjectArchived', patchV2Functions(tracer, functionsConfig, 'storage.object.archived')); + wrap(moduleExports, 'onObjectDeleted', patchV2Functions(tracer, functionsConfig, 'storage.object.deleted')); + wrap( + moduleExports, + 'onObjectMetadataUpdated', + patchV2Functions(tracer, functionsConfig, 'storage.object.metadataUpdated'), + ); + break; + } + + return moduleExports; +} + +function unwrapCommonFunctions( + moduleExports: AvailableFirebaseFunctions, + unwrap: InstrumentationBase['_unwrap'], +): AvailableFirebaseFunctions { + const methods: (keyof AvailableFirebaseFunctions)[] = [ + 'onSchedule', + 'onRequest', + 'onCall', + 'onObjectFinalized', + 'onObjectArchived', + 'onObjectDeleted', + 'onObjectMetadataUpdated', + 'onDocumentCreated', + 'onDocumentUpdated', + 'onDocumentDeleted', + 'onDocumentWritten', + 'onDocumentCreatedWithAuthContext', + 'onDocumentUpdatedWithAuthContext', + 'onDocumentDeletedWithAuthContext', + 'onDocumentWrittenWithAuthContext', + ]; + + for (const method of methods) { + if (isWrapped(moduleExports[method])) { + unwrap(moduleExports, method); + } + } + return moduleExports; +} diff --git a/packages/node/src/integrations/tracing/firebase/otel/types.ts b/packages/node/src/integrations/tracing/firebase/otel/types.ts index ecc48bc09498..ead830fa2c1a 100644 --- a/packages/node/src/integrations/tracing/firebase/otel/types.ts +++ b/packages/node/src/integrations/tracing/firebase/otel/types.ts @@ -88,8 +88,19 @@ export interface FirestoreSettings { */ export interface FirebaseInstrumentationConfig extends InstrumentationConfig { firestoreSpanCreationHook?: FirestoreSpanCreationHook; + functions?: FunctionsConfig; } +export interface FunctionsConfig { + requestHook?: RequestHook; + responseHook?: ResponseHook; + errorHook?: ErrorHook; +} + +export type RequestHook = (span: Span) => void; +export type ResponseHook = (span: Span, error?: unknown) => void; +export type ErrorHook = (span: Span, error?: unknown) => Promise | void; + export interface FirestoreSpanCreationHook { (span: Span): void; } @@ -117,3 +128,40 @@ export type AddDocType = ( export type DeleteDocType = ( reference: DocumentReference, ) => Promise; + +export type OverloadedParameters = T extends { + (...args: infer A1): unknown; + (...args: infer A2): unknown; +} + ? A1 | A2 + : T extends (...args: infer A) => unknown + ? A + : unknown; + +/** + * A bare minimum of how Cloud Functions for Firebase (v2) are defined. + */ +export type FirebaseFunctions = + | ((handler: () => Promise | unknown) => (...args: unknown[]) => Promise | unknown) + | (( + documentOrOptions: string | string[] | Record, + handler: () => Promise | unknown, + ) => (...args: unknown[]) => Promise | unknown); + +export type AvailableFirebaseFunctions = { + onRequest: FirebaseFunctions; + onCall: FirebaseFunctions; + onDocumentCreated: FirebaseFunctions; + onDocumentUpdated: FirebaseFunctions; + onDocumentDeleted: FirebaseFunctions; + onDocumentWritten: FirebaseFunctions; + onDocumentCreatedWithAuthContext: FirebaseFunctions; + onDocumentUpdatedWithAuthContext: FirebaseFunctions; + onDocumentDeletedWithAuthContext: FirebaseFunctions; + onDocumentWrittenWithAuthContext: FirebaseFunctions; + onSchedule: FirebaseFunctions; + onObjectFinalized: FirebaseFunctions; + onObjectArchived: FirebaseFunctions; + onObjectDeleted: FirebaseFunctions; + onObjectMetadataUpdated: FirebaseFunctions; +}; From 027ab90eb7a92c2a4f155dbccf6038a4163966fd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 23 Oct 2025 14:33:47 +0200 Subject: [PATCH 057/190] fix(nextjs): Remove usage of chalk to avoid runtime errors (#18010) This is currently a bandaid fix to remove the `chalk` import statements that break turbopack apps during runtime until we have more details how this code gets bundled into production. ref https://github.com/getsentry/sentry-javascript/pull/17806 closes https://github.com/getsentry/sentry-javascript/issues/17691 --- packages/nextjs/package.json | 1 - .../src/config/loaders/wrappingLoader.ts | 5 +--- packages/nextjs/src/config/webpack.ts | 25 ++++--------------- yarn.lock | 16 ++++++------ 4 files changed, 14 insertions(+), 33 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 40924e8abd31..26b3a172090a 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -87,7 +87,6 @@ "@sentry/react": "10.21.0", "@sentry/vercel-edge": "10.21.0", "@sentry/webpack-plugin": "^4.3.0", - "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index c60563ccd241..3125102e9656 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -1,6 +1,5 @@ import commonjs from '@rollup/plugin-commonjs'; import { stringMatchesSomePattern } from '@sentry/core'; -import * as chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; import type { RollupBuild, RollupError } from 'rollup'; @@ -165,9 +164,7 @@ export default function wrappingLoader( if (!showedMissingAsyncStorageModuleWarning) { // eslint-disable-next-line no-console console.warn( - `${chalk.yellow('warn')} - The Sentry SDK could not access the ${chalk.bold.cyan( - 'RequestAsyncStorage', - )} module. Certain features may not work. There is nothing you can do to fix this yourself, but future SDK updates may resolve this.\n`, + "[@sentry/nextjs] The Sentry SDK could not access the 'RequestAsyncStorage' module. Certain features may not work. There is nothing you can do to fix this yourself, but future SDK updates may resolve this.", ); showedMissingAsyncStorageModuleWarning = true; } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 14f064ae2b0a..4484b1194bd2 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -2,7 +2,6 @@ /* eslint-disable max-lines */ import { debug, escapeStringForRegex, loadModule, parseSemver } from '@sentry/core'; -import * as chalk from 'chalk'; import * as fs from 'fs'; import * as path from 'path'; import { sync as resolveSync } from 'resolve'; @@ -245,11 +244,7 @@ export function constructWebpackConfigFunction({ vercelCronsConfig = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'vercel.json'), 'utf8')).crons; if (vercelCronsConfig) { debug.log( - `${chalk.cyan( - 'info', - )} - Creating Sentry cron monitors for your Vercel Cron Jobs. You can disable this feature by setting the ${chalk.bold.cyan( - 'automaticVercelMonitors', - )} option to false in you Next.js config.`, + "[@sentry/nextjs] Creating Sentry cron monitors for your Vercel Cron Jobs. You can disable this feature by setting the 'automaticVercelMonitors' option to false in you Next.js config.", ); } } @@ -259,9 +254,7 @@ export function constructWebpackConfigFunction({ } else { // log but noop debug.error( - `${chalk.red( - 'error', - )} - Sentry failed to read vercel.json for automatic cron job monitoring instrumentation`, + '[@sentry/nextjs] Failed to read vercel.json for automatic cron job monitoring instrumentation', e, ); } @@ -344,11 +337,7 @@ export function constructWebpackConfigFunction({ ) { // eslint-disable-next-line no-console console.log( - `${chalk.yellow( - 'warn', - )} - It seems like you don't have a global error handler set up. It is recommended that you add a ${chalk.cyan( - 'global-error.js', - )} file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)`, + "[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)", ); showedMissingGlobalErrorWarningMsg = true; } @@ -541,9 +530,7 @@ function warnAboutMissingOnRequestErrorHandler(instrumentationFile: string | nul if (!process.env.SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING) { // eslint-disable-next-line no-console console.warn( - chalk.yellow( - '[@sentry/nextjs] Could not find a Next.js instrumentation file. This indicates an incomplete configuration of the Sentry SDK. An instrumentation file is required for the Sentry SDK to be initialized on the server: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#create-initialization-config-files (you can suppress this warning by setting SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING=1 as environment variable)', - ), + '[@sentry/nextjs] Could not find a Next.js instrumentation file. This indicates an incomplete configuration of the Sentry SDK. An instrumentation file is required for the Sentry SDK to be initialized on the server: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#create-initialization-config-files (you can suppress this warning by setting SENTRY_SUPPRESS_INSTRUMENTATION_FILE_WARNING=1 as environment variable)', ); } return; @@ -552,9 +539,7 @@ function warnAboutMissingOnRequestErrorHandler(instrumentationFile: string | nul if (!instrumentationFile.includes('onRequestError')) { // eslint-disable-next-line no-console console.warn( - chalk.yellow( - '[@sentry/nextjs] Could not find `onRequestError` hook in instrumentation file. This indicates outdated configuration of the Sentry SDK. Use `Sentry.captureRequestError` to instrument the `onRequestError` hook: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#errors-from-nested-react-server-components', - ), + '[@sentry/nextjs] Could not find `onRequestError` hook in instrumentation file. This indicates outdated configuration of the Sentry SDK. Use `Sentry.captureRequestError` to instrument the `onRequestError` hook: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#errors-from-nested-react-server-components', ); } } diff --git a/yarn.lock b/yarn.lock index 7a38d3f22cd3..06f8d3741128 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12615,14 +12615,6 @@ chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4. escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@3.0.0, chalk@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" - integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - chalk@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" @@ -12642,6 +12634,14 @@ chalk@^1.0.0: strip-ansi "^3.0.0" supports-color "^2.0.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" From 7968cd85e321d743a6807459f4deedbfde5867b4 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:37:15 +0200 Subject: [PATCH 058/190] fix(react): Don't trim index route `/` when getting pathname (#17985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, the test in this PR fail: https://github.com/getsentry/sentry-javascript/pull/17789 Root routes can yield an empty transaction name, causing `` instead of `/` as the transaction name for the root. This happens when the router includes children routes with `index: true`. The route matching is also depending on the `allRoutes` Set. The `allRoutes` Set include the children routes twice (once as children of the route and once as a root element of the Set). When only including them once, it works but parametrization does not work anymore. --> First I thought, this duplication is the cause but probably it isn't ## What’s broken Root cause is in `sendIndexPath(...)`: - Mis-parenthesized ternary picks `stripBasenameFromPathname` instead of `pathBuilder`. - Trimming turns `/` into an empty string. --- .../react-router-7-cross-usage/src/index.tsx | 8 + .../instrumentation.tsx | 6 +- .../src/reactrouter-compat-utils/utils.ts | 28 ++- .../instrumentation.test.tsx | 192 ++++++++++++++++++ .../reactrouter-compat-utils/utils.test.ts | 2 +- 5 files changed, 222 insertions(+), 14 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx index bfcc527ded1b..089b27ab974a 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-cross-usage/src/index.tsx @@ -89,6 +89,14 @@ const ProjectsRoutes = () => ( ); const router = sentryCreateBrowserRouter([ + { + path: '/post/:post', + element:
Post
, + children: [ + { index: true, element:
Post Index
}, + { path: '/post/:post/related', element:
Related Posts
}, + ], + }, { children: [ { diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index bf57fdbd74dc..a72c4fd05378 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -106,7 +106,8 @@ export interface ReactRouterOptions { type V6CompatibleVersion = '6' | '7'; // Keeping as a global variable for cross-usage in multiple functions -const allRoutes = new Set(); +// only exported for testing purposes +export const allRoutes = new Set(); /** * Processes resolved routes by adding them to allRoutes and checking for nested async handlers. @@ -679,7 +680,8 @@ export function handleNavigation(opts: { } } -function addRoutesToAllRoutes(routes: RouteObject[]): void { +/* Only exported for testing purposes */ +export function addRoutesToAllRoutes(routes: RouteObject[]): void { routes.forEach(route => { const extractedChildRoutes = getChildRoutesRecursively(route); diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index c0750c17c57c..d6501d0e4dbf 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -45,21 +45,27 @@ export function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch within ) */ +export function routeIsDescendant(route: RouteObject): boolean { return !!(!route.children && route.element && route.path?.endsWith('/*')); } function sendIndexPath(pathBuilder: string, pathname: string, basename: string): [string, TransactionSource] { - const reconstructedPath = pathBuilder || _stripBasename ? stripBasenameFromPathname(pathname, basename) : pathname; - - const formattedPath = - // If the path ends with a slash, remove it - reconstructedPath[reconstructedPath.length - 1] === '/' - ? reconstructedPath.slice(0, -1) - : // If the path ends with a wildcard, remove it - reconstructedPath.slice(-2) === '/*' - ? reconstructedPath.slice(0, -1) - : reconstructedPath; + const reconstructedPath = + pathBuilder && pathBuilder.length > 0 + ? pathBuilder + : _stripBasename + ? stripBasenameFromPathname(pathname, basename) + : pathname; + + let formattedPath = + // If the path ends with a wildcard suffix, remove both the slash and the asterisk + reconstructedPath.slice(-2) === '/*' ? reconstructedPath.slice(0, -2) : reconstructedPath; + + // If the path ends with a slash, remove it (but keep single '/') + if (formattedPath.length > 1 && formattedPath[formattedPath.length - 1] === '/') { + formattedPath = formattedPath.slice(0, -1); + } return [formattedPath, 'route']; } diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 0eeeeb342287..4785849f1192 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -10,6 +10,7 @@ import { createReactRouterV6CompatibleTracingIntegration, updateNavigationSpan, } from '../../src/reactrouter-compat-utils'; +import { addRoutesToAllRoutes, allRoutes } from '../../src/reactrouter-compat-utils/instrumentation'; import type { Location, RouteObject } from '../../src/types'; const mockUpdateName = vi.fn(); @@ -47,6 +48,7 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({ initializeRouterUtils: vi.fn(), getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })), getGlobalPathname: vi.fn(() => '/test'), + routeIsDescendant: vi.fn(() => false), })); vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({ @@ -141,3 +143,193 @@ describe('reactrouter-compat-utils/instrumentation', () => { }); }); }); + +describe('addRoutesToAllRoutes', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + allRoutes.clear(); + }); + + it('should add simple routes without nesting', () => { + const routes = [ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toHaveLength(3); + expect(allRoutesArr).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: '/' }), + expect.objectContaining({ path: '/user/:id' }), + expect.objectContaining({ path: '/group/:group/:user?' }), + ]), + ); + + // Verify exact structure matches manual testing results + allRoutesArr.forEach(route => { + expect(route).toHaveProperty('element'); + expect(route.element).toHaveProperty('props'); + }); + }); + + it('should handle complex nested routes with multiple levels', () => { + const routes = [ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + { + path: '/v1/post/:post', + element:
, + children: [ + { path: 'featured', element:
}, + { path: '/v1/post/:post/related', element:
}, + { + element:
More Nested Children
, + children: [{ path: 'edit', element:
Edit Post
}], + }, + ], + }, + { + path: '/v2/post/:post', + element:
, + children: [ + { index: true, element:
}, + { path: 'featured', element:
}, + { path: '/v2/post/:post/related', element:
}, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { path: '/', element:
}, + { path: '/user/:id', element:
}, + { path: '/group/:group/:user?', element:
}, + // v1 routes ---- + { + path: '/v1/post/:post', + element:
, + children: [ + { element:
, path: 'featured' }, + { element:
, path: '/v1/post/:post/related' }, + { children: [{ element:
Edit Post
, path: 'edit' }], element:
More Nested Children
}, + ], + }, + { element:
, path: 'featured' }, + { element:
, path: '/v1/post/:post/related' }, + { children: [{ element:
Edit Post
, path: 'edit' }], element:
More Nested Children
}, + { element:
Edit Post
, path: 'edit' }, + // v2 routes --- + { + path: '/v2/post/:post', + element: expect.objectContaining({ type: 'div', props: {} }), + children: [ + { element:
, index: true }, + { element:
, path: 'featured' }, + { element:
, path: '/v2/post/:post/related' }, + ], + }, + { element:
, index: true }, + { element:
, path: 'featured' }, + { element:
, path: '/v2/post/:post/related' }, + ]); + }); + + it('should handle routes with nested index routes', () => { + const routes = [ + { + path: '/dashboard', + element:
, + children: [ + { index: true, element:
Dashboard Index
}, + { path: 'settings', element:
Settings
}, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { + path: '/dashboard', + element: expect.objectContaining({ type: 'div' }), + children: [ + { element:
Dashboard Index
, index: true }, + { element:
Settings
, path: 'settings' }, + ], + }, + { element:
Dashboard Index
, index: true }, + { element:
Settings
, path: 'settings' }, + ]); + }); + + it('should handle deeply nested routes with layout wrappers', () => { + const routes = [ + { + path: '/', + element:
Root
, + children: [ + { path: 'dashboard', element:
Dashboard
}, + { + element:
AuthLayout
, + children: [{ path: 'login', element:
Login
}], + }, + ], + }, + ]; + + addRoutesToAllRoutes(routes); + const allRoutesArr = Array.from(allRoutes); + + expect(allRoutesArr).toEqual([ + { + path: '/', + element: expect.objectContaining({ type: 'div', props: { children: 'Root' } }), + children: [ + { + path: 'dashboard', + element: expect.objectContaining({ type: 'div', props: { children: 'Dashboard' } }), + }, + { + element: expect.objectContaining({ type: 'div', props: { children: 'AuthLayout' } }), + children: [ + { + path: 'login', + element: expect.objectContaining({ type: 'div', props: { children: 'Login' } }), + }, + ], + }, + ], + }, + { element:
Dashboard
, path: 'dashboard' }, + { + children: [{ element:
Login
, path: 'login' }], + element:
AuthLayout
, + }, + { element:
Login
, path: 'login' }, + ]); + }); + + it('should not duplicate routes when called multiple times', () => { + const routes = [ + { path: '/', element:
}, + { path: '/about', element:
}, + ]; + + addRoutesToAllRoutes(routes); + const firstCount = allRoutes.size; + + addRoutesToAllRoutes(routes); + const secondCount = allRoutes.size; + + expect(firstCount).toBe(secondCount); + }); +}); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 91885940db31..9ff48e7450bc 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -436,7 +436,7 @@ describe('reactrouter-compat-utils/utils', () => { ]; const result = getNormalizedName(routes, location, branches, ''); - expect(result).toEqual(['', 'route']); + expect(result).toEqual(['/', 'route']); }); it('should handle simple route path', () => { From 925a4ea55434574a261f2f46b0620b4ba4fcd6fd Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Thu, 23 Oct 2025 16:04:38 +0200 Subject: [PATCH 059/190] feat(cloudflare,vercel-edge): Add support for LangChain instrumentation (#17986) Adds support for LangChain manual instrumentation in @sentry/cloudflare and @sentry/vercel-edge. To instrument LangChain operations, create a callback handler with Sentry.createLangChainCallbackHandler and pass it to your LangChain invocations. ``` import * as Sentry from '@sentry/cloudflare'; import { ChatAnthropic } from '@langchain/anthropic'; // Create a LangChain callback handler const callbackHandler = Sentry.createLangChainCallbackHandler({ recordInputs: true, // Optional: record input prompts/messages recordOutputs: true // Optional: record output responses }); // Use with chat models const model = new ChatAnthropic({ model: 'claude-3-5-sonnet-20241022', apiKey: 'your-api-key' }); await model.invoke('Tell me a joke', { callbacks: [callbackHandler] }); ``` The callback handler automatically creates spans for: - Chat model invocations (gen_ai.chat) - LLM invocations (gen_ai.pipeline) - Chain executions (gen_ai.invoke_agent) - Tool executions (gen_ai.execute_tool) --- .../suites/tracing/langchain/index.ts | 50 +++++ .../suites/tracing/langchain/mocks.ts | 197 ++++++++++++++++++ .../suites/tracing/langchain/test.ts | 64 ++++++ .../suites/tracing/langchain/wrangler.jsonc | 6 + packages/cloudflare/src/index.ts | 1 + packages/vercel-edge/src/index.ts | 1 + 6 files changed, 319 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts new file mode 100644 index 000000000000..0d59fd91c2b7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts @@ -0,0 +1,50 @@ +import * as Sentry from '@sentry/cloudflare'; +import { MockChain, MockChatModel, MockTool } from './mocks'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(_request, _env, _ctx) { + // Create LangChain callback handler + const callbackHandler = Sentry.createLangChainCallbackHandler({ + recordInputs: false, + recordOutputs: false, + }); + + // Test 1: Chat model invocation + const chatModel = new MockChatModel({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0.7, + maxTokens: 100, + }); + + await chatModel.invoke('Tell me a joke', { + callbacks: [callbackHandler], + }); + + // Test 2: Chain invocation + const chain = new MockChain('my_test_chain'); + await chain.invoke( + { input: 'test input' }, + { + callbacks: [callbackHandler], + }, + ); + + // Test 3: Tool invocation + const tool = new MockTool('search_tool'); + await tool.call('search query', { + callbacks: [callbackHandler], + }); + + return new Response(JSON.stringify({ success: true })); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts new file mode 100644 index 000000000000..946ae8252dbe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts @@ -0,0 +1,197 @@ +// Mock LangChain types and classes for testing the callback handler + +// Minimal callback handler interface to match LangChain's callback handler signature +export interface CallbackHandler { + handleChatModelStart?: ( + llm: unknown, + messages: unknown, + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[] | Record, + metadata?: Record, + runName?: string, + ) => unknown; + handleLLMEnd?: (output: unknown, runId: string) => unknown; + handleChainStart?: (chain: { name?: string }, inputs: Record, runId: string) => unknown; + handleChainEnd?: (outputs: unknown, runId: string) => unknown; + handleToolStart?: (tool: { name?: string }, input: string, runId: string) => unknown; + handleToolEnd?: (output: unknown, runId: string) => unknown; +} + +export interface LangChainMessage { + role: string; + content: string; +} + +export interface LangChainLLMResult { + generations: Array< + Array<{ + text: string; + generationInfo?: Record; + }> + >; + llmOutput?: { + tokenUsage?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + }; + }; +} + +export interface InvocationParams { + model: string; + temperature?: number; + maxTokens?: number; +} + +// Mock LangChain Chat Model +export class MockChatModel { + private _model: string; + private _temperature?: number; + private _maxTokens?: number; + + public constructor(params: InvocationParams) { + this._model = params.model; + this._temperature = params.temperature; + this._maxTokens = params.maxTokens; + } + + public async invoke( + messages: LangChainMessage[] | string, + options?: { callbacks?: CallbackHandler[] }, + ): Promise { + const callbacks = options?.callbacks || []; + const runId = crypto.randomUUID(); + + // Get invocation params to match LangChain's signature + const invocationParams = { + model: this._model, + temperature: this._temperature, + max_tokens: this._maxTokens, + }; + + // Create serialized representation similar to LangChain + const serialized = { + lc: 1, + type: 'constructor', + id: ['langchain', 'anthropic', 'anthropic'], // Third element is used as system provider + kwargs: invocationParams, + }; + + // Call handleChatModelStart + // Pass tags as a record with invocation_params for proper extraction + // The callback handler's getInvocationParams utility accepts both string[] and Record + for (const callback of callbacks) { + if (callback.handleChatModelStart) { + await callback.handleChatModelStart( + serialized, + messages, + runId, + undefined, + undefined, + { invocation_params: invocationParams }, + { ls_model_name: this._model, ls_provider: 'anthropic' }, + ); + } + } + + // Create mock result + const result: LangChainLLMResult = { + generations: [ + [ + { + text: 'Mock response from LangChain!', + generationInfo: { + finish_reason: 'stop', + }, + }, + ], + ], + llmOutput: { + tokenUsage: { + promptTokens: 10, + completionTokens: 15, + totalTokens: 25, + }, + }, + }; + + // Call handleLLMEnd + for (const callback of callbacks) { + if (callback.handleLLMEnd) { + await callback.handleLLMEnd(result, runId); + } + } + + return result; + } +} + +// Mock LangChain Chain +export class MockChain { + private _name: string; + + public constructor(name: string) { + this._name = name; + } + + public async invoke( + inputs: Record, + options?: { callbacks?: CallbackHandler[] }, + ): Promise> { + const callbacks = options?.callbacks || []; + const runId = crypto.randomUUID(); + + // Call handleChainStart + for (const callback of callbacks) { + if (callback.handleChainStart) { + await callback.handleChainStart({ name: this._name }, inputs, runId); + } + } + + const outputs = { result: 'Chain execution completed!' }; + + // Call handleChainEnd + for (const callback of callbacks) { + if (callback.handleChainEnd) { + await callback.handleChainEnd(outputs, runId); + } + } + + return outputs; + } +} + +// Mock LangChain Tool +export class MockTool { + private _name: string; + + public constructor(name: string) { + this._name = name; + } + + public async call(input: string, options?: { callbacks?: CallbackHandler[] }): Promise { + const callbacks = options?.callbacks || []; + const runId = crypto.randomUUID(); + + // Call handleToolStart + for (const callback of callbacks) { + if (callback.handleToolStart) { + await callback.handleToolStart({ name: this._name }, input, runId); + } + } + + const output = `Tool ${this._name} executed with input: ${input}`; + + // Call handleToolEnd + for (const callback of callbacks) { + if (callback.handleToolEnd) { + await callback.handleToolEnd(output, runId); + } + } + + return output; + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts new file mode 100644 index 000000000000..875b4191b84b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts @@ -0,0 +1,64 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// 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 break in our +// cloudflare SDK. + +it('traces langchain chat model, chain, and tool invocations', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + expect(transactionEvent.transaction).toBe('GET /'); + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + // Chat model span + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.langchain', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-5-sonnet-20241022', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + description: 'chat claude-3-5-sonnet-20241022', + op: 'gen_ai.chat', + origin: 'auto.ai.langchain', + }), + // Chain span + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.ai.langchain', + 'sentry.op': 'gen_ai.invoke_agent', + 'langchain.chain.name': 'my_test_chain', + }), + description: 'chain my_test_chain', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langchain', + }), + // Tool span + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': 'auto.ai.langchain', + 'sentry.op': 'gen_ai.execute_tool', + 'gen_ai.tool.name': 'search_tool', + }), + description: 'execute_tool search_tool', + op: 'gen_ai.execute_tool', + origin: 'auto.ai.langchain', + }), + ]), + ); + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 6f731cb8d980..a6aa7ffc8d9a 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -96,6 +96,7 @@ export { wrapMcpServerWithSentry, consoleLoggingIntegration, createConsolaReporter, + createLangChainCallbackHandler, featureFlagsIntegration, growthbookIntegration, logger, diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index d8362ff31c98..7a73234f535e 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -95,6 +95,7 @@ export { wrapMcpServerWithSentry, consoleLoggingIntegration, createConsolaReporter, + createLangChainCallbackHandler, featureFlagsIntegration, logger, } from '@sentry/core'; From 7d050b545d728a301bf4b239fbd6ff731cea4cae Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:07:07 +0200 Subject: [PATCH 060/190] fix(core): Fix wrong async types when instrumenting anthropic's stream api (#18007) The issue surfaced when `message.stream` was used in conjunction with the `stream: true` option which would lead to us returning async results instead of the expected MessageStream from anthropic ai. We now take this into account and tightened the types. Closes: #17977 --- .../tracing/anthropic/scenario-stream.mjs | 78 ++++++++++++++++++- .../suites/tracing/anthropic/test.ts | 25 ++++++ packages/core/src/utils/anthropic-ai/index.ts | 21 ++--- 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs index ac5eb6019010..4e0fa74fdd0d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario-stream.mjs @@ -32,6 +32,62 @@ function createMockStreamEvents(model = 'claude-3-haiku-20240307') { return generator(); } +// Mimics Anthropic SDK's MessageStream class +class MockMessageStream { + constructor(model) { + this._model = model; + this._eventHandlers = {}; + } + + on(event, handler) { + if (!this._eventHandlers[event]) { + this._eventHandlers[event] = []; + } + this._eventHandlers[event].push(handler); + + // Start processing events asynchronously (don't await) + if (event === 'streamEvent' && !this._processing) { + this._processing = true; + this._processEvents(); + } + + return this; + } + + async _processEvents() { + try { + const generator = createMockStreamEvents(this._model); + for await (const event of generator) { + if (this._eventHandlers['streamEvent']) { + for (const handler of this._eventHandlers['streamEvent']) { + handler(event); + } + } + } + + // Emit 'message' event when done + if (this._eventHandlers['message']) { + for (const handler of this._eventHandlers['message']) { + handler(); + } + } + } catch (error) { + if (this._eventHandlers['error']) { + for (const handler of this._eventHandlers['error']) { + handler(error); + } + } + } + } + + async *[Symbol.asyncIterator]() { + const generator = createMockStreamEvents(this._model); + for await (const event of generator) { + yield event; + } + } +} + class MockAnthropic { constructor(config) { this.apiKey = config.apiKey; @@ -68,9 +124,9 @@ class MockAnthropic { }; } - async _messagesStream(params) { - await new Promise(resolve => setTimeout(resolve, 5)); - return createMockStreamEvents(params?.model); + // This should return synchronously (like the real Anthropic SDK) + _messagesStream(params) { + return new MockMessageStream(params?.model); } } @@ -90,13 +146,27 @@ async function run() { } // 2) Streaming via messages.stream API - const stream2 = await client.messages.stream({ + const stream2 = client.messages.stream({ model: 'claude-3-haiku-20240307', messages: [{ role: 'user', content: 'Stream this too' }], }); for await (const _ of stream2) { void _; } + + // 3) Streaming via messages.stream API with redundant stream: true param + const stream3 = client.messages.stream({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Stream with param' }], + stream: true, // This param is redundant but should not break synchronous behavior + }); + // Verify it has .on() method immediately (not a Promise) + if (typeof stream3.on !== 'function') { + throw new Error('BUG: messages.stream() with stream: true did not return MessageStream synchronously!'); + } + for await (const _ of stream3) { + void _; + } }); } diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts index 57e788b721d1..2c92c6f8d233 100644 --- a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -308,6 +308,23 @@ describe('Anthropic integration', () => { 'gen_ai.usage.total_tokens': 25, }), }), + // messages.stream with redundant stream: true param + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.system': 'anthropic', + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.stream': true, + 'gen_ai.response.streaming': true, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_stream_1', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }), + }), ]), }; @@ -331,6 +348,14 @@ describe('Anthropic integration', () => { 'gen_ai.response.text': 'Hello from stream!', }), }), + expect.objectContaining({ + description: 'messages claude-3-haiku-20240307 stream-response', + op: 'gen_ai.messages', + data: expect.objectContaining({ + 'gen_ai.response.streaming': true, + 'gen_ai.response.text': 'Hello from stream!', + }), + }), ]), }; diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts index d81741668be9..669d8a61b068 100644 --- a/packages/core/src/utils/anthropic-ai/index.ts +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -205,8 +205,8 @@ function handleStreamingError(error: unknown, span: Span, methodPath: string): n * Handle streaming cases with common logic */ function handleStreamingRequest( - originalMethod: (...args: T) => Promise, - target: (...args: T) => Promise, + originalMethod: (...args: T) => R | Promise, + target: (...args: T) => R | Promise, context: unknown, args: T, requestAttributes: Record, @@ -215,7 +215,8 @@ function handleStreamingRequest( params: Record | undefined, options: AnthropicAiOptions, isStreamRequested: boolean, -): Promise { + isStreamingMethod: boolean, +): R | Promise { const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const spanConfig = { name: `${operationName} ${model} stream-response`, @@ -223,7 +224,8 @@ function handleStreamingRequest( attributes: requestAttributes as Record, }; - if (isStreamRequested) { + // messages.stream() always returns a sync MessageStream, even with stream: true param + if (isStreamRequested && !isStreamingMethod) { return startSpanManual(spanConfig, async span => { try { if (options.recordInputs && params) { @@ -260,13 +262,13 @@ function handleStreamingRequest( * @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation */ function instrumentMethod( - originalMethod: (...args: T) => Promise, + originalMethod: (...args: T) => R | Promise, methodPath: AnthropicAiInstrumentedMethod, context: unknown, options: AnthropicAiOptions, -): (...args: T) => Promise { +): (...args: T) => R | Promise { return new Proxy(originalMethod, { - apply(target, thisArg, args: T): Promise { + apply(target, thisArg, args: T): R | Promise { const requestAttributes = extractRequestAttributes(args, methodPath); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const operationName = getFinalOperationName(methodPath); @@ -287,6 +289,7 @@ function instrumentMethod( params, options, isStreamRequested, + isStreamingMethod, ); } @@ -320,7 +323,7 @@ function instrumentMethod( }, ); }, - }) as (...args: T) => Promise; + }) as (...args: T) => R | Promise; } /** @@ -333,7 +336,7 @@ function createDeepProxy(target: T, currentPath = '', options: const methodPath = buildMethodPath(currentPath, String(prop)); if (typeof value === 'function' && shouldInstrument(methodPath)) { - return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, obj, options); + return instrumentMethod(value as (...args: unknown[]) => unknown | Promise, methodPath, obj, options); } if (typeof value === 'function') { From 1513161b12c33305fd5bdea6e1da57733fb65d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 23 Oct 2025 16:18:09 +0200 Subject: [PATCH 061/190] chore: Add required size_check for GH Actions (#18009) It seems that the size limit was not enforced and a PR could be merged without the job being green: --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4066a18eefe2..46d6e7d4fac9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1168,6 +1168,7 @@ jobs: job_lint, job_check_format, job_circular_dep_check, + job_size_check, ] # Always run this, even if a dependent job failed if: always() From 0bac0ea0560a1f636d715215a1667c4cb0ab3158 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 23 Oct 2025 16:20:26 +0200 Subject: [PATCH 062/190] test(react): Add parameterized route tests for `createHashRouter` (#17789) Co-authored-by: s1gr1d Co-authored-by: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> --- .../react-create-hash-router/package.json | 3 +- .../react-create-hash-router/src/index.tsx | 26 ++ .../src/pages/Group.tsx | 7 + .../src/pages/Index.tsx | 18 ++ .../src/pages/Post.tsx | 13 + .../src/pages/PostFeatured.tsx | 7 + .../src/pages/PostIndex.tsx | 7 + .../src/pages/PostRelated.tsx | 7 + .../tests/transactions.test.ts | 258 +++++++++++++++++- 9 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json index 3dea78b20080..afe9486eeebf 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/package.json @@ -11,10 +11,11 @@ "react-dom": "18.2.0", "react-router-dom": "^6.4.1", "react-scripts": "5.0.1", - "typescript": "~5.0.0" + "typescript": "~4.9.5" }, "scripts": { "build": "react-scripts build", + "dev": "react-scripts start", "start": "serve -s build", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx index 2ad9490ccd57..86de5f20378d 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/index.tsx @@ -11,6 +11,7 @@ import { } from 'react-router-dom'; import Index from './pages/Index'; import User from './pages/User'; +import Group from './pages/Group'; const replay = Sentry.replayIntegration(); @@ -52,6 +53,31 @@ const router = sentryCreateHashRouter([ path: '/user/:id', element: , }, + { + path: '/group/:group/:user?', + element: , + }, + { + path: '/v1/post/:post', + element:
, + children: [ + { path: 'featured', element:
}, + { path: '/v1/post/:post/related', element:
}, + { + element:
More Nested Children
, + children: [{ path: 'edit', element:
Edit Post
}], + }, + ], + }, + { + path: '/v2/post/:post', + element:
, + children: [ + { index: true, element:
}, + { path: 'featured', element:
}, + { path: '/v2/post/:post/related', element:
}, + ], + }, ]); const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx new file mode 100644 index 000000000000..9dd9ac110898 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Group.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const Group = () => { + return

Group page

; +}; + +export default Group; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx index 1000dd53df27..20a2ab60fa21 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Index.tsx @@ -15,6 +15,24 @@ const Index = () => { navigate + + Post 1 + + + Edit Post 1 + + + Post 1 featured + + + Post 1 related + + + Group 1 + + + Group 1 user 5 + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx new file mode 100644 index 000000000000..9b844b17ff68 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/Post.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { Outlet } from 'react-router-dom'; + +const Post = () => { + return ( + <> +

Post V2 page

+ + + ); +}; + +export default Post; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx new file mode 100644 index 000000000000..0446fa0ec6ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostFeatured.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const PostFeatured = () => { + return

Post featured page

; +}; + +export default PostFeatured; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx new file mode 100644 index 000000000000..ad3efaa9216a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostIndex.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const PostIndex = () => { + return

Post index page

; +}; + +export default PostIndex; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx new file mode 100644 index 000000000000..ff8d05f6f6f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/src/pages/PostRelated.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const PostRelated = () => { + return

Post related page

; +}; + +export default PostRelated; diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts index 920506838080..36e6d0c18ee2 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts @@ -14,9 +14,9 @@ test('Captures a pageload transaction', async ({ page }) => { deviceMemory: expect.any(String), effectiveConnectionType: expect.any(String), hardwareConcurrency: expect.any(String), - 'lcp.element': 'body > div#root > input#exception-button[type="button"]', - 'lcp.id': 'exception-button', - 'lcp.size': 1650, + 'lcp.element': expect.any(String), + 'lcp.id': expect.any(String), + 'lcp.size': expect.any(Number), 'sentry.idle_span_finish_reason': 'idleTimeout', 'sentry.op': 'pageload', 'sentry.origin': 'auto.pageload.react.reactrouter_v6', @@ -150,3 +150,255 @@ test('Captures a navigation transaction', async ({ page }) => { expect(transactionEvent.spans).toEqual([]); }); + +test('Captures a parameterized path pageload transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v2/post/1'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v2/post/1/featured'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/featured', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for deeply nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v1/post/1/edit'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v1/post/:post/edit', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for nested route with absolute path', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/v2/post/1/related'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/related', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-featured'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/featured', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for deeply nested route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-edit'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v1/post/:post/edit', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for nested route with absolute path', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-post-1-related'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/v2/post/:post/related', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/group/1'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path pageload transaction for nested group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/#/group/1/5'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); + +test('Captures a parameterized path navigation transaction for nested group route', async ({ page }) => { + const transactionEventPromise = waitForTransaction('react-create-hash-router', event => { + return event.contexts?.trace?.op === 'navigation'; + }); + + await page.goto('/'); + const linkElement = page.locator('id=navigation-group-1-user-5'); + await linkElement.click(); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: '/group/:group/:user?', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); From 9c0397c7c10f5b9f902772c33742c3c37745e9cc Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:33:45 +0200 Subject: [PATCH 063/190] test(react-router): Fix `getMetaTagTransformer` tests for Vitest compatibility (#18013) - Migrated from Jest `done` callback to [Promise-based async test pattern](https://vitest.dev/guide/migration.html#done-callback) - Fixed test assertion by adding missing `` tag to trigger `getTraceMetaTags` call (the test did not work before as `getTraceMetaTags` is only called when there is a closing `head` tag - Corrected stream piping logic to properly test transformer functionality The getMetaTagTransformer function internally pipes the transformer TO the bodyStream (`htmlMetaTagTransformer.pipe(body)`). So the correct flow is: 1. Write data to the transformer 2. Transformer processes it and pipes to bodyStream 3. Read the output from bodyStream --- .../test/server/getMetaTagTransformer.test.ts | 127 ++++++++++++++++++ .../test/server/getMetaTagTransformer.ts | 91 ------------- 2 files changed, 127 insertions(+), 91 deletions(-) create mode 100644 packages/react-router/test/server/getMetaTagTransformer.test.ts delete mode 100644 packages/react-router/test/server/getMetaTagTransformer.ts diff --git a/packages/react-router/test/server/getMetaTagTransformer.test.ts b/packages/react-router/test/server/getMetaTagTransformer.test.ts new file mode 100644 index 000000000000..6900e1431ee7 --- /dev/null +++ b/packages/react-router/test/server/getMetaTagTransformer.test.ts @@ -0,0 +1,127 @@ +import { getTraceMetaTags } from '@sentry/core'; +import { PassThrough } from 'stream'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; + +vi.mock('@opentelemetry/core', () => ({ + RPCType: { HTTP: 'http' }, + getRPCMetadata: vi.fn(), +})); + +vi.mock('@sentry/core', () => ({ + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + getTraceMetaTags: vi.fn(), +})); + +describe('getMetaTagTransformer', () => { + beforeEach(() => { + vi.clearAllMocks(); + (getTraceMetaTags as unknown as ReturnType).mockReturnValue( + '', + ); + }); + + test('should inject meta tags before closing head tag', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toContain(''); + expect(outputData).not.toContain(''); + expect(getTraceMetaTags).toHaveBeenCalledTimes(1); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write('Test'); + transformer.end(); + })); + + test('should not modify chunks without head closing tag', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toBe('Test'); + expect(outputData).not.toContain('sentry-trace'); + expect(getTraceMetaTags).not.toHaveBeenCalled(); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write('Test'); + transformer.end(); + })); + + test('should handle buffer input', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toContain(''); + expect(getTraceMetaTags).toHaveBeenCalledTimes(1); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write(Buffer.from('Test')); + transformer.end(); + })); + + test('should handle multiple chunks', () => + new Promise((resolve, reject) => { + const bodyStream = new PassThrough(); + const transformer = getMetaTagTransformer(bodyStream); + + let outputData = ''; + bodyStream.on('data', chunk => { + outputData += chunk.toString(); + }); + + bodyStream.on('end', () => { + try { + expect(outputData).toContain(''); + expect(outputData).toContain('Test content'); + expect(getTraceMetaTags).toHaveBeenCalledTimes(1); + resolve(); + } catch (e) { + reject(e); + } + }); + + transformer.write(''); + transformer.write('Test content'); + transformer.write(''); + transformer.end(); + })); +}); diff --git a/packages/react-router/test/server/getMetaTagTransformer.ts b/packages/react-router/test/server/getMetaTagTransformer.ts deleted file mode 100644 index 16334888627c..000000000000 --- a/packages/react-router/test/server/getMetaTagTransformer.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { getTraceMetaTags } from '@sentry/core'; -import { PassThrough } from 'stream'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { getMetaTagTransformer } from '../../src/server/getMetaTagTransformer'; - -vi.mock('@opentelemetry/core', () => ({ - RPCType: { HTTP: 'http' }, - getRPCMetadata: vi.fn(), -})); - -vi.mock('@sentry/core', () => ({ - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', - getActiveSpan: vi.fn(), - getRootSpan: vi.fn(), - getTraceMetaTags: vi.fn(), -})); - -describe('getMetaTagTransformer', () => { - beforeEach(() => { - vi.clearAllMocks(); - (getTraceMetaTags as unknown as ReturnType).mockReturnValue( - '', - ); - }); - - test('should inject meta tags before closing head tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); - - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toContain(''); - expect(outputData).not.toContain(''); - done(); - }); - - transformer.pipe(outputStream); - - bodyStream.write('Test'); - bodyStream.end(); - }); - - test('should not modify chunks without head closing tag', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); - - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toBe('Test'); - expect(getTraceMetaTags).toHaveBeenCalled(); - done(); - }); - - transformer.pipe(outputStream); - - bodyStream.write('Test'); - bodyStream.end(); - }); - - test('should handle buffer input', done => { - const outputStream = new PassThrough(); - const bodyStream = new PassThrough(); - const transformer = getMetaTagTransformer(bodyStream); - - let outputData = ''; - outputStream.on('data', chunk => { - outputData += chunk.toString(); - }); - - outputStream.on('end', () => { - expect(outputData).toContain(''); - done(); - }); - - transformer.pipe(outputStream); - - bodyStream.write(Buffer.from('Test')); - bodyStream.end(); - }); -}); From fd265698ca5744942269f4f6b6cd18328e7063ab Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 23 Oct 2025 15:34:59 +0100 Subject: [PATCH 064/190] fix(react): Patch `spanEnd` for potentially cancelled lazy-route transactions (#17962) Fixes an issue where `pageload` and `navigation` transactions have incorrect (URL-based or wildcard-based) names when the span is cancelled early before lazy routes finish loading. This occurs when `document.hidden` triggers early span cancellation (e.g., user switches tabs during page load). In React Router applications with lazy routes, the parameterized route information may not be available yet when the span ends, resulting in transaction names like `/user/123/edit` (URL-based) or `/projects/*/views/*` (wildcard-based) instead of the correct parameterized route like `/user/:id/edit` or `/projects/:projectId/views/:viewId`. This fix patches `span.end()` to perform a final route resolution check before the span is sent, using the live global `allRoutes` Set to capture any lazy routes that loaded after the span was created but before it ended. --- .../tests/transactions.test.ts | 121 ++++++++++++++++++ .../instrumentation.tsx | 119 +++++++++++++---- .../instrumentation.test.tsx | 37 ++++++ 3 files changed, 254 insertions(+), 23 deletions(-) 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 59d43c14ae95..3901b0938ca5 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 @@ -377,3 +377,124 @@ test('Allows legitimate POP navigation (back/forward) after pageload completes', expect(backNavigationEvent.transaction).toBe('/'); expect(backNavigationEvent.contexts?.trace?.op).toBe('navigation'); }); + +test('Updates pageload transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + // Set up the page to simulate document.hidden before navigation + await page.addInitScript(() => { + // Wait a bit for Sentry to initialize and start the pageload span + setTimeout(() => { + // Override document.hidden to simulate tab switching + Object.defineProperty(document, 'hidden', { + configurable: true, + get: function () { + return true; + }, + }); + + // Dispatch visibilitychange event to trigger the idle span cancellation logic + document.dispatchEvent(new Event('visibilitychange')); + }, 100); // Small delay to ensure the span has started + }); + + // Navigate to the lazy route URL + await page.goto('/lazy/inner/1/2/3'); + + const event = await transactionPromise; + + // Verify the lazy route content eventually loads (even though span was cancelled early) + const lazyRouteContent = page.locator('id=innermost-lazy-route'); + await expect(lazyRouteContent).toBeVisible(); + + // Validate that the transaction event has the correct parameterized route name + // even though the span was cancelled early due to document.hidden + expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.type).toBe('transaction'); + expect(event.contexts?.trace?.op).toBe('pageload'); + + // Check if the span was indeed cancelled (should have idle_span_finish_reason attribute) + const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + if (idleSpanFinishReason) { + // If the span was cancelled due to visibility change, verify it still got the right name + expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); + } +}); + +test('Updates navigation transaction name correctly when span is cancelled early (document.hidden simulation)', async ({ + page, +}) => { + // First go to home page + await page.goto('/'); + + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/lazy/inner/:id/:anotherId/:someAnotherId' + ); + }); + + // Set up a listener to simulate document.hidden after clicking the navigation link + await page.evaluate(() => { + // Override document.hidden to simulate tab switching + let hiddenValue = false; + Object.defineProperty(document, 'hidden', { + configurable: true, + get: function () { + return hiddenValue; + }, + }); + + // Listen for clicks on the navigation link and simulate document.hidden shortly after + document.addEventListener( + 'click', + () => { + setTimeout(() => { + hiddenValue = true; + // Dispatch visibilitychange event to trigger the idle span cancellation logic + document.dispatchEvent(new Event('visibilitychange')); + }, 50); // Small delay to ensure the navigation span has started + }, + { once: true }, + ); + }); + + // Click the navigation link to navigate to the lazy route + const navigationLink = page.locator('id=navigation'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await navigationPromise; + + // Verify the lazy route content eventually loads (even though span was cancelled early) + const lazyRouteContent = page.locator('id=innermost-lazy-route'); + await expect(lazyRouteContent).toBeVisible(); + + // Validate that the transaction event has the correct parameterized route name + // even though the span was cancelled early due to document.hidden + expect(event.transaction).toBe('/lazy/inner/:id/:anotherId/:someAnotherId'); + expect(event.type).toBe('transaction'); + expect(event.contexts?.trace?.op).toBe('navigation'); + + // Check if the span was indeed cancelled (should have cancellation_reason attribute or idle_span_finish_reason) + const cancellationReason = event.contexts?.trace?.data?.['sentry.cancellation_reason']; + const idleSpanFinishReason = event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']; + + // Verify that the span was cancelled due to document.hidden + if (cancellationReason) { + expect(cancellationReason).toBe('document.hidden'); + } + + if (idleSpanFinishReason) { + expect(['externalFinish', 'cancelled']).toContain(idleSpanFinishReason); + } +}); diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index a72c4fd05378..a6e55f1a967c 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -8,7 +8,7 @@ import { startBrowserTracingPageLoadSpan, WINDOW, } from '@sentry/browser'; -import type { Client, Integration, Span, TransactionSource } from '@sentry/core'; +import type { Client, Integration, Span } from '@sentry/core'; import { addNonEnumerableProperty, debug, @@ -41,14 +41,7 @@ import type { UseRoutes, } from '../types'; import { checkRouteForAsyncHandler } from './lazy-routes'; -import { - getNormalizedName, - initializeRouterUtils, - locationIsInsideDescendantRoute, - prefixWithSlash, - rebuildRoutePathFromAllRoutes, - resolveRouteNameAndSource, -} from './utils'; +import { initializeRouterUtils, resolveRouteNameAndSource } from './utils'; let _useEffect: UseEffect; let _useLocation: UseLocation; @@ -668,7 +661,7 @@ export function handleNavigation(opts: { // Cross usage can result in multiple navigation spans being created without this check if (!isAlreadyInNavigationSpan) { - startBrowserTracingNavigationSpan(client, { + const navigationSpan = startBrowserTracingNavigationSpan(client, { name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, @@ -676,6 +669,11 @@ export function handleNavigation(opts: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, }, }); + + // Patch navigation span to handle early cancellation (e.g., document.hidden) + if (navigationSpan) { + patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); + } } } } @@ -729,29 +727,104 @@ function updatePageloadTransaction({ : (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]); if (branches) { - let name, - source: TransactionSource = 'url'; - - const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes || routes); - - if (isInDescendantRoute) { - name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes || routes, location)); - source = 'route'; - } - - if (!isInDescendantRoute || !name) { - [name, source] = getNormalizedName(routes, location, branches, basename); - } + const [name, source] = resolveRouteNameAndSource(location, routes, allRoutes || routes, branches, basename); getCurrentScope().setTransactionName(name || '/'); if (activeRootSpan) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + + // Patch span.end() to ensure we update the name one last time before the span is sent + patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes); } } } +/** + * Patches the span.end() method to update the transaction name one last time before the span is sent. + * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading. + */ +function patchSpanEnd( + span: Span, + location: Location, + routes: RouteObject[], + basename: string | undefined, + _allRoutes: RouteObject[] | undefined, + spanType: 'pageload' | 'navigation', +): void { + const patchedPropertyName = `__sentry_${spanType}_end_patched__` as const; + const hasEndBeenPatched = (span as unknown as Record)?.[patchedPropertyName]; + + if (hasEndBeenPatched || !span.end) { + return; + } + + const originalEnd = span.end.bind(span); + + span.end = function patchedEnd(...args) { + try { + // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet) + const spanJson = spanToJSON(span); + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + if (currentSource !== 'route') { + // Last chance to update the transaction name with the latest route info + // Use the live global allRoutes Set to include any lazy routes loaded after patching + const currentAllRoutes = Array.from(allRoutes); + const branches = _matchRoutes( + currentAllRoutes.length > 0 ? currentAllRoutes : routes, + location, + basename, + ) as unknown as RouteMatch[]; + + if (branches) { + const [name, source] = resolveRouteNameAndSource( + location, + routes, + currentAllRoutes.length > 0 ? currentAllRoutes : routes, + branches, + basename, + ); + + // Only update if we have a valid name + if (name && (spanType === 'pageload' || !spanJson.timestamp)) { + span.updateName(name); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + } + } + } + } catch (error) { + // Silently catch errors to ensure span.end() is always called + DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + } + + return originalEnd(...args); + }; + + // Mark this span as having its end() method patched to prevent duplicate patching + addNonEnumerableProperty(span as unknown as Record, patchedPropertyName, true); +} + +function patchPageloadSpanEnd( + span: Span, + location: Location, + routes: RouteObject[], + basename: string | undefined, + _allRoutes: RouteObject[] | undefined, +): void { + patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload'); +} + +function patchNavigationSpanEnd( + span: Span, + location: Location, + routes: RouteObject[], + basename: string | undefined, + _allRoutes: RouteObject[] | undefined, +): void { + patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation'); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createV6CompatibleWithSentryReactRouterRouting

, R extends React.FC

>( Routes: R, diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 4785849f1192..bad264d3d6b5 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -142,6 +142,43 @@ describe('reactrouter-compat-utils/instrumentation', () => { expect(typeof integration.afterAllSetup).toBe('function'); }); }); + + describe('span.end() patching for early cancellation', () => { + it('should update transaction name when span.end() is called during cancellation', () => { + const mockEnd = vi.fn(); + let patchedEnd: ((...args: any[]) => any) | null = null; + + const updateNameMock = vi.fn(); + const setAttributeMock = vi.fn(); + + const testSpan = { + updateName: updateNameMock, + setAttribute: setAttributeMock, + get end() { + return patchedEnd || mockEnd; + }, + set end(fn: (...args: any[]) => any) { + patchedEnd = fn; + }, + } as unknown as Span; + + // Simulate the patching behavior + const originalEnd = testSpan.end.bind(testSpan); + (testSpan as any).end = function patchedEndFn(...args: any[]) { + // This simulates what happens in the actual implementation + updateNameMock('Updated Route'); + setAttributeMock('sentry.source', 'route'); + return originalEnd(...args); + }; + + // Call the patched end + testSpan.end(12345); + + expect(updateNameMock).toHaveBeenCalledWith('Updated Route'); + expect(setAttributeMock).toHaveBeenCalledWith('sentry.source', 'route'); + expect(mockEnd).toHaveBeenCalledWith(12345); + }); + }); }); describe('addRoutesToAllRoutes', () => { From 8debb05b5b8924220692a275810e2b9c53ea7cef Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 23 Oct 2025 17:07:55 +0200 Subject: [PATCH 065/190] meta(changelog): Update changelog for 10.22.0 --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9b82b9bc8e..d91a753f6544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,54 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.22.0 + +### Important Changes + +- **feat(node): Instrument cloud functions for firebase v2 ([#17952](https://github.com/getsentry/sentry-javascript/pull/17952))** + + We added instrumentation for Cloud Functions for Firebase v2, enabling automatic performance tracking and error monitoring. This will be added automatically if you have enabled tracing. + +- **feat(core): Instrument LangChain AI ([#17955](https://github.com/getsentry/sentry-javascript/pull/17955))** + + Instrumentation was added for LangChain AI operations. You can configure what is recorded like this: + + ```ts + Sentry.init({ + integrations: [ + Sentry.langChainIntegration({ + recordInputs: true, // Record prompts/messages + recordOutputs: true, // Record responses + }), + ], + }); + ``` + +### Other Changes + +- feat(cloudflare,vercel-edge): Add support for LangChain instrumentation ([#17986](https://github.com/getsentry/sentry-javascript/pull/17986)) +- feat: Align sentry origin with documentation ([#17998](https://github.com/getsentry/sentry-javascript/pull/17998)) +- feat(core): Truncate request messages in AI integrations ([#17921](https://github.com/getsentry/sentry-javascript/pull/17921)) +- feat(nextjs): Support node runtime on proxy files ([#17995](https://github.com/getsentry/sentry-javascript/pull/17995)) +- feat(node): Pass requestHook and responseHook option to OTel ([#17996](https://github.com/getsentry/sentry-javascript/pull/17996)) +- fix(core): Fix wrong async types when instrumenting anthropic's stream api ([#18007](https://github.com/getsentry/sentry-javascript/pull/18007)) +- fix(nextjs): Remove usage of chalk to avoid runtime errors ([#18010](https://github.com/getsentry/sentry-javascript/pull/18010)) +- fix(node): Pino capture serialized `err` ([#17999](https://github.com/getsentry/sentry-javascript/pull/17999)) +- fix(node): Pino child loggers ([#17934](https://github.com/getsentry/sentry-javascript/pull/17934)) +- fix(react): Don't trim index route `/` when getting pathname ([#17985](https://github.com/getsentry/sentry-javascript/pull/17985)) +- fix(react): Patch `spanEnd` for potentially cancelled lazy-route transactions ([#17962](https://github.com/getsentry/sentry-javascript/pull/17962)) + +

+ Internal Changes + +- chore: Add required size_check for GH Actions ([#18009](https://github.com/getsentry/sentry-javascript/pull/18009)) +- chore: Upgrade madge to v8 ([#17957](https://github.com/getsentry/sentry-javascript/pull/17957)) +- test(hono): Fix hono e2e tests ([#18000](https://github.com/getsentry/sentry-javascript/pull/18000)) +- test(react-router): Fix `getMetaTagTransformer` tests for Vitest compatibility ([#18013](https://github.com/getsentry/sentry-javascript/pull/18013)) +- test(react): Add parameterized route tests for `createHashRouter` ([#17789](https://github.com/getsentry/sentry-javascript/pull/17789)) + +
+ ## 10.21.0 ### Important Changes From c6b99c014f565c34aac161cfeda3ff9bc787a2ef Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 23 Oct 2025 15:39:46 +0000 Subject: [PATCH 066/190] release: 10.22.0 --- .../browser-integration-tests/package.json | 4 ++-- .../bundle-analyzer-scenarios/package.json | 2 +- dev-packages/clear-cache-gh-action/package.json | 2 +- .../cloudflare-integration-tests/package.json | 6 +++--- dev-packages/e2e-tests/package.json | 2 +- .../external-contributor-gh-action/package.json | 2 +- .../node-core-integration-tests/package.json | 6 +++--- dev-packages/node-integration-tests/package.json | 14 +++++++------- dev-packages/node-overhead-gh-action/package.json | 4 ++-- dev-packages/rollup-utils/package.json | 2 +- dev-packages/size-limit-gh-action/package.json | 2 +- dev-packages/test-utils/package.json | 4 ++-- lerna.json | 2 +- packages/angular/package.json | 6 +++--- packages/astro/package.json | 8 ++++---- packages/aws-serverless/package.json | 6 +++--- packages/browser-utils/package.json | 4 ++-- packages/browser/package.json | 14 +++++++------- packages/bun/package.json | 6 +++--- packages/cloudflare/package.json | 4 ++-- packages/core/package.json | 2 +- packages/deno/package.json | 4 ++-- packages/ember/package.json | 6 +++--- packages/eslint-config-sdk/package.json | 6 +++--- packages/eslint-plugin-sdk/package.json | 2 +- packages/feedback/package.json | 4 ++-- packages/gatsby/package.json | 6 +++--- packages/google-cloud-serverless/package.json | 6 +++--- packages/integration-shims/package.json | 4 ++-- packages/nestjs/package.json | 6 +++--- packages/nextjs/package.json | 14 +++++++------- packages/node-core/package.json | 6 +++--- packages/node-native/package.json | 6 +++--- packages/node/package.json | 8 ++++---- packages/nuxt/package.json | 12 ++++++------ packages/opentelemetry/package.json | 4 ++-- packages/profiling-node/package.json | 6 +++--- packages/react-router/package.json | 10 +++++----- packages/react/package.json | 6 +++--- packages/remix/package.json | 8 ++++---- packages/replay-canvas/package.json | 6 +++--- packages/replay-internal/package.json | 8 ++++---- packages/replay-worker/package.json | 2 +- packages/solid/package.json | 6 +++--- packages/solidstart/package.json | 8 ++++---- packages/svelte/package.json | 6 +++--- packages/sveltekit/package.json | 10 +++++----- packages/tanstackstart-react/package.json | 10 +++++----- packages/tanstackstart/package.json | 2 +- packages/types/package.json | 4 ++-- packages/typescript/package.json | 2 +- packages/vercel-edge/package.json | 6 +++--- packages/vue/package.json | 6 +++--- packages/wasm/package.json | 6 +++--- 54 files changed, 154 insertions(+), 154 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 9f2b606114c5..6de23391a0ba 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "10.21.0", + "version": "10.22.0", "main": "index.js", "license": "MIT", "engines": { @@ -43,7 +43,7 @@ "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.53.2", "@sentry-internal/rrweb": "2.34.0", - "@sentry/browser": "10.21.0", + "@sentry/browser": "10.22.0", "@supabase/supabase-js": "2.49.3", "axios": "^1.12.2", "babel-loader": "^8.2.2", diff --git a/dev-packages/bundle-analyzer-scenarios/package.json b/dev-packages/bundle-analyzer-scenarios/package.json index 8336749e288c..2286f4a6266e 100644 --- a/dev-packages/bundle-analyzer-scenarios/package.json +++ b/dev-packages/bundle-analyzer-scenarios/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundle-analyzer-scenarios", - "version": "10.21.0", + "version": "10.22.0", "description": "Scenarios to test bundle analysis with", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/dev-packages/bundle-analyzer-scenarios", diff --git a/dev-packages/clear-cache-gh-action/package.json b/dev-packages/clear-cache-gh-action/package.json index fea1ff7db1e1..c4700fe531bb 100644 --- a/dev-packages/clear-cache-gh-action/package.json +++ b/dev-packages/clear-cache-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/clear-cache-gh-action", "description": "An internal Github Action to clear GitHub caches.", - "version": "10.21.0", + "version": "10.22.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index fb0c0e47dcaa..6681839f6106 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/cloudflare-integration-tests", - "version": "10.21.0", + "version": "10.22.0", "license": "MIT", "engines": { "node": ">=18" @@ -13,11 +13,11 @@ "test:watch": "yarn test --watch" }, "dependencies": { - "@sentry/cloudflare": "10.21.0" + "@sentry/cloudflare": "10.22.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", - "@sentry-internal/test-utils": "10.21.0", + "@sentry-internal/test-utils": "10.22.0", "vitest": "^3.2.4", "wrangler": "4.22.0" }, diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 85be1cdc297b..2f39a0978697 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/e2e-tests", - "version": "10.21.0", + "version": "10.22.0", "license": "MIT", "private": true, "scripts": { diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json index dea185d3b8f5..84c7a5d60b64 100644 --- a/dev-packages/external-contributor-gh-action/package.json +++ b/dev-packages/external-contributor-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/external-contributor-gh-action", "description": "An internal Github Action to add external contributors to the CHANGELOG.md file.", - "version": "10.21.0", + "version": "10.22.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 788efcccdc3c..4d0b8bbf2e5e 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-core-integration-tests", - "version": "10.21.0", + "version": "10.22.0", "license": "MIT", "engines": { "node": ">=18" @@ -34,8 +34,8 @@ "@opentelemetry/resources": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.21.0", - "@sentry/node-core": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/node-core": "10.22.0", "body-parser": "^1.20.3", "cors": "^2.8.5", "cron": "^3.1.6", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index b4fd1c3b4125..31554947e8c8 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-integration-tests", - "version": "10.21.0", + "version": "10.22.0", "license": "MIT", "engines": { "node": ">=18" @@ -27,17 +27,17 @@ "@aws-sdk/client-s3": "^3.552.0", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", - "@langchain/anthropic": "^0.3.10", - "@langchain/core": "^0.3.28", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.4", + "@langchain/anthropic": "^0.3.10", + "@langchain/core": "^0.3.28", "@nestjs/common": "^11", "@nestjs/core": "^11", "@nestjs/platform-express": "^11", "@prisma/client": "6.15.0", - "@sentry/aws-serverless": "10.21.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", + "@sentry/aws-serverless": "10.22.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", @@ -82,7 +82,7 @@ "yargs": "^16.2.0" }, "devDependencies": { - "@sentry-internal/test-utils": "10.21.0", + "@sentry-internal/test-utils": "10.22.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", diff --git a/dev-packages/node-overhead-gh-action/package.json b/dev-packages/node-overhead-gh-action/package.json index 3d48173943e3..d176e5d8475c 100644 --- a/dev-packages/node-overhead-gh-action/package.json +++ b/dev-packages/node-overhead-gh-action/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-overhead-gh-action", - "version": "10.21.0", + "version": "10.22.0", "license": "MIT", "engines": { "node": ">=18" @@ -23,7 +23,7 @@ "fix": "eslint . --format stylish --fix" }, "dependencies": { - "@sentry/node": "10.21.0", + "@sentry/node": "10.22.0", "express": "^4.21.1", "mysql2": "^3.14.4" }, diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json index 4f3d0398ada3..7e3421b5488b 100644 --- a/dev-packages/rollup-utils/package.json +++ b/dev-packages/rollup-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/rollup-utils", - "version": "10.21.0", + "version": "10.22.0", "description": "Rollup utilities used at Sentry for the Sentry JavaScript SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/rollup-utils", diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index 7801552bb832..803c96cbac75 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/size-limit-gh-action", "description": "An internal Github Action to compare the current size of a PR against the one on develop.", - "version": "10.21.0", + "version": "10.22.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index d9b663d1bb3f..806a03601188 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "10.21.0", + "version": "10.22.0", "name": "@sentry-internal/test-utils", "author": "Sentry", "license": "MIT", @@ -48,7 +48,7 @@ }, "devDependencies": { "@playwright/test": "~1.53.2", - "@sentry/core": "10.21.0" + "@sentry/core": "10.22.0" }, "volta": { "extends": "../../package.json" diff --git a/lerna.json b/lerna.json index ad4eeababaf1..473fa5d48de0 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "10.21.0", + "version": "10.22.0", "npmClient": "yarn" } diff --git a/packages/angular/package.json b/packages/angular/package.json index 43de1601cce5..3c66c4a0912c 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Angular", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular", @@ -21,8 +21,8 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "10.21.0", - "@sentry/core": "10.21.0", + "@sentry/browser": "10.22.0", + "@sentry/core": "10.22.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/package.json b/packages/astro/package.json index 95e2b6fd29db..1bdb0f423802 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/astro", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Astro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/astro", @@ -56,9 +56,9 @@ "astro": ">=3.x || >=4.0.0-beta || >=5.x" }, "dependencies": { - "@sentry/browser": "10.21.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", + "@sentry/browser": "10.22.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 9e680b775210..6b29b5e34d56 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/aws-serverless", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for AWS Lambda and AWS Serverless Environments", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless", @@ -69,8 +69,8 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-aws-sdk": "0.59.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", "@types/aws-lambda": "^8.10.62" }, "devDependencies": { diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 2947d63ebb45..0f8ad407f901 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-utils", - "version": "10.21.0", + "version": "10.22.0", "description": "Browser Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser-utils", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.21.0" + "@sentry/core": "10.22.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser/package.json b/packages/browser/package.json index 556ef9e83db9..2f18a2573c8b 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for browsers", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser", @@ -39,14 +39,14 @@ "access": "public" }, "dependencies": { - "@sentry-internal/browser-utils": "10.21.0", - "@sentry-internal/feedback": "10.21.0", - "@sentry-internal/replay": "10.21.0", - "@sentry-internal/replay-canvas": "10.21.0", - "@sentry/core": "10.21.0" + "@sentry-internal/browser-utils": "10.22.0", + "@sentry-internal/feedback": "10.22.0", + "@sentry-internal/replay": "10.22.0", + "@sentry-internal/replay-canvas": "10.22.0", + "@sentry/core": "10.22.0" }, "devDependencies": { - "@sentry-internal/integration-shims": "10.21.0", + "@sentry-internal/integration-shims": "10.22.0", "fake-indexeddb": "^4.0.1" }, "scripts": { diff --git a/packages/bun/package.json b/packages/bun/package.json index 4c57b5e3c93b..73dcefe1fb70 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bun", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for bun", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0" + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0" }, "devDependencies": { "bun-types": "^1.2.9" diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 46d21eaa7b0e..674e090a8265 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/cloudflare", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Cloudflare Workers and Pages", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare", @@ -50,7 +50,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.21.0" + "@sentry/core": "10.22.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" diff --git a/packages/core/package.json b/packages/core/package.json index 3fc1bdf1d520..9b9d69abec7e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/core", - "version": "10.21.0", + "version": "10.22.0", "description": "Base implementation for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/core", diff --git a/packages/deno/package.json b/packages/deno/package.json index 36fe4193606c..1727ae8e2d1a 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/deno", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Deno", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", @@ -25,7 +25,7 @@ ], "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.21.0" + "@sentry/core": "10.22.0" }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", diff --git a/packages/ember/package.json b/packages/ember/package.json index 92a8a337eb89..5aa4a958f924 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/ember", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Ember.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/ember", @@ -32,8 +32,8 @@ "dependencies": { "@babel/core": "^7.27.7", "@embroider/macros": "^1.16.0", - "@sentry/browser": "10.21.0", - "@sentry/core": "10.21.0", + "@sentry/browser": "10.22.0", + "@sentry/core": "10.22.0", "ember-auto-import": "^2.7.2", "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^6.1.1", diff --git a/packages/eslint-config-sdk/package.json b/packages/eslint-config-sdk/package.json index 91bd214d8736..eb1ec1b22117 100644 --- a/packages/eslint-config-sdk/package.json +++ b/packages/eslint-config-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-config-sdk", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK eslint config", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-config-sdk", @@ -22,8 +22,8 @@ "access": "public" }, "dependencies": { - "@sentry-internal/eslint-plugin-sdk": "10.21.0", - "@sentry-internal/typescript": "10.21.0", + "@sentry-internal/eslint-plugin-sdk": "10.22.0", + "@sentry-internal/typescript": "10.22.0", "@typescript-eslint/eslint-plugin": "^5.48.0", "@typescript-eslint/parser": "^5.48.0", "eslint-config-prettier": "^6.11.0", diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index 5884e3ab52be..b04194d84bb5 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-plugin-sdk", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK eslint plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-plugin-sdk", diff --git a/packages/feedback/package.json b/packages/feedback/package.json index 8f2d83cee62a..297d70ed3d4c 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/feedback", - "version": "10.21.0", + "version": "10.22.0", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.21.0" + "@sentry/core": "10.22.0" }, "devDependencies": { "preact": "^10.19.4" diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 8ab9743297d5..d67b4b423738 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/gatsby", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Gatsby.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby", @@ -45,8 +45,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.21.0", - "@sentry/react": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/react": "10.22.0", "@sentry/webpack-plugin": "^4.1.1" }, "peerDependencies": { diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index 2aac33028f8d..35d11e2732d6 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/google-cloud-serverless", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Google Cloud Functions", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/google-cloud-serverless", @@ -48,8 +48,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", "@types/express": "^4.17.14" }, "devDependencies": { diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index 6d3416fd15bf..6e1f495b83c7 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/integration-shims", - "version": "10.21.0", + "version": "10.22.0", "description": "Shims for integrations in Sentry SDK.", "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -56,7 +56,7 @@ "url": "https://github.com/getsentry/sentry-javascript/issues" }, "dependencies": { - "@sentry/core": "10.21.0" + "@sentry/core": "10.22.0" }, "engines": { "node": ">=18" diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 1b94378f1450..b5ef30c20df9 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nestjs", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for NestJS", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nestjs", @@ -49,8 +49,8 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-nestjs-core": "0.50.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0" + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 26b3a172090a..4815b2e1a723 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nextjs", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Next.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs", @@ -79,13 +79,13 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@rollup/plugin-commonjs": "28.0.1", - "@sentry-internal/browser-utils": "10.21.0", + "@sentry-internal/browser-utils": "10.22.0", "@sentry/bundler-plugin-core": "^4.3.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", - "@sentry/opentelemetry": "10.21.0", - "@sentry/react": "10.21.0", - "@sentry/vercel-edge": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", + "@sentry/opentelemetry": "10.22.0", + "@sentry/react": "10.22.0", + "@sentry/vercel-edge": "10.22.0", "@sentry/webpack-plugin": "^4.3.0", "resolve": "1.22.8", "rollup": "^4.35.0", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index a68fb2505b48..f13d86c08f6a 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-core", - "version": "10.21.0", + "version": "10.22.0", "description": "Sentry Node-Core SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-core", @@ -67,8 +67,8 @@ }, "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.21.0", - "@sentry/opentelemetry": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/opentelemetry": "10.22.0", "import-in-the-middle": "^1.14.2" }, "devDependencies": { diff --git a/packages/node-native/package.json b/packages/node-native/package.json index 1c2b7e97e476..2a1343389ee3 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-native", - "version": "10.21.0", + "version": "10.22.0", "description": "Native Tools for the Official Sentry Node.js SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-native", @@ -64,8 +64,8 @@ }, "dependencies": { "@sentry-internal/node-native-stacktrace": "^0.2.2", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0" + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/node/package.json b/packages/node/package.json index bd41dc271567..93fcb37b500f 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node", - "version": "10.21.0", + "version": "10.22.0", "description": "Sentry Node SDK using OpenTelemetry for performance instrumentation", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node", @@ -95,9 +95,9 @@ "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.15.0", - "@sentry/core": "10.21.0", - "@sentry/node-core": "10.21.0", - "@sentry/opentelemetry": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/node-core": "10.22.0", + "@sentry/opentelemetry": "10.22.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" }, diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 1224d9538d5c..4402f3bc4510 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nuxt", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Nuxt", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nuxt", @@ -47,13 +47,13 @@ }, "dependencies": { "@nuxt/kit": "^3.13.2", - "@sentry/browser": "10.21.0", - "@sentry/cloudflare": "10.21.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", + "@sentry/browser": "10.22.0", + "@sentry/cloudflare": "10.22.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", "@sentry/rollup-plugin": "^4.3.0", "@sentry/vite-plugin": "^4.3.0", - "@sentry/vue": "10.21.0" + "@sentry/vue": "10.22.0" }, "devDependencies": { "@nuxt/module-builder": "^0.8.4", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index be55cf14347a..fe2acbc29335 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/opentelemetry", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry utilities for OpenTelemetry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/opentelemetry", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.21.0" + "@sentry/core": "10.22.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json index 2d3ad1f66c8c..b74d18c152aa 100644 --- a/packages/profiling-node/package.json +++ b/packages/profiling-node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/profiling-node", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Node.js Profiling", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/profiling-node", @@ -63,8 +63,8 @@ }, "dependencies": { "@sentry-internal/node-cpu-profiler": "^2.2.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0" + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 7571c6e4b414..9791d3f4c0d3 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react-router", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for React Router (Framework)", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react-router", @@ -49,11 +49,11 @@ "@opentelemetry/core": "^2.1.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/browser": "10.21.0", + "@sentry/browser": "10.22.0", "@sentry/cli": "^2.56.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", - "@sentry/react": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", + "@sentry/react": "10.22.0", "@sentry/vite-plugin": "^4.1.0", "glob": "11.0.1" }, diff --git a/packages/react/package.json b/packages/react/package.json index af6059cffa4f..02d02a9a8211 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for React.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.21.0", - "@sentry/core": "10.21.0", + "@sentry/browser": "10.22.0", + "@sentry/core": "10.22.0", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { diff --git a/packages/remix/package.json b/packages/remix/package.json index 653e63cb3a46..8c50c67f7264 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/remix", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Remix", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/remix", @@ -69,9 +69,9 @@ "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", "@sentry/cli": "^2.56.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", - "@sentry/react": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", + "@sentry/react": "10.22.0", "glob": "^10.3.4", "yargs": "^17.6.0" }, diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index d85775b60f9e..4f87d64087f9 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-canvas", - "version": "10.21.0", + "version": "10.22.0", "description": "Replay canvas integration", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -69,8 +69,8 @@ "@sentry-internal/rrweb": "2.37.0" }, "dependencies": { - "@sentry-internal/replay": "10.21.0", - "@sentry/core": "10.21.0" + "@sentry-internal/replay": "10.22.0", + "@sentry/core": "10.22.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index 57bc676a7869..bb6c95a525f5 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay", - "version": "10.21.0", + "version": "10.22.0", "description": "User replays for Sentry", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -81,7 +81,7 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@babel/core": "^7.27.7", - "@sentry-internal/replay-worker": "10.21.0", + "@sentry-internal/replay-worker": "10.22.0", "@sentry-internal/rrweb": "2.37.0", "@sentry-internal/rrweb-snapshot": "2.37.0", "fflate": "0.8.2", @@ -90,8 +90,8 @@ "node-fetch": "^2.6.7" }, "dependencies": { - "@sentry-internal/browser-utils": "10.21.0", - "@sentry/core": "10.21.0" + "@sentry-internal/browser-utils": "10.22.0", + "@sentry/core": "10.22.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index 1fca13cf84e4..e1c7b26f1ea9 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-worker", - "version": "10.21.0", + "version": "10.22.0", "description": "Worker for @sentry-internal/replay", "main": "build/esm/index.js", "module": "build/esm/index.js", diff --git a/packages/solid/package.json b/packages/solid/package.json index dae956cdd977..550ecd50640e 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solid", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Solid", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solid", @@ -54,8 +54,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.21.0", - "@sentry/core": "10.21.0" + "@sentry/browser": "10.22.0", + "@sentry/core": "10.22.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 775b7a3c08e9..3d3f08da76ce 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solidstart", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Solid Start", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solidstart", @@ -66,9 +66,9 @@ } }, "dependencies": { - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", - "@sentry/solid": "10.21.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", + "@sentry/solid": "10.22.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 6114b4c2f4a1..03d8b4aa3b01 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/svelte", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Svelte", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/svelte", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.21.0", - "@sentry/core": "10.21.0", + "@sentry/browser": "10.22.0", + "@sentry/core": "10.22.0", "magic-string": "^0.30.0" }, "peerDependencies": { diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index ed8f6ddb40aa..912aab01aa3d 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/sveltekit", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for SvelteKit", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/sveltekit", @@ -48,10 +48,10 @@ }, "dependencies": { "@babel/parser": "7.26.9", - "@sentry/cloudflare": "10.21.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", - "@sentry/svelte": "10.21.0", + "@sentry/cloudflare": "10.22.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", + "@sentry/svelte": "10.22.0", "@sentry/vite-plugin": "^4.1.0", "magic-string": "0.30.7", "recast": "0.23.11", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index b5f9ab645b5f..4030d36ce0e7 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart-react", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for TanStack Start React", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart-react", @@ -52,10 +52,10 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry-internal/browser-utils": "10.21.0", - "@sentry/core": "10.21.0", - "@sentry/node": "10.21.0", - "@sentry/react": "10.21.0" + "@sentry-internal/browser-utils": "10.22.0", + "@sentry/core": "10.22.0", + "@sentry/node": "10.22.0", + "@sentry/react": "10.22.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/tanstackstart/package.json b/packages/tanstackstart/package.json index 0e263b47c152..9960a534ab56 100644 --- a/packages/tanstackstart/package.json +++ b/packages/tanstackstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart", - "version": "10.21.0", + "version": "10.22.0", "description": "Utilities for the Sentry TanStack Start SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart", diff --git a/packages/types/package.json b/packages/types/package.json index 65e50db67973..d1d75ad623a2 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/types", - "version": "10.21.0", + "version": "10.22.0", "description": "Types for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/types", @@ -57,7 +57,7 @@ "yalc:publish": "yalc publish --push --sig" }, "dependencies": { - "@sentry/core": "10.21.0" + "@sentry/core": "10.22.0" }, "volta": { "extends": "../../package.json" diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 02ce8b6b2e75..b5def3056ebe 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/typescript", - "version": "10.21.0", + "version": "10.22.0", "description": "Typescript configuration used at Sentry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/typescript", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 9abae213b4e7..cb86bc927053 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vercel-edge", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for the Vercel Edge Runtime", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vercel-edge", @@ -41,14 +41,14 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/resources": "^2.1.0", - "@sentry/core": "10.21.0" + "@sentry/core": "10.22.0" }, "devDependencies": { "@edge-runtime/types": "3.0.1", "@opentelemetry/core": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/opentelemetry": "10.21.0" + "@sentry/opentelemetry": "10.22.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/vue/package.json b/packages/vue/package.json index d51e48074dc8..5562ee80eac2 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vue", - "version": "10.21.0", + "version": "10.22.0", "description": "Official Sentry SDK for Vue.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vue", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.21.0", - "@sentry/core": "10.21.0" + "@sentry/browser": "10.22.0", + "@sentry/core": "10.22.0" }, "peerDependencies": { "pinia": "2.x || 3.x", diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 1d4bdd213251..4b62e7ac5918 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/wasm", - "version": "10.21.0", + "version": "10.22.0", "description": "Support for WASM.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/wasm", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.21.0", - "@sentry/core": "10.21.0" + "@sentry/browser": "10.22.0", + "@sentry/core": "10.22.0" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", From 2c6e710b9c54a015ec7b8e9c28257895fddde881 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 24 Oct 2025 10:31:35 +0200 Subject: [PATCH 067/190] feat(react-router): Align options with shared build time options type (#18014) closes https://github.com/getsentry/sentry-javascript/issues/17066 Also updates the shared `BuildTimeOptionsBase` type a bit to align with the latest changes. --- .../buildTimeOptionsBase.ts | 171 ++++++++++-------- packages/react-router/.eslintrc.js | 2 +- .../src/vite/buildEnd/handleOnBuildEnd.ts | 33 +++- packages/react-router/src/vite/plugin.ts | 4 +- packages/react-router/src/vite/types.ts | 156 +++------------- .../test/client/tracingIntegration.test.ts | 1 + .../server/createSentryHandleRequest.test.ts | 7 +- .../instrumentation/reactRouterServer.test.ts | 3 + .../vite/buildEnd/handleOnBuildEnd.test.ts | 13 ++ .../react-router/test/vite/types.test-d.ts | 114 ++++++++++++ packages/react-router/tsconfig.test.json | 2 +- packages/react-router/tsconfig.vite.json | 9 + packages/react-router/vite.config.ts | 4 + 13 files changed, 299 insertions(+), 220 deletions(-) create mode 100644 packages/react-router/test/vite/types.test-d.ts create mode 100644 packages/react-router/tsconfig.vite.json diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index e43160cc03ab..67f74f696dcf 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -243,6 +243,37 @@ interface SourceMapsOptions { filesToDeleteAfterUpload?: string | Array; } +type AutoSetCommitsOptions = { + /** + * Automatically sets `commit` and `previousCommit`. Sets `commit` to `HEAD` + * and `previousCommit` as described in the option's documentation. + * + * If you set this to `true`, manually specified `commit` and `previousCommit` + * options will be overridden. It is best to not specify them at all if you + * set this option to `true`. + */ + auto: true; + repo?: undefined; + commit?: undefined; +}; + +type ManualSetCommitsOptions = { + auto?: false | undefined; + /** + * The full repo name as defined in Sentry. + * + * Required if the `auto` option is not set to `true`. + */ + repo: string; + + /** + * The current (last) commit in the release. + * + * Required if the `auto` option is not set to `true`. + */ + commit: string; +}; + interface ReleaseOptions { /** * Unique identifier for the release you want to create. @@ -299,101 +330,81 @@ interface ReleaseOptions { /** * Configuration for associating the release with its commits in Sentry. + * + * Set to `false` to disable commit association. + * + * @default { auto: true } */ - setCommits?: ( - | { + setCommits?: + | false + | ((AutoSetCommitsOptions | ManualSetCommitsOptions) & { /** - * Automatically sets `commit` and `previousCommit`. Sets `commit` to `HEAD` - * and `previousCommit` as described in the option's documentation. + * The commit before the beginning of this release (in other words, + * the last commit of the previous release). + * + * Defaults to the last commit of the previous release in Sentry. * - * If you set this to `true`, manually specified `commit` and `previousCommit` - * options will be overridden. It is best to not specify them at all if you - * set this option to `true`. + * If there was no previous release, the last 10 commits will be used. */ - auto: true; - repo?: undefined; - commit?: undefined; - } - | { - auto?: false | undefined; + previousCommit?: string; + /** - * The full repo name as defined in Sentry. + * If the flag is to `true` and the previous release commit was not found + * in the repository, the plugin creates a release with the default commits + * count instead of failing the command. * - * Required if the `auto` option is not set to `true`. + * @default false */ - repo: string; + ignoreMissing?: boolean; /** - * The current (last) commit in the release. + * If this flag is set, the setCommits step will not fail and just exit + * silently if no new commits for a given release have been found. * - * Required if the `auto` option is not set to `true`. + * @default false */ - commit: string; - } - ) & { - /** - * The commit before the beginning of this release (in other words, - * the last commit of the previous release). - * - * Defaults to the last commit of the previous release in Sentry. - * - * If there was no previous release, the last 10 commits will be used. - */ - previousCommit?: string; - - /** - * If the flag is to `true` and the previous release commit was not found - * in the repository, the plugin creates a release with the default commits - * count instead of failing the command. - * - * @default false - */ - ignoreMissing?: boolean; - - /** - * If this flag is set, the setCommits step will not fail and just exit - * silently if no new commits for a given release have been found. - * - * @default false - */ - ignoreEmpty?: boolean; - }; + ignoreEmpty?: boolean; + }); /** * Configuration for adding deployment information to the release in Sentry. + * + * Set to `false` to disable automatic deployment detection and creation. */ - deploy?: { - /** - * Environment for this release. Values that make sense here would - * be `production` or `staging`. - */ - env: string; - - /** - * Deployment start time in Unix timestamp (in seconds) or ISO 8601 format. - */ - started?: number | string; - - /** - * Deployment finish time in Unix timestamp (in seconds) or ISO 8601 format. - */ - finished?: number | string; - - /** - * Deployment duration (in seconds). Can be used instead of started and finished. - */ - time?: number; - - /** - * Human-readable name for the deployment. - */ - name?: string; - - /** - * URL that points to the deployment. - */ - url?: string; - }; + deploy?: + | false + | { + /** + * Environment for this release. Values that make sense here would + * be `production` or `staging`. + */ + env: string; + + /** + * Deployment start time in Unix timestamp (in seconds) or ISO 8601 format. + */ + started?: number | string; + + /** + * Deployment finish time in Unix timestamp (in seconds) or ISO 8601 format. + */ + finished?: number | string; + + /** + * Deployment duration (in seconds). Can be used instead of started and finished. + */ + time?: number; + + /** + * Human-readable name for the deployment. + */ + name?: string; + + /** + * URL that points to the deployment. + */ + url?: string; + }; } interface BundleSizeOptimizationsOptions { diff --git a/packages/react-router/.eslintrc.js b/packages/react-router/.eslintrc.js index a22f9710cf6b..e6ea40d78d05 100644 --- a/packages/react-router/.eslintrc.js +++ b/packages/react-router/.eslintrc.js @@ -7,7 +7,7 @@ module.exports = { { files: ['vite.config.ts'], parserOptions: { - project: ['tsconfig.test.json'], + project: ['tsconfig.vite.json'], }, }, ], diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index 959578b6d644..a3d1e78cb285 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -1,6 +1,7 @@ import { rm } from 'node:fs/promises'; import type { Config } from '@react-router/dev/config'; import SentryCli from '@sentry/cli'; +import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; import { glob } from 'glob'; import type { SentryReactRouterBuildOptions } from '../types'; @@ -23,17 +24,31 @@ function getSentryConfig(viteConfig: unknown): SentryReactRouterBuildOptions { export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteConfig }) => { const sentryConfig = getSentryConfig(viteConfig); + // todo(v11): Remove deprecated sourceMapsUploadOptions support (no need for spread/pick anymore) + const { + sourceMapsUploadOptions, // extract to exclude from rest config + ...sentryConfigWithoutDeprecatedSourceMapOption + } = sentryConfig; + const { authToken, org, project, release, - sourceMapsUploadOptions = { enabled: true }, + sourcemaps = { disable: false }, debug = false, - unstable_sentryVitePluginOptions, - }: SentryReactRouterBuildOptions = { + }: Omit & + // Pick 'sourcemaps' from Vite plugin options as the types allow more (e.g. Promise values for `deleteFilesAfterUpload`) + Pick = { ...sentryConfig.unstable_sentryVitePluginOptions, - ...sentryConfig, + ...sentryConfigWithoutDeprecatedSourceMapOption, // spread in the config without the deprecated sourceMapsUploadOptions + sourcemaps: { + ...sentryConfig.unstable_sentryVitePluginOptions?.sourcemaps, + ...sentryConfig.sourcemaps, + ...sourceMapsUploadOptions, + // eslint-disable-next-line deprecation/deprecation + disable: sourceMapsUploadOptions?.enabled === false ? true : sentryConfig.sourcemaps?.disable, + }, release: { ...sentryConfig.unstable_sentryVitePluginOptions?.release, ...sentryConfig.release, @@ -44,8 +59,9 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo authToken, org, project, - ...unstable_sentryVitePluginOptions, + ...sentryConfig.unstable_sentryVitePluginOptions, }); + // check if release should be created if (release?.name) { try { @@ -56,7 +72,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo } } - if (sourceMapsUploadOptions?.enabled ?? (true && viteConfig.build.sourcemap !== false)) { + if (!sourcemaps?.disable && viteConfig.build.sourcemap !== false) { // inject debugIds try { await cliInstance.execute( @@ -84,9 +100,10 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo } } // delete sourcemaps after upload - let updatedFilesToDeleteAfterUpload = sourceMapsUploadOptions?.filesToDeleteAfterUpload; + let updatedFilesToDeleteAfterUpload = await sourcemaps?.filesToDeleteAfterUpload; + // set a default value no option was set - if (typeof sourceMapsUploadOptions?.filesToDeleteAfterUpload === 'undefined') { + if (typeof updatedFilesToDeleteAfterUpload === 'undefined') { updatedFilesToDeleteAfterUpload = [`${reactRouterConfig.buildDirectory}/**/*.map`]; debug && // eslint-disable-next-line no-console diff --git a/packages/react-router/src/vite/plugin.ts b/packages/react-router/src/vite/plugin.ts index 98405771ee1b..8163ee4c6a1b 100644 --- a/packages/react-router/src/vite/plugin.ts +++ b/packages/react-router/src/vite/plugin.ts @@ -14,13 +14,13 @@ import type { SentryReactRouterBuildOptions } from './types'; */ export async function sentryReactRouter( options: SentryReactRouterBuildOptions = {}, - config: ConfigEnv, + viteConfig: ConfigEnv, ): Promise { const plugins: Plugin[] = []; plugins.push(makeConfigInjectorPlugin(options)); - if (process.env.NODE_ENV !== 'development' && config.command === 'build' && config.mode !== 'development') { + if (process.env.NODE_ENV !== 'development' && viteConfig.command === 'build' && viteConfig.mode !== 'development') { plugins.push(makeEnableSourceMapsPlugin(options)); plugins.push(...(await makeCustomSentryVitePlugins(options))); } diff --git a/packages/react-router/src/vite/types.ts b/packages/react-router/src/vite/types.ts index fb488d2ca8bc..c7555630c4fa 100644 --- a/packages/react-router/src/vite/types.ts +++ b/packages/react-router/src/vite/types.ts @@ -1,3 +1,4 @@ +import type { BuildTimeOptionsBase, UnstableVitePluginOptions } from '@sentry/core'; import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; type SourceMapsOptions = { @@ -6,6 +7,7 @@ type SourceMapsOptions = { * automatically generate and upload source maps to Sentry during a production build. * * @default true + * @deprecated Use `sourcemaps.disable` option instead of `sourceMapsUploadOptions.enabled`. */ enabled?: boolean; @@ -16,6 +18,8 @@ type SourceMapsOptions = { * @default [] - By default no files are deleted. * * The globbing patterns follow the implementation of the glob package. (https://www.npmjs.com/package/glob) + * + * @deprecated Use `sourcemaps.filesToDeleteAfterUpload` option instead of `sourceMapsUploadOptions.filesToDeleteAfterUpload`. */ filesToDeleteAfterUpload?: string | Array; @@ -23,7 +27,10 @@ type SourceMapsOptions = { * Options related to managing the Sentry releases for a build. * * More info: https://docs.sentry.io/product/releases/ + * + * @deprecated Use the `release` option at the root of `SentryVitePluginOptions` instead. */ + // todo(v11): Remove this option (currently it's not in use either, but it's kept to not cause a breaking change) release?: { /** * Unique identifier for the release you want to create. @@ -40,136 +47,31 @@ type SourceMapsOptions = { }; }; -type BundleSizeOptimizationOptions = { - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) any debugging code within the Sentry SDK. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * Setting this option to `true` will disable features like the SDK's `debug` option. - */ - excludeDebugStatements?: boolean; - - /** - * If set to true, the plugin will try to tree-shake tracing statements out. - * Note that the success of this depends on tree shaking generally being enabled in your build. - * Attention: DO NOT enable this when you're using any performance monitoring-related SDK features (e.g. Sentry.startSpan()). - */ - excludeTracing?: boolean; - - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) code related to the Sentry SDK's Session Replay Shadow DOM recording functionality. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * This option is safe to be used when you do not want to capture any Shadow DOM activity via Sentry Session Replay. - */ - excludeReplayShadowDom?: boolean; - - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) code related to the Sentry SDK's Session Replay `iframe` recording functionality. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * You can safely do this when you do not want to capture any `iframe` activity via Sentry Session Replay. - */ - excludeReplayIframe?: boolean; - - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) code related to the Sentry SDK's Session Replay's Compression Web Worker. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * **Notice:** You should only do use this option if you manually host a compression worker and configure it in your Sentry Session Replay integration config via the `workerUrl` option. - */ - excludeReplayWorker?: boolean; -}; - -export type SentryReactRouterBuildOptions = { - /** - * Options for configuring the Sentry release. - */ - release?: { - /** - * The name of the release to create in Sentry - */ - name?: string; - }; - - /** - * The auth token to use when uploading source maps to Sentry. - * - * Instead of specifying this option, you can also set the `SENTRY_AUTH_TOKEN` environment variable. - * - * To create an auth token, follow this guide: - * @see https://docs.sentry.io/product/accounts/auth-tokens/#organization-auth-tokens - */ - authToken?: string; - - /** - * The organization slug of your Sentry organization. - * Instead of specifying this option, you can also set the `SENTRY_ORG` environment variable. - */ - org?: string; - - /** - * The project slug of your Sentry project. - * Instead of specifying this option, you can also set the `SENTRY_PROJECT` environment variable. - */ - project?: string; - - /** - * Options for the Sentry Vite plugin to customize bundle size optimizations. - */ - bundleSizeOptimizations?: BundleSizeOptimizationOptions; - - /** - * If this flag is `true`, Sentry will log debug information during build time. - * @default false. - */ - debug?: boolean; - - /** - * Options related to react component name annotations. - * Disabled by default, unless a value is set for this option. - * When enabled, your app's DOM will automatically be annotated during build-time with their respective component names. - * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. - * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components - */ - reactComponentAnnotation?: { +export type SentryReactRouterBuildOptions = BuildTimeOptionsBase & + UnstableVitePluginOptions> & { /** - * Whether the component name annotate plugin should be enabled or not. + * Options related to react component name annotations. + * Disabled by default, unless a value is set for this option. + * When enabled, your app's DOM will automatically be annotated during build-time with their respective component names. + * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. + * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components */ - enabled?: boolean; + reactComponentAnnotation?: { + /** + * Whether the component name annotate plugin should be enabled or not. + */ + enabled?: boolean; + + /** + * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. + */ + ignoredComponents?: string[]; + }; /** - * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. + * Options for the Sentry Vite plugin to customize the source maps upload process. + * */ - ignoredComponents?: string[]; + sourceMapsUploadOptions?: SourceMapsOptions; + // todo(v11): Remove this option (all options already exist in BuildTimeOptionsBase) }; - - /** - * Options for the Sentry Vite plugin to customize the source maps upload process. - * - */ - sourceMapsUploadOptions?: SourceMapsOptions; - - /** - * If this flag is `true`, the Sentry plugin will collect some telemetry data and send it to Sentry. - * It will not collect any sensitive or user-specific data. - * - * @default true - */ - telemetry?: boolean; - - /** - * Options to further customize the Sentry Vite Plugin (@sentry/vite-plugin) behavior directly. - * Options specified in this object take precedence over the options specified in - * the `sourcemaps` and `release` objects. - * - * @see https://www.npmjs.com/package/@sentry/vite-plugin/v/2.22.2#options which lists all available options. - * - * Warning: Options within this object are subject to change at any time. - * We DO NOT guarantee semantic versioning for these options, meaning breaking - * changes can occur at any time within a major SDK version. - * - * Furthermore, some options are untested with SvelteKit specifically. Use with caution. - */ - unstable_sentryVitePluginOptions?: Partial; -}; diff --git a/packages/react-router/test/client/tracingIntegration.test.ts b/packages/react-router/test/client/tracingIntegration.test.ts index 9d511b4c6bde..2469c9b29db6 100644 --- a/packages/react-router/test/client/tracingIntegration.test.ts +++ b/packages/react-router/test/client/tracingIntegration.test.ts @@ -17,6 +17,7 @@ describe('reactRouterTracingIntegration', () => { it('calls instrumentHydratedRouter and browserTracingIntegrationInstance.afterAllSetup in afterAllSetup', () => { const browserTracingSpy = vi.spyOn(sentryBrowser, 'browserTracingIntegration').mockImplementation(() => ({ + setup: vi.fn(), afterAllSetup: vi.fn(), name: 'BrowserTracing', })); diff --git a/packages/react-router/test/server/createSentryHandleRequest.test.ts b/packages/react-router/test/server/createSentryHandleRequest.test.ts index 19e6d9542cbb..f87de2f3b0ea 100644 --- a/packages/react-router/test/server/createSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/createSentryHandleRequest.test.ts @@ -42,8 +42,13 @@ describe('createSentryHandleRequest', () => { url: '/test', version: '1.0.0', }, + ssr: true, + routeDiscovery: { + mode: 'lazy', + manifestPath: '/path/to/manifest', + }, routeModules: {}, - future: {}, + future: { unstable_subResourceIntegrity: false, v8_middleware: false }, isSpaMode: false, staticHandlerContext: { matches: [ diff --git a/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts index 473ad1272ca4..fb5141f8830d 100644 --- a/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts +++ b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts @@ -88,10 +88,13 @@ describe('ReactRouterInstrumentation', () => { it('should start a span for data requests with active root span', async () => { vi.spyOn(Util, 'isDataRequest').mockReturnValue(true); + // @ts-expect-error MockSpan just for testing vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(mockSpan as Span); + // @ts-expect-error MockSpan just for testing vi.spyOn(SentryCore, 'getRootSpan').mockReturnValue(mockSpan as Span); vi.spyOn(SentryCore, 'spanToJSON').mockReturnValue({ data: {} } as SpanJSON); vi.spyOn(Util, 'getSpanName').mockImplementation((pathname, method) => `span:${pathname}:${method}`); + // @ts-expect-error MockSpan just for testing vi.spyOn(SentryCore, 'startSpan').mockImplementation((_opts, fn) => fn(mockSpan as Span)); const proxy = (instrumentation as any)._createPatchedModuleProxy(mockModule); diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts index 29bb5c0527d6..8be1d7764219 100644 --- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts +++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts @@ -81,6 +81,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.new).toHaveBeenCalledWith('v1.0.0'); @@ -102,6 +103,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.new).toHaveBeenCalledWith('v1.0.0-unstable'); @@ -126,6 +128,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.new).toHaveBeenCalledWith('v1.0.0'); @@ -145,6 +148,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.uploadSourceMaps).toHaveBeenCalledTimes(1); @@ -168,12 +172,14 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); }); it('should delete source maps after upload with default pattern', async () => { + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(defaultConfig); expect(glob).toHaveBeenCalledWith(['/build/**/*.map'], { @@ -196,6 +202,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(glob).toHaveBeenCalledWith('/custom/**/*.map', { @@ -221,6 +228,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not create release', expect.any(Error)); @@ -241,6 +249,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); @@ -250,6 +259,7 @@ describe('sentryOnBuildEnd', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockSentryCliInstance.execute.mockRejectedValueOnce(new Error('Injection failed')); + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(defaultConfig); expect(mockSentryCliInstance.execute).toHaveBeenCalledTimes(1); expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); @@ -262,6 +272,7 @@ describe('sentryOnBuildEnd', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockSentryCliInstance.releases.uploadSourceMaps.mockRejectedValueOnce(new Error('Upload failed')); + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(defaultConfig); expect(consoleSpy).toHaveBeenCalledWith('[Sentry] Could not upload sourcemaps', expect.any(Error)); @@ -282,6 +293,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Sentry] Automatically setting')); @@ -312,6 +324,7 @@ describe('sentryOnBuildEnd', () => { } as unknown as TestConfig, }; + // @ts-expect-error - mocking the React config await sentryOnBuildEnd(config); expect(SentryCli).toHaveBeenCalledWith(null, expect.objectContaining(customOptions)); diff --git a/packages/react-router/test/vite/types.test-d.ts b/packages/react-router/test/vite/types.test-d.ts new file mode 100644 index 000000000000..c6a50e92b275 --- /dev/null +++ b/packages/react-router/test/vite/types.test-d.ts @@ -0,0 +1,114 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { SentryReactRouterBuildOptions } from '../../src/vite/types'; + +describe('Sentry React-Router build-time options type', () => { + it('includes all options based on type BuildTimeOptionsBase', () => { + const completeOptions: SentryReactRouterBuildOptions = { + // --- BuildTimeOptionsBase options --- + org: 'test-org', + project: 'test-project', + authToken: 'test-auth-token', + sentryUrl: 'https://sentry.io', + headers: { Authorization: ' Bearer test-auth-token' }, + telemetry: true, + silent: false, + // eslint-disable-next-line no-console + errorHandler: (err: Error) => console.warn(err), + debug: false, + sourcemaps: { + disable: false, + assets: ['./dist/**/*'], + ignore: ['./dist/*.map'], + filesToDeleteAfterUpload: ['./dist/*.map'], + }, + release: { + name: 'test-release-1.0.0', + create: true, + finalize: true, + dist: 'test-dist', + vcsRemote: 'origin', + setCommits: { + auto: false, + repo: 'test/repo', + commit: 'abc123', + previousCommit: 'def456', + ignoreMissing: false, + ignoreEmpty: false, + }, + deploy: { + env: 'production', + started: 1234567890, + finished: 1234567900, + time: 10, + name: 'deployment-name', + url: 'https://example.com', + }, + }, + bundleSizeOptimizations: { + excludeDebugStatements: true, + excludeTracing: false, + excludeReplayShadowDom: true, + excludeReplayIframe: true, + excludeReplayWorker: true, + }, + + // --- SentryReactRouterBuildOptions specific options --- + reactComponentAnnotation: { enabled: true, ignoredComponents: ['IgnoredComponent1', 'IgnoredComponent2'] }, + + unstable_sentryVitePluginOptions: { + // Rollup plugin options + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + // Vite plugin options + sourcemaps: { + assets: './dist/**/*', + }, + }, + }; + + expectTypeOf(completeOptions).toEqualTypeOf(); + }); + + it('includes all deprecated options', () => { + const completeOptions: SentryReactRouterBuildOptions = { + // SentryNuxtModuleOptions specific options + reactComponentAnnotation: { enabled: true, ignoredComponents: ['IgnoredComponent1', 'IgnoredComponent2'] }, + + unstable_sentryVitePluginOptions: { + // Rollup plugin options + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + // Vite plugin options + sourcemaps: { + assets: './dist/**/*', + }, + }, + + // Deprecated sourceMapsUploadOptions + sourceMapsUploadOptions: { + release: { + name: 'deprecated-release', + }, + enabled: true, + filesToDeleteAfterUpload: ['./build/*.map'], + }, + }; + + expectTypeOf(completeOptions).toEqualTypeOf(); + }); + + it('allows partial configuration', () => { + const minimalOptions: SentryReactRouterBuildOptions = { reactComponentAnnotation: { enabled: true } }; + + expectTypeOf(minimalOptions).toEqualTypeOf(); + + const partialOptions: SentryReactRouterBuildOptions = { + reactComponentAnnotation: { enabled: true }, + debug: false, + }; + + expectTypeOf(partialOptions).toEqualTypeOf(); + }); +}); diff --git a/packages/react-router/tsconfig.test.json b/packages/react-router/tsconfig.test.json index 508cf3ea381b..0bb16039ac48 100644 --- a/packages/react-router/tsconfig.test.json +++ b/packages/react-router/tsconfig.test.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*", "vite.config.ts"], + "include": ["test/**/*"], "compilerOptions": { "types": ["node"] diff --git a/packages/react-router/tsconfig.vite.json b/packages/react-router/tsconfig.vite.json new file mode 100644 index 000000000000..3e2d75a55e61 --- /dev/null +++ b/packages/react-router/tsconfig.vite.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + + "include": ["vite.config.ts"], + + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/react-router/vite.config.ts b/packages/react-router/vite.config.ts index 1094fe0d79da..d05c71037c75 100644 --- a/packages/react-router/vite.config.ts +++ b/packages/react-router/vite.config.ts @@ -5,5 +5,9 @@ export default { test: { ...baseConfig.test, environment: 'jsdom', + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + }, }, }; From 2e78b06dc374d57228f08cfa764f3e08bcfbd1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Fri, 24 Oct 2025 10:52:23 +0200 Subject: [PATCH 068/190] feat: Add a note to save changes before starting (#17987) During the release I figured that most of the times you might work on features/bugfixes. In that case the current release process this scenario isn't specified and who knows what it might or might not do. I added point 1. and 9. where we ask Cursor to save the changes (1) and go back to the state it was before (9). This would make the release process more complete. --------- Co-authored-by: Charly Gomez --- .cursor/rules/publishing_release.mdc | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.cursor/rules/publishing_release.mdc b/.cursor/rules/publishing_release.mdc index 4d6fecca5d2a..f50a5ea57f93 100644 --- a/.cursor/rules/publishing_release.mdc +++ b/.cursor/rules/publishing_release.mdc @@ -12,13 +12,18 @@ Use these guidelines when publishing a new Sentry JavaScript SDK release. The release process is outlined in [publishing-a-release.md](mdc:docs/publishing-a-release.md). -1. Make sure you are on the latest version of the `develop` branch. To confirm this, run `git pull origin develop` to get the latest changes from the repo. +1. Ensure you're on the `develop` branch with the latest changes: + - If you have unsaved changes, stash them with `git stash -u`. + - If you're on a different branch than `develop`, check out the develop branch using `git checkout develop`. + - Pull the latest updates from the remote repository by running `git pull origin develop`. + 2. Run `yarn changelog` on the `develop` branch and copy the output. You can use `yarn changelog | pbcopy` to copy the output of `yarn changelog` into your clipboard. 3. Decide on a version for the release based on [semver](mdc:https://semver.org). The version should be decided based on what is in included in the release. For example, if the release includes a new feature, we should increment the minor version. If it includes only bug fixes, we should increment the patch version. You can find the latest version in [CHANGELOG.md](mdc:CHANGELOG.md) at the very top. 4. Create a branch `prepare-release/VERSION`, eg. `prepare-release/8.1.0`, off `develop`. -5. Update [CHANGELOG.md](mdc:CHANGELOG.md) to add an entry for the next release number and a list of changes since the last release from the output of `yarn changelog`. See the `Updating the Changelog` section in [publishing-a-release.md](mdc:docs/publishing-a-release.md) for more details. If you remove changelog entries because they are not applicable, please let the user know. +5. Update [CHANGELOG.md](mdc:CHANGELOG.md) to add an entry for the next release number and a list of changes since the last release from the output of `yarn changelog`. See the `Updating the Changelog` section in [publishing-a-release.md](mdc:docs/publishing-a-release.md) for more details. Do not remove any changelog entries. 6. Commit the changes to [CHANGELOG.md](mdc:CHANGELOG.md) with `meta(changelog): Update changelog for VERSION` where `VERSION` is the version of the release, e.g. `meta(changelog): Update changelog for 8.1.0` 7. Push the `prepare-release/VERSION` branch to origin and remind the user that the release PR needs to be opened from the `master` branch. +8. In case you were working on a different branch, you can checkout back to the branch you were working on and continue your work by unstashing the changes you stashed earlier with the command `git stash pop` (only if you stashed changes). ## Key Commands From d29de56a1ba13af46e7f9ccb27c74097ce3b3806 Mon Sep 17 00:00:00 2001 From: HanSu Lee Date: Fri, 24 Oct 2025 20:32:40 +0900 Subject: [PATCH 069/190] chore(aws-serverless): Fix typo in timeout warning function name (#18031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While exploring the Timeout Warning feature in `Sentry.wrapHandler`, I fount a type in the function name. It seems `setupTimeoutWarning` was intended name, so I've corrected it accordingly. ## Changes - Renamed `setupTimeoutWatning` → `setupTimeoutWarning` - Updated all 3 call sites to use the corrected function name ## Checklist - [x] Code lints successfully (`yarn lint`) - [x] No new tests required (typo fix only, no functional changes) --- packages/aws-serverless/src/sdk.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index b7ac8927813c..5b0100ae4460 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -108,7 +108,7 @@ function enhanceScopeWithEnvironmentData(scope: Scope, context: Context, startTi }); } -function setupTimeoutWatning(context: Context, options: WrapperOptions): NodeJS.Timeout | undefined { +function setupTimeoutWarning(context: Context, options: WrapperOptions): NodeJS.Timeout | undefined { // In seconds. You cannot go any more granular than this in AWS Lambda. const configuredTimeout = Math.ceil(tryGetRemainingTimeInMillis(context) / 1000); const configuredTimeoutMinutes = Math.floor(configuredTimeout / 60); @@ -220,7 +220,7 @@ export function wrapHandler( return async (event: TEvent, context: Context) => { context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; - timeoutWarningTimer = setupTimeoutWatning(context, options); + timeoutWarningTimer = setupTimeoutWarning(context, options); async function processResult(): Promise { const scope = getCurrentScope(); @@ -272,7 +272,7 @@ function wrapStreamingHandler( ): Promise => { context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; - timeoutWarningTimer = setupTimeoutWatning(context, options); + timeoutWarningTimer = setupTimeoutWarning(context, options); async function processStreamingResult(): Promise { const scope = getCurrentScope(); From 749638766641b552e1193353c3f0430cf970787d Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Fri, 24 Oct 2025 13:51:41 +0200 Subject: [PATCH 070/190] chore: Add external contributor to CHANGELOG.md (#18032) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18031 Co-authored-by: andreiborza <168741329+andreiborza@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d91a753f6544..5b3b35020083 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 @hanseo0507. Thank you for your contribution! + ## 10.22.0 ### Important Changes From fe97d67a96d7e7611fe65301edb5c44b6d965924 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 24 Oct 2025 16:00:38 +0300 Subject: [PATCH 071/190] fix(nuxt): check for H3 error cause before re-capturing (#18035) The flakey tests in nuxt-4 pointed out that we have a race condition where a middleware error can bubble up as an H3 event error, which wraps the original error we caught. This means the [`checkOrSetAlreadyCaught`](https://github.com/getsentry/sentry-javascript/blob/749638766641b552e1193353c3f0430cf970787d/packages/core/src/utils/misc.ts#L212-L232) won't actually detect it since it doesn't check the [`.cause` property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause). I added the logic needed for that for the Nuxt SDK but feels like this may come up later if it hadn't already. Note that this does not affect the spans created, just the mechanism of the caught error, the spans would still be marked correctly as errored. --- .../src/runtime/hooks/captureErrorHook.ts | 11 ++ .../runtime/hooks/captureErrorHook.test.ts | 155 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index 9c2bc1011277..b8e53f0ca0c3 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -25,6 +25,17 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture if (error.statusCode >= 300 && error.statusCode < 500) { return; } + + // Check if the cause (original error) was already captured by middleware instrumentation + // H3 wraps errors, so we need to check the cause property + if ( + 'cause' in error && + typeof error.cause === 'object' && + error.cause !== null && + '__sentry_captured__' in error.cause + ) { + return; + } } const { method, path } = { diff --git a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts new file mode 100644 index 000000000000..30776c4d932c --- /dev/null +++ b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts @@ -0,0 +1,155 @@ +import * as SentryCore from '@sentry/core'; +import { H3Error } from 'h3'; +import type { CapturedErrorContext } from 'nitropack/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryCaptureErrorHook } from '../../../src/runtime/hooks/captureErrorHook'; + +vi.mock('@sentry/core', async importOriginal => { + const mod = await importOriginal(); + return { + ...(mod as any), + captureException: vi.fn(), + flushIfServerless: vi.fn(), + getClient: vi.fn(), + getCurrentScope: vi.fn(() => ({ + setTransactionName: vi.fn(), + })), + }; +}); + +vi.mock('../../../src/runtime/utils', () => ({ + extractErrorContext: vi.fn(() => ({ test: 'context' })), +})); + +describe('sentryCaptureErrorHook', () => { + const mockErrorContext: CapturedErrorContext = { + event: { + _method: 'GET', + _path: '/test-path', + } as any, + }; + + beforeEach(() => { + vi.clearAllMocks(); + (SentryCore.getClient as any).mockReturnValue({ + getOptions: () => ({}), + }); + (SentryCore.flushIfServerless as any).mockResolvedValue(undefined); + }); + + it('should capture regular errors', async () => { + const error = new Error('Test error'); + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should skip H3Error with 4xx status codes', async () => { + const error = new H3Error('Not found'); + error.statusCode = 404; + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should skip H3Error with 3xx status codes', async () => { + const error = new H3Error('Redirect'); + error.statusCode = 302; + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should capture H3Error with 5xx status codes', async () => { + const error = new H3Error('Server error'); + error.statusCode = 500; + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should skip H3Error when cause has __sentry_captured__ flag', async () => { + const originalError = new Error('Original error'); + // Mark the original error as already captured by middleware + Object.defineProperty(originalError, '__sentry_captured__', { + value: true, + enumerable: false, + }); + + const h3Error = new H3Error('Wrapped error', { cause: originalError }); + h3Error.statusCode = 500; + + await sentryCaptureErrorHook(h3Error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); + + it('should capture H3Error when cause does not have __sentry_captured__ flag', async () => { + const originalError = new Error('Original error'); + const h3Error = new H3Error('Wrapped error', { cause: originalError }); + h3Error.statusCode = 500; + + await sentryCaptureErrorHook(h3Error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + h3Error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should capture H3Error when cause is not an object', async () => { + const h3Error = new H3Error('Error with string cause', { cause: 'string cause' }); + h3Error.statusCode = 500; + + await sentryCaptureErrorHook(h3Error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + h3Error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should capture H3Error when there is no cause', async () => { + const h3Error = new H3Error('Error without cause'); + h3Error.statusCode = 500; + + await sentryCaptureErrorHook(h3Error, mockErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith( + h3Error, + expect.objectContaining({ + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + }), + ); + }); + + it('should skip when enableNitroErrorHandler is false', async () => { + (SentryCore.getClient as any).mockReturnValue({ + getOptions: () => ({ enableNitroErrorHandler: false }), + }); + + const error = new Error('Test error'); + + await sentryCaptureErrorHook(error, mockErrorContext); + + expect(SentryCore.captureException).not.toHaveBeenCalled(); + }); +}); From 14888ab451a140d3aa2c8428388eb03d4781c9b3 Mon Sep 17 00:00:00 2001 From: Kev <6111995+k-fish@users.noreply.github.com> Date: Tue, 28 Oct 2025 08:16:13 -0400 Subject: [PATCH 072/190] fix(tracemetrics): Bump metrics buffer to 1k (#18039) --- packages/core/src/metrics/internal.ts | 2 +- packages/core/test/lib/metrics/internal.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index 676814f4d4e6..efa204cac5a3 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -12,7 +12,7 @@ import { timestampInSeconds } from '../utils/time'; import { _getTraceInfoFromScope } from '../utils/trace-info'; import { createMetricEnvelope } from './envelope'; -const MAX_METRIC_BUFFER_SIZE = 100; +const MAX_METRIC_BUFFER_SIZE = 1000; /** * Converts a metric attribute to a serialized metric attribute. diff --git a/packages/core/test/lib/metrics/internal.test.ts b/packages/core/test/lib/metrics/internal.test.ts index bb2ddcc413c3..c0279c9a270b 100644 --- a/packages/core/test/lib/metrics/internal.test.ts +++ b/packages/core/test/lib/metrics/internal.test.ts @@ -249,12 +249,12 @@ describe('_INTERNAL_captureMetric', () => { const scope = new Scope(); scope.setClient(client); - // Fill the buffer to max size (100 is the MAX_METRIC_BUFFER_SIZE constant) - for (let i = 0; i < 100; i++) { + // Fill the buffer to max size (1000 is the MAX_METRIC_BUFFER_SIZE constant) + for (let i = 0; i < 1000; i++) { _INTERNAL_captureMetric({ type: 'counter', name: `metric.${i}`, value: i }, { scope }); } - expect(_INTERNAL_getMetricBuffer(client)).toHaveLength(100); + expect(_INTERNAL_getMetricBuffer(client)).toHaveLength(1000); // Add one more to trigger flush _INTERNAL_captureMetric({ type: 'counter', name: 'trigger.flush', value: 999 }, { scope }); From e35ca9dd0e5cf4025b1b54912b3a8aaee6dd22c3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 28 Oct 2025 13:52:32 +0100 Subject: [PATCH 073/190] feat(core): Send `user-agent` header with envelope requests in server SDKs (#17929) As a follow-up of an incident, SDKs were asked to send a `user-agent` Http header to determine by just looking at incoming request headers which SDK the request is coming from. See [develop specification](https://develop.sentry.dev/sdk/overview/#user-agent). This PR makes the following changes to sending user agent HTTP headers with envelope requests made by the transport: - Send `user-agent` in all server-runtime SDKs - Extract the `headers` option from individual transport options to `BaseTransportOptions`. This allows us to type-safely add the user agent header to the SDKs transport options which is the easiest way to pass a header to the transport without having to modify or extend any of the existing transport APIs. I checked and every transport implementation we currently export exposed a `headers` option anyway, so this just unifies it. Given this is an optional property, custom transport implementations extending `BaseTransportOptions` won't break either. The problem here is only that they might not actually support this option which all things considered I think is fine. If reviewers have different opinions, I'm happy to revisit this. - Unit and integration/e2e tests for node and cloudflare --- .size-limit.js | 2 +- .../cloudflare-workers/tests/index.test.ts | 15 ++++++++- .../node-express/tests/misc.test.ts | 15 +++++++++ .../test-utils/src/event-proxy-server.ts | 2 ++ packages/browser/src/transports/types.ts | 2 -- packages/bun/src/transports/index.ts | 7 +--- packages/bun/src/types.ts | 7 ++-- packages/cloudflare/src/transport.ts | 2 -- packages/core/src/server-runtime-client.ts | 3 ++ packages/core/src/transports/userAgent.ts | 22 +++++++++++++ packages/core/src/types-hoist/transport.ts | 5 +++ .../test/lib/server-runtime-client.test.ts | 33 ++++++++++++++++++- packages/deno/src/transports/index.ts | 7 +--- packages/deno/src/types.ts | 7 ++-- packages/node-core/src/transports/http.ts | 2 -- packages/node-core/test/sdk/client.test.ts | 5 +++ packages/node/test/sdk/client.test.ts | 5 +++ packages/vercel-edge/src/transports/index.ts | 2 -- 18 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-express/tests/misc.test.ts create mode 100644 packages/core/src/transports/userAgent.ts diff --git a/.size-limit.js b/.size-limit.js index 7106f2e29b03..ed7fbc7ccc80 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -157,7 +157,7 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '27 KB', + limit: '27.5 KB', }, { name: 'CDN Bundle (incl. Tracing)', diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts index ad63c1a0d307..8c09693c81ed 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; +import { waitForError, waitForRequest } from '@sentry-internal/test-utils'; +import { SDK_VERSION } from '@sentry/cloudflare'; import { WebSocket } from 'ws'; test('Index page', async ({ baseURL }) => { @@ -69,3 +70,15 @@ test('Websocket.webSocketClose', async ({ baseURL }) => { expect(event.exception?.values?.[0]?.value).toBe('Should be recorded in Sentry: webSocketClose'); expect(event.exception?.values?.[0]?.mechanism?.type).toBe('auto.faas.cloudflare.durable_object'); }); + +test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => { + const requestPromise = waitForRequest('cloudflare-workers', () => true); + + await fetch(`${baseURL}/throwException`); + + const request = await requestPromise; + + expect(request.rawProxyRequestHeaders).toMatchObject({ + 'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/misc.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/misc.test.ts new file mode 100644 index 000000000000..427c70b6fa21 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/misc.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test'; +import { waitForRequest } from '@sentry-internal/test-utils'; +import { SDK_VERSION } from '@sentry/node'; + +test('sends user-agent header with SDK name and version in envelope requests', async ({ baseURL }) => { + const requestPromise = waitForRequest('node-express', () => true); + + await fetch(`${baseURL}/test-exception/123`); + + const request = await requestPromise; + + expect(request.rawProxyRequestHeaders).toMatchObject({ + 'user-agent': `sentry.javascript.node/${SDK_VERSION}`, + }); +}); diff --git a/dev-packages/test-utils/src/event-proxy-server.ts b/dev-packages/test-utils/src/event-proxy-server.ts index 0becf5a743f2..08fa39db950f 100644 --- a/dev-packages/test-utils/src/event-proxy-server.ts +++ b/dev-packages/test-utils/src/event-proxy-server.ts @@ -24,6 +24,7 @@ interface EventProxyServerOptions { interface SentryRequestCallbackData { envelope: Envelope; rawProxyRequestBody: string; + rawProxyRequestHeaders: Record; rawSentryResponseBody: string; sentryResponseStatusCode?: number; } @@ -182,6 +183,7 @@ export async function startEventProxyServer(options: EventProxyServerOptions): P const data: SentryRequestCallbackData = { envelope: parseEnvelope(proxyRequestBody), rawProxyRequestBody: proxyRequestBody, + rawProxyRequestHeaders: proxyRequest.headers, rawSentryResponseBody: '', sentryResponseStatusCode: 200, }; diff --git a/packages/browser/src/transports/types.ts b/packages/browser/src/transports/types.ts index fd8c4a93fdd6..a304e9f93d66 100644 --- a/packages/browser/src/transports/types.ts +++ b/packages/browser/src/transports/types.ts @@ -3,6 +3,4 @@ import type { BaseTransportOptions } from '@sentry/core'; export interface BrowserTransportOptions extends BaseTransportOptions { /** Fetch API init parameters. Used by the FetchTransport */ fetchOptions?: RequestInit; - /** Custom headers for the transport. Used by the XHRTransport and FetchTransport */ - headers?: { [key: string]: string }; } diff --git a/packages/bun/src/transports/index.ts b/packages/bun/src/transports/index.ts index 7a27846548b3..20df5bb4b521 100644 --- a/packages/bun/src/transports/index.ts +++ b/packages/bun/src/transports/index.ts @@ -1,15 +1,10 @@ import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/core'; import { createTransport, suppressTracing } from '@sentry/core'; -export interface BunTransportOptions extends BaseTransportOptions { - /** Custom headers for the transport. Used by the XHRTransport and FetchTransport */ - headers?: { [key: string]: string }; -} - /** * Creates a Transport that uses the Fetch API to send events to Sentry. */ -export function makeFetchTransport(options: BunTransportOptions): Transport { +export function makeFetchTransport(options: BaseTransportOptions): Transport { function makeRequest(request: TransportRequest): PromiseLike { const requestOptions: RequestInit = { body: request.body, diff --git a/packages/bun/src/types.ts b/packages/bun/src/types.ts index 755eb3e48a0a..afec75d1ee8d 100644 --- a/packages/bun/src/types.ts +++ b/packages/bun/src/types.ts @@ -1,5 +1,4 @@ -import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; -import type { BunTransportOptions } from './transports'; +import type { BaseTransportOptions, ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; export interface BaseBunOptions { /** @@ -43,10 +42,10 @@ export interface BaseBunOptions { * Configuration options for the Sentry Bun SDK * @see @sentry/core Options for more information. */ -export interface BunOptions extends Options, BaseBunOptions {} +export interface BunOptions extends Options, BaseBunOptions {} /** * Configuration options for the Sentry Bun SDK Client class * @see BunClient for more information. */ -export interface BunClientOptions extends ClientOptions, BaseBunOptions {} +export interface BunClientOptions extends ClientOptions, BaseBunOptions {} diff --git a/packages/cloudflare/src/transport.ts b/packages/cloudflare/src/transport.ts index 2ac401505fbb..eee54bd7a790 100644 --- a/packages/cloudflare/src/transport.ts +++ b/packages/cloudflare/src/transport.ts @@ -4,8 +4,6 @@ import { createTransport, SENTRY_BUFFER_FULL_ERROR, suppressTracing } from '@sen export interface CloudflareTransportOptions extends BaseTransportOptions { /** Fetch API init parameters. */ fetchOptions?: RequestInit; - /** Custom headers for the transport. */ - headers?: { [key: string]: string }; } const DEFAULT_TRANSPORT_BUFFER_SIZE = 30; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 9d037eb3b7c3..988e642d0a27 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -4,6 +4,7 @@ import { getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import type { Scope } from './scope'; import { registerSpanErrorInstrumentation } from './tracing'; +import { addUserAgentToTransportHeaders } from './transports/userAgent'; import type { CheckIn, MonitorConfig, SerializedCheckIn } from './types-hoist/checkin'; import type { Event, EventHint } from './types-hoist/event'; import type { ClientOptions } from './types-hoist/options'; @@ -36,6 +37,8 @@ export class ServerRuntimeClient< // Server clients always support tracing registerSpanErrorInstrumentation(); + addUserAgentToTransportHeaders(options); + super(options); } diff --git a/packages/core/src/transports/userAgent.ts b/packages/core/src/transports/userAgent.ts new file mode 100644 index 000000000000..5508640a855c --- /dev/null +++ b/packages/core/src/transports/userAgent.ts @@ -0,0 +1,22 @@ +import type { ClientOptions } from '../types-hoist/options'; + +/** + * Takes the SDK metadata and adds the user-agent header to the transport options. + * This ensures that the SDK sends the user-agent header with SDK name and version to + * all requests made by the transport. + * + * @see https://develop.sentry.dev/sdk/overview/#user-agent + */ +export function addUserAgentToTransportHeaders(options: ClientOptions): void { + const sdkMetadata = options._metadata?.sdk; + const sdkUserAgent = + sdkMetadata?.name && sdkMetadata?.version ? `${sdkMetadata?.name}/${sdkMetadata?.version}` : undefined; + + options.transportOptions = { + ...options.transportOptions, + headers: { + ...(sdkUserAgent && { 'user-agent': sdkUserAgent }), + ...options.transportOptions?.headers, + }, + }; +} diff --git a/packages/core/src/types-hoist/transport.ts b/packages/core/src/types-hoist/transport.ts index 8e0035c93137..320ed98b00e4 100644 --- a/packages/core/src/types-hoist/transport.ts +++ b/packages/core/src/types-hoist/transport.ts @@ -30,6 +30,11 @@ export interface BaseTransportOptions extends InternalBaseTransportOptions { // transport does not care about dsn specific - client should take care of // parsing and figuring that out url: string; + + /** + * Custom HTTP headers to be added to requests made by the transport. + */ + headers?: { [key: string]: string }; } export interface Transport { diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index 525ee514c1a2..3c5fe874af9f 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, test, vi } from 'vitest'; -import { createTransport, Scope } from '../../src'; +import { applySdkMetadata, createTransport, Scope } from '../../src'; import type { ServerRuntimeClientOptions } from '../../src/server-runtime-client'; import { ServerRuntimeClient } from '../../src/server-runtime-client'; import type { Event, EventHint } from '../../src/types-hoist/event'; @@ -205,4 +205,35 @@ describe('ServerRuntimeClient', () => { ]); }); }); + + describe('user-agent header', () => { + it('sends user-agent header with SDK name and version', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + + // this is done in all `init` functions of the respective SDKs: + applySdkMetadata(options, 'core'); + + client = new ServerRuntimeClient(options); + + expect(client.getOptions().transportOptions?.headers).toEqual({ + 'user-agent': 'sentry.javascript.core/0.0.0-unknown.0', + }); + }); + + it('prefers user-passed headers (including user-agent)', () => { + const options = getDefaultClientOptions({ + dsn: PUBLIC_DSN, + transportOptions: { headers: { 'x-custom-header': 'custom-value', 'user-agent': 'custom-user-agent' } }, + }); + + applySdkMetadata(options, 'core'); + + client = new ServerRuntimeClient(options); + + expect(client.getOptions().transportOptions?.headers).toEqual({ + 'user-agent': 'custom-user-agent', + 'x-custom-header': 'custom-value', + }); + }); + }); }); diff --git a/packages/deno/src/transports/index.ts b/packages/deno/src/transports/index.ts index c5b6594b1c4d..521011fea6b8 100644 --- a/packages/deno/src/transports/index.ts +++ b/packages/deno/src/transports/index.ts @@ -1,15 +1,10 @@ import type { BaseTransportOptions, Transport, TransportMakeRequestResponse, TransportRequest } from '@sentry/core'; import { consoleSandbox, createTransport, debug, suppressTracing } from '@sentry/core'; -export interface DenoTransportOptions extends BaseTransportOptions { - /** Custom headers for the transport. Used by the XHRTransport and FetchTransport */ - headers?: { [key: string]: string }; -} - /** * Creates a Transport that uses the Fetch API to send events to Sentry. */ -export function makeFetchTransport(options: DenoTransportOptions): Transport { +export function makeFetchTransport(options: BaseTransportOptions): Transport { const url = new URL(options.url); Deno.permissions diff --git a/packages/deno/src/types.ts b/packages/deno/src/types.ts index 1659e7a635e1..69eee4ae6313 100644 --- a/packages/deno/src/types.ts +++ b/packages/deno/src/types.ts @@ -1,5 +1,4 @@ -import type { ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; -import type { DenoTransportOptions } from './transports'; +import type { BaseTransportOptions, ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; export interface BaseDenoOptions { /** @@ -44,10 +43,10 @@ export interface BaseDenoOptions { * Configuration options for the Sentry Deno SDK * @see @sentry/core Options for more information. */ -export interface DenoOptions extends Options, BaseDenoOptions {} +export interface DenoOptions extends Options, BaseDenoOptions {} /** * Configuration options for the Sentry Deno SDK Client class * @see DenoClient for more information. */ -export interface DenoClientOptions extends ClientOptions, BaseDenoOptions {} +export interface DenoClientOptions extends ClientOptions, BaseDenoOptions {} diff --git a/packages/node-core/src/transports/http.ts b/packages/node-core/src/transports/http.ts index 49897dfa22b1..3319353aff14 100644 --- a/packages/node-core/src/transports/http.ts +++ b/packages/node-core/src/transports/http.ts @@ -14,8 +14,6 @@ import { HttpsProxyAgent } from '../proxy'; import type { HTTPModule } from './http-module'; export interface NodeTransportOptions extends BaseTransportOptions { - /** Define custom headers */ - headers?: Record; /** Set a proxy that should be used for outbound requests. */ proxy?: string; /** HTTPS proxy CA certificates */ diff --git a/packages/node-core/test/sdk/client.test.ts b/packages/node-core/test/sdk/client.test.ts index 01623f49f0a3..0bcef2669095 100644 --- a/packages/node-core/test/sdk/client.test.ts +++ b/packages/node-core/test/sdk/client.test.ts @@ -32,6 +32,11 @@ describe('NodeClient', () => { dsn: expect.any(String), integrations: [], transport: options.transport, + transportOptions: { + headers: { + 'user-agent': `sentry.javascript.node/${SDK_VERSION}`, + }, + }, stackParser: options.stackParser, _metadata: { sdk: { diff --git a/packages/node/test/sdk/client.test.ts b/packages/node/test/sdk/client.test.ts index 7f57d4772212..ff58698a7931 100644 --- a/packages/node/test/sdk/client.test.ts +++ b/packages/node/test/sdk/client.test.ts @@ -31,6 +31,11 @@ describe('NodeClient', () => { dsn: expect.any(String), integrations: [], transport: options.transport, + transportOptions: { + headers: { + 'user-agent': `sentry.javascript.node/${SDK_VERSION}`, + }, + }, stackParser: options.stackParser, _metadata: { sdk: { diff --git a/packages/vercel-edge/src/transports/index.ts b/packages/vercel-edge/src/transports/index.ts index bb8ea807764c..668fb6a4c236 100644 --- a/packages/vercel-edge/src/transports/index.ts +++ b/packages/vercel-edge/src/transports/index.ts @@ -4,8 +4,6 @@ import { createTransport, SENTRY_BUFFER_FULL_ERROR, suppressTracing } from '@sen export interface VercelEdgeTransportOptions extends BaseTransportOptions { /** Fetch API init parameters. */ fetchOptions?: RequestInit; - /** Custom headers for the transport. */ - headers?: { [key: string]: string }; } const DEFAULT_TRANSPORT_BUFFER_SIZE = 30; From 0e15b3decd3903844211f30eb02632903030692e Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 28 Oct 2025 12:58:06 -0400 Subject: [PATCH 074/190] chore(browser): upgrade fake-indexeddb to v6 (#17975) changelog: https://github.com/dumbmatter/fakeIndexedDB/blob/master/CHANGELOG.md removes a structured clone polyfill, which is fine because we are node 18+ in the SDK. This helps reduce dep count in `yarn.lock`. --- packages/browser/package.json | 2 +- yarn.lock | 73 +++-------------------------------- 2 files changed, 6 insertions(+), 69 deletions(-) diff --git a/packages/browser/package.json b/packages/browser/package.json index 2f18a2573c8b..b6f2fa570ce1 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -47,7 +47,7 @@ }, "devDependencies": { "@sentry-internal/integration-shims": "10.22.0", - "fake-indexeddb": "^4.0.1" + "fake-indexeddb": "^6.2.4" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", diff --git a/yarn.lock b/yarn.lock index 06f8d3741128..0142b73605c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11447,11 +11447,6 @@ bare-stream@^2.6.4: dependencies: streamx "^2.21.0" -base64-arraybuffer-es6@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86" - integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw== - base64-arraybuffer@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" @@ -14501,13 +14496,6 @@ domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== -domexception@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" - integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== - dependencies: - webidl-conversions "^4.0.2" - domexception@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673" @@ -16720,12 +16708,10 @@ extract-zip@^2.0.1: optionalDependencies: "@types/yauzl" "^2.9.1" -fake-indexeddb@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-4.0.2.tgz#e7a884158fa576e00f03e973b9874619947013e4" - integrity sha512-SdTwEhnakbgazc7W3WUXOJfGmhH0YfG4d+dRPOFoYDRTL6U5t8tvrmkf2W/C3W1jk2ylV7Wrnj44RASqpX/lEw== - dependencies: - realistic-structured-clone "^3.0.0" +fake-indexeddb@^6.2.4: + version "6.2.4" + resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-6.2.4.tgz#cf3860b6b37ddc3b33e7840be00a61ed094486a5" + integrity sha512-INKeIKEtSViN4yVtEWEUqbsqmaIy7Ls+MfU0yxQVXg67pOJ/sH1ZxcVrP8XrKULUFohcPD9gnmym+qBfEybACw== fast-check@^3.23.1: version "3.23.2" @@ -21093,7 +21079,7 @@ lodash.uniq@^4.2.0, lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0, lodash@~4.17.21: +lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@~4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -26302,15 +26288,6 @@ real-require@^0.2.0: resolved "https://registry.yarnpkg.com/real-require/-/real-require-0.2.0.tgz#209632dea1810be2ae063a6ac084fee7e33fba78" integrity sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg== -realistic-structured-clone@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-3.0.0.tgz#7b518049ce2dad41ac32b421cd297075b00e3e35" - integrity sha512-rOjh4nuWkAqf9PWu6JVpOWD4ndI+JHfgiZeMmujYcPi+fvILUu7g6l26TC1K5aBIp34nV+jE1cDO75EKOfHC5Q== - dependencies: - domexception "^1.0.1" - typeson "^6.1.0" - typeson-registry "^1.0.0-alpha.20" - recast@0.23.11, recast@^0.23.4: version "0.23.11" resolved "https://registry.yarnpkg.com/recast/-/recast-0.23.11.tgz#8885570bb28cf773ba1dc600da7f502f7883f73f" @@ -29432,13 +29409,6 @@ tough-cookie@^4.1.2: universalify "^0.2.0" url-parse "^1.5.3" -tr46@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" - integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw== - dependencies: - punycode "^2.1.1" - tr46@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" @@ -29787,20 +29757,6 @@ typescript@~5.8.0: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== -typeson-registry@^1.0.0-alpha.20: - version "1.0.0-alpha.39" - resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" - integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw== - dependencies: - base64-arraybuffer-es6 "^0.7.0" - typeson "^6.0.0" - whatwg-url "^8.4.0" - -typeson@^6.0.0, typeson@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b" - integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA== - ua-parser-js@^0.7.18: version "0.7.33" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" @@ -31115,16 +31071,6 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== -webidl-conversions@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" - integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== - -webidl-conversions@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514" - integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== - webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" @@ -31379,15 +31325,6 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" -whatwg-url@^8.4.0: - version "8.7.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77" - integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg== - dependencies: - lodash "^4.7.0" - tr46 "^2.1.0" - webidl-conversions "^6.1.0" - which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" From 993303c935bde04b11a1f081f2981a0684176ab8 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 28 Oct 2025 16:24:04 -0400 Subject: [PATCH 075/190] fix(replay): Linked errors not resetting session id (#17854) This PR fixes a case where we [correctly] tag an error event w/ replay id, but something occurs where the replay event does not end up being flushed. This means the existing session is still in a buffered state, and will keep its session id until a new error event is sampled and a replay is created. When this does happen, we can have a replay with a super long duration (e.g. the time between the two error replays). We now update the session immediately when we tag an error event w/ replay id so that if the replay event does not successfully flush, the session will respect its expiration date. --- .../replay/bufferStalledRequests/init.js | 18 ++ .../replay/bufferStalledRequests/subject.js | 11 + .../bufferStalledRequests/template.html | 11 + .../replay/bufferStalledRequests/test.ts | 270 ++++++++++++++++++ .../src/coreHandlers/handleGlobalEvent.ts | 15 + packages/replay-internal/src/replay.ts | 1 + .../replay-internal/src/session/Session.ts | 2 + packages/replay-internal/src/types/replay.ts | 7 + .../src/util/handleRecordingEmit.ts | 2 +- .../test/integration/errorSampleRate.test.ts | 23 +- .../test/unit/session/fetchSession.test.ts | 4 +- .../unit/session/loadOrCreateSession.test.ts | 9 +- 12 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js new file mode 100644 index 000000000000..f9dccbffb530 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + stickySession: true, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1, + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js new file mode 100644 index 000000000000..1c9b22455261 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js @@ -0,0 +1,11 @@ +document.getElementById('error1').addEventListener('click', () => { + throw new Error('First Error'); +}); + +document.getElementById('error2').addEventListener('click', () => { + throw new Error('Second Error'); +}); + +document.getElementById('click').addEventListener('click', () => { + // Just a click for interaction +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html new file mode 100644 index 000000000000..1beb4b281b28 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts new file mode 100644 index 000000000000..11154caaaa8b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -0,0 +1,270 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; +import { + getReplaySnapshot, + isReplayEvent, + shouldSkipReplayTest, + waitForReplayRunning, +} from '../../../utils/replayHelpers'; + +sentryTest( + 'buffer mode remains after interrupting error event ingest', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + + if (errorCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + waitForErrorRequest(page); + await page.locator('#error1').click(); + + // This resolves, but the route doesn't get fulfilled as we want the reload to "interrupt" this flow + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(0); + expect(replayIds).toHaveLength(1); + + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); + + await page.reload(); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.session?.dirty).toBe(true); + expect(secondSession.recordingMode).toBe('buffer'); + expect(secondSession.session?.id).toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(0); + }, +); + +sentryTest('buffer mode remains after interrupting replay flush', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + if (replayCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(1); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + await page.reload(); + await waitForReplayRunning(page); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.session?.dirty).toBe(true); + expect(secondSession.session?.id).toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(1); + // Because a flush attempt was made and not allowed to complete, segmentId increased from 0, + // so we resume in session mode + expect(secondSession.recordingMode).toBe('session'); +}); + +sentryTest( + 'starts a new session after interrupting replay flush and session "expires"', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + if (replayCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + // Trigger first error - this should change session sampled to "session" + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(1); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + // Now expire the session by manipulating session storage + // Simulate session expiry by setting lastActivity to a time in the past + await page.evaluate(() => { + const replayIntegration = (window as any).Replay; + const replay = replayIntegration['_replay']; + + // Set session as expired (15 minutes ago) + if (replay.session) { + const fifteenMinutesAgo = Date.now() - 15 * 60 * 1000; + replay.session.lastActivity = fifteenMinutesAgo; + replay.session.started = fifteenMinutesAgo; + + // Also update session storage if sticky sessions are enabled + const sessionKey = 'sentryReplaySession'; + const sessionData = sessionStorage.getItem(sessionKey); + if (sessionData) { + const session = JSON.parse(sessionData); + session.lastActivity = fifteenMinutesAgo; + session.started = fifteenMinutesAgo; + sessionStorage.setItem(sessionKey, JSON.stringify(session)); + } + } + }); + + await page.reload(); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.recordingMode).toBe('buffer'); + expect(secondSession.session?.id).not.toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(0); + }, +); diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index 55559c0d4c01..b28d4547265e 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -1,5 +1,6 @@ import type { Event, EventHint } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { saveSession } from '../session/saveSession'; import type { ReplayContainer } from '../types'; import { isErrorEvent, isFeedbackEvent, isReplayEvent, isTransactionEvent } from '../util/eventUtils'; import { isRrwebError } from '../util/isRrwebError'; @@ -69,6 +70,20 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even event.tags = { ...event.tags, replayId: replay.getSessionId() }; } + // If we sampled this error in buffer mode, immediately mark the session as "sampled" + // by changing the sampled state from 'buffer' to 'session'. Otherwise, if the application is interrupte + // before `afterSendEvent` occurs, then the session would remain as "buffer" but we have an error event + // that is tagged with a replay id. This could end up creating replays w/ excessive durations because + // of the linked error. + if (isErrorEventSampled && replay.recordingMode === 'buffer' && replay.session?.sampled === 'buffer') { + const session = replay.session; + session.dirty = true; + // Save the session if sticky sessions are enabled to persist the state change + if (replay.getOptions().stickySession) { + saveSession(session); + } + } + return event; }, { id: 'Replay' }, diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 61676f790b4d..49e8ce092edd 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -606,6 +606,7 @@ export class ReplayContainer implements ReplayContainerInterface { // Once this session ends, we do not want to refresh it if (this.session) { + this.session.dirty = false; this._updateUserActivity(activityTime); this._updateSessionActivity(activityTime); this._maybeSaveSession(); diff --git a/packages/replay-internal/src/session/Session.ts b/packages/replay-internal/src/session/Session.ts index 554f625cc8e9..59e6b09ed43c 100644 --- a/packages/replay-internal/src/session/Session.ts +++ b/packages/replay-internal/src/session/Session.ts @@ -13,6 +13,7 @@ export function makeSession(session: Partial & { sampled: Sampled }): S const segmentId = session.segmentId || 0; const sampled = session.sampled; const previousSessionId = session.previousSessionId; + const dirty = session.dirty || false; return { id, @@ -21,5 +22,6 @@ export function makeSession(session: Partial & { sampled: Sampled }): S segmentId, sampled, previousSessionId, + dirty, }; } diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 1e7891a84e76..a2c84d6c4bbe 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -383,6 +383,13 @@ export interface Session { * Is the session sampled? `false` if not sampled, otherwise, `session` or `buffer` */ sampled: Sampled; + + /** + * Session is dirty when its id has been linked to an event (e.g. error event). + * This is helpful when a session is mistakenly stuck in "buffer" mode (e.g. network issues preventing it from being converted to "session" mode). + * The dirty flag is used to prevent updating the session start time to the earliest event in the buffer so that it can be refreshed if it's been expired. + */ + dirty?: boolean; } export type EventBufferType = 'sync' | 'worker'; diff --git a/packages/replay-internal/src/util/handleRecordingEmit.ts b/packages/replay-internal/src/util/handleRecordingEmit.ts index 0ae87601637b..aeb49f0cd259 100644 --- a/packages/replay-internal/src/util/handleRecordingEmit.ts +++ b/packages/replay-internal/src/util/handleRecordingEmit.ts @@ -72,7 +72,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa // When in buffer mode, make sure we adjust the session started date to the current earliest event of the buffer // this should usually be the timestamp of the checkout event, but to be safe... - if (replay.recordingMode === 'buffer' && session && replay.eventBuffer) { + if (replay.recordingMode === 'buffer' && session && replay.eventBuffer && !session.dirty) { const earliestEvent = replay.eventBuffer.getEarliestTimestamp(); if (earliestEvent) { DEBUG_BUILD && diff --git a/packages/replay-internal/test/integration/errorSampleRate.test.ts b/packages/replay-internal/test/integration/errorSampleRate.test.ts index f79e393df7e3..b49882b72034 100644 --- a/packages/replay-internal/test/integration/errorSampleRate.test.ts +++ b/packages/replay-internal/test/integration/errorSampleRate.test.ts @@ -80,8 +80,21 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); + // session gets immediately marked as dirty since error will + // be linked to current session (replay) id. there's a possibility + // that replay never gets flushed so we must mark as dirty so we + // know to refresh session in the future. + expect(replay.recordingMode).toBe('buffer'); + expect(replay.session?.dirty).toBe(true); + + await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); + // dirty gets reset after replay is flushed + expect(replay.recordingMode).toBe('session'); + expect(replay.session?.dirty).toBe(false); + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ @@ -158,6 +171,7 @@ describe('Integration | errorSampleRate', () => { segmentId: 0, sampled: 'buffer', previousSessionId: 'previoussessionid', + dirty: false, }), })); @@ -179,6 +193,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents + await vi.advanceTimersToNextTimerAsync(); // Converts to session mode expect(replay.recordingMode).toBe('session'); @@ -508,6 +524,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); + await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); expect(replay).toHaveLastSentReplay({ @@ -604,6 +622,8 @@ describe('Integration | errorSampleRate', () => { // should still react to errors later on captureException(new Error('testing')); + await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); expect(replay.session?.id).toBe(oldSessionId); @@ -739,7 +759,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); await vi.advanceTimersToNextTimerAsync(); - // await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents + await vi.advanceTimersToNextTimerAsync(); // This is still the timestamp from the full snapshot we took earlier expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED); diff --git a/packages/replay-internal/test/unit/session/fetchSession.test.ts b/packages/replay-internal/test/unit/session/fetchSession.test.ts index 46f0f05f5c9a..9dee5cb5cee0 100644 --- a/packages/replay-internal/test/unit/session/fetchSession.test.ts +++ b/packages/replay-internal/test/unit/session/fetchSession.test.ts @@ -28,6 +28,7 @@ describe('Unit | session | fetchSession', () => { ); expect(fetchSession()).toEqual({ + dirty: false, id: 'fd09adfc4117477abc8de643e5a5798a', lastActivity: 1648827162658, segmentId: 0, @@ -39,10 +40,11 @@ describe('Unit | session | fetchSession', () => { it('fetches an unsampled session', function () { WINDOW.sessionStorage.setItem( REPLAY_SESSION_KEY, - '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": false,"started":1648827162630,"lastActivity":1648827162658}', + '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": false,"started":1648827162630,"lastActivity":1648827162658,"dirty":true}', ); expect(fetchSession()).toEqual({ + dirty: true, id: 'fd09adfc4117477abc8de643e5a5798a', lastActivity: 1648827162658, segmentId: 0, diff --git a/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts b/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts index 273d401a7afc..dee44638344b 100644 --- a/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts +++ b/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts @@ -77,6 +77,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + dirty: false, }); // Should not have anything in storage @@ -104,6 +105,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + dirty: false, }); // Should not have anything in storage @@ -129,10 +131,10 @@ describe('Unit | session | loadOrCreateSession', () => { sampled: 'session', started: expect.any(Number), previousSessionId: 'previous_session_id', + dirty: false, }); }); }); - describe('stickySession: true', () => { it('creates new session if none exists', function () { const session = loadOrCreateSession( @@ -151,6 +153,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + dirty: false, }; expect(session).toEqual(expectedSession); @@ -181,6 +184,7 @@ describe('Unit | session | loadOrCreateSession', () => { sampled: 'session', started: expect.any(Number), previousSessionId: 'test_old_session_uuid', + dirty: false, }; expect(session).toEqual(expectedSession); expect(session.lastActivity).toBeGreaterThanOrEqual(now); @@ -209,6 +213,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: date, sampled: 'session', started: date, + dirty: false, }); }); @@ -250,6 +255,7 @@ describe('Unit | session | loadOrCreateSession', () => { sampled: 'session', started: expect.any(Number), previousSessionId: 'previous_session_id', + dirty: false, }; expect(session).toEqual(expectedSession); @@ -347,6 +353,7 @@ describe('Unit | session | loadOrCreateSession', () => { segmentId: 0, lastActivity: expect.any(Number), sampled: false, + dirty: false, started: expect.any(Number), }; expect(session).toEqual(expectedSession); From f7295093e11884ee738964ad6818336d04842c50 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:32:10 +0100 Subject: [PATCH 076/190] test(browser): Add test for INP target name after navigation or DOM changes (#18033) This test displays the current behavior of getting the element target name for INP when the DOM changes after clicking on a navigation link. When the DOM changes after clicking on an element, the element name from before the navigation is not captured: ```js description: '', // FIXME: currently unable to get the target name when element is removed from DOM ``` --- .../metrics/web-vitals-inp-navigate/init.js | 27 +++ .../web-vitals-inp-navigate/subject.js | 44 +++++ .../web-vitals-inp-navigate/template.html | 16 ++ .../metrics/web-vitals-inp-navigate/test.ts | 174 ++++++++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html create mode 100644 dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js new file mode 100644 index 000000000000..1044a4b68bda --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/init.js @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + enableLongTask: false, + enableInp: true, + instrumentPageLoad: false, + instrumentNavigation: false, + }), + ], + tracesSampleRate: 1, +}); + +const client = Sentry.getClient(); + +// Force page load transaction name to a testable value +Sentry.startBrowserTracingPageLoadSpan(client, { + name: 'test-url', + attributes: { + [Sentry.SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js new file mode 100644 index 000000000000..730caa3b381e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/subject.js @@ -0,0 +1,44 @@ +const simulateNavigationKeepDOM = e => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 100) { + // Block UI for 100ms to simulate some processing work during navigation + } + + const contentDiv = document.getElementById('content'); + contentDiv.innerHTML = '

Page 1

Successfully navigated!

'; + + contentDiv.classList.add('navigated'); +}; + +const simulateNavigationChangeDOM = e => { + const startTime = Date.now(); + + function getElapsed() { + const time = Date.now(); + return time - startTime; + } + + while (getElapsed() < 100) { + // Block UI for 100ms to simulate some processing work during navigation + } + + const navigationHTML = + ' '; + + const body = document.querySelector('body'); + body.innerHTML = `${navigationHTML}

Page 2

Successfully navigated!

`; + + body.classList.add('navigated'); +}; + +document.querySelector('[data-test-id=nav-link-keepDOM]').addEventListener('click', simulateNavigationKeepDOM); +document.querySelector('[data-test-id=nav-link-changeDOM]').addEventListener('click', simulateNavigationChangeDOM); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html new file mode 100644 index 000000000000..de677aa9a838 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/template.html @@ -0,0 +1,16 @@ + + + + + + + +
+

Home Page

+

Click the navigation link to simulate a route change

+
+ + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts new file mode 100644 index 000000000000..ad7862926ebf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts @@ -0,0 +1,174 @@ +import { expect } from '@playwright/test'; +import type { Event as SentryEvent, SpanEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + hidePage, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +const supportedBrowsers = ['chromium']; + +sentryTest( + 'should capture INP with correct target name when navigation keeps DOM element', + async ({ browserName, getLocalTestUrl, page }) => { + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.goto(url); + await getFirstSentryEnvelopeRequest(page); // wait for page load + + const spanEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'span' }, + properFullEnvelopeRequestParser, + ); + + // Simulating route change (keeping
} /> - // We should check against the branch.pathname for the number of / separators - getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && - // We should not count wildcard operators in the url segments calculation - !pathEndsWithWildcard(pathBuilder) - ) { - return [(_stripBasename ? '' : basename) + newPath, 'route']; - } - - // if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard - if (pathIsWildcardAndHasChildren(pathBuilder, branch)) { - pathBuilder = pathBuilder.slice(0, -1); - } - - return [(_stripBasename ? '' : basename) + pathBuilder, 'route']; - } - } - } + for (const branch of branches) { + const route = branch.route; + if (!route) { + continue; + } + + // Early return for index routes + if (route.index) { + return sendIndexPath(pathBuilder, branch.pathname, basename); } - } - const fallbackTransactionName = _stripBasename - ? stripBasenameFromPathname(location.pathname, basename) - : location.pathname || ''; + const path = route.path; + if (!path || pathIsWildcardAndHasChildren(path, branch)) { + continue; + } + + // Build the route path + const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`; + pathBuilder = trimSlash(pathBuilder) + prefixWithSlash(newPath); + + // Check if this path matches the current location + if (trimSlash(location.pathname) !== trimSlash(basename + branch.pathname)) { + continue; + } + + // Check if this is a parameterized route like /stores/:storeId/products/:productId + if ( + getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && + !pathEndsWithWildcard(pathBuilder) + ) { + return [(_stripBasename ? '' : basename) + newPath, 'route']; + } + + // Handle wildcard routes with children - strip trailing wildcard + if (pathIsWildcardAndHasChildren(pathBuilder, branch)) { + pathBuilder = pathBuilder.slice(0, -1); + } + + return [(_stripBasename ? '' : basename) + pathBuilder, 'route']; + } - return [fallbackTransactionName, 'url']; + // Fallback when no matching route found + return [getFallbackTransactionName(location, basename), 'url']; } /** diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx index 77d8e3d95b2e..be71f4b838c5 100644 --- a/packages/react/test/reactrouter-cross-usage.test.tsx +++ b/packages/react/test/reactrouter-cross-usage.test.tsx @@ -9,7 +9,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setCurrentClient, } from '@sentry/core'; -import { render } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import * as React from 'react'; import { createMemoryRouter, @@ -26,6 +26,7 @@ import { } from 'react-router-6'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../src'; +import { allRoutes } from '../src/reactrouter-compat-utils/instrumentation'; import { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, @@ -101,6 +102,7 @@ describe('React Router cross usage of wrappers', () => { beforeEach(() => { vi.clearAllMocks(); getCurrentScope().setClient(undefined); + allRoutes.clear(); }); describe('wrapCreateBrowserRouter and wrapUseRoutes', () => { @@ -218,16 +220,10 @@ describe('React Router cross usage of wrappers', () => { expect(container.innerHTML).toContain('Details'); - // It's called 1 time from the wrapped `MemoryRouter` expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/second-level/:id/third-level/:id', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); + // In cross-usage scenarios, the first wrapper creates the span and the second updates it + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/second-level/:id/third-level/:id'); + expect(mockNavigationSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); }); }); @@ -339,7 +335,6 @@ describe('React Router cross usage of wrappers', () => { expect(container.innerHTML).toContain('Details'); - // It's called 1 time from the wrapped `MemoryRouter` expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); }); }); @@ -465,17 +460,12 @@ describe('React Router cross usage of wrappers', () => { expect(container.innerHTML).toContain('Details'); - // It's called 1 time from the wrapped `createMemoryRouter` expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); - expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/second-level/:id/third-level/:id', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); + // Cross-usage deduplication: Span created once with initial route name + // With nested lazy routes, initial name may be raw path, updated to parameterized by later wrapper + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/second-level/:id/third-level/:id'); + expect(mockNavigationSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); }); }); @@ -597,14 +587,290 @@ describe('React Router cross usage of wrappers', () => { expect(container.innerHTML).toContain('Details'); expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + // Cross-usage with all three wrappers: span created once, then updated + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/second-level/:id/third-level/:id'); + expect(mockNavigationSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + }); + }); + + describe('consecutive navigations to different routes', () => { + it('should create separate transactions for consecutive navigations to different routes', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { path: '/users', element:
Users
}, + { path: '/settings', element:
Settings
}, + { path: '/profile', element:
Profile
}, + ], + }, + ], + { initialEntries: ['/users'] }, + ); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + + await act(async () => { + router.navigate('/settings'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + }); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { - name: '/second-level/:id/third-level/:id', + name: '/settings', attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', }, }); + + await act(async () => { + router.navigate('/profile'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + + const calls = mockStartBrowserTracingNavigationSpan.mock.calls; + expect(calls[0]![1].name).toBe('/settings'); + expect(calls[1]![1].name).toBe('/profile'); + expect(calls[0]![1].attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('navigation'); + expect(calls[1]![1].attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('navigation'); + }); + + it('should create separate transactions for rapid consecutive navigations', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { path: '/a', element:
A
}, + { path: '/b', element:
B
}, + { path: '/c', element:
C
}, + ], + }, + ], + { initialEntries: ['/a'] }, + ); + + render( + + + , + ); + + await act(async () => { + router.navigate('/b'); + router.navigate('/c'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + + const calls = mockStartBrowserTracingNavigationSpan.mock.calls; + expect(calls[0]![1].name).toBe('/b'); + expect(calls[1]![1].name).toBe('/c'); + }); + + it('should create separate spans for same route with different params', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + + const router = createSentryMemoryRouter( + [ + { + children: [{ path: '/user/:id', element:
User
}], + }, + ], + { initialEntries: ['/user/1'] }, + ); + + render( + + + , + ); + + await act(async () => { + router.navigate('/user/2'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledWith(expect.any(BrowserClient), { + name: '/user/:id', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + + await act(async () => { + router.navigate('/user/3'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2)); + }); + + // Should create 2 spans - different concrete paths are different user actions + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenNthCalledWith(2, expect.any(BrowserClient), { + name: '/user/:id', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', + }, + }); + }); + + it('should handle mixed cross-usage and consecutive navigations correctly', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + const sentryUseRoutes = wrapUseRoutesV6(useRoutes); + + const UsersRoute: React.FC = () => sentryUseRoutes([{ path: '/', element:
Users
}]); + + const SettingsRoute: React.FC = () => sentryUseRoutes([{ path: '/', element:
Settings
}]); + + const router = createSentryMemoryRouter( + [ + { + children: [ + { path: '/users/*', element: }, + { path: '/settings/*', element: }, + ], + }, + ], + { initialEntries: ['/users'] }, + ); + + render( + + + , + ); + + expect(mockStartBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + + await act(async () => { + router.navigate('/settings'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1)); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/settings/*', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }), + }); + }); + + it('should not create duplicate spans for cross-usage on same route', async () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + const createSentryMemoryRouter = wrapCreateMemoryRouterV6(createMemoryRouter); + const sentryUseRoutes = wrapUseRoutesV6(useRoutes); + + const NestedRoute: React.FC = () => sentryUseRoutes([{ path: '/', element:
Details
}]); + + const router = createSentryMemoryRouter( + [ + { + children: [{ path: '/details/*', element: }], + }, + ], + { initialEntries: ['/home'] }, + ); + + render( + + + , + ); + + await act(async () => { + router.navigate('/details'); + await waitFor(() => expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalled()); + }); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledWith(expect.any(BrowserClient), { + name: '/details/*', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + }), + }); }); }); }); diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index fe75bc81e858..a08893694a30 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -25,6 +25,7 @@ import { } from 'react-router-6'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../src'; +import { allRoutes } from '../src/reactrouter-compat-utils/instrumentation'; import { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, @@ -79,6 +80,7 @@ describe('React Router Descendant Routes', () => { beforeEach(() => { vi.clearAllMocks(); getCurrentScope().setClient(undefined); + allRoutes.clear(); }); describe('withSentryReactRouterV6Routing', () => { diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index 61fefdff9b63..fda5043d2e6a 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -28,6 +28,7 @@ import { } from 'react-router-6'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../src'; +import { allRoutes } from '../src/reactrouter-compat-utils/instrumentation'; import { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, @@ -83,6 +84,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { beforeEach(() => { vi.clearAllMocks(); getCurrentScope().setClient(undefined); + allRoutes.clear(); }); it('wrapCreateMemoryRouterV6 starts and updates a pageload transaction - single initialEntry', () => { From d729cdbd568425be29b9cfa72d31eede24b15949 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 18 Nov 2025 11:40:53 +0200 Subject: [PATCH 148/190] feat(nextjs): Add URL to server-side transaction events (#18230) URLs were missing from server-side transaction events (server components, generation functions) in Next.js. This was previously removed in #18113 because we tried to synchronously access `params` and `searchParams`, which cause builds to crash. This PR approach adds the URL at runtime using a `preprocessEvent` hook as suggested. **Implementation** 1. Reads `http.target` (actual request path) and `next.route` (parameterized route) from the transaction's trace data 2. Extracts headers from the captured isolation scope's SDK processing metadata 3. Builds the full URL using the existing `getSanitizedRequestUrl()` utility 4. Adds it to `normalizedRequest.url` so the `requestDataIntegration` includes it in the event This works uniformly for both Webpack and Turbopack across all of our supported Next.js versions (13~16), I added missing tests for this case in the versions that did not have it. Fixes #18115 --- .../tests/server/server-components.test.ts | 48 +++++++++++++++++++ .../nextjs-15/tests/server-components.test.ts | 48 +++++++++++++++++++ .../nextjs-16/tests/server-components.test.ts | 48 +++++++++++++++++++ .../tests/server-components.test.ts | 7 +-- .../tests/app-router/route-handlers.test.ts | 3 +- .../app-router/server-components.test.ts | 48 +++++++++++++++++++ .../common/utils/setUrlProcessingMetadata.ts | 42 ++++++++++++++++ packages/nextjs/src/edge/index.ts | 3 ++ packages/nextjs/src/server/index.ts | 3 ++ 9 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts create mode 100644 packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts new file mode 100644 index 000000000000..c9e3a6ff588c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-13', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts new file mode 100644 index 000000000000..2f3488976d28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-15', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts new file mode 100644 index 000000000000..d5b1a00b30d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-16', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index 25d84cdc28e1..eedb702715de 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -34,11 +34,8 @@ test('Sends a transaction for a request to app router', async ({ page }) => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); - expect(transactionEvent.request).toEqual({ - cookies: {}, - headers: expect.objectContaining({ - 'user-agent': expect.any(String), - }), + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/server-component/parameter/1337/42'), }); // The transaction should not contain any spans with the same name as the transaction diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts index 544ba0084167..13f4f5fa4a58 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/route-handlers.test.ts @@ -66,8 +66,7 @@ test('Should record exceptions and transactions for faulty route handlers', asyn expect(routehandlerError.exception?.values?.[0].value).toBe('Dynamic route handler error'); expect(routehandlerError.request?.method).toBe('GET'); - // todo: make sure url is attached to request object - // expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error'); + expect(routehandlerError.request?.url).toContain('/route-handlers/boop/error'); expect(routehandlerError.transaction).toBe('/route-handlers/[param]/error'); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts new file mode 100644 index 000000000000..d3a6db69fcd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/server-components.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends a transaction for a request to app router with URL', async ({ page }) => { + const serverComponentTransactionPromise = waitForTransaction('nextjs-turbo', transactionEvent => { + return ( + transactionEvent?.transaction === 'GET /parameterized/[one]/beep/[two]' && + transactionEvent.contexts?.trace?.data?.['http.target']?.startsWith('/parameterized/1337/beep/42') + ); + }); + + await page.goto('/parameterized/1337/beep/42'); + + const transactionEvent = await serverComponentTransactionPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.op': 'http.server', + 'sentry.origin': 'auto', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.route': '/parameterized/[one]/beep/[two]', + 'http.status_code': 200, + 'http.target': '/parameterized/1337/beep/42', + 'otel.kind': 'SERVER', + 'next.route': '/parameterized/[one]/beep/[two]', + }), + op: 'http.server', + origin: 'auto', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.request).toMatchObject({ + url: expect.stringContaining('/parameterized/1337/beep/42'), + }); + + // The transaction should not contain any spans with the same name as the transaction + // e.g. "GET /parameterized/[one]/beep/[two]" + expect( + transactionEvent.spans?.filter(span => { + return span.description === transactionEvent.transaction; + }), + ).toHaveLength(0); +}); diff --git a/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts new file mode 100644 index 000000000000..0c7e0c3b33f2 --- /dev/null +++ b/packages/nextjs/src/common/utils/setUrlProcessingMetadata.ts @@ -0,0 +1,42 @@ +import type { Event } from '@sentry/core'; +import { getClient } from '@sentry/core'; +import { getSanitizedRequestUrl } from './urls'; + +/** + * Sets the URL processing metadata for the event. + */ +export function setUrlProcessingMetadata(event: Event): void { + // Skip if not a server-side transaction + if (event.type !== 'transaction' || event.contexts?.trace?.op !== 'http.server' || !event.contexts?.trace?.data) { + return; + } + + // Only add URL if sendDefaultPii is enabled, as URLs may contain PII + const client = getClient(); + if (!client?.getOptions().sendDefaultPii) { + return; + } + + const traceData = event.contexts.trace.data; + + // Get the route from trace data + const componentRoute = traceData['next.route'] || traceData['http.route']; + const httpTarget = traceData['http.target'] as string | undefined; + + if (!componentRoute) { + return; + } + + // Extract headers + const isolationScopeData = event.sdkProcessingMetadata?.capturedSpanIsolationScope?.getScopeData(); + const headersDict = isolationScopeData?.sdkProcessingMetadata?.normalizedRequest?.headers; + + const url = getSanitizedRequestUrl(componentRoute, undefined, headersDict, httpTarget?.toString()); + + // Add URL to the isolation scope's normalizedRequest so requestDataIntegration picks it up + if (url && isolationScopeData?.sdkProcessingMetadata) { + isolationScopeData.sdkProcessingMetadata.normalizedRequest = + isolationScopeData.sdkProcessingMetadata.normalizedRequest || {}; + isolationScopeData.sdkProcessingMetadata.normalizedRequest.url = url; + } +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 6ee523fe72dc..5fd92707b912 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -22,6 +22,7 @@ import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-e import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; +import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/vercel-edge'; @@ -126,6 +127,8 @@ export function init(options: VercelEdgeOptions = {}): void { } } } + + setUrlProcessingMetadata(event); }); client?.on('spanEnd', span => { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index aa6210c2ff6a..ce8ac7c56cea 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -39,6 +39,7 @@ import { } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { isBuild } from '../common/utils/isBuild'; +import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; export * from '@sentry/node'; @@ -391,6 +392,8 @@ export function init(options: NodeOptions): NodeClient | undefined { event.contexts.trace.parent_span_id = traceparentData.parentSpanId; } } + + setUrlProcessingMetadata(event); }); if (process.env.NODE_ENV === 'development') { From 9482a0274f79cd7ae71d18584aec3068787bde79 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 18 Nov 2025 11:41:34 +0100 Subject: [PATCH 149/190] fix(nextjs): Respect PORT variable for dev error symbolication (#18227) Next.js respects the PORT variable. If for some reason this is not sufficient for users we can ship a follow up with a config option, which I wanted to avoid in the first step. Also did a small refactor of the fetching code. closes https://github.com/getsentry/sentry-javascript/issues/18135 closes https://linear.app/getsentry/issue/JS-1139/handle-the-case-where-users-define-a-different-portprotocol --- .../nextjs-orpc/tests/orpc-error.test.ts | 7 +- .../devErrorSymbolicationEventProcessor.ts | 98 ++++---- ...evErrorSymbolicationEventProcessor.test.ts | 215 ++++++++++++++++++ 3 files changed, 268 insertions(+), 52 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts index a503533b6f00..a5f5494ee61c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-orpc/tests/orpc-error.test.ts @@ -1,9 +1,12 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; -test('should capture orpc error', async ({ page }) => { +test('should capture server-side orpc error', async ({ page }) => { const orpcErrorPromise = waitForError('nextjs-orpc', errorEvent => { - return errorEvent.exception?.values?.[0]?.value === 'You are hitting an error'; + return ( + errorEvent.exception?.values?.[0]?.value === 'You are hitting an error' && + errorEvent.contexts?.['runtime']?.name === 'node' + ); }); await page.goto('/'); diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index 9ae0a5ee0bb2..3b02d92d80fb 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -15,6 +15,44 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryNextJsVersion: string | undefined; }; +/** + * Constructs the base URL for the Next.js dev server, including the port and base path. + * Returns only the base path when running in the browser (client-side) for relative URLs. + */ +function getDevServerBaseUrl(): string { + let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; + + // Prefix the basepath with a slash if it doesn't have one + if (basePath !== '' && !basePath.match(/^\//)) { + basePath = `/${basePath}`; + } + + // eslint-disable-next-line no-restricted-globals + if (typeof window !== 'undefined') { + return basePath; + } + + const devServerPort = process.env.PORT || '3000'; + return `http://localhost:${devServerPort}${basePath}`; +} + +/** + * Fetches a URL with a 3-second timeout using AbortController. + */ +async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + + return suppressTracing(() => + fetch(url, { + ...options, + signal: controller.signal, + }).finally(() => { + clearTimeout(timer); + }), + ); +} + /** * Event processor that will symbolicate errors by using the webpack/nextjs dev server that is used to show stack traces * in the dev overlay. @@ -123,28 +161,8 @@ async function resolveStackFrame( params.append(key, (frame[key as keyof typeof frame] ?? '').toString()); }); - let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; - - // Prefix the basepath with a slash if it doesn't have one - if (basePath !== '' && !basePath.match(/^\//)) { - basePath = `/${basePath}`; - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 3000); - const res = await suppressTracing(() => - fetch( - `${ - // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port - }${basePath}/__nextjs_original-stack-frame?${params.toString()}`, - { - signal: controller.signal, - }, - ).finally(() => { - clearTimeout(timer); - }), - ); + const baseUrl = getDevServerBaseUrl(); + const res = await fetchWithTimeout(`${baseUrl}/__nextjs_original-stack-frame?${params.toString()}`); if (!res.ok || res.status === 204) { return null; @@ -191,34 +209,14 @@ async function resolveStackFrames( isAppDirectory: true, }; - let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; - - // Prefix the basepath with a slash if it doesn't have one - if (basePath !== '' && !basePath.match(/^\//)) { - basePath = `/${basePath}`; - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 3000); - - const res = await suppressTracing(() => - fetch( - `${ - // eslint-disable-next-line no-restricted-globals - typeof window === 'undefined' ? 'http://localhost:3000' : '' // TODO: handle the case where users define a different port - }${basePath}/__nextjs_original-stack-frames`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - signal: controller.signal, - body: JSON.stringify(postBody), - }, - ).finally(() => { - clearTimeout(timer); - }), - ); + const baseUrl = getDevServerBaseUrl(); + const res = await fetchWithTimeout(`${baseUrl}/__nextjs_original-stack-frames`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(postBody), + }); if (!res.ok || res.status === 204) { return null; diff --git a/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts index 4305aad537a8..130f8ea685df 100644 --- a/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts +++ b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts @@ -25,6 +25,7 @@ describe('devErrorSymbolicationEventProcessor', () => { vi.clearAllMocks(); delete (GLOBAL_OBJ as any)._sentryNextJsVersion; delete (GLOBAL_OBJ as any)._sentryBasePath; + delete process.env.PORT; }); describe('Next.js version handling', () => { @@ -258,4 +259,218 @@ describe('devErrorSymbolicationEventProcessor', () => { expect(result?.spans).toHaveLength(1); }); }); + + describe('dev server URL construction', () => { + it('should use default port 3000 when PORT env variable is not set (Next.js < 15.2)', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'webpack-internal:///./test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at webpack-internal:///./test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'webpack-internal:///./test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }), + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:3000/__nextjs_original-stack-frame'), + expect.any(Object), + ); + }); + + it('should use PORT env variable when set (Next.js < 15.2)', async () => { + process.env.PORT = '4000'; + + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'webpack-internal:///./test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at webpack-internal:///./test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'webpack-internal:///./test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }), + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:4000/__nextjs_original-stack-frame'), + expect.any(Object), + ); + }); + + it('should use default port 3000 when PORT env variable is not set (Next.js >= 15.2)', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'file:///test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at file:///test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '15.2.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'file:///test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [ + { + value: { + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }, + }, + ], + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:3000/__nextjs_original-stack-frames'), + expect.any(Object), + ); + }); + + it('should use PORT env variable when set (Next.js >= 15.2)', async () => { + process.env.PORT = '4000'; + + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'file:///test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const testError = new Error('test error'); + testError.stack = 'Error: test error\n at file:///test.js:1:1'; + + const mockHint: EventHint = { + originalException: testError, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '15.2.0'; + + const stackTraceParser = await import('stacktrace-parser'); + vi.mocked(stackTraceParser.parse).mockReturnValue([ + { + file: 'file:///test.js', + methodName: 'testMethod', + lineNumber: 1, + column: 1, + arguments: [], + }, + ]); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => [ + { + value: { + originalStackFrame: { file: './test.js', lineNumber: 1, column: 1, methodName: 'testMethod' }, + originalCodeFrame: '> 1 | test code', + }, + }, + ], + } as any); + + await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('http://localhost:4000/__nextjs_original-stack-frames'), + expect.any(Object), + ); + }); + }); }); From 610ae69d36ec5b7d55b20aae3e5d9d62e9744a93 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 18 Nov 2025 14:51:29 +0200 Subject: [PATCH 150/190] feat(browser-utils): bump web-vitals to 5.1.0 (#18091) Bumps the vendored-in web vitals library to include the changes between `5.0.2` <-> `5.1.0` from upstream #### Changes from upstream - Remove `visibilitychange` event listeners when no longer required [#627](https://github.com/GoogleChrome/web-vitals/pull/627) - Register visibility-change early [#637](https://github.com/GoogleChrome/web-vitals/pull/637) - Only finalize LCP on user events (isTrusted=true) [#635](https://github.com/GoogleChrome/web-vitals/pull/635) - Fallback to default getSelector if custom function is null or undefined [#634](https://github.com/GoogleChrome/web-vitals/pull/634) #### Our own Changes - Added `addPageListener` and `removePageListener` utilities because the upstream package changed the listeners to be added on `window` instead of `document`, so I added those utilities to avoid having to check for window every time we try to add a listener. --- .size-limit.js | 6 +- .../src/metrics/web-vitals/README.md | 10 ++- .../src/metrics/web-vitals/getCLS.ts | 10 +-- .../src/metrics/web-vitals/getINP.ts | 9 +- .../src/metrics/web-vitals/getLCP.ts | 28 +++--- .../web-vitals/lib/getVisibilityWatcher.ts | 88 ++++++++++++------- .../metrics/web-vitals/lib/globalListeners.ts | 20 +++++ .../src/metrics/web-vitals/lib/onHidden.ts | 11 ++- .../web-vitals/lib/whenIdleOrHidden.ts | 9 +- .../src/metrics/web-vitals/types/base.ts | 2 +- .../src/metrics/web-vitals/types/cls.ts | 3 +- .../src/metrics/web-vitals/types/inp.ts | 3 +- .../src/metrics/web-vitals/types/lcp.ts | 3 +- 13 files changed, 135 insertions(+), 67 deletions(-) create mode 100644 packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts diff --git a/.size-limit.js b/.size-limit.js index 4e929875dad5..184aad0698f4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '41.3 KB', + limit: '41.38 KB', }, { name: '@sentry/browser (incl. Tracing, Profiling)', @@ -127,7 +127,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '43.3 KB', + limit: '43.33 KB', }, // Vue SDK (ESM) { @@ -142,7 +142,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43.1 KB', + limit: '43.2 KB', }, // Svelte SDK (ESM) { diff --git a/packages/browser-utils/src/metrics/web-vitals/README.md b/packages/browser-utils/src/metrics/web-vitals/README.md index a57937246cdd..1eeaf4df2420 100644 --- a/packages/browser-utils/src/metrics/web-vitals/README.md +++ b/packages/browser-utils/src/metrics/web-vitals/README.md @@ -2,10 +2,10 @@ > A modular library for measuring the [Web Vitals](https://web.dev/vitals/) metrics on real users. -This was vendored from: https://github.com/GoogleChrome/web-vitals: v5.0.2 +This was vendored from: https://github.com/GoogleChrome/web-vitals: v5.1.0 The commit SHA used is: -[463abbd425cda01ed65e0b5d18be9f559fe446cb](https://github.com/GoogleChrome/web-vitals/tree/463abbd425cda01ed65e0b5d18be9f559fe446cb) +[e22d23b22c1440e69c5fc25a2f373b1a425cc940](https://github.com/GoogleChrome/web-vitals/tree/e22d23b22c1440e69c5fc25a2f373b1a425cc940) Current vendored web vitals are: @@ -27,6 +27,12 @@ web-vitals only report once per pageload. ## CHANGELOG +- Bumped from Web Vitals 5.0.2 to 5.1.0 + - Remove `visibilitychange` event listeners when no longer required [#627](https://github.com/GoogleChrome/web-vitals/pull/627) + - Register visibility-change early [#637](https://github.com/GoogleChrome/web-vitals/pull/637) + - Only finalize LCP on user events (isTrusted=true) [#635](https://github.com/GoogleChrome/web-vitals/pull/635) + - Fallback to default getSelector if custom function is null or undefined [#634](https://github.com/GoogleChrome/web-vitals/pull/634) + https://github.com/getsentry/sentry-javascript/pull/17076 - Removed FID-related code with v10 of the SDK diff --git a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts index c40f993f8ca8..2e3f98c599e4 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getCLS.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getCLS.ts @@ -16,6 +16,7 @@ import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; +import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { LayoutShiftManager } from './lib/LayoutShiftManager'; @@ -55,6 +56,7 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = runOnce(() => { const metric = initMetric('CLS', 0); let report: ReturnType; + const visibilityWatcher = getVisibilityWatcher(); const layoutShiftManager = initUnique(opts, LayoutShiftManager); @@ -76,11 +78,9 @@ export const onCLS = (onReport: (metric: CLSMetric) => void, opts: ReportOpts = if (po) { report = bindReporter(onReport, metric, CLSThresholds, opts.reportAllChanges); - WINDOW.document?.addEventListener('visibilitychange', () => { - if (WINDOW.document?.visibilityState === 'hidden') { - handleEntries(po.takeRecords() as CLSMetric['entries']); - report(true); - } + visibilityWatcher.onHidden(() => { + handleEntries(po.takeRecords() as CLSMetric['entries']); + report(true); }); // Queue a task to report (if nothing else triggers a report first). diff --git a/packages/browser-utils/src/metrics/web-vitals/getINP.ts b/packages/browser-utils/src/metrics/web-vitals/getINP.ts index f5efbcbc3afc..df8ac5e1c804 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getINP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getINP.ts @@ -15,11 +15,11 @@ */ import { bindReporter } from './lib/bindReporter'; +import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { InteractionManager } from './lib/InteractionManager'; import { observe } from './lib/observe'; -import { onHidden } from './lib/onHidden'; import { initInteractionCountPolyfill } from './lib/polyfills/interactionCountPolyfill'; import { whenActivated } from './lib/whenActivated'; import { whenIdleOrHidden } from './lib/whenIdleOrHidden'; @@ -67,6 +67,8 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts return; } + const visibilityWatcher = getVisibilityWatcher(); + whenActivated(() => { // TODO(philipwalton): remove once the polyfill is no longer needed. initInteractionCountPolyfill(); @@ -116,10 +118,7 @@ export const onINP = (onReport: (metric: INPMetric) => void, opts: INPReportOpts // where the first interaction is less than the `durationThreshold`. po.observe({ type: 'first-input', buffered: true }); - // sentry: we use onHidden instead of directly listening to visibilitychange - // because some browsers we still support (Safari <14.4) don't fully support - // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. - onHidden(() => { + visibilityWatcher.onHidden(() => { handleEntries(po.takeRecords() as INPMetric['entries']); report(true); }); diff --git a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts index 6eafee698673..9de413c745c0 100644 --- a/packages/browser-utils/src/metrics/web-vitals/getLCP.ts +++ b/packages/browser-utils/src/metrics/web-vitals/getLCP.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { WINDOW } from '../../types'; import { bindReporter } from './lib/bindReporter'; import { getActivationStart } from './lib/getActivationStart'; import { getVisibilityWatcher } from './lib/getVisibilityWatcher'; +import { addPageListener, removePageListener } from './lib/globalListeners'; import { initMetric } from './lib/initMetric'; import { initUnique } from './lib/initUnique'; import { LCPEntryManager } from './lib/LCPEntryManager'; @@ -88,20 +88,28 @@ export const onLCP = (onReport: (metric: LCPMetric) => void, opts: ReportOpts = report(true); }); + // Need a separate wrapper to ensure the `runOnce` function above is + // common for all three functions + const stopListeningWrapper = (event: Event) => { + if (event.isTrusted) { + // Wrap the listener in an idle callback so it's run in a separate + // task to reduce potential INP impact. + // https://github.com/GoogleChrome/web-vitals/issues/383 + whenIdleOrHidden(stopListening); + removePageListener(event.type, stopListeningWrapper, { + capture: true, + }); + } + }; + // Stop listening after input or visibilitychange. // Note: while scrolling is an input that stops LCP observation, it's // unreliable since it can be programmatically generated. // See: https://github.com/GoogleChrome/web-vitals/issues/75 for (const type of ['keydown', 'click', 'visibilitychange']) { - // Wrap the listener in an idle callback so it's run in a separate - // task to reduce potential INP impact. - // https://github.com/GoogleChrome/web-vitals/issues/383 - if (WINDOW.document) { - addEventListener(type, () => whenIdleOrHidden(stopListening), { - capture: true, - once: true, - }); - } + addPageListener(type, stopListeningWrapper, { + capture: true, + }); } } }); diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts index 3a6c0a2e42a9..3eaea296a655 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/getVisibilityWatcher.ts @@ -16,8 +16,10 @@ import { WINDOW } from '../../../types'; import { getActivationStart } from './getActivationStart'; +import { addPageListener, removePageListener } from './globalListeners'; let firstHiddenTime = -1; +const onHiddenFunctions: Set<() => void> = new Set(); const initHiddenTime = () => { // If the document is hidden when this code runs, assume it was always @@ -29,35 +31,34 @@ const initHiddenTime = () => { }; const onVisibilityUpdate = (event: Event) => { - // If the document is 'hidden' and no previous hidden timestamp has been - // set, update it based on the current event data. - if (WINDOW.document!.visibilityState === 'hidden' && firstHiddenTime > -1) { - // If the event is a 'visibilitychange' event, it means the page was - // visible prior to this change, so the event timestamp is the first - // hidden time. - // However, if the event is not a 'visibilitychange' event, then it must - // be a 'prerenderingchange' event, and the fact that the document is - // still 'hidden' from the above check means the tab was activated - // in a background state and so has always been hidden. - firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0; + // Handle changes to hidden state + if (isPageHidden(event) && firstHiddenTime > -1) { + // Sentry-specific change: Also call onHidden callbacks for pagehide events + // to support older browsers (Safari <14.4) that don't properly fire visibilitychange + if (event.type === 'visibilitychange' || event.type === 'pagehide') { + for (const onHiddenFunction of onHiddenFunctions) { + onHiddenFunction(); + } + } - // Remove all listeners now that a `firstHiddenTime` value has been set. - removeChangeListeners(); - } -}; - -const addChangeListeners = () => { - addEventListener('visibilitychange', onVisibilityUpdate, true); - // IMPORTANT: when a page is prerendering, its `visibilityState` is - // 'hidden', so in order to account for cases where this module checks for - // visibility during prerendering, an additional check after prerendering - // completes is also required. - addEventListener('prerenderingchange', onVisibilityUpdate, true); -}; + // If the document is 'hidden' and no previous hidden timestamp has been + // set (so is infinity), update it based on the current event data. + if (!isFinite(firstHiddenTime)) { + // If the event is a 'visibilitychange' event, it means the page was + // visible prior to this change, so the event timestamp is the first + // hidden time. + // However, if the event is not a 'visibilitychange' event, then it must + // be a 'prerenderingchange' or 'pagehide' event, and the fact that the document is + // still 'hidden' from the above check means the tab was activated + // in a background state and so has always been hidden. + firstHiddenTime = event.type === 'visibilitychange' ? event.timeStamp : 0; -const removeChangeListeners = () => { - removeEventListener('visibilitychange', onVisibilityUpdate, true); - removeEventListener('prerenderingchange', onVisibilityUpdate, true); + // We no longer need the `prerenderingchange` event listener now we've + // set an initial init time so remove that + // (we'll keep the visibilitychange and pagehide ones for onHiddenFunction above) + removePageListener('prerenderingchange', onVisibilityUpdate, true); + } + } }; export const getVisibilityWatcher = () => { @@ -75,14 +76,39 @@ export const getVisibilityWatcher = () => { // a perfect heuristic, but it's the best we can do until the // `visibility-state` performance entry becomes available in all browsers. firstHiddenTime = firstVisibilityStateHiddenTime ?? initHiddenTime(); - // We're still going to listen to for changes so we can handle things like - // bfcache restores and/or prerender without having to examine individual - // timestamps in detail. - addChangeListeners(); + // Listen for visibility changes so we can handle things like bfcache + // restores and/or prerender without having to examine individual + // timestamps in detail and also for onHidden function calls. + addPageListener('visibilitychange', onVisibilityUpdate, true); + + // Sentry-specific change: Some browsers have buggy implementations of visibilitychange, + // so we use pagehide in addition, just to be safe. This is also required for older + // Safari versions (<14.4) that we still support. + addPageListener('pagehide', onVisibilityUpdate, true); + + // IMPORTANT: when a page is prerendering, its `visibilityState` is + // 'hidden', so in order to account for cases where this module checks for + // visibility during prerendering, an additional check after prerendering + // completes is also required. + addPageListener('prerenderingchange', onVisibilityUpdate, true); } + return { get firstHiddenTime() { return firstHiddenTime; }, + onHidden(cb: () => void) { + onHiddenFunctions.add(cb); + }, }; }; + +/** + * Check if the page is hidden, uses the `pagehide` event for older browsers support that we used to have in `onHidden` function. + * Some browsers we still support (Safari <14.4) don't fully support `visibilitychange` + * or have known bugs w.r.t the `visibilitychange` event. + * // TODO (v11): If we decide to drop support for Safari 14.4, we can use the logic from web-vitals 4.2.4 + */ +function isPageHidden(event: Event) { + return event.type === 'pagehide' || WINDOW.document?.visibilityState === 'hidden'; +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts b/packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts new file mode 100644 index 000000000000..0e391cff17c2 --- /dev/null +++ b/packages/browser-utils/src/metrics/web-vitals/lib/globalListeners.ts @@ -0,0 +1,20 @@ +import { WINDOW } from '../../../types'; + +/** + * web-vitals 5.1.0 switched listeners to be added on the window rather than the document. + * Instead of having to check for window/document every time we add a listener, we can use this function. + */ +export function addPageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { + if (WINDOW.document) { + WINDOW.addEventListener(type, listener, options); + } +} +/** + * web-vitals 5.1.0 switched listeners to be removed from the window rather than the document. + * Instead of having to check for window/document every time we remove a listener, we can use this function. + */ +export function removePageListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) { + if (WINDOW.document) { + WINDOW.removeEventListener(type, listener, options); + } +} diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts index 5a3c1b4fc810..d9dc2f6718ed 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/onHidden.ts @@ -15,6 +15,7 @@ */ import { WINDOW } from '../../../types'; +import { addPageListener } from './globalListeners'; export interface OnHiddenCallback { (event: Event): void; @@ -37,10 +38,8 @@ export const onHidden = (cb: OnHiddenCallback) => { } }; - if (WINDOW.document) { - addEventListener('visibilitychange', onHiddenOrPageHide, true); - // Some browsers have buggy implementations of visibilitychange, - // so we use pagehide in addition, just to be safe. - addEventListener('pagehide', onHiddenOrPageHide, true); - } + addPageListener('visibilitychange', onHiddenOrPageHide, true); + // Some browsers have buggy implementations of visibilitychange, + // so we use pagehide in addition, just to be safe. + addPageListener('pagehide', onHiddenOrPageHide, true); }; diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts index 32dae5f30f8b..008aac8dc4c2 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/whenIdleOrHidden.ts @@ -15,6 +15,7 @@ */ import { WINDOW } from '../../../types.js'; +import { addPageListener, removePageListener } from './globalListeners.js'; import { onHidden } from './onHidden.js'; import { runOnce } from './runOnce.js'; @@ -32,7 +33,13 @@ export const whenIdleOrHidden = (cb: () => void) => { } else { // eslint-disable-next-line no-param-reassign cb = runOnce(cb); - rIC(cb); + addPageListener('visibilitychange', cb, { once: true, capture: true }); + rIC(() => { + cb(); + // Remove the above event listener since no longer required. + // See: https://github.com/GoogleChrome/web-vitals/issues/622 + removePageListener('visibilitychange', cb, { capture: true }); + }); // sentry: we use onHidden instead of directly listening to visibilitychange // because some browsers we still support (Safari <14.4) don't fully support // `visibilitychange` or have known bugs w.r.t the `visibilitychange` event. diff --git a/packages/browser-utils/src/metrics/web-vitals/types/base.ts b/packages/browser-utils/src/metrics/web-vitals/types/base.ts index 02cb566011ac..cac7fdac1d11 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/base.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/base.ts @@ -116,7 +116,7 @@ export interface ReportOpts { } export interface AttributionReportOpts extends ReportOpts { - generateTarget?: (el: Node | null) => string; + generateTarget?: (el: Node | null) => string | undefined; } /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts index 5acaaa27c9ab..6048c616e1f0 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/cls.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/cls.ts @@ -34,7 +34,8 @@ export interface CLSAttribution { * By default, a selector identifying the first element (in document order) * that shifted when the single largest layout shift that contributed to the * page's CLS score occurred. If the `generateTarget` configuration option - * was passed, then this will instead be the return value of that function. + * was passed, then this will instead be the return value of that function, + * falling back to the default if that returns null or undefined. */ largestShiftTarget?: string; /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts index e73743866301..d2b2063c7d04 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/inp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/inp.ts @@ -60,7 +60,8 @@ export interface INPAttribution { * occurred. If this value is an empty string, that generally means the * element was removed from the DOM after the interaction. If the * `generateTarget` configuration option was passed, then this will instead - * be the return value of that function. + * be the return value of that function, falling back to the default if that + * returns null or undefined. */ interactionTarget: string; /** diff --git a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts index 293531b3d45c..9de6b32a5f94 100644 --- a/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts +++ b/packages/browser-utils/src/metrics/web-vitals/types/lcp.ts @@ -34,7 +34,8 @@ export interface LCPAttribution { * By default, a selector identifying the element corresponding to the * largest contentful paint for the page. If the `generateTarget` * configuration option was passed, then this will instead be the return - * value of that function. + * value of that function, falling back to the default if that returns null + * or undefined. */ target?: string; /** From 935ef55b1cfa4eb78bdcabe91c3ea4caf90e9820 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 18 Nov 2025 16:26:58 +0100 Subject: [PATCH 151/190] feat(core): Support OpenAI embeddings API (#18224) This adds instrumentation for the OpenAI Embeddings API. Specifically, we instrument [Create embeddings](https://platform.openai.com/docs/api-reference/embeddings/create), which is also the only endpoint in the embeddings API atm. Implementation generally follows the same flow we also have for the `completions` and `responses` APIs. To detect `embedding` requests we check whether the model name contains `embeddings`. The embedding results are currently not tracked, as we do not truncate outputs right now as far as I know and these can get large quite easily. For instance, [text-embedding-3 uses dimension 1536 (small) or 3072 (large) by default](https://platform.openai.com/docs/guides/embeddings#use-cases), resulting in single embeddings sizes of 6KB and 12KB, respectively. Test updates: - Added a new scenario-embeddings.mjs file, that covers the embeddings API tests (tried to put this in the main scenario.mjs, but the linter starts complaining about the file being too long). - Added a new scenario file to check that truncation works properly for the embeddings API. Also moved all truncation scenarios to a folder. --- .../{scenario.mjs => scenario-chat.mjs} | 0 .../tracing/openai/scenario-embeddings.mjs | 67 ++++++++ .../suites/tracing/openai/test.ts | 147 ++++++++++++++++-- ...cenario-message-truncation-completions.mjs | 0 ...scenario-message-truncation-embeddings.mjs | 66 ++++++++ .../scenario-message-truncation-responses.mjs | 0 .../core/src/tracing/ai/gen-ai-attributes.ts | 11 ++ packages/core/src/tracing/openai/constants.ts | 2 +- packages/core/src/tracing/openai/index.ts | 93 ++--------- packages/core/src/tracing/openai/types.ts | 24 ++- packages/core/src/tracing/openai/utils.ts | 116 ++++++++++++++ 11 files changed, 432 insertions(+), 94 deletions(-) rename dev-packages/node-integration-tests/suites/tracing/openai/{scenario.mjs => scenario-chat.mjs} (100%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs rename dev-packages/node-integration-tests/suites/tracing/openai/{ => truncation}/scenario-message-truncation-completions.mjs (100%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs rename dev-packages/node-integration-tests/suites/tracing/openai/{ => truncation}/scenario-message-truncation-responses.mjs (100%) diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/scenario.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/scenario-chat.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs new file mode 100644 index 000000000000..9cdb24a42da9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-embeddings.mjs @@ -0,0 +1,67 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.embeddings = { + create: async params => { + 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 { + object: 'list', + data: [ + { + object: 'embedding', + embedding: [0.1, 0.2, 0.3], + index: 0, + }, + ], + model: params.model, + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }; + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // First test: embeddings API + await client.embeddings.create({ + input: 'Embedding test!', + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + + // Second test: embeddings API error model + try { + await client.embeddings.create({ + input: 'Error embedding test!', + model: 'error-model', + }); + } catch { + // Error is expected and handled + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 5cbb27df73bf..116c3a6208fa 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -6,7 +6,7 @@ describe('OpenAI integration', () => { cleanupChildProcesses(); }); - const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT = { transaction: 'main', spans: expect.arrayContaining([ // First span - basic chat completion without PII @@ -147,7 +147,7 @@ describe('OpenAI integration', () => { ]), }; - const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT = { transaction: 'main', spans: expect.arrayContaining([ // First span - basic chat completion with PII @@ -321,27 +321,27 @@ describe('OpenAI integration', () => { ]), }; - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument.mjs', (createRunner, test) => { test('creates openai related spans with sendDefaultPii: false', async () => { await createRunner() .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT }) .start() .completed(); }); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('creates openai related spans with sendDefaultPii: true', async () => { await createRunner() .ignore('event') - .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT }) .start() .completed(); }); }); - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + createEsmAndCjsTests(__dirname, 'scenario-chat.mjs', 'instrument-with-options.mjs', (createRunner, test) => { test('creates openai related spans with custom options', async () => { await createRunner() .ignore('event') @@ -351,6 +351,109 @@ describe('OpenAI integration', () => { }); }); + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.request.messages': 'Embedding test!', + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': 'Error embedding test!', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS }) + .start() + .completed(); + }); + }); + createEsmAndCjsTests(__dirname, 'scenario-root-span.mjs', 'instrument.mjs', (createRunner, test) => { test('it works without a wrapping span', async () => { await createRunner() @@ -400,7 +503,7 @@ describe('OpenAI integration', () => { createEsmAndCjsTests( __dirname, - 'scenario-message-truncation-completions.mjs', + 'truncation/scenario-message-truncation-completions.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { @@ -436,7 +539,7 @@ describe('OpenAI integration', () => { createEsmAndCjsTests( __dirname, - 'scenario-message-truncation-responses.mjs', + 'truncation/scenario-message-truncation-responses.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { test('truncates string inputs when they exceed byte limit', async () => { @@ -469,4 +572,30 @@ describe('OpenAI integration', () => { }); }, ); + + createEsmAndCjsTests( + __dirname, + 'truncation/scenario-message-truncation-embeddings.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('truncates messages when they exceed byte limit - keeps only last message and crops it', async () => { + await createRunner() + .ignore('event') + .expect({ + transaction: { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'embeddings', + }), + }), + ]), + }, + }) + .start() + .completed(); + }); + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-completions.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-completions.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-completions.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs new file mode 100644 index 000000000000..b2e5cf3206fe --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-embeddings.mjs @@ -0,0 +1,66 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.embeddings = { + create: async params => { + await new Promise(resolve => setTimeout(resolve, 10)); + + return { + object: 'list', + data: [ + { + object: 'embedding', + embedding: [0.1, 0.2, 0.3], + index: 0, + }, + ], + model: params.model, + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }; + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // Create 1 large input that gets truncated to fit within the 20KB limit + const largeContent = 'A'.repeat(25000) + 'B'.repeat(25000); // ~50KB gets truncated to include only As + + await client.embeddings.create({ + input: largeContent, + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + + // Create 3 large inputs where: + // - First 2 inputs are very large (will be dropped) + // - Last input is large but will be truncated to fit within the 20KB limit + const largeContent1 = 'A'.repeat(15000); // ~15KB + const largeContent2 = 'B'.repeat(15000); // ~15KB + const largeContent3 = 'C'.repeat(25000); // ~25KB (will be truncated) + + await client.embeddings.create({ + input: [largeContent1, largeContent2, largeContent3], + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-responses.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/openai/scenario-message-truncation-responses.mjs rename to dev-packages/node-integration-tests/suites/tracing/openai/truncation/scenario-message-truncation-responses.mjs diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index b07aa63d306f..e2808d5f2642 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -65,6 +65,16 @@ export const GEN_AI_REQUEST_TOP_K_ATTRIBUTE = 'gen_ai.request.top_k'; */ export const GEN_AI_REQUEST_STOP_SEQUENCES_ATTRIBUTE = 'gen_ai.request.stop_sequences'; +/** + * The encoding format for the model request + */ +export const GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE = 'gen_ai.request.encoding_format'; + +/** + * The dimensions for the model request + */ +export const GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE = 'gen_ai.request.dimensions'; + /** * Array of reasons why the model stopped generating tokens */ @@ -208,6 +218,7 @@ export const OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE = 'openai.usage.prompt_tokens' export const OPENAI_OPERATIONS = { CHAT: 'chat', RESPONSES: 'responses', + EMBEDDINGS: 'embeddings', } as const; // ============================================================================= diff --git a/packages/core/src/tracing/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts index c4952b123b0f..e8b5c6ddc87f 100644 --- a/packages/core/src/tracing/openai/constants.ts +++ b/packages/core/src/tracing/openai/constants.ts @@ -2,7 +2,7 @@ export const OPENAI_INTEGRATION_NAME = 'OpenAI'; // https://platform.openai.com/docs/quickstart?api-mode=responses // https://platform.openai.com/docs/quickstart?api-mode=chat -export const INSTRUMENTED_METHODS = ['responses.create', 'chat.completions.create'] as const; +export const INSTRUMENTED_METHODS = ['responses.create', 'chat.completions.create', 'embeddings.create'] as const; export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ 'response.output_item.added', 'response.function_call_arguments.delta', diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index bb099199772c..bba2ee0f5afd 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -7,6 +7,8 @@ import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_AVAILABLE_TOOLS_ATTRIBUTE, + GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE, + GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, @@ -14,9 +16,7 @@ import { GEN_AI_REQUEST_STREAM_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, GEN_AI_REQUEST_TOP_P_ATTRIBUTE, - GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, - GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { getTruncatedJsonString } from '../ai/utils'; @@ -25,22 +25,22 @@ import { instrumentStream } from './streaming'; import type { ChatCompletionChunk, InstrumentedMethod, - OpenAiChatCompletionObject, OpenAiIntegration, OpenAiOptions, OpenAiResponse, - OpenAIResponseObject, OpenAIStream, ResponseStreamingEvent, } from './types'; import { + addChatCompletionAttributes, + addEmbeddingsAttributes, + addResponsesApiAttributes, buildMethodPath, getOperationName, getSpanOperation, isChatCompletionResponse, + isEmbeddingsResponse, isResponsesApiResponse, - setCommonResponseAttributes, - setTokenUsageAttributes, shouldInstrument, } from './utils'; @@ -82,6 +82,8 @@ function extractRequestAttributes(args: unknown[], methodPath: string): Record choice.finish_reason) - .filter((reason): reason is string => reason !== null); - if (finishReasons.length > 0) { - span.setAttributes({ - [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(finishReasons), - }); - } - - // Extract tool calls from all choices (only if recordOutputs is true) - if (recordOutputs) { - const toolCalls = response.choices - .map(choice => choice.message?.tool_calls) - .filter(calls => Array.isArray(calls) && calls.length > 0) - .flat(); - - if (toolCalls.length > 0) { - span.setAttributes({ - [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls), - }); - } - } - } -} - -/** - * Add attributes for Responses API responses - */ -function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject, recordOutputs?: boolean): void { - setCommonResponseAttributes(span, response.id, response.model, response.created_at); - if (response.status) { - span.setAttributes({ - [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify([response.status]), - }); - } - if (response.usage) { - setTokenUsageAttributes( - span, - response.usage.input_tokens, - response.usage.output_tokens, - response.usage.total_tokens, - ); - } - - // Extract function calls from output (only if recordOutputs is true) - if (recordOutputs) { - const responseWithOutput = response as OpenAIResponseObject & { output?: unknown[] }; - if (Array.isArray(responseWithOutput.output) && responseWithOutput.output.length > 0) { - // Filter for function_call type objects in the output array - const functionCalls = responseWithOutput.output.filter( - (item): unknown => - typeof item === 'object' && item !== null && (item as Record).type === 'function_call', - ); - - if (functionCalls.length > 0) { - span.setAttributes({ - [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls), - }); - } - } - } -} - /** * Add response attributes to spans * This currently supports both Chat Completion and Responses API responses @@ -186,6 +111,8 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool if (recordOutputs && response.output_text) { span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.output_text }); } + } else if (isEmbeddingsResponse(response)) { + addEmbeddingsAttributes(span, response); } } diff --git a/packages/core/src/tracing/openai/types.ts b/packages/core/src/tracing/openai/types.ts index daa478db4ba6..6dcd644bfe17 100644 --- a/packages/core/src/tracing/openai/types.ts +++ b/packages/core/src/tracing/openai/types.ts @@ -131,7 +131,29 @@ export interface OpenAIResponseObject { metadata: Record; } -export type OpenAiResponse = OpenAiChatCompletionObject | OpenAIResponseObject; +/** + * @see https://platform.openai.com/docs/api-reference/embeddings/object + */ +export interface OpenAIEmbeddingsObject { + object: 'embedding'; + embedding: number[]; + index: number; +} + +/** + * @see https://platform.openai.com/docs/api-reference/embeddings/create + */ +export interface OpenAICreateEmbeddingsObject { + object: 'list'; + data: OpenAIEmbeddingsObject[]; + model: string; + usage: { + prompt_tokens: number; + total_tokens: number; + }; +} + +export type OpenAiResponse = OpenAiChatCompletionObject | OpenAIResponseObject | OpenAICreateEmbeddingsObject; /** * Streaming event types for the Responses API diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index 17007693e739..4dff5b4fdbb8 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -1,7 +1,9 @@ import type { Span } from '../../types-hoist/span'; import { + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, @@ -17,6 +19,7 @@ import type { ChatCompletionChunk, InstrumentedMethod, OpenAiChatCompletionObject, + OpenAICreateEmbeddingsObject, OpenAIResponseObject, ResponseStreamingEvent, } from './types'; @@ -31,6 +34,9 @@ export function getOperationName(methodPath: string): string { if (methodPath.includes('responses')) { return OPENAI_OPERATIONS.RESPONSES; } + if (methodPath.includes('embeddings')) { + return OPENAI_OPERATIONS.EMBEDDINGS; + } return methodPath.split('.').pop() || 'unknown'; } @@ -80,6 +86,21 @@ export function isResponsesApiResponse(response: unknown): response is OpenAIRes ); } +/** + * Check if response is an Embeddings API object + */ +export function isEmbeddingsResponse(response: unknown): response is OpenAICreateEmbeddingsObject { + if (response === null || typeof response !== 'object' || !('object' in response)) { + return false; + } + const responseObject = response as Record; + return ( + responseObject.object === 'list' && + typeof responseObject.model === 'string' && + responseObject.model.toLowerCase().includes('embedding') + ); +} + /** * Check if streaming event is from the Responses API */ @@ -105,6 +126,101 @@ export function isChatCompletionChunk(event: unknown): event is ChatCompletionCh ); } +/** + * Add attributes for Chat Completion responses + */ +export function addChatCompletionAttributes( + span: Span, + response: OpenAiChatCompletionObject, + recordOutputs?: boolean, +): void { + setCommonResponseAttributes(span, response.id, response.model, response.created); + if (response.usage) { + setTokenUsageAttributes( + span, + response.usage.prompt_tokens, + response.usage.completion_tokens, + response.usage.total_tokens, + ); + } + if (Array.isArray(response.choices)) { + const finishReasons = response.choices + .map(choice => choice.finish_reason) + .filter((reason): reason is string => reason !== null); + if (finishReasons.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify(finishReasons), + }); + } + + // Extract tool calls from all choices (only if recordOutputs is true) + if (recordOutputs) { + const toolCalls = response.choices + .map(choice => choice.message?.tool_calls) + .filter(calls => Array.isArray(calls) && calls.length > 0) + .flat(); + + if (toolCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(toolCalls), + }); + } + } + } +} + +/** + * Add attributes for Responses API responses + */ +export function addResponsesApiAttributes(span: Span, response: OpenAIResponseObject, recordOutputs?: boolean): void { + setCommonResponseAttributes(span, response.id, response.model, response.created_at); + if (response.status) { + span.setAttributes({ + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: JSON.stringify([response.status]), + }); + } + if (response.usage) { + setTokenUsageAttributes( + span, + response.usage.input_tokens, + response.usage.output_tokens, + response.usage.total_tokens, + ); + } + + // Extract function calls from output (only if recordOutputs is true) + if (recordOutputs) { + const responseWithOutput = response as OpenAIResponseObject & { output?: unknown[] }; + if (Array.isArray(responseWithOutput.output) && responseWithOutput.output.length > 0) { + // Filter for function_call type objects in the output array + const functionCalls = responseWithOutput.output.filter( + (item): unknown => + typeof item === 'object' && item !== null && (item as Record).type === 'function_call', + ); + + if (functionCalls.length > 0) { + span.setAttributes({ + [GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]: JSON.stringify(functionCalls), + }); + } + } + } +} + +/** + * Add attributes for Embeddings API responses + */ +export function addEmbeddingsAttributes(span: Span, response: OpenAICreateEmbeddingsObject): void { + span.setAttributes({ + [OPENAI_RESPONSE_MODEL_ATTRIBUTE]: response.model, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: response.model, + }); + + if (response.usage) { + setTokenUsageAttributes(span, response.usage.prompt_tokens, undefined, response.usage.total_tokens); + } +} + /** * Set token usage attributes * @param span - The span to add attributes to From d12ba2ef6bd2512e8b4576dfc381f9dae173c5b4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 18 Nov 2025 18:14:18 +0100 Subject: [PATCH 152/190] feat(metrics): Add default `server.address` attribute on server runtimes (#18242) Attaches a `server.address` attribute to all captured metrics on a `serverRuntimeClient` Did this by emitting a new `processMetric` hook in core, that we listen to in the `serverRuntimeClient`. This way we do not need to re-export all metrics functions from server runtime packages and still only get a minimal client bundle size bump. Added integration tests for node + cloudflare closes https://github.com/getsentry/sentry-javascript/issues/18240 closes https://linear.app/getsentry/issue/JS-1178/attach-serveraddress-as-a-default-attribute-to-metrics --------- Co-authored-by: Lukas Stracke --- .../metrics/server-address/index.ts | 21 ++++++ .../public-api/metrics/server-address/test.ts | 50 ++++++++++++++ .../metrics/server-address/wrangler.jsonc | 6 ++ .../metrics/server-address-option/scenario.ts | 19 ++++++ .../metrics/server-address-option/test.ts | 36 ++++++++++ .../metrics/server-address/scenario.ts | 18 +++++ .../public-api/metrics/server-address/test.ts | 36 ++++++++++ packages/core/src/client.ts | 14 ++++ packages/core/src/metrics/internal.ts | 2 + packages/core/src/server-runtime-client.ts | 16 +++++ .../test/lib/server-runtime-client.test.ts | 65 +++++++++++++++++++ 11 files changed, 283 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts new file mode 100644 index 000000000000..635fcfc8721e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/index.ts @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + release: '1.0.0', + environment: 'test', + serverName: 'mi-servidor.com', + }), + { + async fetch(_request, _env, _ctx) { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + await Sentry.flush(); + return new Response('OK'); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts new file mode 100644 index 000000000000..5ee5b0954e59 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -0,0 +1,50 @@ +import type { SerializedMetricContainer } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('should add server.address attribute to metrics when serverName is set', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const metric = envelope[1]?.[0]?.[1] as SerializedMetricContainer; + + expect(metric.items[0]).toEqual( + expect.objectContaining({ + name: 'test.counter', + type: 'counter', + value: 1, + span_id: expect.any(String), + timestamp: expect.any(Number), + trace_id: expect.any(String), + attributes: { + endpoint: { + type: 'string', + value: '/api/test', + }, + 'sentry.environment': { + type: 'string', + value: 'test', + }, + 'sentry.release': { + type: 'string', + value: expect.any(String), + }, + 'sentry.sdk.name': { + type: 'string', + value: 'sentry.javascript.cloudflare', + }, + 'sentry.sdk.version': { + type: 'string', + value: expect.any(String), + }, + 'server.address': { + type: 'string', + value: 'mi-servidor.com', + }, + }, + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/metrics/server-address/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts new file mode 100644 index 000000000000..1f6d60f91cac --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/scenario.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + serverName: 'mi-servidor.com', + transport: loggingTransport, +}); + +async function run(): Promise { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts new file mode 100644 index 000000000000..825d94f41624 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address-option/test.ts @@ -0,0 +1,36 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('metrics server.address', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should add server.address attribute to metrics when serverName is set', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'server.address': { value: 'mi-servidor.com', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts new file mode 100644 index 000000000000..a985f2a0fce3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/scenario.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, +}); + +async function run(): Promise { + Sentry.metrics.count('test.counter', 1, { attributes: { endpoint: '/api/test' } }); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts new file mode 100644 index 000000000000..1ee4eda2de3e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/metrics/server-address/test.ts @@ -0,0 +1,36 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +describe('metrics server.address', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('should add server.address attribute to metrics when serverName is set', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: [ + { + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.counter', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/api/test', type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index f363e61becd7..1c925d930036 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -783,6 +783,13 @@ export abstract class Client { */ public on(hook: 'flushMetrics', callback: () => void): () => void; + /** + * A hook that is called when a metric is processed before it is captured and before the `beforeSendMetric` callback is fired. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'processMetric', callback: (metric: Metric) => void): () => void; + /** * A hook that is called when a http server request is started. * This hook is called after request isolation, but before the request is processed. @@ -992,6 +999,13 @@ export abstract class Client { */ public emit(hook: 'flushMetrics'): void; + /** + * + * Emit a hook event for client to process a metric before it is captured. + * This hook is called before the `beforeSendMetric` callback is fired. + */ + public emit(hook: 'processMetric', metric: Metric): void; + /** * Emit a hook event for client when a http server request is started. * This hook is called after request isolation, but before the request is processed. diff --git a/packages/core/src/metrics/internal.ts b/packages/core/src/metrics/internal.ts index b38d61b5195c..7ac1372d1285 100644 --- a/packages/core/src/metrics/internal.ts +++ b/packages/core/src/metrics/internal.ts @@ -227,6 +227,8 @@ export function _INTERNAL_captureMetric(beforeMetric: Metric, options?: Internal // Enrich metric with contextual attributes const enrichedMetric = _enrichMetricAttributes(beforeMetric, client, currentScope); + client.emit('processMetric', enrichedMetric); + // todo(v11): Remove the experimental `beforeSendMetric` // eslint-disable-next-line deprecation/deprecation const beforeSendCallback = beforeSendMetric || _experiments?.beforeSendMetric; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index 988e642d0a27..d1ae8e9063e6 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -40,6 +40,8 @@ export class ServerRuntimeClient< addUserAgentToTransportHeaders(options); super(options); + + this._setUpMetricsProcessing(); } /** @@ -176,6 +178,20 @@ export class ServerRuntimeClient< return super._prepareEvent(event, hint, currentScope, isolationScope); } + + /** + * Process a server-side metric before it is captured. + */ + private _setUpMetricsProcessing(): void { + this.on('processMetric', metric => { + if (this._options.serverName) { + metric.attributes = { + 'server.address': this._options.serverName, + ...metric.attributes, + }; + } + }); + } } function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void { diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index 3c5fe874af9f..24fb60d187ef 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, test, vi } from 'vitest'; import { applySdkMetadata, createTransport, Scope } from '../../src'; +import { _INTERNAL_captureMetric, _INTERNAL_getMetricBuffer } from '../../src/metrics/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'; @@ -236,4 +237,68 @@ describe('ServerRuntimeClient', () => { }); }); }); + + describe('metrics processing', () => { + it('adds server.address attribute to metrics when serverName is set', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, serverName: 'my-server.example.com' }); + client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual( + expect.objectContaining({ + 'server.address': { + value: 'my-server.example.com', + type: 'string', + }, + }), + ); + }); + + it('does not add server.address attribute when serverName is not set', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric({ type: 'counter', name: 'test.metric', value: 1 }, { scope }); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).not.toEqual( + expect.objectContaining({ + 'server.address': expect.anything(), + }), + ); + }); + + it('does not overwrite existing server.address attribute', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, serverName: 'my-server.example.com' }); + client = new ServerRuntimeClient(options); + const scope = new Scope(); + scope.setClient(client); + + _INTERNAL_captureMetric( + { + type: 'counter', + name: 'test.metric', + value: 1, + attributes: { 'server.address': 'existing-server.example.com' }, + }, + { scope }, + ); + + const metricAttributes = _INTERNAL_getMetricBuffer(client)?.[0]?.attributes; + expect(metricAttributes).toEqual( + expect.objectContaining({ + 'server.address': { + value: 'existing-server.example.com', + type: 'string', + }, + }), + ); + }); + }); }); From 584d4bcf72cb2c98242c97841d53ae7c330d8da4 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 19 Nov 2025 10:00:32 +0100 Subject: [PATCH 153/190] feat(cloudflare/vercel-edge): Add manual instrumentation for LangGraph (#18112) This PR adds manual instrumentation support for LangGraph StateGraph operations in Cloudflare Workers and Vercel Edge environments. ``` import * as Sentry from '@sentry/cloudflare'; // or '@sentry/vercel-edge' import { StateGraph, START, END, MessagesAnnotation } from '@langchain/langgraph'; // Create and instrument the graph const graph = new StateGraph(MessagesAnnotation) .addNode('agent', agentFn) .addEdge(START, 'agent') .addEdge('agent', END); Sentry.instrumentLangGraph(graph, { recordInputs: true, recordOutputs: true, }); const compiled = graph.compile({ name: 'weather_assistant' }); await compiled.invoke({ messages: [{ role: 'user', content: 'What is the weather in SF?' }], }); ``` - [x] This PR depends on #18114 --- .../cloudflare-integration-tests/package.json | 3 +- .../suites/tracing/langgraph/index.ts | 66 +++++++++++++++++++ .../suites/tracing/langgraph/test.ts | 59 +++++++++++++++++ .../suites/tracing/langgraph/wrangler.jsonc | 6 ++ packages/cloudflare/src/index.ts | 1 + packages/core/src/index.ts | 2 +- packages/core/src/tracing/langgraph/index.ts | 35 ++++++++++ packages/vercel-edge/src/index.ts | 1 + yarn.lock | 25 +++++++ 9 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index aac6e9c96945..c791a224a2cc 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -13,7 +13,8 @@ "test:watch": "yarn test --watch" }, "dependencies": { - "@sentry/cloudflare": "10.25.0" + "@sentry/cloudflare": "10.25.0", + "@langchain/langgraph": "^1.0.1" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts new file mode 100644 index 000000000000..6837a14be111 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/index.ts @@ -0,0 +1,66 @@ +import { END, MessagesAnnotation, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + sendDefaultPii: true, + }), + { + async fetch(_request, _env, _ctx) { + // Define simple mock LLM function + const mockLlm = (): { + messages: { + role: string; + content: string; + response_metadata: { + model_name: string; + finish_reason: string; + tokenUsage: { promptTokens: number; completionTokens: number; totalTokens: number }; + }; + tool_calls: never[]; + }[]; + } => { + return { + messages: [ + { + role: 'assistant', + content: 'Mock response from LangGraph agent', + response_metadata: { + model_name: 'mock-model', + finish_reason: 'stop', + tokenUsage: { + promptTokens: 20, + completionTokens: 10, + totalTokens: 30, + }, + }, + tool_calls: [], + }, + ], + }; + }; + + // Create and instrument the graph + const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', mockLlm) + .addEdge(START, 'agent') + .addEdge('agent', END); + + Sentry.instrumentLangGraph(graph, { recordInputs: true, recordOutputs: true }); + + const compiled = graph.compile({ name: 'weather_assistant' }); + + await compiled.invoke({ + messages: [{ role: 'user', content: 'What is the weather in SF?' }], + }); + + return new Response(JSON.stringify({ success: true })); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts new file mode 100644 index 000000000000..33023b30fa55 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/test.ts @@ -0,0 +1,59 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// 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 break in our +// cloudflare SDK. + +it('traces langgraph compile and invoke operations', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + expect(transactionEvent.transaction).toBe('GET /'); + + // Check create_agent span + const createAgentSpan = transactionEvent.spans.find((span: any) => span.op === 'gen_ai.create_agent'); + expect(createAgentSpan).toMatchObject({ + data: { + 'gen_ai.operation.name': 'create_agent', + 'sentry.op': 'gen_ai.create_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + }, + description: 'create_agent weather_assistant', + op: 'gen_ai.create_agent', + origin: 'auto.ai.langgraph', + }); + + // Check invoke_agent span + const invokeAgentSpan = transactionEvent.spans.find((span: any) => span.op === 'gen_ai.invoke_agent'); + expect(invokeAgentSpan).toMatchObject({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'invoke_agent', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.ai.langgraph', + 'gen_ai.agent.name': 'weather_assistant', + 'gen_ai.pipeline.name': 'weather_assistant', + 'gen_ai.request.messages': '[{"role":"user","content":"What is the weather in SF?"}]', + 'gen_ai.response.model': 'mock-model', + 'gen_ai.usage.input_tokens': 20, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 30, + }), + description: 'invoke_agent weather_assistant', + op: 'gen_ai.invoke_agent', + origin: 'auto.ai.langgraph', + }); + + // Verify tools are captured + if (invokeAgentSpan.data['gen_ai.request.available_tools']) { + expect(invokeAgentSpan.data['gen_ai.request.available_tools']).toMatch(/get_weather/); + } + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/wrangler.jsonc new file mode 100644 index 000000000000..d6be01281f0c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langgraph/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 36de54816030..827c45327689 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -102,6 +102,7 @@ export { growthbookIntegration, logger, metrics, + instrumentLangGraph, } from '@sentry/core'; export { withSentry } from './handler'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ed3dbe4750d7..014a411d0265 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -152,7 +152,7 @@ export type { GoogleGenAIResponse } from './tracing/google-genai/types'; export { createLangChainCallbackHandler } from './tracing/langchain'; export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; -export { instrumentStateGraphCompile } from './tracing/langgraph'; +export { instrumentStateGraphCompile, instrumentLangGraph } from './tracing/langgraph'; export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index 65d315bf3f63..5601cddf458b 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -155,3 +155,38 @@ function instrumentCompiledGraphInvoke( }, }) as (...args: unknown[]) => Promise; } + +/** + * Directly instruments a StateGraph instance to add tracing spans + * + * This function can be used to manually instrument LangGraph StateGraph instances + * in environments where automatic instrumentation is not available or desired. + * + * @param stateGraph - The StateGraph instance to instrument + * @param options - Optional configuration for recording inputs/outputs + * + * @example + * ```typescript + * import { instrumentLangGraph } from '@sentry/cloudflare'; + * import { StateGraph } from '@langchain/langgraph'; + * + * const graph = new StateGraph(MessagesAnnotation) + * .addNode('agent', mockLlm) + * .addEdge(START, 'agent') + * .addEdge('agent', END); + * + * instrumentLangGraph(graph, { recordInputs: true, recordOutputs: true }); + * const compiled = graph.compile({ name: 'my_agent' }); + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function instrumentLangGraph any }>( + stateGraph: T, + options?: LangGraphOptions, +): T { + const _options: LangGraphOptions = options || {}; + + stateGraph.compile = instrumentStateGraphCompile(stateGraph.compile.bind(stateGraph), _options); + + return stateGraph; +} diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 4f5017bb8f6c..8ece38279732 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -71,6 +71,7 @@ export { // eslint-disable-next-line deprecation/deprecation inboundFiltersIntegration, instrumentOpenAiClient, + instrumentLangGraph, instrumentGoogleGenAIClient, instrumentAnthropicAiClient, eventFiltersIntegration, diff --git a/yarn.lock b/yarn.lock index 0b2a0f0f31dc..082e1a032283 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4931,6 +4931,13 @@ zod "^3.25.32" zod-to-json-schema "^3.22.3" +"@langchain/langgraph-checkpoint@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz#ece2ede439d0d0b0b532c4be7817fd5029afe4f8" + integrity sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A== + dependencies: + uuid "^10.0.0" + "@langchain/langgraph-checkpoint@~0.0.17": version "0.0.18" resolved "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.18.tgz#2f7a9cdeda948ccc8d312ba9463810709d71d0b8" @@ -4948,6 +4955,15 @@ p-retry "4" uuid "^9.0.0" +"@langchain/langgraph-sdk@~1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.0.0.tgz#16faca6cc426432dee9316428d0aecd94e5b7989" + integrity sha512-g25ti2W7Dl5wUPlNK+0uIGbeNFqf98imhHlbdVVKTTkDYLhi/pI1KTgsSSkzkeLuBIfvt2b0q6anQwCs7XBlbw== + dependencies: + p-queue "^6.6.2" + p-retry "4" + uuid "^9.0.0" + "@langchain/langgraph@^0.2.32": version "0.2.74" resolved "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-0.2.74.tgz#37367a1e8bafda3548037a91449a69a84f285def" @@ -4958,6 +4974,15 @@ uuid "^10.0.0" zod "^3.23.8" +"@langchain/langgraph@^1.0.1": + version "1.0.2" + resolved "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.0.2.tgz#62de931edac0dd850daf708bd6f8f3835cf25a5e" + integrity sha512-syxzzWTnmpCL+RhUEvalUeOXFoZy/KkzHa2Da2gKf18zsf9Dkbh3rfnRDrTyUGS1XSTejq07s4rg1qntdEDs2A== + dependencies: + "@langchain/langgraph-checkpoint" "^1.0.0" + "@langchain/langgraph-sdk" "~1.0.0" + uuid "^10.0.0" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" From b3bf56dacea2d39da69a3239dc6cc2fb3dc1addd Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Wed, 19 Nov 2025 10:04:51 +0100 Subject: [PATCH 154/190] feat(node): Add OpenAI SDK v6 support and integration tests (#18244) Upgrades OpenAI instrumentation to support OpenAI SDK v6.0.0 and adds node integration tests to verify compatibility. ### Changes **Instrumentation:** - Bumped OpenAI SDK support to v6.0.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; + } + + // If stream is requested, return an async generator + if (params.stream) { + return this._createChatCompletionStream(params); + } + + return { + 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, + }, + }; + }, + }, + }; + + this.responses = { + create: async params => { + await new Promise(resolve => setTimeout(resolve, 10)); + + // If stream is requested, return an async generator + if (params.stream) { + return this._createResponsesApiStream(params); + } + + return { + id: 'resp_mock456', + object: 'response', + created_at: 1677652290, + model: params.model, + input_text: params.input, + output_text: `Response to: ${params.input}`, + status: 'completed', + usage: { + input_tokens: 5, + output_tokens: 8, + total_tokens: 13, + }, + }; + }, + }; + } + + // Create a mock streaming response for chat completions + async *_createChatCompletionStream(params) { + // First chunk with basic info + yield { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: params.model, + system_fingerprint: 'fp_stream_123', + choices: [ + { + index: 0, + delta: { + role: 'assistant', + content: 'Hello', + }, + finish_reason: null, + }, + ], + }; + + // Second chunk with more content + yield { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model: params.model, + system_fingerprint: 'fp_stream_123', + choices: [ + { + index: 0, + delta: { + content: ' from OpenAI streaming!', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 12, + completion_tokens: 18, + total_tokens: 30, + completion_tokens_details: { + accepted_prediction_tokens: 0, + audio_tokens: 0, + reasoning_tokens: 0, + rejected_prediction_tokens: 0, + }, + prompt_tokens_details: { + audio_tokens: 0, + cached_tokens: 0, + }, + }, + }; + } + + // Create a mock streaming response for responses API + async *_createResponsesApiStream(params) { + // Response created event + yield { + type: 'response.created', + response: { + id: 'resp_stream_456', + object: 'response', + created_at: 1677652310, + model: params.model, + status: 'in_progress', + error: null, + incomplete_details: null, + instructions: params.instructions, + max_output_tokens: 1000, + parallel_tool_calls: false, + previous_response_id: null, + reasoning: { + effort: null, + summary: null, + }, + store: false, + temperature: 0.7, + text: { + format: { + type: 'text', + }, + }, + tool_choice: 'auto', + top_p: 1.0, + truncation: 'disabled', + user: null, + metadata: {}, + output: [], + output_text: '', + usage: { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + }, + }, + sequence_number: 1, + }; + + // Response in progress with output text delta + yield { + type: 'response.output_text.delta', + delta: 'Streaming response to: ', + sequence_number: 2, + }; + + yield { + type: 'response.output_text.delta', + delta: params.input, + sequence_number: 3, + }; + + // Response completed event + yield { + type: 'response.completed', + response: { + id: 'resp_stream_456', + object: 'response', + created_at: 1677652310, + model: params.model, + status: 'completed', + error: null, + incomplete_details: null, + instructions: params.instructions, + max_output_tokens: 1000, + parallel_tool_calls: false, + previous_response_id: null, + reasoning: { + effort: null, + summary: null, + }, + store: false, + temperature: 0.7, + text: { + format: { + type: 'text', + }, + }, + tool_choice: 'auto', + top_p: 1.0, + truncation: 'disabled', + user: null, + metadata: {}, + output: [], + output_text: params.input, + usage: { + input_tokens: 6, + output_tokens: 10, + total_tokens: 16, + }, + }, + sequence_number: 4, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // First test: basic chat completion + 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, + }); + + // Second test: responses API + await client.responses.create({ + model: 'gpt-3.5-turbo', + input: 'Translate this to French: Hello', + instructions: 'You are a translator', + }); + + // Third test: error handling in chat completions + try { + await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // Error is expected and handled + } + + // Fourth test: chat completions streaming + const stream1 = await client.chat.completions.create({ + model: 'gpt-4', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Tell me about streaming' }, + ], + stream: true, + temperature: 0.8, + }); + + // Consume the stream to trigger span instrumentation + for await (const chunk of stream1) { + // Stream chunks are processed automatically by instrumentation + void chunk; // Prevent unused variable warning + } + + // Fifth test: responses API streaming + const stream2 = await client.responses.create({ + model: 'gpt-4', + input: 'Test streaming responses API', + instructions: 'You are a streaming assistant', + stream: true, + }); + + for await (const chunk of stream2) { + void chunk; + } + + // Sixth test: error handling in streaming context + try { + const errorStream = await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + stream: true, + }); + + // Try to consume the stream (this should not execute) + for await (const chunk of errorStream) { + void chunk; + } + } catch { + // Error is expected and handled + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-embeddings.mjs new file mode 100644 index 000000000000..9cdb24a42da9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-embeddings.mjs @@ -0,0 +1,67 @@ +import { instrumentOpenAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockOpenAI { + constructor(config) { + this.apiKey = config.apiKey; + + this.embeddings = { + create: async params => { + 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 { + object: 'list', + data: [ + { + object: 'embedding', + embedding: [0.1, 0.2, 0.3], + index: 0, + }, + ], + model: params.model, + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + }; + }, + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockOpenAI({ + apiKey: 'mock-api-key', + }); + + const client = instrumentOpenAiClient(mockClient); + + // First test: embeddings API + await client.embeddings.create({ + input: 'Embedding test!', + model: 'text-embedding-3-small', + dimensions: 1536, + encoding_format: 'float', + }); + + // Second test: embeddings API error model + try { + await client.embeddings.create({ + input: 'Error embedding test!', + model: 'error-model', + }); + } catch { + // Error is expected and handled + } + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-root-span.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-root-span.mjs new file mode 100644 index 000000000000..2aaca0700312 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-root-span.mjs @@ -0,0 +1,63 @@ +import express from 'express'; +import OpenAI from 'openai'; + +function startMockOpenAiServer() { + const app = express(); + app.use(express.json()); + + app.post('/openai/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.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 new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockOpenAiServer(); + + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + 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, + }); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(response)); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts new file mode 100644 index 000000000000..053f3066a1b0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts @@ -0,0 +1,565 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('OpenAI integration (V6)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - basic chat completion without PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + '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.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + description: 'chat gpt-3.5-turbo', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - responses API + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'resp_mock456', + 'gen_ai.response.finish_reasons': '["completed"]', + 'gen_ai.usage.input_tokens': 5, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.usage.total_tokens': 13, + 'openai.response.id': 'resp_mock456', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:30.000Z', + 'openai.usage.completion_tokens': 8, + 'openai.usage.prompt_tokens': 5, + }, + description: 'responses gpt-3.5-turbo', + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Third span - error handling + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + }, + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + // Fourth span - chat completions streaming + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.stream': true, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'chatcmpl-stream-123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 18, + 'gen_ai.usage.total_tokens': 30, + 'openai.response.id': 'chatcmpl-stream-123', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:40.000Z', + 'openai.usage.completion_tokens': 18, + 'openai.usage.prompt_tokens': 12, + }, + description: 'chat gpt-4 stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Fifth span - responses API streaming + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.response.id': 'resp_stream_456', + 'gen_ai.response.finish_reasons': '["in_progress","completed"]', + 'gen_ai.usage.input_tokens': 6, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 16, + 'openai.response.id': 'resp_stream_456', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:50.000Z', + 'openai.usage.completion_tokens': 10, + 'openai.usage.prompt_tokens': 6, + }, + description: 'responses gpt-4 stream-response', + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Sixth span - error handling in streaming context + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.stream': true, + 'gen_ai.system': 'openai', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + }, + description: 'chat error-model stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - basic chat completion with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.messages': + '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"What is the capital of France?"}]', + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'chatcmpl-mock123', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.response.text': '["Hello from OpenAI mock!"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + description: 'chat gpt-3.5-turbo', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - responses API with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-3.5-turbo', + 'gen_ai.request.messages': 'Translate this to French: Hello', + 'gen_ai.response.text': 'Response to: Translate this to French: Hello', + 'gen_ai.response.finish_reasons': '["completed"]', + 'gen_ai.response.model': 'gpt-3.5-turbo', + 'gen_ai.response.id': 'resp_mock456', + 'gen_ai.usage.input_tokens': 5, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.usage.total_tokens': 13, + 'openai.response.id': 'resp_mock456', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:30.000Z', + 'openai.usage.completion_tokens': 8, + 'openai.usage.prompt_tokens': 5, + }, + description: 'responses gpt-3.5-turbo', + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Third span - error handling with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', + }, + description: 'chat error-model', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + // Fourth span - chat completions streaming with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.temperature': 0.8, + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': + '[{"role":"system","content":"You are a helpful assistant."},{"role":"user","content":"Tell me about streaming"}]', + 'gen_ai.response.text': 'Hello from OpenAI streaming!', + 'gen_ai.response.finish_reasons': '["stop"]', + 'gen_ai.response.id': 'chatcmpl-stream-123', + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 18, + 'gen_ai.usage.total_tokens': 30, + 'openai.response.id': 'chatcmpl-stream-123', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:40.000Z', + 'openai.usage.completion_tokens': 18, + 'openai.usage.prompt_tokens': 12, + }), + description: 'chat gpt-4 stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Fifth span - responses API streaming with PII + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'responses', + 'sentry.op': 'gen_ai.responses', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'gpt-4', + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': 'Test streaming responses API', + 'gen_ai.response.text': 'Streaming response to: Test streaming responses APITest streaming responses API', + 'gen_ai.response.finish_reasons': '["in_progress","completed"]', + 'gen_ai.response.id': 'resp_stream_456', + 'gen_ai.response.model': 'gpt-4', + 'gen_ai.usage.input_tokens': 6, + 'gen_ai.usage.output_tokens': 10, + 'gen_ai.usage.total_tokens': 16, + 'openai.response.id': 'resp_stream_456', + 'openai.response.model': 'gpt-4', + 'gen_ai.response.streaming': true, + 'openai.response.timestamp': '2023-03-01T06:31:50.000Z', + 'openai.usage.completion_tokens': 10, + 'openai.usage.prompt_tokens': 6, + }), + description: 'responses gpt-4 stream-response', + op: 'gen_ai.responses', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Sixth span - error handling in streaming context with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.stream': true, + 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', + 'gen_ai.system': 'openai', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + }, + description: 'chat error-model stream-response', + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_WITH_OPTIONS = { + transaction: 'main', + spans: expect.arrayContaining([ + // Check that custom options are respected + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true + }), + }), + // Check that custom options are respected for streaming + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true + 'gen_ai.request.stream': true, // Should be marked as stream + }), + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embeddings API with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'text-embedding-3-small', + 'gen_ai.request.encoding_format': 'float', + 'gen_ai.request.dimensions': 1536, + 'gen_ai.request.messages': 'Embedding test!', + 'gen_ai.response.model': 'text-embedding-3-small', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.total_tokens': 10, + 'openai.response.model': 'text-embedding-3-small', + 'openai.usage.prompt_tokens': 10, + }, + description: 'embeddings text-embedding-3-small', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'ok', + }), + // Second span - embeddings API error model with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'embeddings', + 'sentry.op': 'gen_ai.embeddings', + 'sentry.origin': 'auto.ai.openai', + 'gen_ai.system': 'openai', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': 'Error embedding test!', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.openai', + status: 'internal_error', + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario-chat.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: false (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_CHAT }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-chat.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: true (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_CHAT }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-chat.mjs', + 'instrument-with-options.mjs', + (createRunner, test) => { + test('creates openai related spans with custom options (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-embeddings.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: false (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-embeddings.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('creates openai related spans with sendDefaultPii: true (v6)', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-root-span.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('it works without a wrapping span (v6)', async () => { + await createRunner() + // First the span that our mock express server is emitting, unrelated to this test + .expect({ + transaction: { + transaction: 'POST /openai/chat/completions', + }, + }) + .expect({ + transaction: { + transaction: 'chat gpt-3.5-turbo', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + '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.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }, + }, + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + express: 'latest', + }, + }, + ); +}); diff --git a/packages/node/src/integrations/tracing/openai/instrumentation.ts b/packages/node/src/integrations/tracing/openai/instrumentation.ts index d71c548395b0..e0682185ff0a 100644 --- a/packages/node/src/integrations/tracing/openai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/openai/instrumentation.ts @@ -13,7 +13,7 @@ import { SDK_VERSION, } from '@sentry/core'; -const supportedVersions = ['>=4.0.0 <6']; +const supportedVersions = ['>=4.0.0 <7']; export interface OpenAiIntegration extends Integration { options: OpenAiOptions; From be29c5617284e014e4fe5a3c0f75454d22036d0b Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 19 Nov 2025 10:36:08 +0100 Subject: [PATCH 155/190] chore(e2e): Bump zod in e2e tests (#18251) The lower version is currently breaking our ci, the version lacks v3 exports that are used by `zod-to-json`. --- .../e2e-tests/test-applications/node-express-v5/package.json | 2 +- .../e2e-tests/test-applications/node-express/package.json | 2 +- .../e2e-tests/test-applications/tsx-express/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json index b7caf4610712..0890fec0f10d 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/package.json @@ -19,7 +19,7 @@ "@types/node": "^18.19.1", "express": "^5.1.0", "typescript": "~5.0.0", - "zod": "~3.24.3" + "zod": "~3.25.0" }, "devDependencies": { "@playwright/test": "~1.53.2", diff --git a/dev-packages/e2e-tests/test-applications/node-express/package.json b/dev-packages/e2e-tests/test-applications/node-express/package.json index 18be5221bd3f..d5bba8591164 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express/package.json @@ -19,7 +19,7 @@ "@types/node": "^18.19.1", "express": "^4.21.2", "typescript": "~5.0.0", - "zod": "~3.24.3" + "zod": "~3.25.0" }, "devDependencies": { "@playwright/test": "~1.53.2", diff --git a/dev-packages/e2e-tests/test-applications/tsx-express/package.json b/dev-packages/e2e-tests/test-applications/tsx-express/package.json index 80dce608dbe5..d99a88bfa3b8 100644 --- a/dev-packages/e2e-tests/test-applications/tsx-express/package.json +++ b/dev-packages/e2e-tests/test-applications/tsx-express/package.json @@ -19,7 +19,7 @@ "@types/node": "^18.19.1", "express": "^4.21.2", "typescript": "~5.0.0", - "zod": "~3.24.3" + "zod": "~3.25.0" }, "devDependencies": { "@playwright/test": "~1.50.0", From be12569677db0ea3455a7449f381973d6e842ff1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 19 Nov 2025 11:00:22 +0200 Subject: [PATCH 156/190] meta(changelog): Update changelog for 10.26.0 --- CHANGELOG.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30abc26e733c..5eba860932aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,101 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -- fix(node): Fix Spotlight configuration precedence to match specification (#18195) +## 10.26.0 + +### Important Changes + +- **feat(core): Instrument LangGraph Agent ([#18114](https://github.com/getsentry/sentry-javascript/pull/18114))** + +Adds support for instrumenting LangGraph StateGraph operations in Node. The LangGraph integration can be configured as follows: + +```js +Sentry.init({ + dsn: '__DSN__', + sendDefaultPii: false, // Even with PII disabled globally + integrations: [ + Sentry.langGraphIntegration({ + recordInputs: true, // Force recording input messages + recordOutputs: true, // Force recording response text + }), + ], +}); +``` + +- **feat(cloudflare/vercel-edge): Add manual instrumentation for LangGraph ([#18112](https://github.com/getsentry/sentry-javascript/pull/18112))** + +Instrumentation for LangGraph in Cloudflare Workers and Vercel Edge environments is supported by manually calling `instrumentLangGraph`: + +```js +import * as Sentry from '@sentry/cloudflare'; // or '@sentry/vercel-edge' +import { StateGraph, START, END, MessagesAnnotation } from '@langchain/langgraph'; + +// Create and instrument the graph +const graph = new StateGraph(MessagesAnnotation) + .addNode('agent', agentFn) + .addEdge(START, 'agent') + .addEdge('agent', END); + +Sentry.instrumentLangGraph(graph, { + recordInputs: true, + recordOutputs: true, +}); + +const compiled = graph.compile({ name: 'weather_assistant' }); + +await compiled.invoke({ + messages: [{ role: 'user', content: 'What is the weather in SF?' }], +}); +``` + +- **feat(node): Add OpenAI SDK v6 support ([#18244](https://github.com/getsentry/sentry-javascript/pull/18244))** + +### Other Changes + +- feat(core): Support OpenAI embeddings API ([#18224](https://github.com/getsentry/sentry-javascript/pull/18224)) +- feat(browser-utils): bump web-vitals to 5.1.0 ([#18091](https://github.com/getsentry/sentry-javascript/pull/18091)) +- feat(core): Support truncation for LangChain integration request messages ([#18157](https://github.com/getsentry/sentry-javascript/pull/18157)) +- feat(metrics): Add default `server.address` attribute on server runtimes ([#18242](https://github.com/getsentry/sentry-javascript/pull/18242)) +- feat(nextjs): Add URL to server-side transaction events ([#18230](https://github.com/getsentry/sentry-javascript/pull/18230)) +- feat(node-core): Add mechanism to prevent wrapping ai providers multiple times([#17972](https://github.com/getsentry/sentry-javascript/pull/17972)) +- feat(replay): Bump limit for minReplayDuration ([#18190](https://github.com/getsentry/sentry-javascript/pull/18190)) +- fix(browser): Add `ok` status to successful `idleSpan`s ([#18139](https://github.com/getsentry/sentry-javascript/pull/18139)) +- fix(core): Check `fetch` support with data URL ([#18225](https://github.com/getsentry/sentry-javascript/pull/18225)) +- fix(core): Decrease number of Sentry stack frames for messages from `captureConsoleIntegration` ([#18096](https://github.com/getsentry/sentry-javascript/pull/18096)) +- fix(core): Emit processed metric ([#18222](https://github.com/getsentry/sentry-javascript/pull/18222)) +- fix(core): Ensure logs past `MAX_LOG_BUFFER_SIZE` are not swallowed ([#18207](https://github.com/getsentry/sentry-javascript/pull/18207)) +- fix(core): Ensure metrics past `MAX_METRIC_BUFFER_SIZE` are not swallowed ([#18212](https://github.com/getsentry/sentry-javascript/pull/18212)) +- fix(core): Fix logs and metrics flush timeout starvation with continuous logging ([#18211](https://github.com/getsentry/sentry-javascript/pull/18211)) +- fix(core): Flatten gen_ai.request.available_tools in google-genai ([#18194](https://github.com/getsentry/sentry-javascript/pull/18194)) +- fix(core): Stringify available tools sent from vercelai ([#18197](https://github.com/getsentry/sentry-javascript/pull/18197)) +- fix(core/vue): Detect and skip normalizing Vue `VNode` objects with high `normalizeDepth` ([#18206](https://github.com/getsentry/sentry-javascript/pull/18206)) +- fix(nextjs): Avoid wrapping middleware files when in standalone mode ([#18172](https://github.com/getsentry/sentry-javascript/pull/18172)) +- fix(nextjs): Drop meta trace tags if rendered page is ISR ([#18192](https://github.com/getsentry/sentry-javascript/pull/18192)) +- fix(nextjs): Respect PORT variable for dev error symbolication ([#18227](https://github.com/getsentry/sentry-javascript/pull/18227)) +- fix(nextjs): use LRU map instead of map for ISR route cache ([#18234](https://github.com/getsentry/sentry-javascript/pull/18234)) +- fix(node): `tracingChannel` export missing in older node versions ([#18191](https://github.com/getsentry/sentry-javascript/pull/18191)) +- fix(node): Fix Spotlight configuration precedence to match specification ([#18195](https://github.com/getsentry/sentry-javascript/pull/18195)) +- fix(react): Prevent navigation span leaks for consecutive navigations ([#18098](https://github.com/getsentry/sentry-javascript/pull/18098)) +- ref(react-router): Deprecate ErrorBoundary exports ([#18208](https://github.com/getsentry/sentry-javascript/pull/18208)) + +
+ Internal Changes + +- chore: Fix missing changelog quote we use for attribution placement ([#18237](https://github.com/getsentry/sentry-javascript/pull/18237)) +- chore: move tip about prioritizing issues ([#18071](https://github.com/getsentry/sentry-javascript/pull/18071)) +- chore(e2e): Pin `@embroider/addon-shim` to 1.10.0 for the e2e ember-embroider ([#18173](https://github.com/getsentry/sentry-javascript/pull/18173)) +- chore(react-router): Fix casing on deprecation notices ([#18221](https://github.com/getsentry/sentry-javascript/pull/18221)) +- chore(test): Use correct `testTimeout` field in bundler-tests vitest config +- chore(e2e): Bump zod in e2e tests ([#18251](https://github.com/getsentry/sentry-javascript/pull/18251)) +- test(browser-integration): Fix incorrect tag value assertions ([#18162](https://github.com/getsentry/sentry-javascript/pull/18162)) +- test(profiling): Add test utils to validate Profile Chunk envelope ([#18170](https://github.com/getsentry/sentry-javascript/pull/18170)) +- ref(e2e-ember): Remove `@embroider/addon-shim` override ([#18180](https://github.com/getsentry/sentry-javascript/pull/18180)) +- ref(browser): Move trace lifecycle listeners to class function ([#18231](https://github.com/getsentry/sentry-javascript/pull/18231)) +- ref(browserprofiling): Move and rename profiler class to UIProfiler ([#18187](https://github.com/getsentry/sentry-javascript/pull/18187)) +- ref(core): Move ai integrations from utils to tracing ([#18185](https://github.com/getsentry/sentry-javascript/pull/18185)) +- ref(core): Optimize `Scope.setTag` bundle size and adjust test ([#18182](https://github.com/getsentry/sentry-javascript/pull/18182)) + +
## 10.25.0 From 8ab6227f365a9a9117e6e115bf8132cba2a1e72f Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Wed, 19 Nov 2025 10:31:51 +0000 Subject: [PATCH 157/190] release: 10.26.0 --- .../browser-integration-tests/package.json | 4 ++-- .../bundle-analyzer-scenarios/package.json | 2 +- dev-packages/bundler-tests/package.json | 4 ++-- dev-packages/clear-cache-gh-action/package.json | 2 +- .../cloudflare-integration-tests/package.json | 8 ++++---- dev-packages/e2e-tests/package.json | 2 +- .../external-contributor-gh-action/package.json | 2 +- .../node-core-integration-tests/package.json | 6 +++--- dev-packages/node-integration-tests/package.json | 10 +++++----- dev-packages/node-overhead-gh-action/package.json | 4 ++-- dev-packages/rollup-utils/package.json | 2 +- dev-packages/size-limit-gh-action/package.json | 2 +- dev-packages/test-utils/package.json | 4 ++-- lerna.json | 2 +- packages/angular/package.json | 6 +++--- packages/astro/package.json | 8 ++++---- packages/aws-serverless/package.json | 8 ++++---- packages/browser-utils/package.json | 4 ++-- packages/browser/package.json | 14 +++++++------- packages/bun/package.json | 6 +++--- packages/cloudflare/package.json | 4 ++-- packages/core/package.json | 2 +- packages/deno/package.json | 4 ++-- packages/ember/package.json | 6 +++--- packages/eslint-config-sdk/package.json | 6 +++--- packages/eslint-plugin-sdk/package.json | 2 +- packages/feedback/package.json | 4 ++-- packages/gatsby/package.json | 6 +++--- packages/google-cloud-serverless/package.json | 6 +++--- packages/integration-shims/package.json | 4 ++-- packages/nestjs/package.json | 6 +++--- packages/nextjs/package.json | 14 +++++++------- packages/node-core/package.json | 6 +++--- packages/node-native/package.json | 6 +++--- packages/node/package.json | 8 ++++---- packages/nuxt/package.json | 12 ++++++------ packages/opentelemetry/package.json | 4 ++-- packages/profiling-node/package.json | 6 +++--- packages/react-router/package.json | 10 +++++----- packages/react/package.json | 6 +++--- packages/remix/package.json | 8 ++++---- packages/replay-canvas/package.json | 6 +++--- packages/replay-internal/package.json | 8 ++++---- packages/replay-worker/package.json | 2 +- packages/solid/package.json | 6 +++--- packages/solidstart/package.json | 8 ++++---- packages/svelte/package.json | 6 +++--- packages/sveltekit/package.json | 10 +++++----- packages/tanstackstart-react/package.json | 10 +++++----- packages/tanstackstart/package.json | 2 +- packages/types/package.json | 4 ++-- packages/typescript/package.json | 2 +- packages/vercel-edge/package.json | 6 +++--- packages/vue/package.json | 6 +++--- packages/wasm/package.json | 6 +++--- 55 files changed, 156 insertions(+), 156 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index b6a7e01e7bfb..6350a0826572 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "10.25.0", + "version": "10.26.0", "main": "index.js", "license": "MIT", "engines": { @@ -43,7 +43,7 @@ "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.53.2", "@sentry-internal/rrweb": "2.34.0", - "@sentry/browser": "10.25.0", + "@sentry/browser": "10.26.0", "@supabase/supabase-js": "2.49.3", "axios": "^1.12.2", "babel-loader": "^8.2.2", diff --git a/dev-packages/bundle-analyzer-scenarios/package.json b/dev-packages/bundle-analyzer-scenarios/package.json index 5de03ee2a113..72928caf3708 100644 --- a/dev-packages/bundle-analyzer-scenarios/package.json +++ b/dev-packages/bundle-analyzer-scenarios/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundle-analyzer-scenarios", - "version": "10.25.0", + "version": "10.26.0", "description": "Scenarios to test bundle analysis with", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/dev-packages/bundle-analyzer-scenarios", diff --git a/dev-packages/bundler-tests/package.json b/dev-packages/bundler-tests/package.json index a7850b3c1cc6..14bfa512f433 100644 --- a/dev-packages/bundler-tests/package.json +++ b/dev-packages/bundler-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundler-tests", - "version": "10.25.0", + "version": "10.26.0", "description": "Bundler tests for Sentry Browser SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bundler-tests", @@ -13,7 +13,7 @@ }, "dependencies": { "@rollup/plugin-node-resolve": "^15.2.3", - "@sentry/browser": "10.25.0", + "@sentry/browser": "10.26.0", "rollup": "^4.0.0", "vite": "^5.0.0", "vitest": "^3.2.4", diff --git a/dev-packages/clear-cache-gh-action/package.json b/dev-packages/clear-cache-gh-action/package.json index c64bdf36eb9a..22f0c16d2680 100644 --- a/dev-packages/clear-cache-gh-action/package.json +++ b/dev-packages/clear-cache-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/clear-cache-gh-action", "description": "An internal Github Action to clear GitHub caches.", - "version": "10.25.0", + "version": "10.26.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index c791a224a2cc..e4af74f7edfe 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/cloudflare-integration-tests", - "version": "10.25.0", + "version": "10.26.0", "license": "MIT", "engines": { "node": ">=18" @@ -13,12 +13,12 @@ "test:watch": "yarn test --watch" }, "dependencies": { - "@sentry/cloudflare": "10.25.0", - "@langchain/langgraph": "^1.0.1" + "@langchain/langgraph": "^1.0.1", + "@sentry/cloudflare": "10.26.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", - "@sentry-internal/test-utils": "10.25.0", + "@sentry-internal/test-utils": "10.26.0", "eslint-plugin-regexp": "^1.15.0", "vitest": "^3.2.4", "wrangler": "4.22.0" diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 556891ca2d55..7712bbac10ee 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/e2e-tests", - "version": "10.25.0", + "version": "10.26.0", "license": "MIT", "private": true, "scripts": { diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json index 4b536a69fc1d..9e99c2b66ae0 100644 --- a/dev-packages/external-contributor-gh-action/package.json +++ b/dev-packages/external-contributor-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/external-contributor-gh-action", "description": "An internal Github Action to add external contributors to the CHANGELOG.md file.", - "version": "10.25.0", + "version": "10.26.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index bf4bf9f3578a..fe755f16cc6d 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-core-integration-tests", - "version": "10.25.0", + "version": "10.26.0", "license": "MIT", "engines": { "node": ">=18" @@ -34,8 +34,8 @@ "@opentelemetry/resources": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.25.0", - "@sentry/node-core": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/node-core": "10.26.0", "body-parser": "^1.20.3", "cors": "^2.8.5", "cron": "^3.1.6", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 0bc5b8b83b1c..1799e7c9b306 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-integration-tests", - "version": "10.25.0", + "version": "10.26.0", "license": "MIT", "engines": { "node": ">=18" @@ -36,9 +36,9 @@ "@nestjs/core": "^11", "@nestjs/platform-express": "^11", "@prisma/client": "6.15.0", - "@sentry/aws-serverless": "10.25.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", + "@sentry/aws-serverless": "10.26.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", @@ -83,7 +83,7 @@ "yargs": "^16.2.0" }, "devDependencies": { - "@sentry-internal/test-utils": "10.25.0", + "@sentry-internal/test-utils": "10.26.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", diff --git a/dev-packages/node-overhead-gh-action/package.json b/dev-packages/node-overhead-gh-action/package.json index 02dca29a291f..498cefecf053 100644 --- a/dev-packages/node-overhead-gh-action/package.json +++ b/dev-packages/node-overhead-gh-action/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-overhead-gh-action", - "version": "10.25.0", + "version": "10.26.0", "license": "MIT", "engines": { "node": ">=18" @@ -23,7 +23,7 @@ "fix": "eslint . --format stylish --fix" }, "dependencies": { - "@sentry/node": "10.25.0", + "@sentry/node": "10.26.0", "express": "^4.21.1", "mysql2": "^3.14.4" }, diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json index 60237502ec71..62cec9c7f9f9 100644 --- a/dev-packages/rollup-utils/package.json +++ b/dev-packages/rollup-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/rollup-utils", - "version": "10.25.0", + "version": "10.26.0", "description": "Rollup utilities used at Sentry for the Sentry JavaScript SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/rollup-utils", diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index e29d747dcbaa..5de80ac560c7 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/size-limit-gh-action", "description": "An internal Github Action to compare the current size of a PR against the one on develop.", - "version": "10.25.0", + "version": "10.26.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index c8c0c09b353d..7d0400748c94 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "10.25.0", + "version": "10.26.0", "name": "@sentry-internal/test-utils", "author": "Sentry", "license": "MIT", @@ -48,7 +48,7 @@ }, "devDependencies": { "@playwright/test": "~1.53.2", - "@sentry/core": "10.25.0", + "@sentry/core": "10.26.0", "eslint-plugin-regexp": "^1.15.0" }, "volta": { diff --git a/lerna.json b/lerna.json index 3b70b74ac476..7363d9e257e2 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "10.25.0", + "version": "10.26.0", "npmClient": "yarn" } diff --git a/packages/angular/package.json b/packages/angular/package.json index 6102320de760..01912cb13f79 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Angular", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular", @@ -21,8 +21,8 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "10.25.0", - "@sentry/core": "10.25.0", + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/package.json b/packages/astro/package.json index c8ff489ca77b..32a2cf0e3833 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/astro", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Astro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/astro", @@ -56,9 +56,9 @@ "astro": ">=3.x || >=4.0.0-beta || >=5.x" }, "dependencies": { - "@sentry/browser": "10.25.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 7ee6a0a50d97..cd0ad16d9e7c 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/aws-serverless", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for AWS Lambda and AWS Serverless Environments", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless", @@ -69,9 +69,9 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-aws-sdk": "0.59.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", - "@sentry/node-core": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", + "@sentry/node-core": "10.26.0", "@types/aws-lambda": "^8.10.62" }, "devDependencies": { diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 338175ef5ad5..117fec1f473c 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-utils", - "version": "10.25.0", + "version": "10.26.0", "description": "Browser Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser-utils", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.25.0" + "@sentry/core": "10.26.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser/package.json b/packages/browser/package.json index 555062e2744f..c7b21beb6766 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for browsers", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser", @@ -44,14 +44,14 @@ "access": "public" }, "dependencies": { - "@sentry-internal/browser-utils": "10.25.0", - "@sentry-internal/feedback": "10.25.0", - "@sentry-internal/replay": "10.25.0", - "@sentry-internal/replay-canvas": "10.25.0", - "@sentry/core": "10.25.0" + "@sentry-internal/browser-utils": "10.26.0", + "@sentry-internal/feedback": "10.26.0", + "@sentry-internal/replay": "10.26.0", + "@sentry-internal/replay-canvas": "10.26.0", + "@sentry/core": "10.26.0" }, "devDependencies": { - "@sentry-internal/integration-shims": "10.25.0", + "@sentry-internal/integration-shims": "10.26.0", "fake-indexeddb": "^6.2.4" }, "scripts": { diff --git a/packages/bun/package.json b/packages/bun/package.json index 288e327bf6eb..fcd538862c17 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bun", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for bun", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0" + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0" }, "devDependencies": { "bun-types": "^1.2.9" diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index a620dc84b3d9..ee8cba264e5f 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/cloudflare", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Cloudflare Workers and Pages", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare", @@ -50,7 +50,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.25.0" + "@sentry/core": "10.26.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" diff --git a/packages/core/package.json b/packages/core/package.json index e45152996f0f..8ffc256e2f26 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/core", - "version": "10.25.0", + "version": "10.26.0", "description": "Base implementation for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/core", diff --git a/packages/deno/package.json b/packages/deno/package.json index 814d76142cc3..04ac8f592fd1 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/deno", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Deno", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", @@ -25,7 +25,7 @@ ], "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.25.0" + "@sentry/core": "10.26.0" }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", diff --git a/packages/ember/package.json b/packages/ember/package.json index 333eda5345d5..69a111ab54a9 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/ember", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Ember.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/ember", @@ -32,8 +32,8 @@ "dependencies": { "@babel/core": "^7.27.7", "@embroider/macros": "^1.16.0", - "@sentry/browser": "10.25.0", - "@sentry/core": "10.25.0", + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0", "ember-auto-import": "^2.7.2", "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^6.1.1", diff --git a/packages/eslint-config-sdk/package.json b/packages/eslint-config-sdk/package.json index 63c6f926f934..d45a301e1590 100644 --- a/packages/eslint-config-sdk/package.json +++ b/packages/eslint-config-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-config-sdk", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK eslint config", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-config-sdk", @@ -22,8 +22,8 @@ "access": "public" }, "dependencies": { - "@sentry-internal/eslint-plugin-sdk": "10.25.0", - "@sentry-internal/typescript": "10.25.0", + "@sentry-internal/eslint-plugin-sdk": "10.26.0", + "@sentry-internal/typescript": "10.26.0", "@typescript-eslint/eslint-plugin": "^5.48.0", "@typescript-eslint/parser": "^5.48.0", "eslint-config-prettier": "^6.11.0", diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index f8d9f4c615ee..bad2ae248475 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-plugin-sdk", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK eslint plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-plugin-sdk", diff --git a/packages/feedback/package.json b/packages/feedback/package.json index aa65e00d8ab5..7c9899ffd305 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/feedback", - "version": "10.25.0", + "version": "10.26.0", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.25.0" + "@sentry/core": "10.26.0" }, "devDependencies": { "preact": "^10.19.4" diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 1da49b3a4dca..8236ee72e0e9 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/gatsby", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Gatsby.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby", @@ -45,8 +45,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.25.0", - "@sentry/react": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/react": "10.26.0", "@sentry/webpack-plugin": "^4.1.1" }, "peerDependencies": { diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index c742b5a3a154..6afb10e14c42 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/google-cloud-serverless", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Google Cloud Functions", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/google-cloud-serverless", @@ -48,8 +48,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", "@types/express": "^4.17.14" }, "devDependencies": { diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index a2535fc2b9b4..8569a5b837f6 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/integration-shims", - "version": "10.25.0", + "version": "10.26.0", "description": "Shims for integrations in Sentry SDK.", "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -56,7 +56,7 @@ "url": "https://github.com/getsentry/sentry-javascript/issues" }, "dependencies": { - "@sentry/core": "10.25.0" + "@sentry/core": "10.26.0" }, "engines": { "node": ">=18" diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 80fdb673cc6d..53c3064ed08f 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nestjs", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for NestJS", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nestjs", @@ -49,8 +49,8 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/instrumentation-nestjs-core": "0.50.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0" + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 47adab59a32d..15b6a2bf3040 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nextjs", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Next.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs", @@ -79,13 +79,13 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@rollup/plugin-commonjs": "28.0.1", - "@sentry-internal/browser-utils": "10.25.0", + "@sentry-internal/browser-utils": "10.26.0", "@sentry/bundler-plugin-core": "^4.3.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", - "@sentry/opentelemetry": "10.25.0", - "@sentry/react": "10.25.0", - "@sentry/vercel-edge": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", + "@sentry/opentelemetry": "10.26.0", + "@sentry/react": "10.26.0", + "@sentry/vercel-edge": "10.26.0", "@sentry/webpack-plugin": "^4.3.0", "resolve": "1.22.8", "rollup": "^4.35.0", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 43af0a610e61..89dbe5461165 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-core", - "version": "10.25.0", + "version": "10.26.0", "description": "Sentry Node-Core SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-core", @@ -67,8 +67,8 @@ }, "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.25.0", - "@sentry/opentelemetry": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/opentelemetry": "10.26.0", "import-in-the-middle": "^1.14.2" }, "devDependencies": { diff --git a/packages/node-native/package.json b/packages/node-native/package.json index 23ce0068019c..9a4c40818556 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-native", - "version": "10.25.0", + "version": "10.26.0", "description": "Native Tools for the Official Sentry Node.js SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-native", @@ -64,8 +64,8 @@ }, "dependencies": { "@sentry-internal/node-native-stacktrace": "^0.2.2", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0" + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/node/package.json b/packages/node/package.json index 830fb510f9f9..6f0bec49c92a 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node", - "version": "10.25.0", + "version": "10.26.0", "description": "Sentry Node SDK using OpenTelemetry for performance instrumentation", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node", @@ -95,9 +95,9 @@ "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.15.0", - "@sentry/core": "10.25.0", - "@sentry/node-core": "10.25.0", - "@sentry/opentelemetry": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/node-core": "10.26.0", + "@sentry/opentelemetry": "10.26.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" }, diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 3a9679553475..e054d1b9938a 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nuxt", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Nuxt", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nuxt", @@ -49,13 +49,13 @@ }, "dependencies": { "@nuxt/kit": "^3.13.2", - "@sentry/browser": "10.25.0", - "@sentry/cloudflare": "10.25.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", + "@sentry/browser": "10.26.0", + "@sentry/cloudflare": "10.26.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", "@sentry/rollup-plugin": "^4.3.0", "@sentry/vite-plugin": "^4.3.0", - "@sentry/vue": "10.25.0" + "@sentry/vue": "10.26.0" }, "devDependencies": { "@nuxt/module-builder": "^0.8.4", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 94457ff83b65..95b7f54cd000 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/opentelemetry", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry utilities for OpenTelemetry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/opentelemetry", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.25.0" + "@sentry/core": "10.26.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json index 7040f0a53c21..e897b8d5611e 100644 --- a/packages/profiling-node/package.json +++ b/packages/profiling-node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/profiling-node", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Node.js Profiling", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/profiling-node", @@ -63,8 +63,8 @@ }, "dependencies": { "@sentry-internal/node-cpu-profiler": "^2.2.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0" + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 52dd1a71fee7..3d57ad54469f 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react-router", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for React Router (Framework)", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react-router", @@ -49,11 +49,11 @@ "@opentelemetry/core": "^2.1.0", "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/browser": "10.25.0", + "@sentry/browser": "10.26.0", "@sentry/cli": "^2.56.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", - "@sentry/react": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", + "@sentry/react": "10.26.0", "@sentry/vite-plugin": "^4.1.0", "glob": "11.0.1" }, diff --git a/packages/react/package.json b/packages/react/package.json index c7e7163a9a5e..c51a7e2d3ee8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for React.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.25.0", - "@sentry/core": "10.25.0", + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { diff --git a/packages/remix/package.json b/packages/remix/package.json index 9a255af786ce..667f77d53648 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/remix", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Remix", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/remix", @@ -69,9 +69,9 @@ "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", "@sentry/cli": "^2.56.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", - "@sentry/react": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", + "@sentry/react": "10.26.0", "glob": "^10.3.4", "yargs": "^17.6.0" }, diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index 491d919e55dc..63ee871ddfdf 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-canvas", - "version": "10.25.0", + "version": "10.26.0", "description": "Replay canvas integration", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -69,8 +69,8 @@ "@sentry-internal/rrweb": "2.40.0" }, "dependencies": { - "@sentry-internal/replay": "10.25.0", - "@sentry/core": "10.25.0" + "@sentry-internal/replay": "10.26.0", + "@sentry/core": "10.26.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index d2e1efbd84b7..af121d6a74c0 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay", - "version": "10.25.0", + "version": "10.26.0", "description": "User replays for Sentry", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -81,7 +81,7 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@babel/core": "^7.27.7", - "@sentry-internal/replay-worker": "10.25.0", + "@sentry-internal/replay-worker": "10.26.0", "@sentry-internal/rrweb": "2.40.0", "@sentry-internal/rrweb-snapshot": "2.40.0", "fflate": "0.8.2", @@ -90,8 +90,8 @@ "node-fetch": "^2.6.7" }, "dependencies": { - "@sentry-internal/browser-utils": "10.25.0", - "@sentry/core": "10.25.0" + "@sentry-internal/browser-utils": "10.26.0", + "@sentry/core": "10.26.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index a0d25235c7d7..e9a968061841 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-worker", - "version": "10.25.0", + "version": "10.26.0", "description": "Worker for @sentry-internal/replay", "main": "build/esm/index.js", "module": "build/esm/index.js", diff --git a/packages/solid/package.json b/packages/solid/package.json index bdb3dbe50a15..c75a0eadb4ce 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solid", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Solid", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solid", @@ -54,8 +54,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.25.0", - "@sentry/core": "10.25.0" + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index bb4acc3404f4..8bb0e4e0471f 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solidstart", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Solid Start", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solidstart", @@ -66,9 +66,9 @@ } }, "dependencies": { - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", - "@sentry/solid": "10.25.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", + "@sentry/solid": "10.26.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/svelte/package.json b/packages/svelte/package.json index dc8c662012c3..6e9cbdf5b54a 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/svelte", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Svelte", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/svelte", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.25.0", - "@sentry/core": "10.25.0", + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0", "magic-string": "^0.30.0" }, "peerDependencies": { diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 01f88fcd60b9..7802db426793 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/sveltekit", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for SvelteKit", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/sveltekit", @@ -48,10 +48,10 @@ }, "dependencies": { "@babel/parser": "7.26.9", - "@sentry/cloudflare": "10.25.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", - "@sentry/svelte": "10.25.0", + "@sentry/cloudflare": "10.26.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", + "@sentry/svelte": "10.26.0", "@sentry/vite-plugin": "^4.1.0", "magic-string": "0.30.7", "recast": "0.23.11", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 49c0bf12d971..03688bdd6aad 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart-react", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for TanStack Start React", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart-react", @@ -52,10 +52,10 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry-internal/browser-utils": "10.25.0", - "@sentry/core": "10.25.0", - "@sentry/node": "10.25.0", - "@sentry/react": "10.25.0" + "@sentry-internal/browser-utils": "10.26.0", + "@sentry/core": "10.26.0", + "@sentry/node": "10.26.0", + "@sentry/react": "10.26.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/tanstackstart/package.json b/packages/tanstackstart/package.json index 56e0c7992fea..3918ecd9a3fa 100644 --- a/packages/tanstackstart/package.json +++ b/packages/tanstackstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart", - "version": "10.25.0", + "version": "10.26.0", "description": "Utilities for the Sentry TanStack Start SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart", diff --git a/packages/types/package.json b/packages/types/package.json index 1c53b6c05275..2287d86014da 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/types", - "version": "10.25.0", + "version": "10.26.0", "description": "Types for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/types", @@ -57,7 +57,7 @@ "yalc:publish": "yalc publish --push --sig" }, "dependencies": { - "@sentry/core": "10.25.0" + "@sentry/core": "10.26.0" }, "volta": { "extends": "../../package.json" diff --git a/packages/typescript/package.json b/packages/typescript/package.json index c1c99704c346..ab02fe16f828 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/typescript", - "version": "10.25.0", + "version": "10.26.0", "description": "Typescript configuration used at Sentry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/typescript", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index c7c6db556ccb..d5c9ef78b8c1 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vercel-edge", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for the Vercel Edge Runtime", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vercel-edge", @@ -41,14 +41,14 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/resources": "^2.1.0", - "@sentry/core": "10.25.0" + "@sentry/core": "10.26.0" }, "devDependencies": { "@edge-runtime/types": "3.0.1", "@opentelemetry/core": "^2.1.0", "@opentelemetry/sdk-trace-base": "^2.1.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/opentelemetry": "10.25.0" + "@sentry/opentelemetry": "10.26.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/vue/package.json b/packages/vue/package.json index 31bef1a870c8..abdb956018c6 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vue", - "version": "10.25.0", + "version": "10.26.0", "description": "Official Sentry SDK for Vue.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vue", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.25.0", - "@sentry/core": "10.25.0" + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0" }, "peerDependencies": { "pinia": "2.x || 3.x", diff --git a/packages/wasm/package.json b/packages/wasm/package.json index f7a97139c120..2e7883644276 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/wasm", - "version": "10.25.0", + "version": "10.26.0", "description": "Support for WASM.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/wasm", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.25.0", - "@sentry/core": "10.25.0" + "@sentry/browser": "10.26.0", + "@sentry/core": "10.26.0" }, "scripts": { "build": "run-p build:transpile build:bundle build:types", From 680607c460f9dfae8cd0c5f1d496274d4460ca1b Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:32:17 +0100 Subject: [PATCH 158/190] chore: Add `bump_otel_instrumentations` cursor command (#18253) Bumping OpenTelemetry instrumentations is an important but tedious task, all instrumentations have to be bumped in lockstep across the codebase. That includes easy to miss dev-packages and third party instrumentations like prisma's. This command should make it easier to do that. Example of a PR that was kicked off with this command: https://github.com/getsentry/sentry-javascript/pull/18239 --- .../commands/bump_otel_instrumentations.md | 32 ++++++++++++++++++ ...upgrade_opentelemetry_instrumentations.mdc | 33 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 .cursor/commands/bump_otel_instrumentations.md create mode 100644 .cursor/rules/upgrade_opentelemetry_instrumentations.mdc diff --git a/.cursor/commands/bump_otel_instrumentations.md b/.cursor/commands/bump_otel_instrumentations.md new file mode 100644 index 000000000000..ff1e6cfcbcc8 --- /dev/null +++ b/.cursor/commands/bump_otel_instrumentations.md @@ -0,0 +1,32 @@ +# Bump OpenTelemetry instrumentations + +1. Ensure you're on the `develop` branch with the latest changes: + - If you have unsaved changes, stash them with `git stash -u`. + - If you're on a different branch than `develop`, check out the develop branch using `git checkout develop`. + - Pull the latest updates from the remote repository by running `git pull origin develop`. + +2. Create a new branch `bump-otel-{yyyy-mm-dd}`, e.g. `bump-otel-2025-03-03` + +3. Create a new empty commit with the commit message `feat(deps): Bump OpenTelemetry instrumentations` + +4. Push the branch and create a draft PR, note down the PR number as {PR_NUMBER} + +5. Create a changelog entry in `CHANGELOG.md` under + `- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott` with the following format: + `- feat(deps): Bump OpenTelemetry instrumentations ([#{PR_NUMBER}](https://github.com/getsentry/sentry-javascript/pull/{PR_NUMBER}))` + +6. Find the "Upgrade OpenTelemetry instrumentations" rule in `.cursor/rules/upgrade_opentelemetry_instrumentations` and + follow those complete instructions step by step. + - Create one commit per package in `packages/**` with the commit message + `Bump OpenTelemetry instrumentations for {SDK}`, e.g. `Bump OpenTelemetry instrumentation for @sentry/node` + + - For each OpenTelemetry dependency bump, record an entry in the changelog with the format indented under the main + entry created in step 5: `- Bump @opentelemetry/{instrumentation} from {previous_version} to {new_version}`, e.g. + `- Bump @opentelemetry/instrumentation from 0.204.0 to 0.207.0` **CRITICAL**: Avoid duplicated entries, e.g. if we + bump @opentelemetry/instrumentation in two packages, keep a single changelog entry. + +7. Regenerate the yarn lockfile and run `yarn yarn-deduplicate` + +8. Run `yarn fix` to fix all formatting issues + +9. Finally update the PR description to list all dependency bumps diff --git a/.cursor/rules/upgrade_opentelemetry_instrumentations.mdc b/.cursor/rules/upgrade_opentelemetry_instrumentations.mdc new file mode 100644 index 000000000000..b650ae1f5041 --- /dev/null +++ b/.cursor/rules/upgrade_opentelemetry_instrumentations.mdc @@ -0,0 +1,33 @@ +--- +description: Use this rule if you are looking to grade OpenTelemetry instrumentations for the Sentry JavaScript SDKs +globs: * +alwaysApply: false +--- + +# Upgrading OpenTelemetry instrumentations + +1. For every package in packages/\*\*: + - When upgrading dependencies for OpenTelemetry instrumentations we need to first upgrade `@opentelemetry/instrumentation` to the latest version. + **CRITICAL**: `@opentelemetry/instrumentation` MUST NOT include any breaking changes. + Read through the changelog of `@opentelemetry/instrumentation` to figure out if breaking changes are included and fail with the reason if it does include breaking changes. + You can find the changelog at `https://github.com/open-telemetry/opentelemetry-js/blob/main/experimental/CHANGELOG.md` + + - After successfully upgrading `@opentelemetry/instrumentation` upgrade all `@opentelemetry/instrumentation-{instrumentation}` packages, e.g. `@opentelemetry/instrumentation-pg` + **CRITICAL**: `@opentelemetry/instrumentation-{instrumentation}` MUST NOT include any breaking changes. + Read through the changelog of `@opentelemetry/instrumentation-{instrumentation}` to figure out if breaking changes are included and fail with the reason if it does including breaking changes. + You can find the changelogs at `https://github.com/open-telemetry/opentelemetry-js-contrib/blob/main/packages/instrumentation-{instrumentation}/CHANGELOG.md`. + + - Finally, upgrade third party instrumentations to their latest versions, these are currently: + - @prisma/instrumentation + + **CRITICAL**: Upgrades to third party instrumentations MUST NOT include breaking changes. + Read through the changelog of each third party instrumentation to figure out if breaking changes are included and fail with the reason if it does include breaking changes. + +2. For packages and apps in dev-packages/\*\*: + - If an app depends on `@opentelemetry/instrumentation` >= 0.200.x upgrade it to the latest version. + **CRITICAL**: `@opentelemetry/instrumentation` MUST NOT include any breaking changes. + + - If an app depends on `@opentelemetry/instrumentation-http` >= 0.200.x upgrade it to the latest version. + **CRITICAL**: `@opentelemetry/instrumentation-http` MUST NOT include any breaking changes. + +3. Generate a new yarn lock file. From bee20fb0eaacab58d81c995e1509bce6d46b23a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:11:41 +0100 Subject: [PATCH 159/190] ci(deps): bump actions/upload-artifact from 4 to 5 (#18075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
Release notes

Sourced from actions/upload-artifact's releases.

v5.0.0

What's Changed

BREAKING CHANGE: this update supports Node v24.x. This is not a breaking change per-se but we're treating it as such.

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v5.0.0

v4.6.2

What's Changed

New Contributors

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.2

v4.6.1

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.1

v4.6.0

What's Changed

Full Changelog: https://github.com/actions/upload-artifact/compare/v4...v4.6.0

v4.5.0

What's Changed

New Contributors

... (truncated)

Commits
  • 330a01c Merge pull request #734 from actions/danwkennedy/prepare-5.0.0
  • 03f2824 Update github.dep.yml
  • 905a1ec Prepare v5.0.0
  • 2d9f9cd Merge pull request #725 from patrikpolyak/patch-1
  • 9687587 Merge branch 'main' into patch-1
  • 2848b2c Merge pull request #727 from danwkennedy/patch-1
  • 9b51177 Spell out the first use of GHES
  • cd231ca Update GHES guidance to include reference to Node 20 version
  • de65e23 Merge pull request #712 from actions/nebuk89-patch-1
  • 8747d8c Update README.md
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/upload-artifact&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jan Peer Stöcklmair --- .github/workflows/build.yml | 14 +++++++------- .github/workflows/flaky-test-detector.yml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 46d6e7d4fac9..610dced85c1b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -181,7 +181,7 @@ jobs: run: yarn build - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: build-output path: ${{ env.CACHED_BUILD_PATHS }} @@ -386,7 +386,7 @@ jobs: run: yarn build:tarball - name: Archive artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: ${{ github.sha }} retention-days: 90 @@ -629,7 +629,7 @@ jobs: format(' --shard={0}/{1}', matrix.shard, matrix.shards) || '' }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: @@ -692,7 +692,7 @@ jobs: yarn test:loader - name: Upload Playwright Traces - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: playwright-traces-job_browser_loader_tests-${{ matrix.bundle}} @@ -1005,7 +1005,7 @@ jobs: run: ${{ matrix.assert-command || 'pnpm test:assert' }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: playwright-traces-job_e2e_playwright_tests-${{ matrix.test-application}} @@ -1019,7 +1019,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) @@ -1131,7 +1131,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 5103f1f43a2d..21a851fb43d6 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -71,7 +71,7 @@ jobs: TEST_RUN_COUNT: 'AUTO' - name: Upload Playwright Traces - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: failure() && steps.test.outcome == 'failure' with: name: playwright-test-results From 81c5d56d644d9f003ff06d08c0a3fe985cde6562 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 12:12:01 +0100 Subject: [PATCH 160/190] ci(deps): bump actions/setup-node from 4 to 6 (#18077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
Release notes

Sourced from actions/setup-node's releases.

v6.0.0

What's Changed

Breaking Changes

Dependency Upgrades

Full Changelog: https://github.com/actions/setup-node/compare/v5...v6.0.0

v5.0.0

What's Changed

Breaking Changes

This update, introduces automatic caching when a valid packageManager field is present in your package.json. This aims to improve workflow performance and make dependency management more seamless. To disable this automatic caching, set package-manager-cache: false

steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
  with:
    package-manager-cache: false

Make sure your runner is on version v2.327.1 or later to ensure compatibility with this release. See Release Notes

Dependency Upgrades

New Contributors

Full Changelog: https://github.com/actions/setup-node/compare/v4...v5.0.0

v4.4.0

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/setup-node&package-manager=github_actions&previous-version=4&new-version=6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jan Peer Stöcklmair --- .github/workflows/auto-release.yml | 2 +- .github/workflows/build.yml | 40 ++++++++++----------- .github/workflows/canary.yml | 4 +-- .github/workflows/clear-cache.yml | 2 +- .github/workflows/external-contributors.yml | 2 +- .github/workflows/flaky-test-detector.yml | 2 +- .github/workflows/release.yml | 2 +- 7 files changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 0507fe879c27..e1fdd98549ae 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -42,7 +42,7 @@ jobs: echo "version=$version" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 610dced85c1b..881b5f4b6580 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -142,7 +142,7 @@ jobs: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' @@ -242,7 +242,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -271,7 +271,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -300,7 +300,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -330,7 +330,7 @@ jobs: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' @@ -352,7 +352,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -374,7 +374,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -415,7 +415,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -456,7 +456,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Set up Bun @@ -481,7 +481,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Set up Deno @@ -518,7 +518,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -607,7 +607,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -671,7 +671,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -719,7 +719,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -757,7 +757,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -793,7 +793,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -821,7 +821,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - name: Restore caches @@ -873,7 +873,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Restore caches @@ -941,7 +941,7 @@ jobs: with: version: 9.15.9 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Bun @@ -1071,7 +1071,7 @@ jobs: with: version: 9.15.9 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Restore caches diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 29814ffea09c..57290080c8de 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -35,7 +35,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Check canary cache @@ -125,7 +125,7 @@ jobs: version: 9.15.9 - name: Set up Node if: matrix.test-application != 'angular-20' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' diff --git a/.github/workflows/clear-cache.yml b/.github/workflows/clear-cache.yml index 97aeb53365e7..0f5f2241b34a 100644 --- a/.github/workflows/clear-cache.yml +++ b/.github/workflows/clear-cache.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v5 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml index 1735a89a5446..c085f9958452 100644 --- a/.github/workflows/external-contributors.yml +++ b/.github/workflows/external-contributors.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 21a851fb43d6..a6ed22e04f6a 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -32,7 +32,7 @@ jobs: - name: Check out current branch uses: actions/checkout@v5 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' cache: 'yarn' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05c465036ce4..954e8d3e5e84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: token: ${{ steps.token.outputs.token }} fetch-depth: 0 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version-file: 'package.json' - name: Prepare release From d710314881a19a84ca76c78c18752ef2d08a1728 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 19 Nov 2025 14:01:22 +0100 Subject: [PATCH 161/190] test(nextjs): Remove debug logs from e2e test (#18250) --- .../test-applications/nextjs-15/instrumentation-client.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts index 0737d2043169..4870c64e7959 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts @@ -2,11 +2,10 @@ import * as Sentry from '@sentry/nextjs'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://username@domain/123', + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, - debug: true, }); export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; From 1e5af715f4ebb42748ba75e47c3aaaf15f0678df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 14:39:19 +0100 Subject: [PATCH 162/190] ci(deps): bump github/codeql-action from 3 to 4 (#18076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
Release notes

Sourced from github/codeql-action's releases.

v3.31.2

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.31.2 - 30 Oct 2025

No user facing changes.

See the full CHANGELOG.md for more information.

v3.31.1

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.31.1 - 30 Oct 2025

  • The add-snippets input has been removed from the analyze action. This input has been deprecated since CodeQL Action 3.26.4 in August 2024 when this removal was announced.

See the full CHANGELOG.md for more information.

v3.31.0

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.31.0 - 24 Oct 2025

  • Bump minimum CodeQL bundle version to 2.17.6. #3223
  • When SARIF files are uploaded by the analyze or upload-sarif actions, the CodeQL Action automatically performs post-processing steps to prepare the data for the upload. Previously, these post-processing steps were only performed before an upload took place. We are now changing this so that the post-processing steps will always be performed, even when the SARIF files are not uploaded. This does not change anything for the upload-sarif action. For analyze, this may affect Advanced Setup for CodeQL users who specify a value other than always for the upload input. #3222

See the full CHANGELOG.md for more information.

v3.30.9

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.30.9 - 17 Oct 2025

  • Update default CodeQL bundle version to 2.23.3. #3205
  • Experimental: A new setup-codeql action has been added which is similar to init, except it only installs the CodeQL CLI and does not initialize a database. Do not use this in production as it is part of an internal experiment and subject to change at any time. #3204

See the full CHANGELOG.md for more information.

v3.30.8

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

... (truncated)

Changelog

Sourced from github/codeql-action's changelog.

4.31.2 - 30 Oct 2025

No user facing changes.

4.31.1 - 30 Oct 2025

  • The add-snippets input has been removed from the analyze action. This input has been deprecated since CodeQL Action 3.26.4 in August 2024 when this removal was announced.

4.31.0 - 24 Oct 2025

  • Bump minimum CodeQL bundle version to 2.17.6. #3223
  • When SARIF files are uploaded by the analyze or upload-sarif actions, the CodeQL Action automatically performs post-processing steps to prepare the data for the upload. Previously, these post-processing steps were only performed before an upload took place. We are now changing this so that the post-processing steps will always be performed, even when the SARIF files are not uploaded. This does not change anything for the upload-sarif action. For analyze, this may affect Advanced Setup for CodeQL users who specify a value other than always for the upload input. #3222

4.30.9 - 17 Oct 2025

  • Update default CodeQL bundle version to 2.23.3. #3205
  • Experimental: A new setup-codeql action has been added which is similar to init, except it only installs the CodeQL CLI and does not initialize a database. Do not use this in production as it is part of an internal experiment and subject to change at any time. #3204

4.30.8 - 10 Oct 2025

No user facing changes.

4.30.7 - 06 Oct 2025

  • [v4+ only] The CodeQL Action now runs on Node.js v24. #3169

3.30.6 - 02 Oct 2025

  • Update default CodeQL bundle version to 2.23.2. #3168

3.30.5 - 26 Sep 2025

  • We fixed a bug that was introduced in 3.30.4 with upload-sarif which resulted in files without a .sarif extension not getting uploaded. #3160

3.30.4 - 25 Sep 2025

  • We have improved the CodeQL Action's ability to validate that the workflow it is used in does not use different versions of the CodeQL Action for different workflow steps. Mixing different versions of the CodeQL Action in the same workflow is unsupported and can lead to unpredictable results. A warning will now be emitted from the codeql-action/init step if different versions of the CodeQL Action are detected in the workflow file. Additionally, an error will now be thrown by the other CodeQL Action steps if they load a configuration file that was generated by a different version of the codeql-action/init step. #3099 and #3100
  • We added support for reducing the size of dependency caches for Java analyses, which will reduce cache usage and speed up workflows. This will be enabled automatically at a later time. #3107
  • You can now run the latest CodeQL nightly bundle by passing tools: nightly to the init action. In general, the nightly bundle is unstable and we only recommend running it when directed by GitHub staff. #3130
  • Update default CodeQL bundle version to 2.23.1. #3118

3.30.3 - 10 Sep 2025

No user facing changes.

3.30.2 - 09 Sep 2025

  • Fixed a bug which could cause language autodetection to fail. #3084
  • Experimental: The quality-queries input that was added in 3.29.2 as part of an internal experiment is now deprecated and will be removed in an upcoming version of the CodeQL Action. It has been superseded by a new analysis-kinds input, which is part of the same internal experiment. Do not use this in production as it is subject to change at any time. #3064

... (truncated)

Commits
  • 74c8748 Update analyze/action.yml
  • 34c50c1 Merge pull request #3251 from github/mbg/user-error/enablement
  • 4ae68af Warn if the add-snippets input is used
  • 52a7bd7 Check for 403 status
  • 194ba0e Make error message tests less brittle
  • 53acf0b Turn enablement errors into configuration errors
  • ac9aeee Merge pull request #3249 from github/henrymercer/api-logging
  • d49e837 Merge branch 'main' into henrymercer/api-logging
  • 3d988b2 Pass minimal copy of core
  • 8cc18ac Merge pull request #3250 from github/henrymercer/prefer-fs-delete
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=3&new-version=4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jan Peer Stöcklmair --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8c042c5aa44f..6d6b67201d5e 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -50,7 +50,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: config-file: ./.github/codeql/codeql-config.yml queries: security-extended @@ -63,7 +63,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions @@ -77,4 +77,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 From 902a06e75a034c4aca6d4228d897d0f31d4d3f9e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 14:43:18 +0100 Subject: [PATCH 163/190] ci(deps): Bump actions/create-github-app-token from 2.1.1 to 2.1.4 (#17825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 2.1.1 to 2.1.4.
Release notes

Sourced from actions/create-github-app-token's releases.

v2.1.4

2.1.4 (2025-09-13)

Bug Fixes

  • deps: bump @​octokit/auth-app from 7.2.1 to 8.0.1 (#257) (bef1eaf)

v2.1.3

2.1.3 (2025-09-13)

Bug Fixes

  • deps: bump undici from 7.8.0 to 7.10.0 in the production-dependencies group (#254) (f3d5ec2)

v2.1.2

2.1.2 (2025-09-12)

Bug Fixes

  • deps: bump @​octokit/request from 9.2.3 to 10.0.2 (#256) (5d7307b)
Commits
  • 6701853 build(release): 2.1.4 [skip ci]
  • bef1eaf fix(deps): bump @​octokit/auth-app from 7.2.1 to 8.0.1 (#257)
  • 1526738 build(release): 2.1.3 [skip ci]
  • f3d5ec2 fix(deps): bump undici from 7.8.0 to 7.10.0 in the production-dependencies gr...
  • def152b build(release): 2.1.2 [skip ci]
  • 5d7307b fix(deps): bump @​octokit/request from 9.2.3 to 10.0.2 (#256)
  • 525760a build(deps): bump stefanzweifel/git-auto-commit-action from 5.2.0 to 6.0.1 (#...
  • 8ab05a8 Add beta branch support for releases (#282)
  • d00315e build(deps): bump actions/checkout from 4 to 5 (#279)
  • fcc6c28 build(deps-dev): bump dotenv from 16.5.0 to 17.2.1 (#269)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/create-github-app-token&package-manager=github_actions&previous-version=2.1.1&new-version=2.1.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) You can trigger a rebase of this PR by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- > [!NOTE] > Update actions/create-github-app-token from 2.1.1 to 2.1.4 in auto-release and release workflows. > > - **CI Workflows**: > - Bump `actions/create-github-app-token` to `v2.1.4` in `.github/workflows/auto-release.yml` and `.github/workflows/release.yml`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e8434a819664875d674d4bc8759b315ce79d32a8. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). > **Note** > Automatic rebases have been disabled on this pull request as it has been open for over 30 days. Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jan Peer Stöcklmair --- .github/workflows/auto-release.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index e1fdd98549ae..cfaf6db8abef 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 954e8d3e5e84..a2cb3fcd9600 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Get auth token id: token - uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 with: app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} From 013f0bec0f686bcaeb5d2be45313efea8044123a Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 19 Nov 2025 15:07:11 +0100 Subject: [PATCH 164/190] chore(dev-deps): Update some dev dependencies (#17816) Just bumping some transitive dev deps to fix security warnings. --- .github/dependency-review-config.yml | 2 + packages/react/package.json | 2 - packages/replay-internal/package.json | 3 +- yarn.lock | 734 +++++++++++++------------- 4 files changed, 377 insertions(+), 364 deletions(-) diff --git a/.github/dependency-review-config.yml b/.github/dependency-review-config.yml index 1a8f76e430d1..8608d2381ace 100644 --- a/.github/dependency-review-config.yml +++ b/.github/dependency-review-config.yml @@ -9,3 +9,5 @@ allow-ghsas: - GHSA-v784-fjjh-f8r4 # Next.js Cache poisoning - We require a vulnerable version for E2E testing - GHSA-gp8f-8m3g-qvj9 + # devalue vulnerability - this is just used by nuxt & astro as transitive dependency + - GHSA-vj54-72f3-p5jv diff --git a/packages/react/package.json b/packages/react/package.json index c51a7e2d3ee8..afd5f5f25bdd 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -52,7 +52,6 @@ "@types/history-4": "npm:@types/history@4.7.8", "@types/history-5": "npm:@types/history@4.7.8", "@types/hoist-non-react-statics": "^3.3.5", - "@types/node-fetch": "^2.6.11", "@types/react": "17.0.3", "@types/react-router-4": "npm:@types/react-router@4.0.25", "@types/react-router-5": "npm:@types/react-router@5.1.20", @@ -60,7 +59,6 @@ "eslint-plugin-react-hooks": "^4.0.8", "history-4": "npm:history@4.6.0", "history-5": "npm:history@4.9.0", - "node-fetch": "^2.6.7", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-3": "npm:react-router@3.2.0", diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index af121d6a74c0..f1678cea2a15 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -86,8 +86,7 @@ "@sentry-internal/rrweb-snapshot": "2.40.0", "fflate": "0.8.2", "jest-matcher-utils": "^29.0.0", - "jsdom-worker": "^0.3.0", - "node-fetch": "^2.6.7" + "jsdom-worker": "^0.3.0" }, "dependencies": { "@sentry-internal/browser-utils": "10.26.0", diff --git a/yarn.lock b/yarn.lock index 082e1a032283..c93b8a45e748 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3012,6 +3012,11 @@ dependencies: tslib "^2.4.0" +"@esbuild/aix-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f" + integrity sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA== + "@esbuild/aix-ppc64@0.20.0": version "0.20.0" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.0.tgz#509621cca4e67caf0d18561a0c56f8b70237472f" @@ -3032,6 +3037,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz#51299374de171dbd80bb7d838e1cfce9af36f353" integrity sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ== +"@esbuild/aix-ppc64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz#ee6b7163a13528e099ecf562b972f2bcebe0aa97" + integrity sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw== + "@esbuild/aix-ppc64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz#830d6476cbbca0c005136af07303646b419f1162" @@ -3042,20 +3052,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18" integrity sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA== -"@esbuild/aix-ppc64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz#164b19122e2ed54f85469df9dea98ddb01d5e79e" - integrity sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw== - "@esbuild/android-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== -"@esbuild/android-arm64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.4.tgz#74752a09301b8c6b9a415fbda9fb71406a62a7b7" - integrity sha512-mRsi2vJsk4Bx/AFsNBqOH2fqedxn5L/moT58xgg51DjX1la64Z3Npicut2VbhvDFO26qjWtPMsVxCd80YTFVeg== +"@esbuild/android-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4" + integrity sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA== "@esbuild/android-arm64@0.20.0": version "0.20.0" @@ -3077,6 +3082,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz#58565291a1fe548638adb9c584237449e5e14018" integrity sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw== +"@esbuild/android-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz#115fc76631e82dd06811bfaf2db0d4979c16e2cb" + integrity sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg== + "@esbuild/android-arm64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz#d11d4fc299224e729e2190cacadbcc00e7a9fd67" @@ -3087,11 +3097,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz#bc766407f1718923f6b8079c8c61bf86ac3a6a4f" integrity sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg== -"@esbuild/android-arm64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz#8f539e7def848f764f6432598e51cc3820fde3a5" - integrity sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA== - "@esbuild/android-arm@0.15.18": version "0.15.18" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.18.tgz#266d40b8fdcf87962df8af05b76219bc786b4f80" @@ -3102,10 +3107,10 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== -"@esbuild/android-arm@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.4.tgz#c27363e1e280e577d9b5c8fa7c7a3be2a8d79bf5" - integrity sha512-uBIbiYMeSsy2U0XQoOGVVcpIktjLMEKa7ryz2RLr7L/vTnANNEsPVAh4xOv7ondGz6ac1zVb0F8Jx20rQikffQ== +"@esbuild/android-arm@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824" + integrity sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w== "@esbuild/android-arm@0.20.0": version "0.20.0" @@ -3127,6 +3132,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.23.1.tgz#5eb8c652d4c82a2421e3395b808e6d9c42c862ee" integrity sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ== +"@esbuild/android-arm@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.10.tgz#8d5811912da77f615398611e5bbc1333fe321aa9" + integrity sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w== + "@esbuild/android-arm@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.4.tgz#5660bd25080553dd2a28438f2a401a29959bd9b1" @@ -3137,20 +3147,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz#4290d6d3407bae3883ad2cded1081a234473ce26" integrity sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA== -"@esbuild/android-arm@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.6.tgz#4ceb0f40113e9861169be83e2a670c260dd234ff" - integrity sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg== - "@esbuild/android-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== -"@esbuild/android-x64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.4.tgz#6c9ee03d1488973d928618100048b75b147e0426" - integrity sha512-4iPufZ1TMOD3oBlGFqHXBpa3KFT46aLl6Vy7gwed0ZSYgHaZ/mihbYb4t7Z9etjkC9Al3ZYIoOaHrU60gcMy7g== +"@esbuild/android-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d" + integrity sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew== "@esbuild/android-x64@0.20.0": version "0.20.0" @@ -3172,6 +3177,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.23.1.tgz#ae19d665d2f06f0f48a6ac9a224b3f672e65d517" integrity sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg== +"@esbuild/android-x64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.10.tgz#e3e96516b2d50d74105bb92594c473e30ddc16b1" + integrity sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg== + "@esbuild/android-x64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.4.tgz#18ddde705bf984e8cd9efec54e199ac18bc7bee1" @@ -3182,20 +3192,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz#40c11d9cbca4f2406548c8a9895d321bc3b35eff" integrity sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw== -"@esbuild/android-x64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.6.tgz#ad4f280057622c25fe985c08999443a195dc63a8" - integrity sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A== - "@esbuild/darwin-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== -"@esbuild/darwin-arm64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.4.tgz#64e2ee945e5932cd49812caa80e8896e937e2f8b" - integrity sha512-Lviw8EzxsVQKpbS+rSt6/6zjn9ashUZ7Tbuvc2YENgRl0yZTktGlachZ9KMJUsVjZEGFVu336kl5lBgDN6PmpA== +"@esbuild/darwin-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e" + integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g== "@esbuild/darwin-arm64@0.20.0": version "0.20.0" @@ -3217,6 +3222,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz#05b17f91a87e557b468a9c75e9d85ab10c121b16" integrity sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q== +"@esbuild/darwin-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz#6af6bb1d05887dac515de1b162b59dc71212ed76" + integrity sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA== + "@esbuild/darwin-arm64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz#b0b7fb55db8fc6f5de5a0207ae986eb9c4766e67" @@ -3227,20 +3237,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz#49d8bf8b1df95f759ac81eb1d0736018006d7e34" integrity sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ== -"@esbuild/darwin-arm64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz#d1f04027396b3d6afc96bacd0d13167dfd9f01f7" - integrity sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA== - "@esbuild/darwin-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== -"@esbuild/darwin-x64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.4.tgz#d8e26e1b965df284692e4d1263ba69a49b39ac7a" - integrity sha512-YHbSFlLgDwglFn0lAO3Zsdrife9jcQXQhgRp77YiTDja23FrC2uwnhXMNkAucthsf+Psr7sTwYEryxz6FPAVqw== +"@esbuild/darwin-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd" + integrity sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A== "@esbuild/darwin-x64@0.20.0": version "0.20.0" @@ -3262,6 +3267,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz#c58353b982f4e04f0d022284b8ba2733f5ff0931" integrity sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw== +"@esbuild/darwin-x64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz#99ae82347fbd336fc2d28ffd4f05694e6e5b723d" + integrity sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg== + "@esbuild/darwin-x64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz#e6813fdeba0bba356cb350a4b80543fbe66bf26f" @@ -3272,20 +3282,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz#e27a5d92a14886ef1d492fd50fc61a2d4d87e418" integrity sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ== -"@esbuild/darwin-x64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz#2b4a6cedb799f635758d7832d75b23772c8ef68f" - integrity sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg== - "@esbuild/freebsd-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== -"@esbuild/freebsd-arm64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.4.tgz#29751a41b242e0a456d89713b228f1da4f45582f" - integrity sha512-vz59ijyrTG22Hshaj620e5yhs2dU1WJy723ofc+KUgxVCM6zxQESmWdMuVmUzxtGqtj5heHyB44PjV/HKsEmuQ== +"@esbuild/freebsd-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487" + integrity sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA== "@esbuild/freebsd-arm64@0.20.0": version "0.20.0" @@ -3307,6 +3312,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz#f9220dc65f80f03635e1ef96cfad5da1f446f3bc" integrity sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA== +"@esbuild/freebsd-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz#0c6d5558a6322b0bdb17f7025c19bd7d2359437d" + integrity sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg== + "@esbuild/freebsd-arm64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz#dc11a73d3ccdc308567b908b43c6698e850759be" @@ -3317,20 +3327,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz#97cede59d638840ca104e605cdb9f1b118ba0b1c" integrity sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw== -"@esbuild/freebsd-arm64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz#a26266cc97dd78dc3c3f3d6788b1b83697b1055d" - integrity sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg== - "@esbuild/freebsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== -"@esbuild/freebsd-x64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.4.tgz#873edc0f73e83a82432460ea59bf568c1e90b268" - integrity sha512-3sRbQ6W5kAiVQRBWREGJNd1YE7OgzS0AmOGjDmX/qZZecq8NFlQsQH0IfXjjmD0XtUYqr64e0EKNFjMUlPL3Cw== +"@esbuild/freebsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c" + integrity sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg== "@esbuild/freebsd-x64@0.20.0": version "0.20.0" @@ -3352,6 +3357,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz#69bd8511fa013b59f0226d1609ac43f7ce489730" integrity sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g== +"@esbuild/freebsd-x64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz#8c35873fab8c0857a75300a3dcce4324ca0b9844" + integrity sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA== + "@esbuild/freebsd-x64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz#91da08db8bd1bff5f31924c57a81dab26e93a143" @@ -3362,20 +3372,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz#71c77812042a1a8190c3d581e140d15b876b9c6f" integrity sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw== -"@esbuild/freebsd-x64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz#9feb8e826735c568ebfd94859b22a3fbb6a9bdd2" - integrity sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ== - "@esbuild/linux-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== -"@esbuild/linux-arm64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.4.tgz#659f2fa988d448dbf5010b5cc583be757cc1b914" - integrity sha512-ZWmWORaPbsPwmyu7eIEATFlaqm0QGt+joRE9sKcnVUG3oBbr/KYdNE2TnkzdQwX6EDRdg/x8Q4EZQTXoClUqqA== +"@esbuild/linux-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b" + integrity sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA== "@esbuild/linux-arm64@0.20.0": version "0.20.0" @@ -3397,6 +3402,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz#8050af6d51ddb388c75653ef9871f5ccd8f12383" integrity sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g== +"@esbuild/linux-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz#3edc2f87b889a15b4cedaf65f498c2bed7b16b90" + integrity sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ== + "@esbuild/linux-arm64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz#efc15e45c945a082708f9a9f73bfa8d4db49728a" @@ -3407,20 +3417,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz#f7b7c8f97eff8ffd2e47f6c67eb5c9765f2181b8" integrity sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg== -"@esbuild/linux-arm64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz#c07cbed8e249f4c28e7f32781d36fc4695293d28" - integrity sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ== - "@esbuild/linux-arm@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== -"@esbuild/linux-arm@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.4.tgz#d5b13a7ec1f1c655ce05c8d319b3950797baee55" - integrity sha512-z/4ArqOo9EImzTi4b6Vq+pthLnepFzJ92BnofU1jgNlcVb+UqynVFdoXMCFreTK7FdhqAzH0vmdwW5373Hm9pg== +"@esbuild/linux-arm@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef" + integrity sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w== "@esbuild/linux-arm@0.20.0": version "0.20.0" @@ -3442,6 +3447,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz#ecaabd1c23b701070484990db9a82f382f99e771" integrity sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ== +"@esbuild/linux-arm@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz#86501cfdfb3d110176d80c41b27ed4611471cde7" + integrity sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg== + "@esbuild/linux-arm@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz#9b93c3e54ac49a2ede6f906e705d5d906f6db9e8" @@ -3452,20 +3462,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz#2a0be71b6cd8201fa559aea45598dffabc05d911" integrity sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw== -"@esbuild/linux-arm@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz#d6e2cd8ef3196468065d41f13fa2a61aaa72644a" - integrity sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw== - "@esbuild/linux-ia32@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== -"@esbuild/linux-ia32@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.4.tgz#878cd8bf24c9847c77acdb5dd1b2ef6e4fa27a82" - integrity sha512-EGc4vYM7i1GRUIMqRZNCTzJh25MHePYsnQfKDexD8uPTCm9mK56NIL04LUfX2aaJ+C9vyEp2fJ7jbqFEYgO9lQ== +"@esbuild/linux-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601" + integrity sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA== "@esbuild/linux-ia32@0.20.0": version "0.20.0" @@ -3487,6 +3492,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz#3ed2273214178109741c09bd0687098a0243b333" integrity sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ== +"@esbuild/linux-ia32@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz#e6589877876142537c6864680cd5d26a622b9d97" + integrity sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ== + "@esbuild/linux-ia32@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz#be8ef2c3e1d99fca2d25c416b297d00360623596" @@ -3497,11 +3507,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz#763414463cd9ea6fa1f96555d2762f9f84c61783" integrity sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA== -"@esbuild/linux-ia32@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz#3e682bd47c4eddcc4b8f1393dfc8222482f17997" - integrity sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw== - "@esbuild/linux-loong64@0.15.18": version "0.15.18" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz#128b76ecb9be48b60cf5cfc1c63a4f00691a3239" @@ -3517,10 +3522,10 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== -"@esbuild/linux-loong64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.4.tgz#df890499f6e566b7de3aa2361be6df2b8d5fa015" - integrity sha512-WVhIKO26kmm8lPmNrUikxSpXcgd6HDog0cx12BUfA2PkmURHSgx9G6vA19lrlQOMw+UjMZ+l3PpbtzffCxFDRg== +"@esbuild/linux-loong64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299" + integrity sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA== "@esbuild/linux-loong64@0.20.0": version "0.20.0" @@ -3542,6 +3547,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz#a0fdf440b5485c81b0fbb316b08933d217f5d3ac" integrity sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw== +"@esbuild/linux-loong64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz#11119e18781f136d8083ea10eb6be73db7532de8" + integrity sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg== + "@esbuild/linux-loong64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz#b0840a2707c3fc02eec288d3f9defa3827cd7a87" @@ -3552,20 +3562,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz#428cf2213ff786a502a52c96cf29d1fcf1eb8506" integrity sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg== -"@esbuild/linux-loong64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz#473f5ea2e52399c08ad4cd6b12e6dbcddd630f05" - integrity sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg== - "@esbuild/linux-mips64el@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== -"@esbuild/linux-mips64el@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.4.tgz#76eae4e88d2ce9f4f1b457e93892e802851b6807" - integrity sha512-keYY+Hlj5w86hNp5JJPuZNbvW4jql7c1eXdBUHIJGTeN/+0QFutU3GrS+c27L+NTmzi73yhtojHk+lr2+502Mw== +"@esbuild/linux-mips64el@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec" + integrity sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w== "@esbuild/linux-mips64el@0.20.0": version "0.20.0" @@ -3587,6 +3592,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz#e11a2806346db8375b18f5e104c5a9d4e81807f6" integrity sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q== +"@esbuild/linux-mips64el@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz#3052f5436b0c0c67a25658d5fc87f045e7def9e6" + integrity sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA== + "@esbuild/linux-mips64el@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz#2a198e5a458c9f0e75881a4e63d26ba0cf9df39f" @@ -3597,20 +3607,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz#5cbcc7fd841b4cd53358afd33527cd394e325d96" integrity sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg== -"@esbuild/linux-mips64el@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz#9960631c9fd61605b0939c19043acf4ef2b51718" - integrity sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw== - "@esbuild/linux-ppc64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== -"@esbuild/linux-ppc64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.4.tgz#c49032f4abbcfa3f747b543a106931fe3dce41ff" - integrity sha512-tQ92n0WMXyEsCH4m32S21fND8VxNiVazUbU4IUGVXQpWiaAxOBvtOtbEt3cXIV3GEBydYsY8pyeRMJx9kn3rvw== +"@esbuild/linux-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8" + integrity sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg== "@esbuild/linux-ppc64@0.20.0": version "0.20.0" @@ -3632,6 +3637,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz#06a2744c5eaf562b1a90937855b4d6cf7c75ec96" integrity sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw== +"@esbuild/linux-ppc64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz#2f098920ee5be2ce799f35e367b28709925a8744" + integrity sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA== + "@esbuild/linux-ppc64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz#64f4ae0b923d7dd72fb860b9b22edb42007cf8f5" @@ -3642,20 +3652,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz#0d954ab39ce4f5e50f00c4f8c4fd38f976c13ad9" integrity sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ== -"@esbuild/linux-ppc64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz#477cbf8bb04aa034b94f362c32c86b5c31db8d3e" - integrity sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw== - "@esbuild/linux-riscv64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== -"@esbuild/linux-riscv64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.4.tgz#0f815a090772138503ee0465a747e16865bf94b1" - integrity sha512-tRRBey6fG9tqGH6V75xH3lFPpj9E8BH+N+zjSUCnFOX93kEzqS0WdyJHkta/mmJHn7MBaa++9P4ARiU4ykjhig== +"@esbuild/linux-riscv64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf" + integrity sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg== "@esbuild/linux-riscv64@0.20.0": version "0.20.0" @@ -3677,6 +3682,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz#65b46a2892fc0d1af4ba342af3fe0fa4a8fe08e7" integrity sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA== +"@esbuild/linux-riscv64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz#fa51d7fd0a22a62b51b4b94b405a3198cf7405dd" + integrity sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA== + "@esbuild/linux-riscv64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz#fb2844b11fdddd39e29d291c7cf80f99b0d5158d" @@ -3687,20 +3697,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz#0e7dd30730505abd8088321e8497e94b547bfb1e" integrity sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA== -"@esbuild/linux-riscv64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz#bcdb46c8fb8e93aa779e9a0a62cd4ac00dcac626" - integrity sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w== - "@esbuild/linux-s390x@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== -"@esbuild/linux-s390x@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.4.tgz#8d2cca20cd4e7c311fde8701d9f1042664f8b92b" - integrity sha512-152aLpQqKZYhThiJ+uAM4PcuLCAOxDsCekIbnGzPKVBRUDlgaaAfaUl5NYkB1hgY6WN4sPkejxKlANgVcGl9Qg== +"@esbuild/linux-s390x@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8" + integrity sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg== "@esbuild/linux-s390x@0.20.0": version "0.20.0" @@ -3722,6 +3727,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz#e71ea18c70c3f604e241d16e4e5ab193a9785d6f" integrity sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw== +"@esbuild/linux-s390x@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz#a27642e36fc282748fdb38954bd3ef4f85791e8a" + integrity sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew== + "@esbuild/linux-s390x@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz#1466876e0aa3560c7673e63fdebc8278707bc750" @@ -3732,20 +3742,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz#5669af81327a398a336d7e40e320b5bbd6e6e72d" integrity sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ== -"@esbuild/linux-s390x@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz#f412cf5fdf0aea849ff51c73fd817c6c0234d46d" - integrity sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw== - "@esbuild/linux-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== -"@esbuild/linux-x64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.4.tgz#f618bec2655de49bff91c588777e37b5e3169d4a" - integrity sha512-Mi4aNA3rz1BNFtB7aGadMD0MavmzuuXNTaYL6/uiYIs08U7YMPETpgNn5oue3ICr+inKwItOwSsJDYkrE9ekVg== +"@esbuild/linux-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78" + integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg== "@esbuild/linux-x64@0.20.0": version "0.20.0" @@ -3767,6 +3772,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz#d47f97391e80690d4dfe811a2e7d6927ad9eed24" integrity sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ== +"@esbuild/linux-x64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz#9d9b09c0033d17529570ced6d813f98315dfe4e9" + integrity sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA== + "@esbuild/linux-x64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz#c10fde899455db7cba5f11b3bccfa0e41bf4d0cd" @@ -3777,10 +3787,10 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz#b2357dd153aa49038967ddc1ffd90c68a9d2a0d4" integrity sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw== -"@esbuild/linux-x64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz#d8233c09b5ebc0c855712dc5eeb835a3a3341108" - integrity sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig== +"@esbuild/netbsd-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz#25c09a659c97e8af19e3f2afd1c9190435802151" + integrity sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A== "@esbuild/netbsd-arm64@0.25.4": version "0.25.4" @@ -3792,20 +3802,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz#53b4dfb8fe1cee93777c9e366893bd3daa6ba63d" integrity sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw== -"@esbuild/netbsd-arm64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz#f51ae8dd1474172e73cf9cbaf8a38d1c72dd8f1a" - integrity sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q== - "@esbuild/netbsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== -"@esbuild/netbsd-x64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.4.tgz#7889744ca4d60f1538d62382b95e90a49687cef2" - integrity sha512-9+Wxx1i5N/CYo505CTT7T+ix4lVzEdz0uCoYGxM5JDVlP2YdDC1Bdz+Khv6IbqmisT0Si928eAxbmGkcbiuM/A== +"@esbuild/netbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b" + integrity sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA== "@esbuild/netbsd-x64@0.20.0": version "0.20.0" @@ -3827,6 +3832,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz#44e743c9778d57a8ace4b72f3c6b839a3b74a653" integrity sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA== +"@esbuild/netbsd-x64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz#7fa5f6ffc19be3a0f6f5fd32c90df3dc2506937a" + integrity sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig== + "@esbuild/netbsd-x64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz#ec401fb0b1ed0ac01d978564c5fc8634ed1dc2ed" @@ -3837,16 +3847,16 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz#a0206f6314ce7dc8713b7732703d0f58de1d1e79" integrity sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ== -"@esbuild/netbsd-x64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz#a267538602c0e50a858cf41dcfe5d8036f8da8e7" - integrity sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g== - "@esbuild/openbsd-arm64@0.23.1": version "0.23.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz#05c5a1faf67b9881834758c69f3e51b7dee015d7" integrity sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q== +"@esbuild/openbsd-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz#8faa6aa1afca0c6d024398321d6cb1c18e72a1c3" + integrity sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw== + "@esbuild/openbsd-arm64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz#f272c2f41cfea1d91b93d487a51b5c5ca7a8c8c4" @@ -3857,20 +3867,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz#2a796c87c44e8de78001d808c77d948a21ec22fd" integrity sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw== -"@esbuild/openbsd-arm64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz#a51be60c425b85c216479b8c344ad0511635f2d2" - integrity sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg== - "@esbuild/openbsd-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== -"@esbuild/openbsd-x64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.4.tgz#c3e436eb9271a423d2e8436fcb120e3fd90e2b01" - integrity sha512-MFsHleM5/rWRW9EivFssop+OulYVUoVcqkyOkjiynKBCGBj9Lihl7kh9IzrreDyXa4sNkquei5/DTP4uCk25xw== +"@esbuild/openbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0" + integrity sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw== "@esbuild/openbsd-x64@0.20.0": version "0.20.0" @@ -3892,6 +3897,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz#2e58ae511bacf67d19f9f2dcd9e8c5a93f00c273" integrity sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA== +"@esbuild/openbsd-x64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz#a42979b016f29559a8453d32440d3c8cd420af5e" + integrity sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw== + "@esbuild/openbsd-x64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz#2e25950bc10fa9db1e5c868e3d50c44f7c150fd7" @@ -3902,25 +3912,20 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz#28d0cd8909b7fa3953af998f2b2ed34f576728f0" integrity sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg== -"@esbuild/openbsd-x64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz#7e4a743c73f75562e29223ba69d0be6c9c9008da" - integrity sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw== - -"@esbuild/openharmony-arm64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz#2087a5028f387879154ebf44bdedfafa17682e5b" - integrity sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA== +"@esbuild/openharmony-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz#fd87bfeadd7eeb3aa384bbba907459ffa3197cb1" + integrity sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag== "@esbuild/sunos-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== -"@esbuild/sunos-x64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.4.tgz#f63f5841ba8c8c1a1c840d073afc99b53e8ce740" - integrity sha512-6Xq8SpK46yLvrGxjp6HftkDwPP49puU4OF0hEL4dTxqCbfx09LyrbUj/D7tmIRMj5D5FCUPksBbxyQhp8tmHzw== +"@esbuild/sunos-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30" + integrity sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA== "@esbuild/sunos-x64@0.20.0": version "0.20.0" @@ -3942,6 +3947,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz#adb022b959d18d3389ac70769cef5a03d3abd403" integrity sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA== +"@esbuild/sunos-x64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz#3a18f590e36cb78ae7397976b760b2b8c74407f4" + integrity sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ== + "@esbuild/sunos-x64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz#cd596fa65a67b3b7adc5ecd52d9f5733832e1abd" @@ -3952,20 +3962,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz#a28164f5b997e8247d407e36c90d3fd5ddbe0dc5" integrity sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA== -"@esbuild/sunos-x64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz#56531f861723ea0dc6283a2bb8837304223cb736" - integrity sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA== - "@esbuild/win32-arm64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== -"@esbuild/win32-arm64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.4.tgz#80be69cec92da4da7781cf7a8351b95cc5a236b0" - integrity sha512-PkIl7Jq4mP6ke7QKwyg4fD4Xvn8PXisagV/+HntWoDEdmerB2LTukRZg728Yd1Fj+LuEX75t/hKXE2Ppk8Hh1w== +"@esbuild/win32-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae" + integrity sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A== "@esbuild/win32-arm64@0.20.0": version "0.20.0" @@ -3987,6 +3992,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz#84906f50c212b72ec360f48461d43202f4c8b9a2" integrity sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A== +"@esbuild/win32-arm64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz#e71741a251e3fd971408827a529d2325551f530c" + integrity sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw== + "@esbuild/win32-arm64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz#b4dbcb57b21eeaf8331e424c3999b89d8951dc88" @@ -3997,20 +4007,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz#6eadbead38e8bd12f633a5190e45eff80e24007e" integrity sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw== -"@esbuild/win32-arm64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz#f4989f033deac6fae323acff58764fa8bc01436e" - integrity sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q== - "@esbuild/win32-ia32@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== -"@esbuild/win32-ia32@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.4.tgz#15dc0ed83d2794872b05d8edc4a358fecf97eb54" - integrity sha512-ga676Hnvw7/ycdKB53qPusvsKdwrWzEyJ+AtItHGoARszIqvjffTwaaW3b2L6l90i7MO9i+dlAW415INuRhSGg== +"@esbuild/win32-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67" + integrity sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ== "@esbuild/win32-ia32@0.20.0": version "0.20.0" @@ -4032,6 +4037,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz#5e3eacc515820ff729e90d0cb463183128e82fac" integrity sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ== +"@esbuild/win32-ia32@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz#c6f010b5d3b943d8901a0c87ea55f93b8b54bf94" + integrity sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw== + "@esbuild/win32-ia32@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz#410842e5d66d4ece1757634e297a87635eb82f7a" @@ -4042,20 +4052,15 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz#bab6288005482f9ed2adb9ded7e88eba9a62cc0d" integrity sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ== -"@esbuild/win32-ia32@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz#b260e9df71e3939eb33925076d39f63cec7d1525" - integrity sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ== - "@esbuild/win32-x64@0.18.20": version "0.18.20" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== -"@esbuild/win32-x64@0.19.4": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.4.tgz#d46a6e220a717f31f39ae80f49477cc3220be0f0" - integrity sha512-HP0GDNla1T3ZL8Ko/SHAS2GgtjOg+VmWnnYLhuTksr++EnduYB0f3Y2LzHsUwb2iQ13JGoY6G3R8h6Du/WG6uA== +"@esbuild/win32-x64@0.19.12": + version "0.19.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae" + integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA== "@esbuild/win32-x64@0.20.0": version "0.20.0" @@ -4077,6 +4082,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz#81fd50d11e2c32b2d6241470e3185b70c7b30699" integrity sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg== +"@esbuild/win32-x64@0.25.10": + version "0.25.10" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz#e4b3e255a1b4aea84f6e1d2ae0b73f826c3785bd" + integrity sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw== + "@esbuild/win32-x64@0.25.4": version "0.25.4" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz#0b17ec8a70b2385827d52314c1253160a0b9bacc" @@ -4087,11 +4097,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz#7fc114af5f6563f19f73324b5d5ff36ece0803d1" integrity sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g== -"@esbuild/win32-x64@0.25.6": - version "0.25.6" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz#4276edd5c105bc28b11c6a1f76fb9d29d1bd25c1" - integrity sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA== - "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.9.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" @@ -8863,14 +8868,6 @@ resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.11.tgz#70b7131f65038ae63cfe841354c8aba363632344" integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg== -"@types/node-fetch@^2.6.11": - version "2.6.11" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" - integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== - dependencies: - "@types/node" "*" - form-data "^4.0.0" - "@types/node-forge@^1.3.0": version "1.3.11" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" @@ -10507,12 +10504,12 @@ ansi-html@^0.0.7: ansi-regex@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + integrity sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA== ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.1.tgz#123d6479e92ad45ad897d4054e3c7ca7db4944e1" + integrity sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw== ansi-regex@^4.1.0: version "4.1.1" @@ -10525,9 +10522,9 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1: integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== ansi-regex@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" - integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== ansi-styles@^2.2.1: version "2.2.1" @@ -11204,7 +11201,7 @@ autoprefixer@^10.4.13, autoprefixer@^10.4.19, autoprefixer@^10.4.20, autoprefixe picocolors "^1.0.1" postcss-value-parser "^4.2.0" -available-typed-arrays@^1.0.2, available-typed-arrays@^1.0.7: +available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== @@ -12534,7 +12531,7 @@ calculate-cache-key-for-tree@^2.0.0: dependencies: json-stable-stringify "^1.0.1" -call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== @@ -12542,18 +12539,17 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: es-errors "^1.3.0" function-bind "^1.1.2" -call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" - integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== +call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7, call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== dependencies: + call-bind-apply-helpers "^1.0.0" es-define-property "^1.0.0" - es-errors "^1.3.0" - function-bind "^1.1.2" get-intrinsic "^1.2.4" - set-function-length "^1.2.1" + set-function-length "^1.2.2" -call-bound@^1.0.2: +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== @@ -13698,9 +13694,9 @@ cross-argv@^2.0.0: integrity sha512-YIaY9TR5Nxeb8SMdtrU8asWVM4jqJDNDYlKV21LxtYcfNJhp1kEsgSa6qXwXgzN0WQWGODps0+TlGp2xQSHwOg== cross-spawn@^6.0.0: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + version "6.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57" + integrity sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw== dependencies: nice-try "^1.0.4" path-key "^2.0.1" @@ -14436,14 +14432,14 @@ deterministic-object-hash@^1.3.1: integrity sha512-kQDIieBUreEgY+akq0N7o4FzZCr27dPG1xr3wq267vPwDlSXQ3UMcBXHqTGUBaM/5WDS1jwTYjxRhUzHeuiAvw== devalue@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-4.3.2.tgz#cc44e4cf3872ac5a78229fbce3b77e57032727b5" - integrity sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg== + version "4.3.3" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-4.3.3.tgz#e35df3bdc49136837e77986f629b9fa6fef50726" + integrity sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg== devalue@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.0.0.tgz#1ca0099a7d715b4d6cac3924e770ccbbc584ad98" - integrity sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA== + version "5.3.2" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.3.2.tgz#1d9a00f0d126a2f768589f236da8b67d6988d285" + integrity sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw== devlop@^1.0.0: version "1.1.0" @@ -15959,32 +15955,33 @@ esbuild@^0.18.10: "@esbuild/win32-x64" "0.18.20" esbuild@^0.19.2: - version "0.19.4" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.4.tgz#cdf5c4c684956d550bc3c6d0c01dac7fef6c75b1" - integrity sha512-x7jL0tbRRpv4QUyuDMjONtWFciygUxWaUM1kMX2zWxI0X2YWOt7MSA0g4UdeSiHM8fcYVzpQhKYOycZwxTdZkA== + version "0.19.12" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04" + integrity sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg== optionalDependencies: - "@esbuild/android-arm" "0.19.4" - "@esbuild/android-arm64" "0.19.4" - "@esbuild/android-x64" "0.19.4" - "@esbuild/darwin-arm64" "0.19.4" - "@esbuild/darwin-x64" "0.19.4" - "@esbuild/freebsd-arm64" "0.19.4" - "@esbuild/freebsd-x64" "0.19.4" - "@esbuild/linux-arm" "0.19.4" - "@esbuild/linux-arm64" "0.19.4" - "@esbuild/linux-ia32" "0.19.4" - "@esbuild/linux-loong64" "0.19.4" - "@esbuild/linux-mips64el" "0.19.4" - "@esbuild/linux-ppc64" "0.19.4" - "@esbuild/linux-riscv64" "0.19.4" - "@esbuild/linux-s390x" "0.19.4" - "@esbuild/linux-x64" "0.19.4" - "@esbuild/netbsd-x64" "0.19.4" - "@esbuild/openbsd-x64" "0.19.4" - "@esbuild/sunos-x64" "0.19.4" - "@esbuild/win32-arm64" "0.19.4" - "@esbuild/win32-ia32" "0.19.4" - "@esbuild/win32-x64" "0.19.4" + "@esbuild/aix-ppc64" "0.19.12" + "@esbuild/android-arm" "0.19.12" + "@esbuild/android-arm64" "0.19.12" + "@esbuild/android-x64" "0.19.12" + "@esbuild/darwin-arm64" "0.19.12" + "@esbuild/darwin-x64" "0.19.12" + "@esbuild/freebsd-arm64" "0.19.12" + "@esbuild/freebsd-x64" "0.19.12" + "@esbuild/linux-arm" "0.19.12" + "@esbuild/linux-arm64" "0.19.12" + "@esbuild/linux-ia32" "0.19.12" + "@esbuild/linux-loong64" "0.19.12" + "@esbuild/linux-mips64el" "0.19.12" + "@esbuild/linux-ppc64" "0.19.12" + "@esbuild/linux-riscv64" "0.19.12" + "@esbuild/linux-s390x" "0.19.12" + "@esbuild/linux-x64" "0.19.12" + "@esbuild/netbsd-x64" "0.19.12" + "@esbuild/openbsd-x64" "0.19.12" + "@esbuild/sunos-x64" "0.19.12" + "@esbuild/win32-arm64" "0.19.12" + "@esbuild/win32-ia32" "0.19.12" + "@esbuild/win32-x64" "0.19.12" esbuild@^0.20.2: version "0.20.2" @@ -16075,36 +16072,36 @@ esbuild@^0.23.0, esbuild@^0.23.1: "@esbuild/win32-x64" "0.23.1" esbuild@^0.25.0, esbuild@^0.25.5: - version "0.25.6" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.6.tgz#9b82a3db2fa131aec069ab040fd57ed0a880cdcd" - integrity sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg== + version "0.25.10" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.10.tgz#37f5aa5cd14500f141be121c01b096ca83ac34a9" + integrity sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ== optionalDependencies: - "@esbuild/aix-ppc64" "0.25.6" - "@esbuild/android-arm" "0.25.6" - "@esbuild/android-arm64" "0.25.6" - "@esbuild/android-x64" "0.25.6" - "@esbuild/darwin-arm64" "0.25.6" - "@esbuild/darwin-x64" "0.25.6" - "@esbuild/freebsd-arm64" "0.25.6" - "@esbuild/freebsd-x64" "0.25.6" - "@esbuild/linux-arm" "0.25.6" - "@esbuild/linux-arm64" "0.25.6" - "@esbuild/linux-ia32" "0.25.6" - "@esbuild/linux-loong64" "0.25.6" - "@esbuild/linux-mips64el" "0.25.6" - "@esbuild/linux-ppc64" "0.25.6" - "@esbuild/linux-riscv64" "0.25.6" - "@esbuild/linux-s390x" "0.25.6" - "@esbuild/linux-x64" "0.25.6" - "@esbuild/netbsd-arm64" "0.25.6" - "@esbuild/netbsd-x64" "0.25.6" - "@esbuild/openbsd-arm64" "0.25.6" - "@esbuild/openbsd-x64" "0.25.6" - "@esbuild/openharmony-arm64" "0.25.6" - "@esbuild/sunos-x64" "0.25.6" - "@esbuild/win32-arm64" "0.25.6" - "@esbuild/win32-ia32" "0.25.6" - "@esbuild/win32-x64" "0.25.6" + "@esbuild/aix-ppc64" "0.25.10" + "@esbuild/android-arm" "0.25.10" + "@esbuild/android-arm64" "0.25.10" + "@esbuild/android-x64" "0.25.10" + "@esbuild/darwin-arm64" "0.25.10" + "@esbuild/darwin-x64" "0.25.10" + "@esbuild/freebsd-arm64" "0.25.10" + "@esbuild/freebsd-x64" "0.25.10" + "@esbuild/linux-arm" "0.25.10" + "@esbuild/linux-arm64" "0.25.10" + "@esbuild/linux-ia32" "0.25.10" + "@esbuild/linux-loong64" "0.25.10" + "@esbuild/linux-mips64el" "0.25.10" + "@esbuild/linux-ppc64" "0.25.10" + "@esbuild/linux-riscv64" "0.25.10" + "@esbuild/linux-s390x" "0.25.10" + "@esbuild/linux-x64" "0.25.10" + "@esbuild/netbsd-arm64" "0.25.10" + "@esbuild/netbsd-x64" "0.25.10" + "@esbuild/openbsd-arm64" "0.25.10" + "@esbuild/openbsd-x64" "0.25.10" + "@esbuild/openharmony-arm64" "0.25.10" + "@esbuild/sunos-x64" "0.25.10" + "@esbuild/win32-arm64" "0.25.10" + "@esbuild/win32-ia32" "0.25.10" + "@esbuild/win32-x64" "0.25.10" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -17323,23 +17320,18 @@ follow-redirects@^1.0.0, follow-redirects@^1.15.6, follow-redirects@^1.15.9: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== -for-each@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" - integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== +for-each@^0.3.3, for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== dependencies: - is-callable "^1.1.3" + is-callable "^1.2.7" for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= -foreach@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" - integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= - foreground-child@^3.1.0: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" @@ -18911,9 +18903,9 @@ http-proxy-agent@^7.0.0: debug "^4.3.4" http-proxy-middleware@^2.0.3: - version "2.0.7" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6" - integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA== + version "2.0.9" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz#e9e63d68afaa4eee3d147f39149ab84c0c2815ef" + integrity sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q== dependencies: "@types/http-proxy" "^1.17.8" http-proxy "^1.18.1" @@ -19460,7 +19452,7 @@ is-builtin-module@^3.1.0, is-builtin-module@^3.2.1: dependencies: builtin-modules "^3.3.0" -is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: +is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== @@ -19809,16 +19801,12 @@ is-type@0.0.1: dependencies: core-util-is "~1.0.0" -is-typed-array@^1.1.3: - version "1.1.5" - resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.5.tgz#f32e6e096455e329eb7b423862456aa213f0eb4e" - integrity sha512-S+GRDgJlR3PyEbsX/Fobd9cqpZBuvUS+8asRqYDMLCb2qMzt1oz5m5oxQCxOgUDxiWsOVNi4yaF+/uvdlHlYug== +is-typed-array@^1.1.14, is-typed-array@^1.1.3: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== dependencies: - available-typed-arrays "^1.0.2" - call-bind "^1.0.2" - es-abstract "^1.18.0-next.2" - foreach "^2.0.5" - has-symbols "^1.0.1" + which-typed-array "^1.1.16" is-typedarray@^1.0.0: version "1.0.0" @@ -20282,18 +20270,18 @@ json-stringify-safe@^5.0.1: json5@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" - integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE= + integrity sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw== json5@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" - integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + version "1.0.2" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" + integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA== dependencies: minimist "^1.2.0" json5@^2.1.2, json5@^2.2.1, json5@^2.2.2, json5@^2.2.3: version "2.2.3" - resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== jsonc-parser@3.1.0: @@ -24606,7 +24594,7 @@ path-to-regexp@6.3.0, path-to-regexp@^6.2.0, path-to-regexp@^6.2.1: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== -path-to-regexp@8.2.0, path-to-regexp@^8.0.0, path-to-regexp@^8.1.0: +path-to-regexp@8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== @@ -24618,6 +24606,11 @@ path-to-regexp@^1.5.3, path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" +path-to-regexp@^8.0.0, path-to-regexp@^8.1.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f" + integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== + path-type@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" @@ -27252,7 +27245,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -27593,7 +27586,7 @@ set-cookie-parser@^2.4.8, set-cookie-parser@^2.6.0: resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz#131921e50f62ff1a66a461d7d62d7b21d5d15a51" integrity sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ== -set-function-length@^1.2.1: +set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -27641,12 +27634,13 @@ setprototypeof@1.2.0: integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== sha.js@^2.4.11: - version "2.4.11" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" - integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + version "2.4.12" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.12.tgz#eb8b568bf383dfd1867a32c3f2b74eb52bdbf23f" + integrity sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w== dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" + inherits "^2.0.4" + safe-buffer "^5.2.1" + to-buffer "^1.2.0" shallow-clone@^3.0.0: version "3.0.1" @@ -29043,9 +29037,9 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0: integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== tar-fs@^2.0.0: - version "2.1.3" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.3.tgz#fb3b8843a26b6f13a08e606f7922875eb1fbbf92" - integrity sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg== + version "2.1.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" + integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== dependencies: chownr "^1.1.1" mkdirp-classic "^0.5.2" @@ -29053,9 +29047,9 @@ tar-fs@^2.0.0: tar-stream "^2.1.4" tar-fs@^3.0.4: - version "3.0.10" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.10.tgz#60f8ccd60fe30164bdd3d6606619650236ed38f7" - integrity sha512-C1SwlQGNLe/jPNqapK8epDsXME7CAJR5RL3GcE6KWx1d9OUByzoHVcbu1VPI8tevg9H8Alae0AApHHFGzrD5zA== + version "3.1.1" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.1.tgz#4f164e59fb60f103d472360731e8c6bb4a7fe9ef" + integrity sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg== dependencies: pump "^3.0.0" tar-stream "^3.1.5" @@ -29426,7 +29420,7 @@ tmp-promise@^3.0.2: tmp@0.0.28: version "0.0.28" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.28.tgz#172735b7f614ea7af39664fa84cf0de4e515d120" - integrity sha1-Fyc1t/YU6nrzlmT6hM8N5OUV0SA= + integrity sha512-c2mmfiBmND6SOVxzogm1oda0OJ1HZVIk/5n26N59dDTh80MUeavpiCls4PGAdkX1PFkKokLpcf7prSjCeXLsJg== dependencies: os-tmpdir "~1.0.1" @@ -29445,15 +29439,24 @@ tmp@^0.1.0: rimraf "^2.6.3" tmp@^0.2.0, tmp@^0.2.1, tmp@~0.2.1: - version "0.2.3" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" - integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== + version "0.2.5" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8" + integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== tmpl@1.0.x: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== +to-buffer@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.2.2.tgz#ffe59ef7522ada0a2d1cb5dfe03bb8abc3cdc133" + integrity sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw== + dependencies: + isarray "^2.0.5" + safe-buffer "^5.2.1" + typed-array-buffer "^1.0.3" + to-object-path@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" @@ -29827,6 +29830,15 @@ type-level-regexp@~0.1.17: resolved "https://registry.yarnpkg.com/type-level-regexp/-/type-level-regexp-0.1.17.tgz#ec1bf7dd65b85201f9863031d6f023bdefc2410f" integrity sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg== +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + typed-assert@^1.0.8: version "1.0.9" resolved "https://registry.yarnpkg.com/typed-assert/-/typed-assert-1.0.9.tgz#8af9d4f93432c4970ec717e3006f33f135b06213" @@ -30007,9 +30019,9 @@ undici@^5.25.4, undici@^5.28.5: "@fastify/busboy" "^2.0.0" undici@^6.11.1, undici@^6.19.2: - version "6.21.1" - resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.1.tgz#336025a14162e6837e44ad7b819b35b6c6af0e05" - integrity sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ== + version "6.21.3" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a" + integrity sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw== unenv@2.0.0-rc.17: version "2.0.0-rc.17" @@ -31484,15 +31496,17 @@ which-pm@^2.1.1: load-yaml-file "^0.2.0" path-exists "^4.0.0" -which-typed-array@^1.1.13, which-typed-array@^1.1.2: - version "1.1.15" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" - integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== +which-typed-array@^1.1.13, which-typed-array@^1.1.16, which-typed-array@^1.1.2: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== dependencies: available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" has-tostringtag "^1.0.2" which@^1.2.14, which@^1.2.9: From 49badb04a655513fdb0bd87ae949ce13d8c60f75 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 19 Nov 2025 16:03:13 +0100 Subject: [PATCH 165/190] feat(core): Add `gibibyte` and `pebibyte` to `InformationUnit` type (#18241) Both of these units are supported by Relay, see https://getsentry.github.io/relay/relay_metrics/enum.InformationUnit.html. --- packages/core/src/types-hoist/measurement.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/types-hoist/measurement.ts b/packages/core/src/types-hoist/measurement.ts index 8aea675d5254..40649ddab439 100644 --- a/packages/core/src/types-hoist/measurement.ts +++ b/packages/core/src/types-hoist/measurement.ts @@ -17,9 +17,11 @@ export type InformationUnit = | 'megabyte' | 'mebibyte' | 'gigabyte' + | 'gibibyte' | 'terabyte' | 'tebibyte' | 'petabyte' + | 'pebibyte' | 'exabyte' | 'exbibyte'; From 93faf616244c7ca7480f2a738543923f54b01b78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:42:40 +0000 Subject: [PATCH 166/190] chore(deps): bump glob from 11.0.1 to 11.1.0 in /packages/react-router (#18243) --- packages/react-router/package.json | 2 +- yarn.lock | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 3d57ad54469f..d988e14aea8a 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -55,7 +55,7 @@ "@sentry/node": "10.26.0", "@sentry/react": "10.26.0", "@sentry/vite-plugin": "^4.1.0", - "glob": "11.0.1" + "glob": "11.1.0" }, "devDependencies": { "@react-router/dev": "^7.5.2", diff --git a/yarn.lock b/yarn.lock index c93b8a45e748..93c288553b5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17332,7 +17332,7 @@ for-in@^1.0.2: resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= -foreground-child@^3.1.0: +foreground-child@^3.1.0, foreground-child@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== @@ -17907,14 +17907,14 @@ glob-to-regexp@0.4.1, glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@11.0.1: - version "11.0.1" - resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.1.tgz#1c3aef9a59d680e611b53dcd24bb8639cef064d9" - integrity sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw== +glob@11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.1.0.tgz#4f826576e4eb99c7dad383793d2f9f08f67e50a6" + integrity sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw== dependencies: - foreground-child "^3.1.0" - jackspeak "^4.0.1" - minimatch "^10.0.0" + foreground-child "^3.3.1" + jackspeak "^4.1.1" + minimatch "^10.1.1" minipass "^7.1.2" package-json-from-dist "^1.0.0" path-scurry "^2.0.0" @@ -20023,7 +20023,7 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" -jackspeak@^4.0.1: +jackspeak@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" integrity sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ== @@ -22289,10 +22289,10 @@ minimatch@5.1.0, minimatch@^5.0.1, minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" -minimatch@^10.0.0: - version "10.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" - integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== +minimatch@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.1.tgz#e6e61b9b0c1dcab116b5a7d1458e8b6ae9e73a55" + integrity sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ== dependencies: "@isaacs/brace-expansion" "^5.0.0" From 6d75c8f2894c79596a8489b87cdd0315bf4e7dd4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 20 Nov 2025 10:24:44 +0100 Subject: [PATCH 167/190] fix(metrics): Update return type of `beforeSendMetric` (#18261) --- packages/core/src/types-hoist/options.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index ccdc3b180e15..59c4609f01c4 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -420,9 +420,9 @@ export interface ClientOptions Metric; + beforeSendMetric?: (metric: Metric) => Metric | null; /** * Function to compute tracing sample rate dynamically and filter unwanted traces. From 5503d96ed85d6a32383f702456526a4f83e04753 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 11:17:17 +0100 Subject: [PATCH 168/190] chore(deps): bump astro from 4.16.18 to 5.15.9 in /dev-packages/e2e-tests/test-applications/cloudflare-astro (#18259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.16.18 to 5.15.9.
Release notes

Sourced from astro's releases.

astro@5.15.9

Patch Changes

  • #14786 758a891 Thanks @​mef! - Add handling of invalid encrypted props and slots in server islands.

  • #14783 504958f Thanks @​florian-lefebvre! - Improves the experimental Fonts API build log to show the number of downloaded files. This can help spotting excessive downloading because of misconfiguration

  • #14791 9e9c528 Thanks @​Princesseuh! - Changes the remote protocol checks for images to require explicit authorization in order to use data URIs.

    In order to allow data URIs for remote images, you will need to update your astro.config.mjs file to include the following configuration:

    // astro.config.mjs
    import { defineConfig } from 'astro/config';
    

    export default defineConfig({ images: { remotePatterns: [ { protocol: 'data', }, ], }, });

  • #14787 0f75f6b Thanks @​matthewp! - Fixes wildcard hostname pattern matching to correctly reject hostnames without dots

    Previously, hostnames like localhost or other single-part names would incorrectly match patterns like *.example.com. The wildcard matching logic has been corrected to ensure that only valid subdomains matching the pattern are accepted.

  • #14776 3537876 Thanks @​ktym4a! - Fixes the behavior of passthroughImageService so it does not generate webp.

  • Updated dependencies [9e9c528, 0f75f6b]:

    • @​astrojs/internal-helpers@​0.7.5
    • @​astrojs/markdown-remark@​6.3.9

astro@5.15.8

Patch Changes

  • #14772 00c579a Thanks @​matthewp! - Improves the security of Server Islands slots by encrypting them before transmission to the browser, matching the security model used for props. This improves the integrity of slot content and prevents injection attacks, even when component templates don't explicitly support slots.

    Slots continue to work as expected for normal usage—this change has no breaking changes for legitimate requests.

  • #14771 6f80081 Thanks @​matthewp! - Fix middleware pathname matching by normalizing URL-encoded paths

    Middleware now receives normalized pathname values, ensuring that encoded paths like /%61dmin are properly decoded to /admin before middleware checks. This prevents potential security issues where middleware checks might be bypassed through URL encoding.

astro@5.15.7

Patch Changes

... (truncated)

Changelog

Sourced from astro's changelog.

5.15.9

Patch Changes

  • #14786 758a891 Thanks @​mef! - Add handling of invalid encrypted props and slots in server islands.

  • #14783 504958f Thanks @​florian-lefebvre! - Improves the experimental Fonts API build log to show the number of downloaded files. This can help spotting excessive downloading because of misconfiguration

  • #14791 9e9c528 Thanks @​Princesseuh! - Changes the remote protocol checks for images to require explicit authorization in order to use data URIs.

    In order to allow data URIs for remote images, you will need to update your astro.config.mjs file to include the following configuration:

    // astro.config.mjs
    import { defineConfig } from 'astro/config';
    

    export default defineConfig({ images: { remotePatterns: [ { protocol: 'data', }, ], }, });

  • #14787 0f75f6b Thanks @​matthewp! - Fixes wildcard hostname pattern matching to correctly reject hostnames without dots

    Previously, hostnames like localhost or other single-part names would incorrectly match patterns like *.example.com. The wildcard matching logic has been corrected to ensure that only valid subdomains matching the pattern are accepted.

  • #14776 3537876 Thanks @​ktym4a! - Fixes the behavior of passthroughImageService so it does not generate webp.

  • Updated dependencies [9e9c528, 0f75f6b]:

    • @​astrojs/internal-helpers@​0.7.5
    • @​astrojs/markdown-remark@​6.3.9

5.15.8

Patch Changes

  • #14772 00c579a Thanks @​matthewp! - Improves the security of Server Islands slots by encrypting them before transmission to the browser, matching the security model used for props. This improves the integrity of slot content and prevents injection attacks, even when component templates don't explicitly support slots.

    Slots continue to work as expected for normal usage—this change has no breaking changes for legitimate requests.

  • #14771 6f80081 Thanks @​matthewp! - Fix middleware pathname matching by normalizing URL-encoded paths

    Middleware now receives normalized pathname values, ensuring that encoded paths like /%61dmin are properly decoded to /admin before middleware checks. This prevents potential security issues where middleware checks might be bypassed through URL encoding.

5.15.7

... (truncated)

Commits
Maintainer changes

This version was pushed to npm by [GitHub Actions](https://www.npmjs.com/~GitHub Actions), a new releaser for astro since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astro&package-manager=npm_and_yarn&previous-version=4.16.18&new-version=5.15.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../e2e-tests/test-applications/cloudflare-astro/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json index 5adbcd6ad75f..4db15edabbd7 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json @@ -19,7 +19,7 @@ "dependencies": { "@astrojs/cloudflare": "8.1.0", "@sentry/astro": "latest || *", - "astro": "4.16.18" + "astro": "5.15.9" }, "devDependencies": { "@astrojs/internal-helpers": "0.4.1" From f0d5d76046b476f5ab9beaa75803d2987ba2f435 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 11:17:34 +0100 Subject: [PATCH 169/190] build(deps): bump hono from 4.9.7 to 4.10.3 in /dev-packages/e2e-tests/test-applications/cloudflare-hono (#18038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [hono](https://github.com/honojs/hono) from 4.9.7 to 4.10.3.
Release notes

Sourced from hono's releases.

v4.10.3

Securiy Fix

A security issue in the CORS middleware has been fixed. In some cases, a request header could affect the Vary response header. Please update to the latest version if you are using the CORS middleware.

What's Changed

New Contributors

Full Changelog: https://github.com/honojs/hono/compare/v4.10.2...v4.10.3

v4.10.2

Security hardening improvement

If you are using JWT middleware, please read the following and consider applying the configuration.

Improper Authorization in Hono (JWT Audience Validation)

Hono’s JWT authentication middleware did not validate the aud (Audience) claim by default. As a result, applications using the middleware without an explicit audience check could accept tokens intended for other audiences, leading to potential cross-service access (token mix-up).

The issue is addressed by adding a new verification.aud configuration option to allow RFC 7519–compliant audience validation. This change is classified as a security hardening improvement, but the lack of validation can still be considered a vulnerability in deployments that rely on default JWT verification.

Recommended secure configuration

You can enable RFC 7519–compliant audience validation using the new verification.aud option:

import { Hono } from 'hono'
import { jwt } from 'hono/jwt'

const app = new Hono()

app.use(
'/api/*',
jwt({
secret: 'my-secret',
verification: {
// Require this API to only accept tokens with aud = 'service-a'
aud: 'service-a',
},
})
)

What's Changed

... (truncated)

Commits
  • fcefd50 4.10.3
  • 95ae4d3 refactor(jwt): reduce the size of the code generated by minification (#4480)
  • d9b8b4b Merge commit from fork
  • 5216117 fix(request-id): validation accepts = (#4478)
  • 253ec28 fix(aws-lambda): serve microsoft office files as binary in lambda handler (#4...
  • 0c6455d 4.10.2
  • 45ba3bf Merge commit from fork
  • 4cbad8b tests: Fix test case of handlers without a path (#4472)
  • db764c2 4.10.1
  • 8774bf9 fix(types): cannot .use non-return mw from createMiddleware (#4465)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=hono&package-manager=npm_and_yarn&previous-version=4.9.7&new-version=4.10.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jan Peer Stöcklmair --- .../e2e-tests/test-applications/cloudflare-hono/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json index de22031fdda9..5a582b0aa127 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-hono/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@sentry/cloudflare": "latest || *", - "hono": "4.9.7" + "hono": "4.10.3" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.8.31", From 28e9cc659914c0239e0a488b56e75560b6885826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 20 Nov 2025 16:35:45 +0000 Subject: [PATCH 170/190] chore: Do not update opentelemetry (#18254) This disables the creation of dependabot updates for opentelemetry. Based on the image below there was not a real benefit of having this, except noise. Screenshot 2025-11-19 at 11 21 02 --- .github/dependabot.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1df50881932d..66d551fabef8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,9 +14,15 @@ updates: interval: 'weekly' allow: - dependency-name: '@sentry/*' - - dependency-name: '@opentelemetry/*' - - dependency-name: '@prisma/instrumentation' - dependency-name: '@playwright/test' + - dependency-name: '@opentelemetry/*' + ignore: + - dependency-name: '@opentelemetry/instrumentation' + - dependency-name: '@opentelemetry/instrumentation-*' + groups: + opentelemetry: + patterns: + - '@opentelemetry/*' versioning-strategy: increase commit-message: prefix: feat From c7e88d4416f2527b279e6fefb9bc8e36589f11b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 20 Nov 2025 16:43:26 +0000 Subject: [PATCH 171/190] fix(core): Add a PromiseBuffer for incoming events on the client (#18120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Previously, the client would process all incoming events without any limit, which could lead to unbounded growth of pending events/promises in memory. This could cause performance issues and memory pressure in high-throughput scenarios. This occurs when two conditions are met: - when an integration with an async `processEvent` are added (e.g. `ContextLines`, which is a defaultIntegration) - events, e.g. `Sentry.captureException`, are called synchronously ```js Sentry.init({ ... }); // ... for (let i = 0; i < 5000; i++) { Sentry.captureException(new Error()); } ``` ## Solution This PR adds a `PromiseBuffer` to the `Client` class to limit the number of concurrent event processing operations. - Introduced a `_promiseBuffer` in the `Client` class that limits concurrent event processing - The buffer size defaults to `DEFAULT_TRANSPORT_BUFFER_SIZE` (64) but can be configured via `transportOptions.bufferSize` - When the buffer is full, events are rejected and properly tracked as dropped events with the `queue_overflow` reason - Please tak - Modified the `_process()` method to: - Accept a task producer function instead of a promise directly (lazy evaluation) - Use the promise buffer to manage concurrent operations - Track the data category for proper dropped event categorization ## Special 👀 on - About reusing `transportOptions.bufferSize`: Not sure if this is the best technique, but IMO both should have the same size - because if it wouldn't it would be capped at a later stage (asking myself if the transport still needs the promise buffer - as we have it now way earlier in place) - The `_process` takes now a `DataCategory`. At the time of the process the event type is almost unknown. Not sure if I assumed the categories correctly there, or if there is another technique of getting the type (**edit:** a [comment by Cursor](https://github.com/getsentry/sentry-javascript/pull/18120/files/2ee14b484d00432145d4f9a6773fbd31f92921d7#r2504259236) helped a little and I added [a helper function](https://github.com/getsentry/sentry-javascript/pull/18120/commits/7381a49ac34964d637f56625b2bf48617820b29d)) - `recordDroppedEvent` is now printing it one after each other - theoretically we can count all occurences and print the count on it. I decided against this one, since it would delay the user feedback - this can be challenged though --- .size-limit.js | 10 ++-- packages/core/src/client.ts | 44 ++++++++++++----- packages/core/test/lib/client.test.ts | 65 ++++++++++++++++++++++++- packages/core/test/mocks/integration.ts | 10 ++++ 4 files changed, 112 insertions(+), 17 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 184aad0698f4..100444907e06 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '41.38 KB', + limit: '41.5 KB', }, { name: '@sentry/browser (incl. Tracing, Profiling)', @@ -127,7 +127,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '43.33 KB', + limit: '43.5 KB', }, // Vue SDK (ESM) { @@ -142,7 +142,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43.2 KB', + limit: '43.3 KB', }, // Svelte SDK (ESM) { @@ -163,7 +163,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '42 KB', + limit: '42.1 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -231,7 +231,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '51 KB', + limit: '51.1 KB', }, // Node SDK (ESM) { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 1c925d930036..b7e0cab509c1 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -11,13 +11,14 @@ import { _INTERNAL_flushMetricsBuffer } from './metrics/internal'; import type { Scope } from './scope'; import { updateSession } from './session'; import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext'; +import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base'; 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 { DataCategory } from './types-hoist/datacategory'; import type { DsnComponents } from './types-hoist/dsn'; import type { DynamicSamplingContext, Envelope } from './types-hoist/envelope'; -import type { ErrorEvent, Event, EventHint, TransactionEvent } from './types-hoist/event'; +import type { ErrorEvent, Event, EventHint, EventType, TransactionEvent } from './types-hoist/event'; import type { EventProcessor } from './types-hoist/eventprocessor'; import type { FeedbackEvent } from './types-hoist/feedback'; import type { Integration } from './types-hoist/integration'; @@ -43,6 +44,7 @@ import { merge } from './utils/merge'; import { checkOrSetAlreadyCaught, uuid4 } from './utils/misc'; import { parseSampleRate } from './utils/parseSampleRate'; import { prepareEvent } from './utils/prepareEvent'; +import { type PromiseBuffer, makePromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from './utils/promisebuffer'; import { reparentChildSpans, shouldIgnoreSpan } from './utils/should-ignore-span'; import { showSpanDropWarning } from './utils/spanUtils'; import { rejectedSyncPromise } from './utils/syncpromise'; @@ -201,6 +203,8 @@ export abstract class Client { // eslint-disable-next-line @typescript-eslint/ban-types private _hooks: Record>; + private _promiseBuffer: PromiseBuffer; + /** * Initializes this client instance. * @@ -213,6 +217,7 @@ export abstract class Client { this._outcomes = {}; this._hooks = {}; this._eventProcessors = []; + this._promiseBuffer = makePromiseBuffer(options.transportOptions?.bufferSize ?? DEFAULT_TRANSPORT_BUFFER_SIZE); if (options.dsn) { this._dsn = makeDsn(options.dsn); @@ -275,9 +280,11 @@ export abstract class Client { }; this._process( - this.eventFromException(exception, hintWithEventId).then(event => - this._captureEvent(event, hintWithEventId, scope), - ), + () => + this.eventFromException(exception, hintWithEventId) + .then(event => this._captureEvent(event, hintWithEventId, scope)) + .then(res => res), + 'error', ); return hintWithEventId.event_id; @@ -300,12 +307,15 @@ export abstract class Client { }; const eventMessage = isParameterizedString(message) ? message : String(message); - - const promisedEvent = isPrimitive(message) + const isMessage = isPrimitive(message); + const promisedEvent = isMessage ? this.eventFromMessage(eventMessage, level, hintWithEventId) : this.eventFromException(message, hintWithEventId); - this._process(promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope))); + this._process( + () => promisedEvent.then(event => this._captureEvent(event, hintWithEventId, currentScope)), + isMessage ? 'unknown' : 'error', + ); return hintWithEventId.event_id; } @@ -332,9 +342,11 @@ export abstract class Client { const sdkProcessingMetadata = event.sdkProcessingMetadata || {}; const capturedSpanScope: Scope | undefined = sdkProcessingMetadata.capturedSpanScope; const capturedSpanIsolationScope: Scope | undefined = sdkProcessingMetadata.capturedSpanIsolationScope; + const dataCategory = getDataCategoryByType(event.type); this._process( - this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope), + () => this._captureEvent(event, hintWithEventId, capturedSpanScope || currentScope, capturedSpanIsolationScope), + dataCategory, ); return hintWithEventId.event_id; @@ -1252,7 +1264,7 @@ export abstract class Client { ); } - const dataCategory = (eventType === 'replay_event' ? 'replay' : eventType) satisfies DataCategory; + const dataCategory = getDataCategoryByType(event.type); return this._prepareEvent(event, hint, currentScope, isolationScope) .then(prepared => { @@ -1335,15 +1347,21 @@ export abstract class Client { /** * Occupies the client with processing and event */ - protected _process(promise: PromiseLike): void { + protected _process(taskProducer: () => PromiseLike, dataCategory: DataCategory): void { this._numProcessing++; - void promise.then( + + void this._promiseBuffer.add(taskProducer).then( value => { this._numProcessing--; return value; }, reason => { this._numProcessing--; + + if (reason === SENTRY_BUFFER_FULL_ERROR) { + this.recordDroppedEvent('queue_overflow', dataCategory); + } + return reason; }, ); @@ -1408,6 +1426,10 @@ export abstract class Client { ): PromiseLike; } +function getDataCategoryByType(type: EventType | 'replay_event' | undefined): DataCategory { + return type === 'replay_event' ? 'replay' : type || 'error'; +} + /** * Verifies that return value of configured `beforeSend` or `beforeSendTransaction` is of expected type, and returns the value if so. */ diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index c009d0e0c2a8..19ef8a95dff5 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -15,6 +15,7 @@ import { import * as integrationModule from '../../src/integration'; import { _INTERNAL_captureLog } from '../../src/logs/internal'; import { _INTERNAL_captureMetric } from '../../src/metrics/internal'; +import { DEFAULT_TRANSPORT_BUFFER_SIZE } from '../../src/transports/base'; 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'; @@ -23,7 +24,7 @@ import * as miscModule from '../../src/utils/misc'; import * as stringModule from '../../src/utils/string'; import * as timeModule from '../../src/utils/time'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; -import { AdHocIntegration, TestIntegration } from '../mocks/integration'; +import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration'; import { makeFakeTransport } from '../mocks/transport'; import { clearGlobalScope } from '../testutils'; @@ -2935,4 +2936,66 @@ describe('Client', () => { expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); }); }); + + describe('promise buffer usage', () => { + it('respects the default value of the buffer size', async () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + + client.addIntegration(new AsyncTestIntegration()); + + Array.from({ length: DEFAULT_TRANSPORT_BUFFER_SIZE + 1 }).forEach(() => { + client.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻')); + }); + + expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 1 }]); + }); + + it('records queue_overflow when promise buffer is full', async () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } }); + const client = new TestClient(options); + + client.addIntegration(new AsyncTestIntegration()); + + client.captureException(new Error('first')); + client.captureException(new Error('second')); + client.captureException(new Error('third')); + + expect(client._clearOutcomes()).toEqual([{ reason: 'queue_overflow', category: 'error', quantity: 2 }]); + }); + + it('records different types of dropped events', async () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } }); + const client = new TestClient(options); + + client.addIntegration(new AsyncTestIntegration()); + + client.captureException(new Error('first')); // error + client.captureException(new Error('second')); // error + client.captureMessage('third'); // unknown + client.captureEvent({ message: 'fourth' }); // error + client.captureEvent({ message: 'fifth', type: 'replay_event' }); // replay + client.captureEvent({ message: 'sixth', type: 'transaction' }); // transaction + + expect(client._clearOutcomes()).toEqual([ + { reason: 'queue_overflow', category: 'error', quantity: 2 }, + { reason: 'queue_overflow', category: 'unknown', quantity: 1 }, + { reason: 'queue_overflow', category: 'replay', quantity: 1 }, + { reason: 'queue_overflow', category: 'transaction', quantity: 1 }, + ]); + }); + + it('should skip the promise buffer with sync integrations', async () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, transportOptions: { bufferSize: 1 } }); + const client = new TestClient(options); + + client.addIntegration(new TestIntegration()); + + client.captureException(new Error('first')); + client.captureException(new Error('second')); + client.captureException(new Error('third')); + + expect(client._clearOutcomes()).toEqual([]); + }); + }); }); diff --git a/packages/core/test/mocks/integration.ts b/packages/core/test/mocks/integration.ts index 72a18dabe7b3..f5fc5682265a 100644 --- a/packages/core/test/mocks/integration.ts +++ b/packages/core/test/mocks/integration.ts @@ -24,6 +24,16 @@ export class TestIntegration implements Integration { } } +export class AsyncTestIntegration implements Integration { + public static id: string = 'AsyncTestIntegration'; + + public name: string = 'AsyncTestIntegration'; + + processEvent(event: Event): Event | null | PromiseLike { + return new Promise(resolve => setTimeout(() => resolve(event), 1)); + } +} + export class AddAttachmentTestIntegration implements Integration { public static id: string = 'AddAttachmentTestIntegration'; From 67095c8508e8d546f6e762fea161d1b8039e6c74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 18:22:03 +0100 Subject: [PATCH 172/190] feat(deps): bump @sentry/cli from 2.56.0 to 2.58.2 (#18271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@sentry/cli](https://github.com/getsentry/sentry-cli) from 2.56.0 to 2.58.2.
Release notes

Sourced from @​sentry/cli's releases.

2.58.2

Improvements

  • Added validation for the sentry-cli build upload command's --head-sha and --base-sha arguments (#2945). The CLI now validates that these are valid SHA1 sums. Passing an empty string is also allowed; this prevents the default values from being used, causing the values to instead be unset.

Fixes

  • Fixed a bug where providing empty-string values for the sentry-cli build upload command's --vcs-provider, --head-repo-name, --head-ref, --base-ref, and --base-repo-name arguments resulted in 400 errors (#2946). Now, setting these to empty strings instead explicitly clears the default value we would set otherwise, as expected.

2.58.1

Deprecations

  • Deprecated API key authentication (#2934, #2937). Users who are still using API keys to authenticate Sentry CLI should generate and use an Auth Token instead.

Improvements

  • The sentry-cli debug-files bundle-jvm no longer makes any HTTP requests to Sentry, meaning auth tokens are no longer needed, and the command can be run offline (#2926).

Fixes

  • Skip setting base_sha and base_ref when they equal head_sha during auto-inference, since comparing a commit to itself provides no meaningful baseline (#2924).
  • Improved error message when supplying a non-existent organization to sentry-cli sourcemaps upload. The error now correctly indicates the organization doesn't exist, rather than incorrectly suggesting the Sentry server lacks artifact bundle support (#2931).

2.58.0

New Features

  • Removed experimental status from the sentry-cli build upload commands (#2899, #2905). At the time of this release, build uploads are still in closed beta on the server side, so most customers cannot use this functionality quite yet.
  • Added CLI version metadata to build upload archives (#2890).

Deprecations

  • Deprecated the upload-proguard subcommand's --platform flag (#2863). This flag was a no-op for some time, so we will remove it in the next major.
  • Deprecated the upload-proguard subcommand's --android-manifest flag (#2891). This flag was a no-op for some time, so we will remove it in the next major.
  • Deprecated the sentry-cli sourcemaps upload command's --no-dedupe flag (#2913). The flag was no longer relevant for sourcemap uploads to modern Sentry servers and was made a no-op.

Fixes

  • Fixed autofilled git base metadata (--base-ref, --base-sha) when using the build upload subcommand in git repos. Previously this worked only in the context of GitHub workflows (#2897, #2898).

Performance

  • Slightly sped up the sentry-cli sourcemaps upload command by eliminating an HTTP request to the Sentry server, which was not required in most cases (#2913).

2.57.0

New Features

  • (JS API) Add projects field to SentryCliUploadSourceMapsOptions (#2856)

Deprecations

... (truncated)

Changelog

Sourced from @​sentry/cli's changelog.

2.58.2

Improvements

  • Added validation for the sentry-cli build upload command's --head-sha and --base-sha arguments (#2945). The CLI now validates that these are valid SHA1 sums. Passing an empty string is also allowed; this prevents the default values from being used, causing the values to instead be unset.

Fixes

  • Fixed a bug where providing empty-string values for the sentry-cli build upload command's --vcs-provider, --head-repo-name, --head-ref, --base-ref, and --base-repo-name arguments resulted in 400 errors (#2946). Now, setting these to empty strings instead explicitly clears the default value we would set otherwise, as expected.

2.58.1

Deprecations

  • Deprecated API key authentication (#2934, #2937). Users who are still using API keys to authenticate Sentry CLI should generate and use an Auth Token instead.

Improvements

  • The sentry-cli debug-files bundle-jvm no longer makes any HTTP requests to Sentry, meaning auth tokens are no longer needed, and the command can be run offline (#2926).

Fixes

  • Skip setting base_sha and base_ref when they equal head_sha during auto-inference, since comparing a commit to itself provides no meaningful baseline (#2924).
  • Improved error message when supplying a non-existent organization to sentry-cli sourcemaps upload. The error now correctly indicates the organization doesn't exist, rather than incorrectly suggesting the Sentry server lacks artifact bundle support (#2931).

2.58.0

New Features

  • Removed experimental status from the sentry-cli build upload commands (#2899, #2905). At the time of this release, build uploads are still in closed beta on the server side, so most customers cannot use this functionality quite yet.
  • Added CLI version metadata to build upload archives (#2890).

Deprecations

  • Deprecated the upload-proguard subcommand's --platform flag (#2863). This flag was a no-op for some time, so we will remove it in the next major.
  • Deprecated the upload-proguard subcommand's --android-manifest flag (#2891). This flag was a no-op for some time, so we will remove it in the next major.
  • Deprecated the sentry-cli sourcemaps upload command's --no-dedupe flag (#2913). The flag was no longer relevant for sourcemap uploads to modern Sentry servers and was made a no-op.

Fixes

  • Fixed autofilled git base metadata (--base-ref, --base-sha) when using the build upload subcommand in git repos. Previously this worked only in the context of GitHub workflows (#2897, #2898).

Performance

  • Slightly sped up the sentry-cli sourcemaps upload command by eliminating an HTTP request to the Sentry server, which was not required in most cases (#2913).

Internal changes

  • Migrated JavaScript wrapper to TypeScript for better type safety (#2910)

... (truncated)

Commits
  • b8965a3 release: 2.58.2
  • f99509f fix(build): Allow clearing string arguments to build upload (#2946)
  • a2cef20 ref(build): Add client-side validation for SHA fields (#2945)
  • c550aa7 ref(build): Move VcsInfo beside other build upload API types (#2944)
  • f303fd4 ref(build): Use VcsInfo directly in ChunkedBuildRequest (#2943)
  • 63b187c meta(cargo): Remove authors from Cargo.toml (#2939)
  • 1ccff9d build(npm): 🤖 Bump optional dependencies to 2.58.1
  • 4362cf6 Merge branch 'release/2.58.1'
  • b25423a release: 2.58.1
  • 7595ba9 chore(js): Deprecate apiKey field (#2937)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@sentry/cli&package-manager=npm_and_yarn&previous-version=2.56.0&new-version=2.58.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/react-router/package.json | 2 +- packages/remix/package.json | 2 +- yarn.lock | 105 +++++++++++++++-------------- 3 files changed, 55 insertions(+), 54 deletions(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index d988e14aea8a..a65bd845bdab 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -50,7 +50,7 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/browser": "10.26.0", - "@sentry/cli": "^2.56.0", + "@sentry/cli": "^2.58.2", "@sentry/core": "10.26.0", "@sentry/node": "10.26.0", "@sentry/react": "10.26.0", diff --git a/packages/remix/package.json b/packages/remix/package.json index 667f77d53648..181d6e23a63f 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -68,7 +68,7 @@ "@opentelemetry/instrumentation": "^0.204.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", - "@sentry/cli": "^2.56.0", + "@sentry/cli": "^2.58.2", "@sentry/core": "10.26.0", "@sentry/node": "10.26.0", "@sentry/react": "10.26.0", diff --git a/yarn.lock b/yarn.lock index 93c288553b5b..e3f84550ba9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7103,50 +7103,50 @@ magic-string "0.30.8" unplugin "1.0.1" -"@sentry/cli-darwin@2.56.0": - version "2.56.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.56.0.tgz#53fa7de2c26f6450d5454ba997c26c2471d112c8" - integrity sha512-CzXFWbv3GrjU0gFlUM9jt0fvJmyo5ktty4HGxRFfS/eMC6xW58Gg/sEeMVEkdvk5osKooX/YEgfLBdo4zvuWDA== - -"@sentry/cli-linux-arm64@2.56.0": - version "2.56.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.56.0.tgz#5041c8877416a607ddae87b948cbe6c9e86d7f54" - integrity sha512-91d5ZlC989j/t+TXor/glPyx6SnLFS/SlJ9fIrHIQohdGKyWWSFb4VKUan8Ok3GYu9SUzKTMByryIOoYEmeGVw== - -"@sentry/cli-linux-arm@2.56.0": - version "2.56.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.56.0.tgz#c7875cf5f76e254ff1c0f49cf99d8c26b6ec4959" - integrity sha512-vQCCMhZLugPmr25XBoP94dpQsFa110qK5SBUVJcRpJKyzMZd+6ueeHNslq2mB0OF4BwL1qd/ZDIa4nxa1+0rjQ== - -"@sentry/cli-linux-i686@2.56.0": - version "2.56.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.56.0.tgz#aeaff32f9f0d405e413373223e406d66b1d56176" - integrity sha512-MZzXuq1Q/TktN81DUs6XSBU752pG3XWSJdZR+NCStIg3l8s3O/Pwh6OcDHTYqgwsYJaGBpA0fP2Afl5XeSAUNg== - -"@sentry/cli-linux-x64@2.56.0": - version "2.56.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.56.0.tgz#3dd4ef83c2d710c3e6f5d078d05391fda2ce23ee" - integrity sha512-INOO2OQ90Y3UzYgHRdrHdKC/0es3YSHLv0iNNgQwllL0YZihSVNYSSrZqcPq8oSDllEy9Vt9oOm/7qEnUP2Kfw== - -"@sentry/cli-win32-arm64@2.56.0": - version "2.56.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.56.0.tgz#2113bcac721970ca4dbd04a6dab37dfb0ec147d2" - integrity sha512-eUvkVk9KK01q6/qyugQPh7dAxqFPbgOa62QAoSwo11WQFYc3NPgJLilFWLQo+nahHGYKh6PKuCJ5tcqnQq5Hkg== - -"@sentry/cli-win32-i686@2.56.0": - version "2.56.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.56.0.tgz#bd8e646f4b5a98aa80bc9751a6e0db6514a935f5" - integrity sha512-mpCA8hKXuvT17bl1H/54KOa5i+02VBBHVlOiP3ltyBuQUqfvX/30Zl/86Spy+ikodovZWAHv5e5FpyXbY1/mPw== - -"@sentry/cli-win32-x64@2.56.0": - version "2.56.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.56.0.tgz#1acc7ca166ed531075a31b2bc1700294747da6b8" - integrity sha512-UV0pXNls+/ViAU/3XsHLLNEHCsRYaGEwJdY3HyGIufSlglxrX6BVApkV9ziGi4WAxcJWLjQdfcEs6V5B+wBy0A== - -"@sentry/cli@^2.51.0", "@sentry/cli@^2.56.0": - version "2.56.0" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.56.0.tgz#13dc043c78687b47285cc45db5bcfb65bbdb6dd9" - integrity sha512-br6+1nTPUV5EG1oaxLzxv31kREFKr49Y1+3jutfMUz9Nl8VyVP7o9YwakB/YWl+0Vi0NXg5vq7qsd/OOuV5j8w== +"@sentry/cli-darwin@2.58.2": + version "2.58.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.58.2.tgz#61f6f836de8ac2e1992ccadc0368bc403f23c609" + integrity sha512-MArsb3zLhA2/cbd4rTm09SmTpnEuZCoZOpuZYkrpDw1qzBVJmRFA1W1hGAQ9puzBIk/ubY3EUhhzuU3zN2uD6w== + +"@sentry/cli-linux-arm64@2.58.2": + version "2.58.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.2.tgz#3a7a9c83e31b482599ce08d93d5ba6c8a1a44c7f" + integrity sha512-ay3OeObnbbPrt45cjeUyQjsx5ain1laj1tRszWj37NkKu55NZSp4QCg1gGBZ0gBGhckI9nInEsmKtix00alw2g== + +"@sentry/cli-linux-arm@2.58.2": + version "2.58.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.2.tgz#f9bef6802cb707d1603a02e0727fed22d834e133" + integrity sha512-HU9lTCzcHqCz/7Mt5n+cv+nFuJdc1hGD2h35Uo92GgxX3/IujNvOUfF+nMX9j6BXH6hUt73R5c0Ycq9+a3Parg== + +"@sentry/cli-linux-i686@2.58.2": + version "2.58.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.2.tgz#a3e6cb24d314f2d948b96457731f9345dc8370f9" + integrity sha512-CN9p0nfDFsAT1tTGBbzOUGkIllwS3hygOUyTK7LIm9z+UHw5uNgNVqdM/3Vg+02ymjkjISNB3/+mqEM5osGXdA== + +"@sentry/cli-linux-x64@2.58.2": + version "2.58.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.2.tgz#8e071e11b03524b08d369075f3203b05529ca233" + integrity sha512-oX/LLfvWaJO50oBVOn4ZvG2SDWPq0MN8SV9eg5tt2nviq+Ryltfr7Rtoo+HfV+eyOlx1/ZXhq9Wm7OT3cQuz+A== + +"@sentry/cli-win32-arm64@2.58.2": + version "2.58.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.2.tgz#af109a165c25245458a6c58b79a91c639b1df1b0" + integrity sha512-+cl3x2HPVMpoSVGVM1IDWlAEREZrrVQj4xBb0TRKII7g3hUxRsAIcsrr7+tSkie++0FuH4go/b5fGAv51OEF3w== + +"@sentry/cli-win32-i686@2.58.2": + version "2.58.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.2.tgz#53038b43b2c14c419fb71586f7448e7580ed4e39" + integrity sha512-omFVr0FhzJ8oTJSg1Kf+gjLgzpYklY0XPfLxZ5iiMiYUKwF5uo1RJRdkUOiEAv0IqpUKnmKcmVCLaDxsWclB7Q== + +"@sentry/cli-win32-x64@2.58.2": + version "2.58.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.2.tgz#b4c81a3c163344ae8b27523a0391e7f99c533f41" + integrity sha512-2NAFs9UxVbRztQbgJSP5i8TB9eJQ7xraciwj/93djrSMHSEbJ0vC47TME0iifgvhlHMs5vqETOKJtfbbpQAQFA== + +"@sentry/cli@^2.51.0", "@sentry/cli@^2.58.2": + version "2.58.2" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.58.2.tgz#0d6e19a1771d27aae8b2765a6f3e96062e2c7502" + integrity sha512-U4u62V4vaTWF+o40Mih8aOpQKqKUbZQt9A3LorIJwaE3tO3XFLRI70eWtW2se1Qmy0RZ74zB14nYcFNFl2t4Rw== dependencies: https-proxy-agent "^5.0.0" node-fetch "^2.6.7" @@ -7154,14 +7154,14 @@ proxy-from-env "^1.1.0" which "^2.0.2" optionalDependencies: - "@sentry/cli-darwin" "2.56.0" - "@sentry/cli-linux-arm" "2.56.0" - "@sentry/cli-linux-arm64" "2.56.0" - "@sentry/cli-linux-i686" "2.56.0" - "@sentry/cli-linux-x64" "2.56.0" - "@sentry/cli-win32-arm64" "2.56.0" - "@sentry/cli-win32-i686" "2.56.0" - "@sentry/cli-win32-x64" "2.56.0" + "@sentry/cli-darwin" "2.58.2" + "@sentry/cli-linux-arm" "2.58.2" + "@sentry/cli-linux-arm64" "2.58.2" + "@sentry/cli-linux-i686" "2.58.2" + "@sentry/cli-linux-x64" "2.58.2" + "@sentry/cli-win32-arm64" "2.58.2" + "@sentry/cli-win32-i686" "2.58.2" + "@sentry/cli-win32-x64" "2.58.2" "@sentry/rollup-plugin@^4.3.0": version "4.3.0" @@ -28836,6 +28836,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From 1a6be80e573ebe27226eb92fb4125390155c9db4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 10:49:25 +0100 Subject: [PATCH 173/190] chore(angular): Add Angular 21 Support (#18274) Angular 21 was [released](https://www.npmjs.com/package/@angular/cli) silently yesterday. Migration docs don't seem to indicate any breaking change for us. --- .../angular-21/.editorconfig | 17 + .../test-applications/angular-21/.gitignore | 44 +++ .../test-applications/angular-21/.npmrc | 2 + .../test-applications/angular-21/README.md | 3 + .../test-applications/angular-21/angular.json | 87 +++++ .../test-applications/angular-21/package.json | 60 ++++ .../angular-21/playwright.config.mjs | 8 + .../angular-21/public/favicon.ico | Bin 0 -> 15086 bytes .../angular-21/src/app/app.component.ts | 12 + .../angular-21/src/app/app.config.ts | 29 ++ .../angular-21/src/app/app.routes.ts | 42 +++ .../angular-21/src/app/cancel-guard.guard.ts | 5 + .../src/app/cancel/cancel.components.ts | 8 + .../component-tracking.components.ts | 21 ++ .../angular-21/src/app/home/home.component.ts | 26 ++ .../sample-component.components.ts | 12 + .../angular-21/src/app/user/user.component.ts | 25 ++ .../angular-21/src/index.html | 13 + .../test-applications/angular-21/src/main.ts | 15 + .../angular-21/src/styles.css | 1 + .../angular-21/start-event-proxy.mjs | 6 + .../angular-21/tests/errors.test.ts | 65 ++++ .../angular-21/tests/performance.test.ts | 327 ++++++++++++++++++ .../angular-21/tsconfig.app.json | 11 + .../angular-21/tsconfig.json | 27 ++ .../angular-21/tsconfig.spec.json | 10 + packages/angular/package.json | 6 +- 27 files changed, 879 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/.editorconfig create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/README.md create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/angular.json create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/package.json create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/app/app.component.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/app/app.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/app/app.routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel-guard.guard.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel/cancel.components.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/app/component-tracking/component-tracking.components.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/app/home/home.component.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/app/sample-component/sample-component.components.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/app/user/user.component.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/index.html create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/src/styles.css create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/tsconfig.app.json create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/angular-21/tsconfig.spec.json diff --git a/dev-packages/e2e-tests/test-applications/angular-21/.editorconfig b/dev-packages/e2e-tests/test-applications/angular-21/.editorconfig new file mode 100644 index 000000000000..f166060da1cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/.editorconfig @@ -0,0 +1,17 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/dev-packages/e2e-tests/test-applications/angular-21/.gitignore b/dev-packages/e2e-tests/test-applications/angular-21/.gitignore new file mode 100644 index 000000000000..315c644a53e8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/.gitignore @@ -0,0 +1,44 @@ +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# Compiled output +/dist +/tmp +/out-tsc +/bazel-out + +# Node +/node_modules +npm-debug.log +yarn-error.log + +# IDEs and editors +.idea/ +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# Visual Studio Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history/* + +# Miscellaneous +/.angular/cache +.sass-cache/ +/connect.lock +/coverage +/libpeerconnection.log +testem.log +/typings + +# System files +.DS_Store +Thumbs.db + +test-results diff --git a/dev-packages/e2e-tests/test-applications/angular-21/.npmrc b/dev-packages/e2e-tests/test-applications/angular-21/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/angular-21/README.md b/dev-packages/e2e-tests/test-applications/angular-21/README.md new file mode 100644 index 000000000000..6d3a1ff489df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/README.md @@ -0,0 +1,3 @@ +# Angular 21 + +E2E test app for Angular 21 and `@sentry/angular`. diff --git a/dev-packages/e2e-tests/test-applications/angular-21/angular.json b/dev-packages/e2e-tests/test-applications/angular-21/angular.json new file mode 100644 index 000000000000..18bf58596766 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/angular.json @@ -0,0 +1,87 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "angular-21": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/angular-21", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": ["zone.js"], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": ["src/styles.css"], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "angular-21:build:production" + }, + "development": { + "buildTarget": "angular-21:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": ["zone.js", "zone.js/testing"], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": ["src/styles.css"], + "scripts": [] + } + } + } + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/package.json b/dev-packages/e2e-tests/test-applications/angular-21/package.json new file mode 100644 index 000000000000..315f7eea0492 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/package.json @@ -0,0 +1,60 @@ +{ + "name": "angular-21", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "dev": "ng serve", + "proxy": "node start-event-proxy.mjs", + "preview": "http-server dist/angular-21/browser --port 8080 --silent", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm install && pnpm add @angular/animations@next @angular/common@next @angular/compiler@next @angular/core@next @angular/forms@next @angular/platform-browser@next @angular/platform-browser-dynamic@next @angular/router@next && pnpm add -D @angular-devkit/build-angular@next @angular/cli@next @angular/compiler-cli@next && pnpm build", + "test:assert": "playwright test", + "clean": "npx rimraf .angular node_modules pnpm-lock.yaml dist" + }, + "private": true, + "dependencies": { + "@angular/animations": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/compiler": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/forms": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-browser-dynamic": "^21.0.0", + "@angular/router": "^21.0.0", + "@sentry/angular": "* || latest", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.15.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^21.0.0", + "@angular/cli": "^21.0.0", + "@angular/compiler-cli": "^21.0.0", + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "latest || *", + "@types/jasmine": "~5.1.0", + "http-server": "^14.1.1", + "jasmine-core": "~5.4.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.9.0" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optionalVariants": [ + { + "build-command": "pnpm test:build-canary", + "label": "angular (canary)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/angular-21/playwright.config.mjs new file mode 100644 index 000000000000..0845325879c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview`, + port: 8080, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/angular-21/public/favicon.ico b/dev-packages/e2e-tests/test-applications/angular-21/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..57614f9c967596fad0a3989bec2b1deff33034f6 GIT binary patch literal 15086 zcmd^G33O9Omi+`8$@{|M-I6TH3wzF-p5CV8o}7f~KxR60LK+ApEFB<$bcciv%@SmA zV{n>g85YMFFeU*Uvl=i4v)C*qgnb;$GQ=3XTe9{Y%c`mO%su)noNCCQ*@t1WXn|B(hQ7i~ zrUK8|pUkD6#lNo!bt$6)jR!&C?`P5G(`e((P($RaLeq+o0Vd~f11;qB05kdbAOm?r zXv~GYr_sibQO9NGTCdT;+G(!{4Xs@4fPak8#L8PjgJwcs-Mm#nR_Z0s&u?nDX5^~@ z+A6?}g0|=4e_LoE69pPFO`yCD@BCjgKpzMH0O4Xs{Ahc?K3HC5;l=f zg>}alhBXX&);z$E-wai+9TTRtBX-bWYY@cl$@YN#gMd~tM_5lj6W%8ah4;uZ;jP@Q zVbuel1rPA?2@x9Y+u?e`l{Z4ngfG5q5BLH5QsEu4GVpt{KIp1?U)=3+KQ;%7ec8l* zdV=zZgN5>O3G(3L2fqj3;oBbZZw$Ij@`Juz@?+yy#OPw)>#wsTewVgTK9BGt5AbZ&?K&B3GVF&yu?@(Xj3fR3n+ZP0%+wo)D9_xp>Z$`A4 zfV>}NWjO#3lqumR0`gvnffd9Ka}JJMuHS&|55-*mCD#8e^anA<+sFZVaJe7{=p*oX zE_Uv?1>e~ga=seYzh{9P+n5<+7&9}&(kwqSaz;1aD|YM3HBiy<))4~QJSIryyqp| z8nGc(8>3(_nEI4n)n7j(&d4idW1tVLjZ7QbNLXg;LB ziHsS5pXHEjGJZb59KcvS~wv;uZR-+4qEqow`;JCfB*+b^UL^3!?;-^F%yt=VjU|v z39SSqKcRu_NVvz!zJzL0CceJaS6%!(eMshPv_0U5G`~!a#I$qI5Ic(>IONej@aH=f z)($TAT#1I{iCS4f{D2+ApS=$3E7}5=+y(rA9mM#;Cky%b*Gi0KfFA`ofKTzu`AV-9 znW|y@19rrZ*!N2AvDi<_ZeR3O2R{#dh1#3-d%$k${Rx42h+i&GZo5!C^dSL34*AKp z27mTd>k>?V&X;Nl%GZ(>0s`1UN~Hfyj>KPjtnc|)xM@{H_B9rNr~LuH`Gr5_am&Ep zTjZA8hljNj5H1Ipm-uD9rC}U{-vR!eay5&6x6FkfupdpT*84MVwGpdd(}ib)zZ3Ky z7C$pnjc82(W_y_F{PhYj?o!@3__UUvpX)v69aBSzYj3 zdi}YQkKs^SyXyFG2LTRz9{(w}y~!`{EuAaUr6G1M{*%c+kP1olW9z23dSH!G4_HSK zzae-DF$OGR{ofP*!$a(r^5Go>I3SObVI6FLY)N@o<*gl0&kLo-OT{Tl*7nCz>Iq=? zcigIDHtj|H;6sR?or8Wd_a4996GI*CXGU}o;D9`^FM!AT1pBY~?|4h^61BY#_yIfO zKO?E0 zJ{Pc`9rVEI&$xxXu`<5E)&+m(7zX^v0rqofLs&bnQT(1baQkAr^kEsk)15vlzAZ-l z@OO9RF<+IiJ*O@HE256gCt!bF=NM*vh|WVWmjVawcNoksRTMvR03H{p@cjwKh(CL4 z7_PB(dM=kO)!s4fW!1p0f93YN@?ZSG` z$B!JaAJCtW$B97}HNO9(x-t30&E}Mo1UPi@Av%uHj~?T|!4JLwV;KCx8xO#b9IlUW zI6+{a@Wj|<2Y=U;a@vXbxqZNngH8^}LleE_4*0&O7#3iGxfJ%Id>+sb;7{L=aIic8 z|EW|{{S)J-wr@;3PmlxRXU8!e2gm_%s|ReH!reFcY8%$Hl4M5>;6^UDUUae?kOy#h zk~6Ee_@ZAn48Bab__^bNmQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWkGNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!EzjeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1dt)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1g~2B{%N-!mWz<`)G)>V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm<`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^Tw$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7*Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9{XjwBmqAiOxOL` zt?XK-iTEOWV}f>Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.component.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.component.ts new file mode 100644 index 000000000000..90cd343e9449 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + template: ``, +}) +export class AppComponent { + title = 'angular-21'; +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.config.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.config.ts new file mode 100644 index 000000000000..f5cc30f3615b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.config.ts @@ -0,0 +1,29 @@ +import { + ApplicationConfig, + ErrorHandler, + inject, + provideAppInitializer, + provideZoneChangeDetection, +} from '@angular/core'; +import { Router, provideRouter } from '@angular/router'; + +import { TraceService, createErrorHandler } from '@sentry/angular'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + { + provide: ErrorHandler, + useValue: createErrorHandler(), + }, + { + provide: TraceService, + deps: [Router], + }, + provideAppInitializer(() => { + inject(TraceService); + }), + ], +}; diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.routes.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.routes.ts new file mode 100644 index 000000000000..24bf8b769051 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/app.routes.ts @@ -0,0 +1,42 @@ +import { Routes } from '@angular/router'; +import { cancelGuard } from './cancel-guard.guard'; +import { CancelComponent } from './cancel/cancel.components'; +import { ComponentTrackingComponent } from './component-tracking/component-tracking.components'; +import { HomeComponent } from './home/home.component'; +import { UserComponent } from './user/user.component'; + +export const routes: Routes = [ + { + path: 'users/:id', + component: UserComponent, + }, + { + path: 'home', + component: HomeComponent, + }, + { + path: 'cancel', + component: CancelComponent, + canActivate: [cancelGuard], + }, + { + path: 'component-tracking', + component: ComponentTrackingComponent, + }, + { + path: 'redirect1', + redirectTo: '/redirect2', + }, + { + path: 'redirect2', + redirectTo: '/redirect3', + }, + { + path: 'redirect3', + redirectTo: '/users/456', + }, + { + path: '**', + redirectTo: 'home', + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel-guard.guard.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel-guard.guard.ts new file mode 100644 index 000000000000..16ec4a2ab164 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel-guard.guard.ts @@ -0,0 +1,5 @@ +import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router'; + +export const cancelGuard: CanActivateFn = (_next: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return false; +}; diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel/cancel.components.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel/cancel.components.ts new file mode 100644 index 000000000000..b6ee1876e035 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/cancel/cancel.components.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-cancel', + standalone: true, + template: `
`, +}) +export class CancelComponent {} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/component-tracking/component-tracking.components.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/component-tracking/component-tracking.components.ts new file mode 100644 index 000000000000..76bd580ecaf6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/component-tracking/component-tracking.components.ts @@ -0,0 +1,21 @@ +import { AfterViewInit, Component, OnInit } from '@angular/core'; +import { TraceClass, TraceMethod, TraceModule } from '@sentry/angular'; +import { SampleComponent } from '../sample-component/sample-component.components'; + +@Component({ + selector: 'app-component-tracking', + standalone: true, + imports: [TraceModule, SampleComponent], + template: ` + + + `, +}) +@TraceClass({ name: 'ComponentTrackingComponent' }) +export class ComponentTrackingComponent implements OnInit, AfterViewInit { + @TraceMethod({ name: 'ngOnInit' }) + ngOnInit() {} + + @TraceMethod() + ngAfterViewInit() {} +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/home/home.component.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/home/home.component.ts new file mode 100644 index 000000000000..78b914602eb9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/home/home.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-home', + standalone: true, + imports: [RouterLink], + template: ` +
+

Welcome to Sentry's Angular 21 E2E test app

+ + +
+ `, +}) +export class HomeComponent { + throwError() { + throw new Error('Error thrown from Angular 21 E2E test app'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/sample-component/sample-component.components.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/sample-component/sample-component.components.ts new file mode 100644 index 000000000000..da09425c7565 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/sample-component/sample-component.components.ts @@ -0,0 +1,12 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-sample-component', + standalone: true, + template: `
Component
`, +}) +export class SampleComponent implements OnInit { + ngOnInit() { + console.log('SampleComponent'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/app/user/user.component.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/app/user/user.component.ts new file mode 100644 index 000000000000..db02568d395f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/app/user/user.component.ts @@ -0,0 +1,25 @@ +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, map } from 'rxjs'; + +@Component({ + selector: 'app-user', + standalone: true, + imports: [AsyncPipe], + template: ` +

Hello User {{ userId$ | async }}

+ + `, +}) +export class UserComponent { + public userId$: Observable; + + constructor(private route: ActivatedRoute) { + this.userId$ = this.route.paramMap.pipe(map(params => params.get('id') || 'UNKNOWN USER')); + } + + throwError() { + throw new Error('Error thrown from user page'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/index.html b/dev-packages/e2e-tests/test-applications/angular-21/src/index.html new file mode 100644 index 000000000000..ffc9a3f96de6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/index.html @@ -0,0 +1,13 @@ + + + + + Angular 21 + + + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/main.ts b/dev-packages/e2e-tests/test-applications/angular-21/src/main.ts new file mode 100644 index 000000000000..a0b841afc333 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/main.ts @@ -0,0 +1,15 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +import * as Sentry from '@sentry/angular'; + +Sentry.init({ + // Cannot use process.env here, so we hardcode the DSN + dsn: 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + tracesSampleRate: 1.0, + integrations: [Sentry.browserTracingIntegration({})], + tunnel: `http://localhost:3031/`, // proxy server +}); + +bootstrapApplication(AppComponent, appConfig).catch(err => console.error(err)); diff --git a/dev-packages/e2e-tests/test-applications/angular-21/src/styles.css b/dev-packages/e2e-tests/test-applications/angular-21/src/styles.css new file mode 100644 index 000000000000..90d4ee0072ce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/dev-packages/e2e-tests/test-applications/angular-21/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/angular-21/start-event-proxy.mjs new file mode 100644 index 000000000000..2ea1a8ef918c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'angular-21', +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/angular-21/tests/errors.test.ts new file mode 100644 index 000000000000..f4f219373104 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tests/errors.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends an error', async ({ page }) => { + const errorPromise = waitForError('angular-21', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + + await page.locator('#errorBtn').click(); + + const error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Angular 21 E2E test app', + mechanism: { + type: 'auto.function.angular.error_handler', + handled: false, + }, + }, + ], + }, + transaction: '/home/', + }); +}); + +test('assigns the correct transaction value after a navigation', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorPromise = waitForError('angular-21', async errorEvent => { + return !errorEvent.type; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + await page.locator('#navLink').click(); + + const [_, error] = await Promise.all([page.locator('#userErrorBtn').click(), errorPromise]); + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from user page', + mechanism: { + type: 'auto.function.angular.error_handler', + handled: false, + }, + }, + ], + }, + transaction: '/users/:id/', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts new file mode 100644 index 000000000000..cee1f939c4c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tests/performance.test.ts @@ -0,0 +1,327 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +// Cannot use @sentry/angular here due to build stuff +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + const [_, navigationTxn] = await Promise.all([page.locator('#navLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction even if the pageload span is still active', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, pageloadTxn, navigationTxn] = await Promise.all([ + page.locator('#navLink').click(), + pageloadTxnPromise, + navigationTxnPromise, + ]); + + expect(pageloadTxn).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.angular', + }, + }, + transaction: '/home/', + transaction_info: { + source: 'route', + }, + }); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('groups redirects within one navigation root span', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#redirectLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/users/:id/', + transaction_info: { + source: 'route', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe('/redirect1'); +}); + +test.describe('finish routing span', () => { + test('finishes routing span on navigation cancel', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#cancelLink').click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: '/cancel', + transaction_info: { + source: 'url', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe('/cancel'); + }); + + test('finishes routing span on navigation error', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#nonExistentLink').click(), navigationTxnPromise]); + + const nonExistentRoute = '/non-existent'; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.angular', + }, + }, + transaction: nonExistentRoute, + transaction_info: { + source: 'url', + }, + }); + + const routingSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.routing'); + + expect(routingSpan).toBeDefined(); + expect(routingSpan?.description).toBe(nonExistentRoute); + }); +}); + +test.describe('TraceDirective', () => { + test('creates a child span with the component name as span name on ngOnInit', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const traceDirectiveSpans = navigationTxn.spans?.filter( + span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_directive', + ); + + expect(traceDirectiveSpans).toHaveLength(2); + expect(traceDirectiveSpans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + }, + description: '', // custom component name passed to trace directive + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_directive', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_directive', + }, + description: '', // fallback selector name + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_directive', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ]), + ); + }); +}); + +test.describe('TraceClass Decorator', () => { + test('adds init span for decorated class', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const classDecoratorSpan = navigationTxn.spans?.find( + span => span?.data && span?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.ui.angular.trace_class_decorator', + ); + + expect(classDecoratorSpan).toBeDefined(); + expect(classDecoratorSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.init', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_class_decorator', + }, + description: '', + op: 'ui.angular.init', + origin: 'auto.ui.angular.trace_class_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); + +test.describe('TraceMethod Decorator', () => { + test('adds name to span description of decorated method `ngOnInit`', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const ngInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngOnInit'); + + expect(ngInitSpan).toBeDefined(); + expect(ngInitSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngOnInit', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator', + }, + description: '', + op: 'ui.angular.ngOnInit', + origin: 'auto.ui.angular.trace_method_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); + + test('adds fallback name to span description of decorated method `ngAfterViewInit`', async ({ page }) => { + const navigationTxnPromise = waitForTransaction('angular-21', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + + // immediately navigate to a different route + const [_, navigationTxn] = await Promise.all([page.locator('#componentTracking').click(), navigationTxnPromise]); + + const ngAfterViewInitSpan = navigationTxn.spans?.find(span => span.op === 'ui.angular.ngAfterViewInit'); + + expect(ngAfterViewInitSpan).toBeDefined(); + expect(ngAfterViewInitSpan).toEqual( + expect.objectContaining({ + data: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.angular.ngAfterViewInit', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.angular.trace_method_decorator', + }, + description: '', + op: 'ui.angular.ngAfterViewInit', + origin: 'auto.ui.angular.trace_method_decorator', + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + }), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.app.json new file mode 100644 index 000000000000..8886e903f8d0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.app.json @@ -0,0 +1,11 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": ["src/main.ts"], + "include": ["src/**/*.d.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.json b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.json new file mode 100644 index 000000000000..5525117c6744 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.json @@ -0,0 +1,27 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.spec.json b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.spec.json new file mode 100644 index 000000000000..e00e30e6d4fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/angular-21/tsconfig.spec.json @@ -0,0 +1,10 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": ["jasmine"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/packages/angular/package.json b/packages/angular/package.json index 01912cb13f79..fd378f4af2d8 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -15,9 +15,9 @@ "access": "public" }, "peerDependencies": { - "@angular/common": ">= 14.x <= 20.x", - "@angular/core": ">= 14.x <= 20.x", - "@angular/router": ">= 14.x <= 20.x", + "@angular/common": ">= 14.x <= 21.x", + "@angular/core": ">= 14.x <= 21.x", + "@angular/router": ">= 14.x <= 21.x", "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { From 108b027446cfb166a7dca04e69ec9207c2bf2406 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 12:57:05 +0100 Subject: [PATCH 174/190] feat(deps): bump @sentry/bundler-plugin-core from 4.3.0 to 4.6.1 (#18273) --- packages/nextjs/package.json | 2 +- yarn.lock | 31 +++++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 15b6a2bf3040..9afdfec16d7b 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -80,7 +80,7 @@ "@opentelemetry/semantic-conventions": "^1.37.0", "@rollup/plugin-commonjs": "28.0.1", "@sentry-internal/browser-utils": "10.26.0", - "@sentry/bundler-plugin-core": "^4.3.0", + "@sentry/bundler-plugin-core": "^4.6.1", "@sentry/core": "10.26.0", "@sentry/node": "10.26.0", "@sentry/opentelemetry": "10.26.0", diff --git a/yarn.lock b/yarn.lock index e3f84550ba9e..8687df6cfa53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7089,7 +7089,12 @@ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80" integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw== -"@sentry/bundler-plugin-core@4.3.0", "@sentry/bundler-plugin-core@^4.3.0": +"@sentry/babel-plugin-component-annotate@4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.6.1.tgz#94eec0293be8289daa574e18783e64d29203c236" + integrity sha512-aSIk0vgBqv7PhX6/Eov+vlI4puCE0bRXzUG5HdCsHBpAfeMkI8Hva6kSOusnzKqs8bf04hU7s3Sf0XxGTj/1AA== + +"@sentry/bundler-plugin-core@4.3.0": version "4.3.0" resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.3.0.tgz#cf302522a3e5b8a3bf727635d0c6a7bece981460" integrity sha512-dmR4DJhJ4jqVWGWppuTL2blNFqOZZnt4aLkewbD1myFG3KVfUx8CrMQWEmGjkgPOtj5TO6xH9PyTJjXC6o5tnA== @@ -7103,6 +7108,20 @@ magic-string "0.30.8" unplugin "1.0.1" +"@sentry/bundler-plugin-core@^4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.6.1.tgz#d6013e6233bf663114f581bbd3c3a380ff9311d4" + integrity sha512-WPeRbnMXm927m4Kr69NTArPfI+p5/34FHftdCRI3LFPMyhZDzz6J3wLy4hzaVUgmMf10eLzmq2HGEMvpQmdynA== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/babel-plugin-component-annotate" "4.6.1" + "@sentry/cli" "^2.57.0" + dotenv "^16.3.1" + find-up "^5.0.0" + glob "^10.5.0" + magic-string "0.30.8" + unplugin "1.0.1" + "@sentry/cli-darwin@2.58.2": version "2.58.2" resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.58.2.tgz#61f6f836de8ac2e1992ccadc0368bc403f23c609" @@ -7143,7 +7162,7 @@ resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.2.tgz#b4c81a3c163344ae8b27523a0391e7f99c533f41" integrity sha512-2NAFs9UxVbRztQbgJSP5i8TB9eJQ7xraciwj/93djrSMHSEbJ0vC47TME0iifgvhlHMs5vqETOKJtfbbpQAQFA== -"@sentry/cli@^2.51.0", "@sentry/cli@^2.58.2": +"@sentry/cli@^2.51.0", "@sentry/cli@^2.57.0", "@sentry/cli@^2.58.2": version "2.58.2" resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.58.2.tgz#0d6e19a1771d27aae8b2765a6f3e96062e2c7502" integrity sha512-U4u62V4vaTWF+o40Mih8aOpQKqKUbZQt9A3LorIJwaE3tO3XFLRI70eWtW2se1Qmy0RZ74zB14nYcFNFl2t4Rw== @@ -17942,10 +17961,10 @@ glob@8.0.3: minimatch "^5.0.1" once "^1.3.0" -glob@^10.0.0, glob@^10.2.2, glob@^10.3.10, glob@^10.3.4, glob@^10.3.7, glob@^10.4.1, glob@^10.4.5: - version "10.4.5" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" - integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== +glob@^10.0.0, glob@^10.2.2, glob@^10.3.10, glob@^10.3.4, glob@^10.3.7, glob@^10.4.1, glob@^10.4.5, glob@^10.5.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== dependencies: foreground-child "^3.1.0" jackspeak "^3.1.2" From edc1f09dc7f8e6a67647f8c91aa00169319be4fb Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 21 Nov 2025 14:31:26 +0100 Subject: [PATCH 175/190] test(e2e): Fix astro config in test app (#18282) https://5-0-0-beta.docs.astro.build/en/guides/upgrade-to/v5/#removed-hybrid-rendering-mode the test app was bumped to v5 from dependabot in https://github.com/getsentry/sentry-javascript/pull/18259 --------- Co-authored-by: Andrei Borza --- .../test-applications/cloudflare-astro/astro.config.mjs | 1 - .../e2e-tests/test-applications/cloudflare-astro/package.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-astro/astro.config.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-astro/astro.config.mjs index 36414cf24b7c..026e6e4dac7c 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-astro/astro.config.mjs +++ b/dev-packages/e2e-tests/test-applications/cloudflare-astro/astro.config.mjs @@ -6,7 +6,6 @@ const dsn = process.env.E2E_TEST_DSN; // https://astro.build/config export default defineConfig({ - output: 'hybrid', adapter: cloudflare({ imageService: 'passthrough', }), diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json index 4db15edabbd7..776cf271e86e 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-astro/package.json @@ -17,7 +17,7 @@ "test:assert": "pnpm -v" }, "dependencies": { - "@astrojs/cloudflare": "8.1.0", + "@astrojs/cloudflare": "12.6.11", "@sentry/astro": "latest || *", "astro": "5.15.9" }, From 3375de056e152cd58345a5b87b77a8a1068957bd Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 21 Nov 2025 15:42:21 +0100 Subject: [PATCH 176/190] feat(core): Add scope attribute APIs (#18165) This PR adds `scope.setAttribute`, `scope.setAttributes` and `scope.removeAttribute` methods, as specified in our [develop docs](https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes). This intial PR only enables setting the attributes (including attributes with units) as well as the usual scope data operations (clone(), update(), clear(), getSpanData()). These attributes are not yet applied to any of the telemetry we eventually want them to apply to. I'll take care of this in a follow-up PR. closes https://github.com/getsentry/sentry-javascript/issues/18140 ref https://linear.app/getsentry/project/implement-global-attributes-api-javascript-02c3c74184fc/issues --------- Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com> --- .size-limit.js | 14 +- packages/core/src/attributes.ts | 141 +++++++++++ packages/core/src/scope.ts | 96 +++++++- packages/core/test/lib/attributes.test.ts | 286 ++++++++++++++++++++++ packages/core/test/lib/scope.test.ts | 167 ++++++++++++- 5 files changed, 695 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/attributes.ts create mode 100644 packages/core/test/lib/attributes.test.ts diff --git a/.size-limit.js b/.size-limit.js index 100444907e06..6e6ee0f68303 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -38,7 +38,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '41.5 KB', + limit: '42 KB', }, { name: '@sentry/browser (incl. Tracing, Profiling)', @@ -127,7 +127,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '43.5 KB', + limit: '44 KB', }, // Vue SDK (ESM) { @@ -142,7 +142,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '43.3 KB', + limit: '44 KB', }, // Svelte SDK (ESM) { @@ -163,7 +163,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '42.1 KB', + limit: '42.5 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -183,14 +183,14 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '80 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing) - uncompressed', path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '125 KB', + limit: '127 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', @@ -231,7 +231,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '51.1 KB', + limit: '52 KB', }, // Node SDK (ESM) { diff --git a/packages/core/src/attributes.ts b/packages/core/src/attributes.ts new file mode 100644 index 000000000000..d979d5c4350f --- /dev/null +++ b/packages/core/src/attributes.ts @@ -0,0 +1,141 @@ +import { DEBUG_BUILD } from './debug-build'; +import type { DurationUnit, FractionUnit, InformationUnit } from './types-hoist/measurement'; +import { debug } from './utils/debug-logger'; + +export type RawAttributes = T & ValidatedAttributes; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RawAttribute = T extends { value: any } | { unit: any } ? AttributeObject : T; + +export type Attributes = Record; + +export type AttributeValueType = string | number | boolean | Array | Array | Array; + +type AttributeTypeMap = { + string: string; + integer: number; + double: number; + boolean: boolean; + 'string[]': Array; + 'integer[]': Array; + 'double[]': Array; + 'boolean[]': Array; +}; + +/* Generates a type from the AttributeTypeMap like: + | { value: string; type: 'string' } + | { value: number; type: 'integer' } + | { value: number; type: 'double' } + */ +type AttributeUnion = { + [K in keyof AttributeTypeMap]: { + value: AttributeTypeMap[K]; + type: K; + }; +}[keyof AttributeTypeMap]; + +export type TypedAttributeValue = AttributeUnion & { unit?: AttributeUnit }; + +export type AttributeObject = { + value: unknown; + unit?: AttributeUnit; +}; + +// Unfortunately, we loose type safety if we did something like Exclude +// so therefore we unionize between the three supported unit categories. +type AttributeUnit = DurationUnit | InformationUnit | FractionUnit; + +/* If an attribute has either a 'value' or 'unit' property, we use the ValidAttributeObject type. */ +export type ValidatedAttributes = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [K in keyof T]: T[K] extends { value: any } | { unit: any } ? AttributeObject : unknown; +}; + +/** + * Type-guard: The attribute object has the shape the official attribute object (value, type, unit). + * https://develop.sentry.dev/sdk/telemetry/scopes/#setting-attributes + */ +export function isAttributeObject(maybeObj: unknown): maybeObj is AttributeObject { + return ( + typeof maybeObj === 'object' && + maybeObj != null && + !Array.isArray(maybeObj) && + Object.keys(maybeObj).includes('value') + ); +} + +/** + * Converts an attribute value to a typed attribute value. + * + * Does not allow mixed arrays. In case of a mixed array, the value is stringified and the type is 'string'. + * All values besides the supported attribute types (see {@link AttributeTypeMap}) are stringified to a string attribute value. + * + * @param value - The value of the passed attribute. + * @returns The typed attribute. + */ +export function attributeValueToTypedAttributeValue(rawValue: unknown): TypedAttributeValue { + const { value, unit } = isAttributeObject(rawValue) ? rawValue : { value: rawValue, unit: undefined }; + return { ...getTypedAttributeValue(value), ...(unit && typeof unit === 'string' ? { unit } : {}) }; +} + +// Only allow string, boolean, or number types +const getPrimitiveType: ( + item: unknown, +) => keyof Pick | null = item => + typeof item === 'string' + ? 'string' + : typeof item === 'boolean' + ? 'boolean' + : typeof item === 'number' && !Number.isNaN(item) + ? Number.isInteger(item) + ? 'integer' + : 'double' + : null; + +function getTypedAttributeValue(value: unknown): TypedAttributeValue { + const primitiveType = getPrimitiveType(value); + if (primitiveType) { + // @ts-expect-error - TS complains because {@link TypedAttributeValue} is strictly typed to + // avoid setting the wrong `type` on the attribute value. + // In this case, getPrimitiveType already does the check but TS doesn't know that. + // The "clean" alternative is to return an object per `typeof value` case + // but that would require more bundle size + // Therefore, we ignore it. + return { value, type: primitiveType }; + } + + if (Array.isArray(value)) { + const coherentArrayType = value.reduce((acc: 'string' | 'boolean' | 'integer' | 'double' | null, item) => { + if (!acc || getPrimitiveType(item) !== acc) { + return null; + } + return acc; + }, getPrimitiveType(value[0])); + + if (coherentArrayType) { + return { value, type: `${coherentArrayType}[]` }; + } + } + + // Fallback: stringify the passed value + let fallbackValue = ''; + try { + fallbackValue = JSON.stringify(value) ?? String(value); + } catch { + try { + fallbackValue = String(value); + } catch { + DEBUG_BUILD && debug.warn('Failed to stringify attribute value', value); + // ignore + } + } + + // This is quite a low-quality message but we cannot safely log the original `value` + // here due to String() or JSON.stringify() potentially throwing. + DEBUG_BUILD && + debug.log(`Stringified attribute value to ${fallbackValue} because it's not a supported attribute value type`); + + return { + value: fallbackValue, + type: 'string', + }; +} diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index b23b01664431..2ec1f6480788 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import type { AttributeObject, RawAttribute, RawAttributes } from './attributes'; import type { Client } from './client'; import { DEBUG_BUILD } from './debug-build'; import { updateSession } from './session'; @@ -46,6 +47,7 @@ export interface ScopeContext { extra: Extras; contexts: Contexts; tags: { [key: string]: Primitive }; + attributes?: RawAttributes>; fingerprint: string[]; propagationContext: PropagationContext; } @@ -71,6 +73,8 @@ export interface ScopeData { breadcrumbs: Breadcrumb[]; user: User; tags: { [key: string]: Primitive }; + // TODO(v11): Make this a required field (could be subtly breaking if we did it today) + attributes?: RawAttributes>; extra: Extras; contexts: Contexts; attachments: Attachment[]; @@ -104,6 +108,9 @@ export class Scope { /** Tags */ protected _tags: { [key: string]: Primitive }; + /** Attributes */ + protected _attributes: RawAttributes>; + /** Extra */ protected _extra: Extras; @@ -155,6 +162,7 @@ export class Scope { this._attachments = []; this._user = {}; this._tags = {}; + this._attributes = {}; this._extra = {}; this._contexts = {}; this._sdkProcessingMetadata = {}; @@ -171,6 +179,7 @@ export class Scope { const newScope = new Scope(); newScope._breadcrumbs = [...this._breadcrumbs]; newScope._tags = { ...this._tags }; + newScope._attributes = { ...this._attributes }; newScope._extra = { ...this._extra }; newScope._contexts = { ...this._contexts }; if (this._contexts.flags) { @@ -294,6 +303,79 @@ export class Scope { return this.setTags({ [key]: value }); } + /** + * Sets attributes onto the scope. + * + * TODO: + * Currently, these attributes are not applied to any telemetry data but they will be in the future. + * + * @param newAttributes - The attributes to set on the scope. You can either pass in key-value pairs, or + * an object with a `value` and an optional `unit` (if applicable to your attribute). + * + * @example + * ```typescript + * scope.setAttributes({ + * is_admin: true, + * payment_selection: 'credit_card', + * clicked_products: [130, 554, 292], + * render_duration: { value: 'render_duration', unit: 'ms' }, + * }); + * ``` + */ + public setAttributes>(newAttributes: RawAttributes): this { + this._attributes = { + ...this._attributes, + ...newAttributes, + }; + + this._notifyScopeListeners(); + return this; + } + + /** + * Sets an attribute onto the scope. + * + * TODO: + * Currently, these attributes are not applied to any telemetry data but they will be in the future. + * + * @param key - The attribute key. + * @param value - the attribute value. You can either pass in a raw value, or an attribute + * object with a `value` and an optional `unit` (if applicable to your attribute). + * + * @example + * ```typescript + * scope.setAttribute('is_admin', true); + * scope.setAttribute('clicked_products', [130, 554, 292]); + * scope.setAttribute('render_duration', { value: 'render_duration', unit: 'ms' }); + * ``` + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public setAttribute extends { value: any } | { unit: any } ? AttributeObject : unknown>( + key: string, + value: RawAttribute, + ): this { + return this.setAttributes({ [key]: value }); + } + + /** + * Removes the attribute with the given key from the scope. + * + * @param key - The attribute key. + * + * @example + * ```typescript + * scope.removeAttribute('is_admin'); + * ``` + */ + public removeAttribute(key: string): this { + if (key in this._attributes) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete this._attributes[key]; + this._notifyScopeListeners(); + } + return this; + } + /** * Set an object that will be merged into existing extra on the scope, * and will be sent as extra data with the event. @@ -409,9 +491,19 @@ export class Scope { ? (captureContext as ScopeContext) : undefined; - const { tags, extra, user, contexts, level, fingerprint = [], propagationContext } = scopeInstance || {}; + const { + tags, + attributes, + extra, + user, + contexts, + level, + fingerprint = [], + propagationContext, + } = scopeInstance || {}; this._tags = { ...this._tags, ...tags }; + this._attributes = { ...this._attributes, ...attributes }; this._extra = { ...this._extra, ...extra }; this._contexts = { ...this._contexts, ...contexts }; @@ -442,6 +534,7 @@ export class Scope { // client is not cleared here on purpose! this._breadcrumbs = []; this._tags = {}; + this._attributes = {}; this._extra = {}; this._user = {}; this._contexts = {}; @@ -528,6 +621,7 @@ export class Scope { attachments: this._attachments, contexts: this._contexts, tags: this._tags, + attributes: this._attributes, extra: this._extra, user: this._user, level: this._level, diff --git a/packages/core/test/lib/attributes.test.ts b/packages/core/test/lib/attributes.test.ts new file mode 100644 index 000000000000..99aa20d07c85 --- /dev/null +++ b/packages/core/test/lib/attributes.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it } from 'vitest'; +import { attributeValueToTypedAttributeValue, isAttributeObject } from '../../src/attributes'; + +describe('attributeValueToTypedAttributeValue', () => { + describe('primitive values', () => { + it('converts a string value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue('test'); + expect(result).toStrictEqual({ + value: 'test', + type: 'string', + }); + }); + + it('converts an interger number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42); + expect(result).toStrictEqual({ + value: 42, + type: 'integer', + }); + }); + + it('converts a double number value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(42.34); + expect(result).toStrictEqual({ + value: 42.34, + type: 'double', + }); + }); + + it('converts a boolean value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(true); + expect(result).toStrictEqual({ + value: true, + type: 'boolean', + }); + }); + }); + + describe('arrays', () => { + it('converts an array of strings to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue(['foo', 'bar']); + expect(result).toStrictEqual({ + value: ['foo', 'bar'], + type: 'string[]', + }); + }); + + it('converts an array of integer numbers to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 2, 3]); + expect(result).toStrictEqual({ + value: [1, 2, 3], + type: 'integer[]', + }); + }); + + it('converts an array of double numbers to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue([1.1, 2.2, 3.3]); + expect(result).toStrictEqual({ + value: [1.1, 2.2, 3.3], + type: 'double[]', + }); + }); + + it('converts an array of booleans to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue([true, false, true]); + expect(result).toStrictEqual({ + value: [true, false, true], + type: 'boolean[]', + }); + }); + }); + + describe('attribute objects without units', () => { + // Note: These tests only test exemplar type and fallback behaviour (see above for more cases) + it('converts a primitive value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45 }); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + }); + }); + + it('converts an array of primitive values to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: [true, false] }); + expect(result).toStrictEqual({ + value: [true, false], + type: 'boolean[]', + }); + }); + + it('converts an unsupported object value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: { foo: 'bar' } }); + expect(result).toStrictEqual({ + value: '{"foo":"bar"}', + type: 'string', + }); + }); + }); + + describe('attribute objects with units', () => { + // Note: These tests only test exemplar type and fallback behaviour (see above for more cases) + it('converts a primitive value to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: 123.45, unit: 'ms' }); + expect(result).toStrictEqual({ + value: 123.45, + type: 'double', + unit: 'ms', + }); + }); + + it('converts an array of primitive values to a typed attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: [true, false], unit: 'count' }); + expect(result).toStrictEqual({ + value: [true, false], + type: 'boolean[]', + unit: 'count', + }); + }); + + it('converts an unsupported object value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue({ value: { foo: 'bar' }, unit: 'bytes' }); + expect(result).toStrictEqual({ + value: '{"foo":"bar"}', + type: 'string', + unit: 'bytes', + }); + }); + + it('extracts the value property of an object with a value property', () => { + // and ignores other properties. + // For now we're fine with this but we may reconsider in the future. + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit: 'ms', bar: 'baz' }); + expect(result).toStrictEqual({ + value: 'foo', + unit: 'ms', + type: 'string', + }); + }); + }); + + describe('unsupported value types', () => { + it('stringifies mixed float and integer numbers to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 2.2, 3]); + expect(result).toStrictEqual({ + value: '[1,2.2,3]', + type: 'string', + }); + }); + + it('stringifies an array of allowed but incoherent types to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([1, 'foo', true]); + expect(result).toStrictEqual({ + value: '[1,"foo",true]', + type: 'string', + }); + }); + + it('stringifies an array of disallowed and incoherent types to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue([null, undefined, NaN]); + expect(result).toStrictEqual({ + value: '[null,null,null]', + type: 'string', + }); + }); + + it('stringifies an object value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue({ foo: 'bar' }); + expect(result).toStrictEqual({ + value: '{"foo":"bar"}', + type: 'string', + }); + }); + + it('stringifies a null value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(null); + expect(result).toStrictEqual({ + value: 'null', + type: 'string', + }); + }); + + it('stringifies an undefined value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(undefined); + expect(result).toStrictEqual({ + value: 'undefined', + type: 'string', + }); + }); + + it('stringifies an NaN number value to a string attribute value', () => { + const result = attributeValueToTypedAttributeValue(NaN); + expect(result).toStrictEqual({ + value: 'null', + type: 'string', + }); + }); + + it('converts an object toString if stringification fails', () => { + const result = attributeValueToTypedAttributeValue({ + value: { + toJson: () => { + throw new Error('test'); + }, + }, + }); + expect(result).toStrictEqual({ + value: '{}', + type: 'string', + }); + }); + + it('falls back to an empty string if stringification and toString fails', () => { + const result = attributeValueToTypedAttributeValue({ + value: { + toJSON: () => { + throw new Error('test'); + }, + toString: () => { + throw new Error('test'); + }, + }, + }); + expect(result).toStrictEqual({ + value: '', + type: 'string', + }); + }); + + it('converts a function toString ', () => { + const result = attributeValueToTypedAttributeValue(() => { + return 'test'; + }); + + expect(result).toStrictEqual({ + value: '() => {\n return "test";\n }', + type: 'string', + }); + }); + + it('converts a symbol toString', () => { + const result = attributeValueToTypedAttributeValue(Symbol('test')); + expect(result).toStrictEqual({ + value: 'Symbol(test)', + type: 'string', + }); + }); + }); + + it.each([1, true, null, undefined, NaN, Symbol('test'), { foo: 'bar' }])( + 'ignores invalid (non-string) units (%s)', + unit => { + const result = attributeValueToTypedAttributeValue({ value: 'foo', unit }); + expect(result).toStrictEqual({ + value: 'foo', + type: 'string', + }); + }, + ); +}); + +describe('isAttributeObject', () => { + it.each([ + { value: 123.45, unit: 'ms' }, + { value: [true, false], unit: 'count' }, + { value: { foo: 'bar' }, unit: 'bytes' }, + { value: { value: 123.45, unit: 'ms' }, unit: 'ms' }, + { value: 1 }, + ])('returns true for a valid attribute object (%s)', obj => { + const result = isAttributeObject(obj); + expect(result).toBe(true); + }); + + it('returns true for an object with a value property', () => { + // Explicitly demonstrate this behaviour which for now we're fine with. + // We may reconsider in the future. + expect(isAttributeObject({ value: 123.45, some: 'other property' })).toBe(true); + }); + + it.each([1, true, 'test', null, undefined, NaN, Symbol('test')])( + 'returns false for an invalid attribute object (%s)', + obj => { + const result = isAttributeObject(obj); + expect(result).toBe(false); + }, + ); +}); diff --git a/packages/core/test/lib/scope.test.ts b/packages/core/test/lib/scope.test.ts index 221ac14a6fa2..339a57828e5b 100644 --- a/packages/core/test/lib/scope.test.ts +++ b/packages/core/test/lib/scope.test.ts @@ -27,6 +27,7 @@ describe('Scope', () => { attachments: [], contexts: {}, tags: {}, + attributes: {}, extra: {}, user: {}, level: undefined, @@ -42,6 +43,7 @@ describe('Scope', () => { scope.update({ tags: { foo: 'bar' }, extra: { foo2: 'bar2' }, + attributes: { attr1: { value: 'value1' } }, }); expect(scope.getScopeData()).toEqual({ @@ -51,6 +53,7 @@ describe('Scope', () => { tags: { foo: 'bar', }, + attributes: { attr1: { value: 'value1' } }, extra: { foo2: 'bar2', }, @@ -71,6 +74,7 @@ describe('Scope', () => { scope.update({ tags: { foo: 'bar' }, + attributes: { attr1: { value: 'value1', type: 'string' } }, extra: { foo2: 'bar2' }, }); @@ -85,6 +89,7 @@ describe('Scope', () => { tags: { foo: 'bar', }, + attributes: { attr1: { value: 'value1', type: 'string' } }, extra: { foo2: 'bar2', }, @@ -114,7 +119,7 @@ describe('Scope', () => { }); }); - describe('attributes modification', () => { + describe('scope data modification', () => { test('setFingerprint', () => { const scope = new Scope(); scope.setFingerprint(['abcd']); @@ -183,6 +188,159 @@ describe('Scope', () => { }); }); + describe('setAttribute', () => { + it('accepts a key-value pair', () => { + const scope = new Scope(); + + scope.setAttribute('str', 'b'); + scope.setAttribute('int', 1); + scope.setAttribute('double', 1.1); + scope.setAttribute('bool', true); + + expect(scope['_attributes']).toEqual({ + str: 'b', + bool: true, + double: 1.1, + int: 1, + }); + }); + + it('accepts an attribute value object', () => { + const scope = new Scope(); + scope.setAttribute('str', { value: 'b' }); + expect(scope['_attributes']).toEqual({ + str: { value: 'b' }, + }); + }); + + it('accepts an attribute value object with a unit', () => { + const scope = new Scope(); + scope.setAttribute('str', { value: 1, unit: 'millisecond' }); + expect(scope['_attributes']).toEqual({ + str: { value: 1, unit: 'millisecond' }, + }); + }); + + it('still accepts a custom unit but TS-errors on it', () => { + // mostly there for type checking purposes. + const scope = new Scope(); + /** @ts-expect-error we don't support custom units type-wise but we don't actively block them */ + scope.setAttribute('str', { value: 3, unit: 'inch' }); + expect(scope['_attributes']).toEqual({ + str: { value: 3, unit: 'inch' }, + }); + }); + + it('accepts an array', () => { + const scope = new Scope(); + + scope.setAttribute('strArray', ['a', 'b', 'c']); + scope.setAttribute('intArray', { value: [1, 2, 3], unit: 'millisecond' }); + + expect(scope['_attributes']).toEqual({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'millisecond' }, + }); + }); + + it('notifies scope listeners once per call', () => { + const scope = new Scope(); + const listener = vi.fn(); + scope.addScopeListener(listener); + scope.setAttribute('str', 'b'); + scope.setAttribute('int', 1); + expect(listener).toHaveBeenCalledTimes(2); + }); + }); + + describe('setAttributes', () => { + it('accepts key-value pairs', () => { + const scope = new Scope(); + scope.setAttributes({ str: 'b', int: 1, double: 1.1, bool: true }); + expect(scope['_attributes']).toEqual({ + str: 'b', + int: 1, + double: 1.1, + bool: true, + }); + }); + + it('accepts attribute value objects', () => { + const scope = new Scope(); + scope.setAttributes({ str: { value: 'b' }, int: { value: 1 } }); + expect(scope['_attributes']).toEqual({ + str: { value: 'b' }, + int: { value: 1 }, + }); + }); + + it('accepts attribute value objects with units', () => { + const scope = new Scope(); + scope.setAttributes({ str: { value: 'b', unit: 'millisecond' }, int: { value: 12, unit: 'second' } }); + expect(scope['_attributes']).toEqual({ + str: { value: 'b', unit: 'millisecond' }, + int: { value: 12, unit: 'second' }, + }); + }); + + it('accepts arrays', () => { + const scope = new Scope(); + scope.setAttributes({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'millisecond' }, + }); + + expect(scope['_attributes']).toEqual({ + strArray: ['a', 'b', 'c'], + intArray: { value: [1, 2, 3], unit: 'millisecond' }, + }); + }); + + it('notifies scope listeners once per call', () => { + const scope = new Scope(); + const listener = vi.fn(); + scope.addScopeListener(listener); + scope.setAttributes({ str: 'b', int: 1 }); + scope.setAttributes({ bool: true }); + expect(listener).toHaveBeenCalledTimes(2); + }); + }); + + describe('removeAttribute', () => { + it('removes an attribute', () => { + const scope = new Scope(); + scope.setAttribute('str', 'b'); + scope.setAttribute('int', 1); + scope.removeAttribute('str'); + expect(scope['_attributes']).toEqual({ int: 1 }); + }); + + it('notifies scope listeners after deletion', () => { + const scope = new Scope(); + const listener = vi.fn(); + + scope.addScopeListener(listener); + scope.setAttribute('str', { value: 'b' }); + expect(listener).toHaveBeenCalledTimes(1); + + listener.mockClear(); + + scope.removeAttribute('str'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('does nothing if the attribute does not exist', () => { + const scope = new Scope(); + const listener = vi.fn(); + + scope.addScopeListener(listener); + scope.removeAttribute('str'); + + expect(scope['_attributes']).toEqual({}); + expect(listener).not.toHaveBeenCalled(); + }); + }); + test('setUser', () => { const scope = new Scope(); scope.setUser({ id: '1' }); @@ -329,12 +487,18 @@ describe('Scope', () => { const oldPropagationContext = scope.getScopeData().propagationContext; scope.setExtra('a', 2); scope.setTag('a', 'b'); + scope.setAttribute('c', 'd'); scope.setUser({ id: '1' }); scope.setFingerprint(['abcd']); scope.addBreadcrumb({ message: 'test' }); + + expect(scope['_attributes']).toEqual({ c: 'd' }); expect(scope['_extra']).toEqual({ a: 2 }); + scope.clear(); + expect(scope['_extra']).toEqual({}); + expect(scope['_attributes']).toEqual({}); expect(scope['_propagationContext']).toEqual({ traceId: expect.any(String), sampled: undefined, @@ -357,6 +521,7 @@ describe('Scope', () => { beforeEach(() => { scope = new Scope(); scope.setTags({ foo: '1', bar: '2' }); + scope.setAttribute('attr1', 'value1'); scope.setExtras({ foo: '1', bar: '2' }); scope.setContext('foo', { id: '1' }); scope.setContext('bar', { id: '2' }); From e8a1826167e19bccd5d4bb5bfdb5cbe2f9b70d73 Mon Sep 17 00:00:00 2001 From: Abdul Mateen <59867217+JealousGx@users.noreply.github.com> Date: Sat, 6 Sep 2025 10:44:26 +0500 Subject: [PATCH 177/190] feat(node): Fix local variables capturing for out-of-app frames (#18245) Address an issue where local variables were not being captured for out-of-app frames, even when the `includeOutOfAppFrames` option was enabled. The `localVariablesSyncIntegration` had a race condition where it would process events before the debugger session was fully initialized. Fix this by awaiting the session creation in `setupOnce`. The tests for this integration were failing because they were not setting up a Sentry client, which is required for the integration to be enabled. Correct by adding a client to the test setup. Additionally, add tests for the `localVariablesAsyncIntegration` to ensure it correctly handles the `includeOutOfAppFrames` option. The `LocalVariables` integrations `setupOnce` method was `async`, which violates the `Integration` interface. This caused a race condition where events could be processed before the integration was fully initialized, leading to missed local variables. Fix the race condition by: - Make `setupOnce` synchronous to adhere to the interface contract - Move the asynchronous initialization logic to a separate `setup` function - Make `processEvent` asynchronous and await the result of the `setup` function, so the integration is fully initialized before processing any events - Update tests to correctly `await` the `processEvent` method Fixes GH-12588 Fixes GH-17545 --- .../local-variables-out-of-app-default.js | 28 +++ .../local-variables-out-of-app.js | 33 +++ .../suites/public-api/LocalVariables/test.ts | 73 +++++- .../integrations/local-variables/common.ts | 6 + .../local-variables/local-variables-async.ts | 4 +- .../local-variables/local-variables-sync.ts | 218 +++++++++--------- 6 files changed, 253 insertions(+), 109 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js create mode 100644 dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js new file mode 100644 index 000000000000..9a53436867d9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js @@ -0,0 +1,28 @@ +/* eslint-disable no-unused-vars */ + +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const externalFunctionFile = require.resolve('./node_modules/out-of-app-function.js'); + +const { out_of_app_function } = require(externalFunctionFile); + +function in_app_function() { + const inAppVar = 'in app value'; + out_of_app_function(`${inAppVar} modified value`); +} + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + includeLocalVariables: true, +}); + +setTimeout(async () => { + try { + in_app_function(); + } catch (e) { + Sentry.captureException(e); + await Sentry.flush(); + } +}, 500); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js new file mode 100644 index 000000000000..9bbe40004fc7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js @@ -0,0 +1,33 @@ +/* eslint-disable no-unused-vars */ + +const Sentry = require('@sentry/node'); +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); + +const externalFunctionFile = require.resolve('./node_modules/out-of-app-function.js'); + +const { out_of_app_function } = require(externalFunctionFile); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + includeLocalVariables: true, + integrations: [ + Sentry.localVariablesIntegration({ + includeOutOfAppFrames: true, + }), + ], +}); + +function in_app_function() { + const inAppVar = 'in app value'; + out_of_app_function(`${inAppVar} modified value`); +} + +setTimeout(async () => { + try { + in_app_function(); + } catch (e) { + Sentry.captureException(e); + await Sentry.flush(); + } +}, 500); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts index 2c87d14c2b45..6c042d3ecf1f 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts @@ -1,5 +1,6 @@ +import { mkdirSync, rmdirSync, unlinkSync, writeFileSync } from 'fs'; import * as path from 'path'; -import { afterAll, describe, expect, test } from 'vitest'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { conditionalTest } from '../../../utils'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; @@ -39,8 +40,35 @@ const EXPECTED_LOCAL_VARIABLES_EVENT = { }; describe('LocalVariables integration', () => { + const nodeModules = `${__dirname}/node_modules`; + const externalModule = `${nodeModules}//out-of-app-function.js`; + function cleanupExternalModuleFile() { + try { + unlinkSync(externalModule); + // eslint-disable-next-line no-empty + } catch {} + try { + rmdirSync(nodeModules); + // eslint-disable-next-line no-empty + } catch {} + } + + beforeAll(() => { + cleanupExternalModuleFile(); + mkdirSync(nodeModules, { recursive: true }); + writeFileSync( + externalModule, + ` +function out_of_app_function(passedArg) { + const outOfAppVar = "out of app value " + passedArg.substring(13); + throw new Error("out-of-app error"); +} +module.exports = { out_of_app_function };`, + ); + }); afterAll(() => { cleanupChildProcesses(); + cleanupExternalModuleFile(); }); test('Should not include local variables by default', async () => { @@ -127,4 +155,47 @@ describe('LocalVariables integration', () => { .start() .completed(); }); + + test('adds local variables to out of app frames when includeOutOfAppFrames is true', async () => { + await createRunner(__dirname, 'local-variables-out-of-app.js') + .expect({ + event: event => { + const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; + + const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); + const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); + + expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); + expect(inAppFrame?.in_app).toEqual(true); + + expect(outOfAppFrame?.vars).toEqual({ + outOfAppVar: 'out of app value modified value', + passedArg: 'in app value modified value', + }); + expect(outOfAppFrame?.in_app).toEqual(false); + }, + }) + .start() + .completed(); + }); + + test('does not add local variables to out of app frames by default', async () => { + await createRunner(__dirname, 'local-variables-out-of-app-default.js') + .expect({ + event: event => { + const frames = event.exception?.values?.[0]?.stacktrace?.frames || []; + + const inAppFrame = frames.find(frame => frame.function === 'in_app_function'); + const outOfAppFrame = frames.find(frame => frame.function === 'out_of_app_function'); + + expect(inAppFrame?.vars).toEqual({ inAppVar: 'in app value' }); + expect(inAppFrame?.in_app).toEqual(true); + + expect(outOfAppFrame?.vars).toBeUndefined(); + expect(outOfAppFrame?.in_app).toEqual(false); + }, + }) + .start() + .completed(); + }); }); diff --git a/packages/node-core/src/integrations/local-variables/common.ts b/packages/node-core/src/integrations/local-variables/common.ts index 471fa1a69864..f86988b4cbfc 100644 --- a/packages/node-core/src/integrations/local-variables/common.ts +++ b/packages/node-core/src/integrations/local-variables/common.ts @@ -99,6 +99,12 @@ export interface LocalVariablesIntegrationOptions { * Maximum number of exceptions to capture local variables for per second before rate limiting is triggered. */ maxExceptionsPerSecond?: number; + /** + * When true, local variables will be captured for all frames, including those that are not in_app. + * + * Defaults to `false`. + */ + includeOutOfAppFrames?: boolean; } export interface LocalVariablesWorkerArgs extends LocalVariablesIntegrationOptions { diff --git a/packages/node-core/src/integrations/local-variables/local-variables-async.ts b/packages/node-core/src/integrations/local-variables/local-variables-async.ts index 32fff66bab4e..7bad543c2588 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-async.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-async.ts @@ -39,8 +39,8 @@ export const localVariablesAsyncIntegration = defineIntegration((( if ( // We need to have vars to add frameLocalVariables.vars === undefined || - // We're not interested in frames that are not in_app because the vars are not relevant - frame.in_app === false || + // Only skip out-of-app frames if includeOutOfAppFrames is not true + (frame.in_app === false && integrationOptions.includeOutOfAppFrames !== true) || // The function names need to match !functionNamesMatch(frame.function, frameLocalVariables.function) ) { diff --git a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts index 7de91a54276e..b2af37b0c7fb 100644 --- a/packages/node-core/src/integrations/local-variables/local-variables-sync.ts +++ b/packages/node-core/src/integrations/local-variables/local-variables-sync.ts @@ -268,8 +268,8 @@ const _localVariablesSyncIntegration = (( if ( // We need to have vars to add cachedFrameVariable.vars === undefined || - // We're not interested in frames that are not in_app because the vars are not relevant - frameVariable.in_app === false || + // Only skip out-of-app frames if includeOutOfAppFrames is not true + (frameVariable.in_app === false && options.includeOutOfAppFrames !== true) || // The function names need to match !functionNamesMatch(frameVariable.function, cachedFrameVariable.function) ) { @@ -288,122 +288,128 @@ const _localVariablesSyncIntegration = (( return event; } - return { - name: INTEGRATION_NAME, - async setupOnce() { - const client = getClient(); - const clientOptions = client?.getOptions(); + let setupPromise: Promise | undefined; - if (!clientOptions?.includeLocalVariables) { - return; - } + async function setup(): Promise { + const client = getClient(); + const clientOptions = client?.getOptions(); - // Only setup this integration if the Node version is >= v18 - // https://github.com/getsentry/sentry-javascript/issues/7697 - const unsupportedNodeVersion = NODE_MAJOR < 18; + if (!clientOptions?.includeLocalVariables) { + return; + } - if (unsupportedNodeVersion) { - debug.log('The `LocalVariables` integration is only supported on Node >= v18.'); - return; - } + // Only setup this integration if the Node version is >= v18 + // https://github.com/getsentry/sentry-javascript/issues/7697 + const unsupportedNodeVersion = NODE_MAJOR < 18; - if (await isDebuggerEnabled()) { - debug.warn('Local variables capture has been disabled because the debugger was already enabled'); - return; - } + if (unsupportedNodeVersion) { + debug.log('The `LocalVariables` integration is only supported on Node >= v18.'); + return; + } - AsyncSession.create(sessionOverride).then( - session => { - function handlePaused( - stackParser: StackParser, - { params: { reason, data, callFrames } }: InspectorNotification, - complete: () => void, - ): void { - if (reason !== 'exception' && reason !== 'promiseRejection') { - complete(); - return; - } - - rateLimiter?.(); - - // data.description contains the original error.stack - const exceptionHash = hashFromStack(stackParser, data.description); - - if (exceptionHash == undefined) { - complete(); - return; - } - - const { add, next } = createCallbackList(frames => { - cachedFrames.set(exceptionHash, frames); - complete(); - }); + if (await isDebuggerEnabled()) { + debug.warn('Local variables capture has been disabled because the debugger was already enabled'); + return; + } - // Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack - // For this reason we only attempt to get local variables for the first 5 frames - for (let i = 0; i < Math.min(callFrames.length, 5); i++) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { scopeChain, functionName, this: obj } = callFrames[i]!; - - const localScope = scopeChain.find(scope => scope.type === 'local'); - - // obj.className is undefined in ESM modules - const fn = - obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; - - if (localScope?.object.objectId === undefined) { - add(frames => { - frames[i] = { function: fn }; - next(frames); - }); - } else { - const id = localScope.object.objectId; - add(frames => - session.getLocalVariables(id, vars => { - frames[i] = { function: fn, vars }; - next(frames); - }), - ); - } - } - - next([]); - } + try { + const session = await AsyncSession.create(sessionOverride); + + const handlePaused = ( + stackParser: StackParser, + { params: { reason, data, callFrames } }: InspectorNotification, + complete: () => void, + ): void => { + if (reason !== 'exception' && reason !== 'promiseRejection') { + complete(); + return; + } + + rateLimiter?.(); + + // data.description contains the original error.stack + const exceptionHash = hashFromStack(stackParser, data.description); + + if (exceptionHash == undefined) { + complete(); + return; + } - const captureAll = options.captureAllExceptions !== false; - - session.configureAndConnect( - (ev, complete) => - handlePaused(clientOptions.stackParser, ev as InspectorNotification, complete), - captureAll, - ); - - if (captureAll) { - const max = options.maxExceptionsPerSecond || 50; - - rateLimiter = createRateLimiter( - max, - () => { - debug.log('Local variables rate-limit lifted.'); - session.setPauseOnExceptions(true); - }, - seconds => { - debug.log( - `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, - ); - session.setPauseOnExceptions(false); - }, + const { add, next } = createCallbackList(frames => { + cachedFrames.set(exceptionHash, frames); + complete(); + }); + + // Because we're queuing up and making all these calls synchronously, we can potentially overflow the stack + // For this reason we only attempt to get local variables for the first 5 frames + for (let i = 0; i < Math.min(callFrames.length, 5); i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { scopeChain, functionName, this: obj } = callFrames[i]!; + + const localScope = scopeChain.find(scope => scope.type === 'local'); + + // obj.className is undefined in ESM modules + const fn = obj.className === 'global' || !obj.className ? functionName : `${obj.className}.${functionName}`; + + if (localScope?.object.objectId === undefined) { + add(frames => { + frames[i] = { function: fn }; + next(frames); + }); + } else { + const id = localScope.object.objectId; + add(frames => + session.getLocalVariables(id, vars => { + frames[i] = { function: fn, vars }; + next(frames); + }), ); } + } + + next([]); + }; - shouldProcessEvent = true; - }, - error => { - debug.log('The `LocalVariables` integration failed to start.', error); - }, + const captureAll = options.captureAllExceptions !== false; + + session.configureAndConnect( + (ev, complete) => + handlePaused(clientOptions.stackParser, ev as InspectorNotification, complete), + captureAll, ); + + if (captureAll) { + const max = options.maxExceptionsPerSecond || 50; + + rateLimiter = createRateLimiter( + max, + () => { + debug.log('Local variables rate-limit lifted.'); + session.setPauseOnExceptions(true); + }, + seconds => { + debug.log( + `Local variables rate-limit exceeded. Disabling capturing of caught exceptions for ${seconds} seconds.`, + ); + session.setPauseOnExceptions(false); + }, + ); + } + + shouldProcessEvent = true; + } catch (error) { + debug.log('The `LocalVariables` integration failed to start.', error); + } + } + + return { + name: INTEGRATION_NAME, + setupOnce() { + setupPromise = setup(); }, - processEvent(event: Event): Event { + async processEvent(event: Event): Promise { + await setupPromise; + if (shouldProcessEvent) { return addLocalVariablesToEvent(event); } From cbecbdf97b0c402afc324226e63427dfbfc21727 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Sat, 22 Nov 2025 00:11:18 +0100 Subject: [PATCH 178/190] feat(deps): Bump OpenTelemetry instrumentations (#18239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR bumps OpenTelemetry instrumentations and SDK packages to their latest versions. ## Dependency Updates: * @opentelemetry/context-async-hooks: 2.1.0 → 2.2.0 * @opentelemetry/core: 2.1.0 → 2.2.0 * @opentelemetry/resources: 2.1.0 → 2.2.0 * @opentelemetry/sdk-trace-base: 2.1.0 → 2.2.0 * @opentelemetry/sdk-trace-node: 2.1.0 → 2.2.0 * @opentelemetry/instrumentation: 0.204.0 → 0.208.0 * @opentelemetry/instrumentation-mongodb: 0.57.0 → 0.61.0 * @opentelemetry/instrumentation-pg: 0.57.0 → 0.61.0 * @opentelemetry/instrumentation-mysql: 0.50.0 → 0.54.0 * @opentelemetry/instrumentation-mysql2: 0.51.0 → 0.55.0 * @opentelemetry/instrumentation-tedious: 0.23.0 → 0.27.0 * @opentelemetry/instrumentation-mongoose: 0.51.0 → 0.55.0 * @opentelemetry/instrumentation-redis: 0.53.0 → 0.57.0 * @opentelemetry/instrumentation-ioredis: 0.52.0 → 0.56.0 * @opentelemetry/instrumentation-express: 0.53.0 → 0.57.0 * @opentelemetry/instrumentation-koa: 0.52.0 → 0.57.0 * @opentelemetry/instrumentation-hapi: 0.51.0 → 0.55.0 * @opentelemetry/instrumentation-connect: 0.48.0 → 0.52.0 * @opentelemetry/instrumentation-nestjs-core: 0.50.0 → 0.55.0 * @opentelemetry/instrumentation-http: 0.204.0 → 0.208.0 * @opentelemetry/instrumentation-graphql: 0.52.0 → 0.56.0 * @opentelemetry/instrumentation-amqplib: 0.51.0 → 0.55.0 * @opentelemetry/instrumentation-aws-sdk: 0.59.0 → 0.64.0 * @opentelemetry/instrumentation-dataloader: 0.22.0 → 0.26.0 * @opentelemetry/instrumentation-fs: 0.24.0 → 0.28.0 * @opentelemetry/instrumentation-generic-pool: 0.48.0 → 0.52.0 * @opentelemetry/instrumentation-kafkajs: 0.14.0 → 0.18.0 * @opentelemetry/instrumentation-knex: 0.49.0 → 0.53.0 * @opentelemetry/instrumentation-lru-memoizer: 0.49.0 → 0.53.0 * @opentelemetry/instrumentation-undici: 0.15.0 → 0.19.0 * @prisma/instrumentation: 6.15.0 → 6.19.0 Closes: #18178 --- CHANGELOG.md | 33 ++ .../test-applications/nextjs-16/package.json | 4 +- .../package.json | 12 +- .../package.json | 16 +- .../node-core-express-otel-v2/package.json | 12 +- .../node-otel-sdk-node/package.json | 4 +- .../node-otel-without-tracing/package.json | 8 +- .../tests/transactions.test.ts | 4 + .../test-applications/node-otel/package.json | 4 +- .../node-core-integration-tests/package.json | 12 +- packages/aws-serverless/package.json | 4 +- packages/nestjs/package.json | 6 +- packages/node-core/package.json | 20 +- .../src/integrations/diagnostic_channel.d.ts | 556 ------------------ packages/node/package.json | 58 +- packages/opentelemetry/package.json | 12 +- packages/react-router/package.json | 4 +- packages/remix/package.json | 2 +- packages/sveltekit/src/client/sdk.ts | 4 +- packages/vercel-edge/package.json | 6 +- .../abstract-async-hooks-context-manager.ts | 14 +- .../async-local-storage-context-manager.ts | 12 +- yarn.lock | 423 ++++++------- 23 files changed, 347 insertions(+), 883 deletions(-) delete mode 100644 packages/node-core/src/integrations/diagnostic_channel.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eba860932aa..479b72fc2f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239)) + - Bump @opentelemetry/context-async-hooks from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/core from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/resources from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/sdk-trace-base from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/sdk-trace-node from ^2.1.0 to ^2.2.0 + - Bump @opentelemetry/instrumentation from 0.204.0 to 0.208.0 + - Bump @opentelemetry/instrumentation-amqplib from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-aws-sdk from 0.59.0 to 0.64.0 + - Bump @opentelemetry/instrumentation-connect from 0.48.0 to 0.52.0 + - Bump @opentelemetry/instrumentation-dataloader from 0.22.0 to 0.26.0 + - Bump @opentelemetry/instrumentation-express from 0.53.0 to 0.57.0 + - Bump @opentelemetry/instrumentation-fs from 0.24.0 to 0.28.0 + - Bump @opentelemetry/instrumentation-generic-pool from 0.48.0 to 0.52.0 + - Bump @opentelemetry/instrumentation-graphql from 0.52.0 to 0.56.0 + - Bump @opentelemetry/instrumentation-hapi from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-http from 0.204.0 to 0.208.0 + - Bump @opentelemetry/instrumentation-ioredis from 0.52.0 to 0.56.0 + - Bump @opentelemetry/instrumentation-kafkajs from 0.14.0 to 0.18.0 + - Bump @opentelemetry/instrumentation-knex from 0.49.0 to 0.53.0 + - Bump @opentelemetry/instrumentation-koa from 0.52.0 to 0.57.0 + - Bump @opentelemetry/instrumentation-lru-memoizer from 0.49.0 to 0.53.0 + - Bump @opentelemetry/instrumentation-mongodb from 0.57.0 to 0.61.0 + - Bump @opentelemetry/instrumentation-mongoose from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-mysql from 0.50.0 to 0.54.0 + - Bump @opentelemetry/instrumentation-mysql2 from 0.51.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-nestjs-core from 0.50.0 to 0.55.0 + - Bump @opentelemetry/instrumentation-pg from 0.57.0 to 0.61.0 + - Bump @opentelemetry/instrumentation-redis from 0.53.0 to 0.57.0 + - Bump @opentelemetry/instrumentation-tedious from 0.23.0 to 0.27.0 + - Bump @opentelemetry/instrumentation-undici from 0.15.0 to 0.19.0 + - Bump @prisma/instrumentation from 6.15.0 to 6.19.0 + ## 10.26.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index af9f306f017d..662e1b85936a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -26,11 +26,11 @@ "@sentry/nextjs": "latest || *", "@sentry/core": "latest || *", "ai": "^3.0.0", - "import-in-the-middle": "^1", + "import-in-the-middle": "^2", "next": "16.0.0", "react": "19.1.0", "react-dom": "19.1.0", - "require-in-the-middle": "^7", + "require-in-the-middle": "^8", "zod": "^3.22.4" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json index e29a40c2887e..5710105d4ab8 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json @@ -12,12 +12,12 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-http": "^0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-node": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json index 34b050f350c1..f6074d159bbe 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json @@ -12,15 +12,15 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-http": "^0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-node": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@opentelemetry/sdk-node": "^0.204.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.204.0", + "@opentelemetry/sdk-node": "^0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@types/express": "4.17.17", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json index 2252750e423e..b9ba557d67b5 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json @@ -14,12 +14,12 @@ "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-http": "^0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-node": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@types/express": "^4.17.21", "@types/node": "^18.19.1", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json index 7296f72218cd..1eb93f281cf8 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -11,8 +11,8 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-node": "0.204.0", - "@opentelemetry/exporter-trace-otlp-http": "0.204.0", + "@opentelemetry/sdk-node": "0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "0.208.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json index f13daab2ef6c..fc153ddceeb8 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json @@ -11,11 +11,11 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-trace-node": "2.1.0", - "@opentelemetry/exporter-trace-otlp-http": "0.204.0", + "@opentelemetry/sdk-trace-node": "2.2.0", + "@opentelemetry/exporter-trace-otlp-http": "0.208.0", "@opentelemetry/instrumentation-undici": "0.13.2", - "@opentelemetry/instrumentation-http": "0.204.0", - "@opentelemetry/instrumentation": "0.204.0", + "@opentelemetry/instrumentation-http": "0.208.0", + "@opentelemetry/instrumentation": "0.208.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts index 678841bdb249..26c9d7de5496 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts @@ -65,6 +65,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { status: { code: 0 }, links: [], droppedLinksCount: 0, + flags: expect.any(Number), }, { traceId: expect.any(String), @@ -80,6 +81,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { status: { code: 0 }, links: [], droppedLinksCount: 0, + flags: expect.any(Number), }, ]); @@ -116,6 +118,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { status: { code: 0 }, links: [], droppedLinksCount: 0, + flags: expect.any(Number), }, ]); @@ -157,6 +160,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { }, links: [], droppedLinksCount: 0, + flags: expect.any(Number), }, ]); }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel/package.json b/dev-packages/e2e-tests/test-applications/node-otel/package.json index 31cf99c32c91..e2b7086f23ba 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel/package.json @@ -11,8 +11,8 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-node": "0.204.0", - "@opentelemetry/exporter-trace-otlp-http": "0.204.0", + "@opentelemetry/sdk-node": "0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "0.208.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index fe755f16cc6d..24ac2f57ea9e 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -27,12 +27,12 @@ "@nestjs/core": "^11", "@nestjs/platform-express": "^11", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-http": "0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-http": "0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/core": "10.26.0", "@sentry/node-core": "10.26.0", diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index cd0ad16d9e7c..8d6360e82d2a 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -66,8 +66,8 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-aws-sdk": "0.59.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-aws-sdk": "0.64.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/core": "10.26.0", "@sentry/node": "10.26.0", diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 53c3064ed08f..ae39e2dc5d4f 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -45,9 +45,9 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-nestjs-core": "0.50.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-nestjs-core": "0.55.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/core": "10.26.0", "@sentry/node": "10.26.0" diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 89dbe5461165..1f845acfa16b 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -58,27 +58,27 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/resources": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0" }, "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", "@sentry/core": "10.26.0", "@sentry/opentelemetry": "10.26.0", - "import-in-the-middle": "^1.14.2" + "import-in-the-middle": "^2" }, "devDependencies": { "@apm-js-collab/code-transformer": "^0.8.2", "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@types/node": "^18.19.1" }, diff --git a/packages/node-core/src/integrations/diagnostic_channel.d.ts b/packages/node-core/src/integrations/diagnostic_channel.d.ts deleted file mode 100644 index abf3649a617f..000000000000 --- a/packages/node-core/src/integrations/diagnostic_channel.d.ts +++ /dev/null @@ -1,556 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/ban-types */ -/* eslint-disable @typescript-eslint/explicit-member-accessibility */ - -/** - * The `node:diagnostics_channel` module provides an API to create named channels - * to report arbitrary message data for diagnostics purposes. - * - * It can be accessed using: - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * ``` - * - * It is intended that a module writer wanting to report diagnostics messages - * will create one or many top-level channels to report messages through. - * Channels may also be acquired at runtime but it is not encouraged - * due to the additional overhead of doing so. Channels may be exported for - * convenience, but as long as the name is known it can be acquired anywhere. - * - * If you intend for your module to produce diagnostics data for others to - * consume it is recommended that you include documentation of what named - * channels are used along with the shape of the message data. Channel names - * should generally include the module name to avoid collisions with data from - * other modules. - * @since v15.1.0, v14.17.0 - * @see [source](https://github.com/nodejs/node/blob/v22.x/lib/diagnostics_channel.js) - */ -declare module 'diagnostics_channel' { - import type { AsyncLocalStorage } from 'node:async_hooks'; - /** - * Check if there are active subscribers to the named channel. This is helpful if - * the message you want to send might be expensive to prepare. - * - * This API is optional but helpful when trying to publish messages from very - * performance-sensitive code. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * if (diagnostics_channel.hasSubscribers('my-channel')) { - * // There are subscribers, prepare and publish message - * } - * ``` - * @since v15.1.0, v14.17.0 - * @param name The channel name - * @return If there are active subscribers - */ - function hasSubscribers(name: string | symbol): boolean; - /** - * This is the primary entry-point for anyone wanting to publish to a named - * channel. It produces a channel object which is optimized to reduce overhead at - * publish time as much as possible. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * ``` - * @since v15.1.0, v14.17.0 - * @param name The channel name - * @return The named channel object - */ - function channel(name: string | symbol): Channel; - type ChannelListener = (message: unknown, name: string | symbol) => void; - /** - * Register a message handler to subscribe to this channel. This message handler - * will be run synchronously whenever a message is published to the channel. Any - * errors thrown in the message handler will trigger an `'uncaughtException'`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * diagnostics_channel.subscribe('my-channel', (message, name) => { - * // Received data - * }); - * ``` - * @since v18.7.0, v16.17.0 - * @param name The channel name - * @param onMessage The handler to receive channel messages - */ - function subscribe(name: string | symbol, onMessage: ChannelListener): void; - /** - * Remove a message handler previously registered to this channel with {@link subscribe}. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * function onMessage(message, name) { - * // Received data - * } - * - * diagnostics_channel.subscribe('my-channel', onMessage); - * - * diagnostics_channel.unsubscribe('my-channel', onMessage); - * ``` - * @since v18.7.0, v16.17.0 - * @param name The channel name - * @param onMessage The previous subscribed handler to remove - * @return `true` if the handler was found, `false` otherwise. - */ - function unsubscribe(name: string | symbol, onMessage: ChannelListener): boolean; - /** - * Creates a `TracingChannel` wrapper for the given `TracingChannel Channels`. If a name is given, the corresponding tracing - * channels will be created in the form of `tracing:${name}:${eventType}` where `eventType` corresponds to the types of `TracingChannel Channels`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channelsByName = diagnostics_channel.tracingChannel('my-channel'); - * - * // or... - * - * const channelsByCollection = diagnostics_channel.tracingChannel({ - * start: diagnostics_channel.channel('tracing:my-channel:start'), - * end: diagnostics_channel.channel('tracing:my-channel:end'), - * asyncStart: diagnostics_channel.channel('tracing:my-channel:asyncStart'), - * asyncEnd: diagnostics_channel.channel('tracing:my-channel:asyncEnd'), - * error: diagnostics_channel.channel('tracing:my-channel:error'), - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param nameOrChannels Channel name or object containing all the `TracingChannel Channels` - * @return Collection of channels to trace with - */ - function tracingChannel< - StoreType = unknown, - ContextType extends object = StoreType extends object ? StoreType : object, - >(nameOrChannels: string | TracingChannelCollection): TracingChannel; - /** - * The class `Channel` represents an individual named channel within the data - * pipeline. It is used to track subscribers and to publish messages when there - * are subscribers present. It exists as a separate object to avoid channel - * lookups at publish time, enabling very fast publish speeds and allowing - * for heavy use while incurring very minimal cost. Channels are created with {@link channel}, constructing a channel directly - * with `new Channel(name)` is not supported. - * @since v15.1.0, v14.17.0 - */ - class Channel { - readonly name: string | symbol; - /** - * Check if there are active subscribers to this channel. This is helpful if - * the message you want to send might be expensive to prepare. - * - * This API is optional but helpful when trying to publish messages from very - * performance-sensitive code. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * if (channel.hasSubscribers) { - * // There are subscribers, prepare and publish message - * } - * ``` - * @since v15.1.0, v14.17.0 - */ - readonly hasSubscribers: boolean; - private constructor(name: string | symbol); - /** - * Publish a message to any subscribers to the channel. This will trigger - * message handlers synchronously so they will execute within the same context. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.publish({ - * some: 'message', - * }); - * ``` - * @since v15.1.0, v14.17.0 - * @param message The message to send to the channel subscribers - */ - publish(message: unknown): void; - /** - * Register a message handler to subscribe to this channel. This message handler - * will be run synchronously whenever a message is published to the channel. Any - * errors thrown in the message handler will trigger an `'uncaughtException'`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.subscribe((message, name) => { - * // Received data - * }); - * ``` - * @since v15.1.0, v14.17.0 - * @deprecated Since v18.7.0,v16.17.0 - Use {@link subscribe(name, onMessage)} - * @param onMessage The handler to receive channel messages - */ - subscribe(onMessage: ChannelListener): void; - /** - * Remove a message handler previously registered to this channel with `channel.subscribe(onMessage)`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * function onMessage(message, name) { - * // Received data - * } - * - * channel.subscribe(onMessage); - * - * channel.unsubscribe(onMessage); - * ``` - * @since v15.1.0, v14.17.0 - * @deprecated Since v18.7.0,v16.17.0 - Use {@link unsubscribe(name, onMessage)} - * @param onMessage The previous subscribed handler to remove - * @return `true` if the handler was found, `false` otherwise. - */ - unsubscribe(onMessage: ChannelListener): void; - /** - * When `channel.runStores(context, ...)` is called, the given context data - * will be applied to any store bound to the channel. If the store has already been - * bound the previous `transform` function will be replaced with the new one. - * The `transform` function may be omitted to set the given context data as the - * context directly. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * import { AsyncLocalStorage } from 'node:async_hooks'; - * - * const store = new AsyncLocalStorage(); - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.bindStore(store, (data) => { - * return { data }; - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param store The store to which to bind the context data - * @param transform Transform context data before setting the store context - */ - bindStore(store: AsyncLocalStorage, transform?: (context: ContextType) => StoreType): void; - /** - * Remove a message handler previously registered to this channel with `channel.bindStore(store)`. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * import { AsyncLocalStorage } from 'node:async_hooks'; - * - * const store = new AsyncLocalStorage(); - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.bindStore(store); - * channel.unbindStore(store); - * ``` - * @since v19.9.0 - * @experimental - * @param store The store to unbind from the channel. - * @return `true` if the store was found, `false` otherwise. - */ - unbindStore(store: any): void; - /** - * Applies the given data to any AsyncLocalStorage instances bound to the channel - * for the duration of the given function, then publishes to the channel within - * the scope of that data is applied to the stores. - * - * If a transform function was given to `channel.bindStore(store)` it will be - * applied to transform the message data before it becomes the context value for - * the store. The prior storage context is accessible from within the transform - * function in cases where context linking is required. - * - * The context applied to the store should be accessible in any async code which - * continues from execution which began during the given function, however - * there are some situations in which `context loss` may occur. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * import { AsyncLocalStorage } from 'node:async_hooks'; - * - * const store = new AsyncLocalStorage(); - * - * const channel = diagnostics_channel.channel('my-channel'); - * - * channel.bindStore(store, (message) => { - * const parent = store.getStore(); - * return new Span(message, parent); - * }); - * channel.runStores({ some: 'message' }, () => { - * store.getStore(); // Span({ some: 'message' }) - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param context Message to send to subscribers and bind to stores - * @param fn Handler to run within the entered storage context - * @param thisArg The receiver to be used for the function call. - * @param args Optional arguments to pass to the function. - */ - runStores(): void; - } - interface TracingChannelSubscribers { - start: (message: ContextType) => void; - end: ( - message: ContextType & { - error?: unknown; - result?: unknown; - }, - ) => void; - asyncStart: ( - message: ContextType & { - error?: unknown; - result?: unknown; - }, - ) => void; - asyncEnd: ( - message: ContextType & { - error?: unknown; - result?: unknown; - }, - ) => void; - error: ( - message: ContextType & { - error: unknown; - }, - ) => void; - } - interface TracingChannelCollection { - start: Channel; - end: Channel; - asyncStart: Channel; - asyncEnd: Channel; - error: Channel; - } - /** - * The class `TracingChannel` is a collection of `TracingChannel Channels` which - * together express a single traceable action. It is used to formalize and - * simplify the process of producing events for tracing application flow. {@link tracingChannel} is used to construct a `TracingChannel`. As with `Channel` it is recommended to create and reuse a - * single `TracingChannel` at the top-level of the file rather than creating them - * dynamically. - * @since v19.9.0 - * @experimental - */ - class TracingChannel implements TracingChannelCollection { - start: Channel; - end: Channel; - asyncStart: Channel; - asyncEnd: Channel; - error: Channel; - /** - * Helper to subscribe a collection of functions to the corresponding channels. - * This is the same as calling `channel.subscribe(onMessage)` on each channel - * individually. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.subscribe({ - * start(message) { - * // Handle start message - * }, - * end(message) { - * // Handle end message - * }, - * asyncStart(message) { - * // Handle asyncStart message - * }, - * asyncEnd(message) { - * // Handle asyncEnd message - * }, - * error(message) { - * // Handle error message - * }, - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param subscribers Set of `TracingChannel Channels` subscribers - */ - subscribe(subscribers: TracingChannelSubscribers): void; - /** - * Helper to unsubscribe a collection of functions from the corresponding channels. - * This is the same as calling `channel.unsubscribe(onMessage)` on each channel - * individually. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.unsubscribe({ - * start(message) { - * // Handle start message - * }, - * end(message) { - * // Handle end message - * }, - * asyncStart(message) { - * // Handle asyncStart message - * }, - * asyncEnd(message) { - * // Handle asyncEnd message - * }, - * error(message) { - * // Handle error message - * }, - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param subscribers Set of `TracingChannel Channels` subscribers - * @return `true` if all handlers were successfully unsubscribed, and `false` otherwise. - */ - unsubscribe(subscribers: TracingChannelSubscribers): void; - /** - * Trace a synchronous function call. This will always produce a `start event` and `end event` around the execution and may produce an `error event` if the given function throws an error. - * This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all - * events should have any bound stores set to match this trace context. - * - * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions - * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.traceSync(() => { - * // Do something - * }, { - * some: 'thing', - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param fn Function to wrap a trace around - * @param context Shared object to correlate events through - * @param thisArg The receiver to be used for the function call - * @param args Optional arguments to pass to the function - * @return The return value of the given function - */ - traceSync( - fn: (this: ThisArg, ...args: Args) => any, - context?: ContextType, - thisArg?: ThisArg, - ...args: Args - ): void; - /** - * Trace a promise-returning function call. This will always produce a `start event` and `end event` around the synchronous portion of the - * function execution, and will produce an `asyncStart event` and `asyncEnd event` when a promise continuation is reached. It may also - * produce an `error event` if the given function throws an error or the - * returned promise rejects. This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all - * events should have any bound stores set to match this trace context. - * - * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions - * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.tracePromise(async () => { - * // Do something - * }, { - * some: 'thing', - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param fn Promise-returning function to wrap a trace around - * @param context Shared object to correlate trace events through - * @param thisArg The receiver to be used for the function call - * @param args Optional arguments to pass to the function - * @return Chained from promise returned by the given function - */ - tracePromise( - fn: (this: ThisArg, ...args: Args) => Promise, - context?: ContextType, - thisArg?: ThisArg, - ...args: Args - ): void; - /** - * Trace a callback-receiving function call. This will always produce a `start event` and `end event` around the synchronous portion of the - * function execution, and will produce a `asyncStart event` and `asyncEnd event` around the callback execution. It may also produce an `error event` if the given function throws an error or - * the returned - * promise rejects. This will run the given function using `channel.runStores(context, ...)` on the `start` channel which ensures all - * events should have any bound stores set to match this trace context. - * - * The `position` will be -1 by default to indicate the final argument should - * be used as the callback. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * - * channels.traceCallback((arg1, callback) => { - * // Do something - * callback(null, 'result'); - * }, 1, { - * some: 'thing', - * }, thisArg, arg1, callback); - * ``` - * - * The callback will also be run with `channel.runStores(context, ...)` which - * enables context loss recovery in some cases. - * - * To ensure only correct trace graphs are formed, events will only be published if subscribers are present prior to starting the trace. Subscriptions - * which are added after the trace begins will not receive future events from that trace, only future traces will be seen. - * - * ```js - * import diagnostics_channel from 'node:diagnostics_channel'; - * import { AsyncLocalStorage } from 'node:async_hooks'; - * - * const channels = diagnostics_channel.tracingChannel('my-channel'); - * const myStore = new AsyncLocalStorage(); - * - * // The start channel sets the initial store data to something - * // and stores that store data value on the trace context object - * channels.start.bindStore(myStore, (data) => { - * const span = new Span(data); - * data.span = span; - * return span; - * }); - * - * // Then asyncStart can restore from that data it stored previously - * channels.asyncStart.bindStore(myStore, (data) => { - * return data.span; - * }); - * ``` - * @since v19.9.0 - * @experimental - * @param fn callback using function to wrap a trace around - * @param position Zero-indexed argument position of expected callback - * @param context Shared object to correlate trace events through - * @param thisArg The receiver to be used for the function call - * @param args Optional arguments to pass to the function - * @return The return value of the given function - */ - traceCallback any>( - fn: Fn, - position?: number, - context?: ContextType, - thisArg?: any, - ...args: Parameters - ): void; - } -} -declare module 'node:diagnostics_channel' { - export * from 'diagnostics_channel'; -} diff --git a/packages/node/package.json b/packages/node/package.json index 6f0bec49c92a..e43d7b04a0ee 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -66,39 +66,39 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", - "@opentelemetry/instrumentation-amqplib": "0.51.0", - "@opentelemetry/instrumentation-connect": "0.48.0", - "@opentelemetry/instrumentation-dataloader": "0.22.0", - "@opentelemetry/instrumentation-express": "0.53.0", - "@opentelemetry/instrumentation-fs": "0.24.0", - "@opentelemetry/instrumentation-generic-pool": "0.48.0", - "@opentelemetry/instrumentation-graphql": "0.52.0", - "@opentelemetry/instrumentation-hapi": "0.51.0", - "@opentelemetry/instrumentation-http": "0.204.0", - "@opentelemetry/instrumentation-ioredis": "0.52.0", - "@opentelemetry/instrumentation-kafkajs": "0.14.0", - "@opentelemetry/instrumentation-knex": "0.49.0", - "@opentelemetry/instrumentation-koa": "0.52.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.49.0", - "@opentelemetry/instrumentation-mongodb": "0.57.0", - "@opentelemetry/instrumentation-mongoose": "0.51.0", - "@opentelemetry/instrumentation-mysql": "0.50.0", - "@opentelemetry/instrumentation-mysql2": "0.51.0", - "@opentelemetry/instrumentation-pg": "0.57.0", - "@opentelemetry/instrumentation-redis": "0.53.0", - "@opentelemetry/instrumentation-tedious": "0.23.0", - "@opentelemetry/instrumentation-undici": "0.15.0", - "@opentelemetry/resources": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-amqplib": "0.55.0", + "@opentelemetry/instrumentation-connect": "0.52.0", + "@opentelemetry/instrumentation-dataloader": "0.26.0", + "@opentelemetry/instrumentation-express": "0.57.0", + "@opentelemetry/instrumentation-fs": "0.28.0", + "@opentelemetry/instrumentation-generic-pool": "0.52.0", + "@opentelemetry/instrumentation-graphql": "0.56.0", + "@opentelemetry/instrumentation-hapi": "0.55.0", + "@opentelemetry/instrumentation-http": "0.208.0", + "@opentelemetry/instrumentation-ioredis": "0.56.0", + "@opentelemetry/instrumentation-kafkajs": "0.18.0", + "@opentelemetry/instrumentation-knex": "0.53.0", + "@opentelemetry/instrumentation-koa": "0.57.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.53.0", + "@opentelemetry/instrumentation-mongodb": "0.61.0", + "@opentelemetry/instrumentation-mongoose": "0.55.0", + "@opentelemetry/instrumentation-mysql": "0.54.0", + "@opentelemetry/instrumentation-mysql2": "0.55.0", + "@opentelemetry/instrumentation-pg": "0.61.0", + "@opentelemetry/instrumentation-redis": "0.57.0", + "@opentelemetry/instrumentation-tedious": "0.27.0", + "@opentelemetry/instrumentation-undici": "0.19.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@prisma/instrumentation": "6.15.0", + "@prisma/instrumentation": "6.19.0", "@sentry/core": "10.26.0", "@sentry/node-core": "10.26.0", "@sentry/opentelemetry": "10.26.0", - "import-in-the-middle": "^1.14.2", + "import-in-the-middle": "^2", "minimatch": "^9.0.0" }, "devDependencies": { diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 95b7f54cd000..86d73b8555b2 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -43,16 +43,16 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.1.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0" }, "scripts": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index a65bd845bdab..51ce7bb94122 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -46,8 +46,8 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/instrumentation": "^0.204.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/browser": "10.26.0", "@sentry/cli": "^2.58.2", diff --git a/packages/remix/package.json b/packages/remix/package.json index 181d6e23a63f..98bdd9a39c7c 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -65,7 +65,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.204.0", + "@opentelemetry/instrumentation": "^0.208.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", "@sentry/cli": "^2.58.2", diff --git a/packages/sveltekit/src/client/sdk.ts b/packages/sveltekit/src/client/sdk.ts index 5c3f482cb7d0..a6294ad25977 100644 --- a/packages/sveltekit/src/client/sdk.ts +++ b/packages/sveltekit/src/client/sdk.ts @@ -64,7 +64,7 @@ function getDefaultIntegrations(options: BrowserOptions): Integration[] | undefi * @returns the function that was previously on `window.fetch`. */ function switchToFetchProxy(): typeof fetch | undefined { - const globalWithSentryFetchProxy: WindowWithSentryFetchProxy = WINDOW as WindowWithSentryFetchProxy; + const globalWithSentryFetchProxy: WindowWithSentryFetchProxy = WINDOW; // eslint-disable-next-line @typescript-eslint/unbound-method const actualFetch = globalWithSentryFetchProxy.fetch; @@ -81,7 +81,7 @@ function switchToFetchProxy(): typeof fetch | undefined { * and puts our fetch proxy back onto `window._sentryFetchProxy`. */ function restoreFetch(actualFetch: typeof fetch): void { - const globalWithSentryFetchProxy: WindowWithSentryFetchProxy = WINDOW as WindowWithSentryFetchProxy; + const globalWithSentryFetchProxy: WindowWithSentryFetchProxy = WINDOW; // eslint-disable-next-line @typescript-eslint/unbound-method globalWithSentryFetchProxy._sentryFetchProxy = globalWithSentryFetchProxy.fetch; diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index d5c9ef78b8c1..1efd9c1af34c 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -40,13 +40,13 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/resources": "^2.1.0", + "@opentelemetry/resources": "^2.2.0", "@sentry/core": "10.26.0" }, "devDependencies": { "@edge-runtime/types": "3.0.1", - "@opentelemetry/core": "^2.1.0", - "@opentelemetry/sdk-trace-base": "^2.1.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@sentry/opentelemetry": "10.26.0" }, diff --git a/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts b/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts index 72373afdebdf..3ec1934af3f7 100644 --- a/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts +++ b/packages/vercel-edge/src/vendored/abstract-async-hooks-context-manager.ts @@ -32,10 +32,22 @@ /* eslint-disable @typescript-eslint/no-this-alias */ import type { Context, ContextManager } from '@opentelemetry/api'; -import type { EventEmitter } from 'events'; type Func = (...args: unknown[]) => T; +// Inline EventEmitter interface to avoid Node.js module dependency +// This prevents Node.js type leaks in edge runtime environments +interface EventEmitter { + addListener?(event: string, listener: Func): this; + on?(event: string, listener: Func): this; + once?(event: string, listener: Func): this; + prependListener?(event: string, listener: Func): this; + prependOnceListener?(event: string, listener: Func): this; + removeListener?(event: string, listener: Func): this; + off?(event: string, listener: Func): this; + removeAllListeners?(event?: string): this; +} + /** * Store a map for each event of all original listeners and their "patched" * version. So when a listener is removed by the user, the corresponding diff --git a/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts b/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts index 3fd89f28af7c..257c6c27f041 100644 --- a/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts +++ b/packages/vercel-edge/src/vendored/async-local-storage-context-manager.ts @@ -28,10 +28,17 @@ import type { Context } from '@opentelemetry/api'; import { ROOT_CONTEXT } from '@opentelemetry/api'; import { debug, GLOBAL_OBJ } from '@sentry/core'; -import type { AsyncLocalStorage } from 'async_hooks'; import { DEBUG_BUILD } from '../debug-build'; import { AbstractAsyncHooksContextManager } from './abstract-async-hooks-context-manager'; +// Inline AsyncLocalStorage interface to avoid Node.js module dependency +// This prevents Node.js type leaks in edge runtime environments +interface AsyncLocalStorage { + getStore(): T | undefined; + run(store: T, callback: (...args: any[]) => R, ...args: any[]): R; + disable(): void; +} + export class AsyncLocalStorageContextManager extends AbstractAsyncHooksContextManager { private _asyncLocalStorage: AsyncLocalStorage; @@ -46,12 +53,11 @@ export class AsyncLocalStorageContextManager extends AbstractAsyncHooksContextMa "Tried to register AsyncLocalStorage async context strategy in a runtime that doesn't support AsyncLocalStorage.", ); - // @ts-expect-error Vendored type shenanigans this._asyncLocalStorage = { getStore() { return undefined; }, - run(_store: unknown, callback: () => Context, ...args: unknown[]) { + run(_store: Context, callback: (...args: any[]) => R, ...args: any[]): R { return callback.apply(this, args); }, disable() { diff --git a/yarn.lock b/yarn.lock index 8687df6cfa53..41c2c2fe1486 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5959,17 +5959,10 @@ dependencies: "@octokit/openapi-types" "^18.0.0" -"@opentelemetry/api-logs@0.204.0": - version "0.204.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.204.0.tgz#c0285aa5c79625a1c424854393902d21732fd76b" - integrity sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw== - dependencies: - "@opentelemetry/api" "^1.3.0" - -"@opentelemetry/api-logs@0.57.2": - version "0.57.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz#d4001b9aa3580367b40fe889f3540014f766cc87" - integrity sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A== +"@opentelemetry/api-logs@0.208.0": + version "0.208.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz#56d3891010a1fa1cf600ba8899ed61b43ace511c" + integrity sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg== dependencies: "@opentelemetry/api" "^1.3.0" @@ -5978,277 +5971,260 @@ resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@opentelemetry/context-async-hooks@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.1.0.tgz#de1de21d9536abfe73769f822b52a59a8c97b083" - integrity sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg== +"@opentelemetry/context-async-hooks@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz#5465f6fad6350f52cf9d95a92907a3a464d50644" + integrity sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ== -"@opentelemetry/core@2.1.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.1.0.tgz#5539f04eb9e5245e000b0c3f77bdfaa07557e3a7" - integrity sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ== +"@opentelemetry/core@2.2.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.2.0.tgz#2f857d7790ff160a97db3820889b5f4cade6eaee" + integrity sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw== dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/instrumentation-amqplib@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.51.0.tgz#1779326433f1ab8a743bbf8e1957e1b1252cf036" - integrity sha512-XGmjYwjVRktD4agFnWBWQXo9SiYHKBxR6Ag3MLXwtLE4R99N3a08kGKM5SC1qOFKIELcQDGFEFT9ydXMH00Luw== +"@opentelemetry/instrumentation-amqplib@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.55.0.tgz#4d1afc47e7690693efa690ed06fbda3acc585a2f" + integrity sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-aws-sdk@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.59.0.tgz#bd612836a6158f1773369a5646984b95f805273d" - integrity sha512-GN/9YGBMb//s0vnchM2jMCkCaIKDB/Piau72fcuqcDXNBffMgu+AA9vCHZD2umriciXLtXJ2GXTh2/yaaHwLIw== +"@opentelemetry/instrumentation-aws-sdk@0.64.0": + version "0.64.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.64.0.tgz#714508bde88be99c936f2191c7ba7f54ccdb5bc0" + integrity sha512-8+Y8IcUfME5jD03LISBcd9sFipgOon2uAoiLKSCroiGD6MPuwMzqlVvhlKSzq7uxwtZIhR6CTmjCpLsCHum59A== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.34.0" -"@opentelemetry/instrumentation-connect@0.48.0": - version "0.48.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.48.0.tgz#4481c84315b33b54a67c6e787be0eb72a84b23b3" - integrity sha512-OMjc3SFL4pC16PeK+tDhwP7MRvDPalYCGSvGqUhX5rASkI2H0RuxZHOWElYeXkV0WP+70Gw6JHWac/2Zqwmhdw== +"@opentelemetry/instrumentation-connect@0.52.0": + version "0.52.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.52.0.tgz#60cde91c548e9da4528ae47fe69af41d05eeb485" + integrity sha512-GXPxfNB5szMbV3I9b7kNWSmQBoBzw7MT0ui6iU/p+NIzVx3a06Ri2cdQO7tG9EKb4aKSLmfX9Cw5cKxXqX6Ohg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.27.0" "@types/connect" "3.4.38" -"@opentelemetry/instrumentation-dataloader@0.22.0": - version "0.22.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.22.0.tgz#a34f8ac6ec18e8f1585dcd89f9f611570868d1a2" - integrity sha512-bXnTcwtngQsI1CvodFkTemrrRSQjAjZxqHVc+CJZTDnidT0T6wt3jkKhnsjU/Kkkc0lacr6VdRpCu2CUWa0OKw== +"@opentelemetry/instrumentation-dataloader@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.26.0.tgz#d10d22854ee8eac4471c82b8862b177a40f3bf8e" + integrity sha512-P2BgnFfTOarZ5OKPmYfbXfDFjQ4P9WkQ1Jji7yH5/WwB6Wm/knynAoA1rxbjWcDlYupFkyT0M1j6XLzDzy0aCA== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-express@0.53.0": - version "0.53.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.53.0.tgz#902634e3de640bd4fa5370924397e716608ecb90" - integrity sha512-r/PBafQmFYRjuxLYEHJ3ze1iBnP2GDA1nXOSS6E02KnYNZAVjj6WcDA1MSthtdAUUK0XnotHvvWM8/qz7DMO5A== +"@opentelemetry/instrumentation-express@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.57.0.tgz#7a2a7e90a84ad6c109f42c15acabdc7f6646a412" + integrity sha512-HAdx/o58+8tSR5iW+ru4PHnEejyKrAy9fYFhlEI81o10nYxrGahnMAHWiSjhDC7UQSY3I4gjcPgSKQz4rm/asg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-fs@0.24.0": - version "0.24.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.24.0.tgz#edf0f7418f6a1cdcbe135857ab75629e7d94b910" - integrity sha512-HjIxJ6CBRD770KNVaTdMXIv29Sjz4C1kPCCK5x1Ujpc6SNnLGPqUVyJYZ3LUhhnHAqdbrl83ogVWjCgeT4Q0yw== +"@opentelemetry/instrumentation-fs@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.28.0.tgz#6387fb7c19213afa31a2eb1b646d6356b95176bf" + integrity sha512-FFvg8fq53RRXVBRHZViP+EMxMR03tqzEGpuq55lHNbVPyFklSVfQBN50syPhK5UYYwaStx0eyCtHtbRreusc5g== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-generic-pool@0.48.0": - version "0.48.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.48.0.tgz#76fc08d76515db04f3833d730c5cb18cb0b237d4" - integrity sha512-TLv/On8pufynNR+pUbpkyvuESVASZZKMlqCm4bBImTpXKTpqXaJJ3o/MUDeMlM91rpen+PEv2SeyOKcHCSlgag== +"@opentelemetry/instrumentation-generic-pool@0.52.0": + version "0.52.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.52.0.tgz#12b57774ca3664edb9649687674320955e025906" + integrity sha512-ISkNcv5CM2IwvsMVL31Tl61/p2Zm2I2NAsYq5SSBgOsOndT0TjnptjufYVScCnD5ZLD1tpl4T3GEYULLYOdIdQ== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-graphql@0.52.0": - version "0.52.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.52.0.tgz#a2d23a669bdd0a1b031f785fe447d5a34ac56343" - integrity sha512-3fEJ8jOOMwopvldY16KuzHbRhPk8wSsOTSF0v2psmOCGewh6ad+ZbkTx/xyUK9rUdUMWAxRVU0tFpj4Wx1vkPA== +"@opentelemetry/instrumentation-graphql@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.56.0.tgz#77464dec65efe5aa53d8787d0760534cf2e2a88f" + integrity sha512-IPvNk8AFoVzTAM0Z399t34VDmGDgwT6rIqCUug8P9oAGerl2/PEIYMPOl/rerPGu+q8gSWdmbFSjgg7PDVRd3Q== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-hapi@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.51.0.tgz#879926dfbb2e1609cc8658392167b1456c75d9e0" - integrity sha512-qyf27DaFNL1Qhbo/da+04MSCw982B02FhuOS5/UF+PMhM61CcOiu7fPuXj8TvbqyReQuJFljXE6UirlvoT/62g== +"@opentelemetry/instrumentation-hapi@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.55.0.tgz#a687b9bddfcc484f2cc85f022c123f83c19883a4" + integrity sha512-prqAkRf9e4eEpy4G3UcR32prKE8NLNlA90TdEU1UsghOTg0jUvs40Jz8LQWFEs5NbLbXHYGzB4CYVkCI8eWEVQ== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-http@0.204.0": - version "0.204.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.204.0.tgz#faaf009b75e6a68729923b0a2a5270dc7d336f1d" - integrity sha512-1afJYyGRA4OmHTv0FfNTrTAzoEjPQUYgd+8ih/lX0LlZBnGio/O80vxA0lN3knsJPS7FiDrsDrWq25K7oAzbkw== +"@opentelemetry/instrumentation-http@0.208.0": + version "0.208.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.208.0.tgz#64fcc02bfbc80eb3bbb91cd3c7e0e24c695f2bef" + integrity sha512-rhmK46DRWEbQQB77RxmVXGyjs6783crXCnFjYQj+4tDH/Kpv9Rbg3h2kaNyp5Vz2emF1f9HOQQvZoHzwMWOFZQ== dependencies: - "@opentelemetry/core" "2.1.0" - "@opentelemetry/instrumentation" "0.204.0" + "@opentelemetry/core" "2.2.0" + "@opentelemetry/instrumentation" "0.208.0" "@opentelemetry/semantic-conventions" "^1.29.0" forwarded-parse "2.1.2" -"@opentelemetry/instrumentation-ioredis@0.52.0": - version "0.52.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.52.0.tgz#ca5d7b1a49798ed2d29a0f212a7ca5ef95c173c5" - integrity sha512-rUvlyZwI90HRQPYicxpDGhT8setMrlHKokCtBtZgYxQWRF5RBbG4q0pGtbZvd7kyseuHbFpA3I/5z7M8b/5ywg== +"@opentelemetry/instrumentation-ioredis@0.56.0": + version "0.56.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.56.0.tgz#9b89cca6c3e440ae9e896f81dc6d2ab1dfee2581" + integrity sha512-XSWeqsd3rKSsT3WBz/JKJDcZD4QYElZEa0xVdX8f9dh4h4QgXhKRLorVsVkK3uXFbC2sZKAS2Ds+YolGwD83Dg== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/redis-common" "^0.38.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/redis-common" "^0.38.2" -"@opentelemetry/instrumentation-kafkajs@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.14.0.tgz#ffc30728b5845907d2c5b9f3883676c754ef4927" - integrity sha512-kbB5yXS47dTIdO/lfbbXlzhvHFturbux4EpP0+6H78Lk0Bn4QXiZQW7rmZY1xBCY16mNcCb8Yt0mhz85hTnSVA== +"@opentelemetry/instrumentation-kafkajs@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.18.0.tgz#b836e6883afb7ca6df9fd3b6e024408dcc5e584b" + integrity sha512-KCL/1HnZN5zkUMgPyOxfGjLjbXjpd4odDToy+7c+UsthIzVLFf99LnfIBE8YSSrYE4+uS7OwJMhvhg3tWjqMBg== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-knex@0.49.0": - version "0.49.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.49.0.tgz#8c04c80c00ead5fbdf600cd2460dcd21b4069157" - integrity sha512-NKsRRT27fbIYL4Ix+BjjP8h4YveyKc+2gD6DMZbr5R5rUeDqfC8+DTfIt3c3ex3BIc5Vvek4rqHnN7q34ZetLQ== +"@opentelemetry/instrumentation-knex@0.53.0": + version "0.53.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.53.0.tgz#c2158c9259ff6789f6c2849bfd3c319edc0fcdf6" + integrity sha512-xngn5cH2mVXFmiT1XfQ1aHqq1m4xb5wvU6j9lSgLlihJ1bXzsO543cpDwjrZm2nMrlpddBf55w8+bfS4qDh60g== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.33.1" -"@opentelemetry/instrumentation-koa@0.52.0": - version "0.52.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.52.0.tgz#7266785ea85334366c3e50dc2b45468df438eb3f" - integrity sha512-JJSBYLDx/mNSy8Ibi/uQixu2rH0bZODJa8/cz04hEhRaiZQoeJ5UrOhO/mS87IdgVsHrnBOsZ6vDu09znupyuA== +"@opentelemetry/instrumentation-koa@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.57.0.tgz#9a9edcde7de472f7f03904c00d31d87c6ee0ee42" + integrity sha512-3JS8PU/D5E3q295mwloU2v7c7/m+DyCqdu62BIzWt+3u9utjxC9QS7v6WmUNuoDN3RM+Q+D1Gpj13ERo+m7CGg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/semantic-conventions" "^1.36.0" -"@opentelemetry/instrumentation-lru-memoizer@0.49.0": - version "0.49.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.49.0.tgz#6353b877628339e3f07189f4fb15919a73fe1503" - integrity sha512-ctXu+O/1HSadAxtjoEg2w307Z5iPyLOMM8IRNwjaKrIpNAthYGSOanChbk1kqY6zU5CrpkPHGdAT6jk8dXiMqw== +"@opentelemetry/instrumentation-lru-memoizer@0.53.0": + version "0.53.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.53.0.tgz#936c05263b719ee66999a9240b82fded044ebd2c" + integrity sha512-LDwWz5cPkWWr0HBIuZUjslyvijljTwmwiItpMTHujaULZCxcYE9eU44Qf/pbVC8TulT0IhZi+RoGvHKXvNhysw== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-mongodb@0.57.0": - version "0.57.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.57.0.tgz#e697261b2eac05280134e1851b72c89d5b4b3da8" - integrity sha512-KD6Rg0KSHWDkik+qjIOWoksi1xqSpix8TSPfquIK1DTmd9OTFb5PHmMkzJe16TAPVEuElUW8gvgP59cacFcrMQ== +"@opentelemetry/instrumentation-mongodb@0.61.0": + version "0.61.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.61.0.tgz#4db130d537d630c3089115d2d214d29bcfb49f41" + integrity sha512-OV3i2DSoY5M/pmLk+68xr5RvkHU8DRB3DKMzYJdwDdcxeLs62tLbkmRyqJZsYf3Ht7j11rq35pHOWLuLzXL7pQ== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-mongoose@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.51.0.tgz#688e9f3448e3d0979c4aaab5b566e14f30a1aa72" - integrity sha512-gwWaAlhhV2By7XcbyU3DOLMvzsgeaymwP/jktDC+/uPkCmgB61zurwqOQdeiRq9KAf22Y2dtE5ZLXxytJRbEVA== +"@opentelemetry/instrumentation-mongoose@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.55.0.tgz#e6851aba996b23b9709143c2b640084e92313dea" + integrity sha512-5afj0HfF6aM6Nlqgu6/PPHFk8QBfIe3+zF9FGpX76jWPS0/dujoEYn82/XcLSaW5LPUDW8sni+YeK0vTBNri+w== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" -"@opentelemetry/instrumentation-mysql2@0.51.0": - version "0.51.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.51.0.tgz#7eec3a0b9e4b27759df5df1c82eaedcf34b27528" - integrity sha512-zT2Wg22Xn43RyfU3NOUmnFtb5zlDI0fKcijCj9AcK9zuLZ4ModgtLXOyBJSSfO+hsOCZSC1v/Fxwj+nZJFdzLQ== +"@opentelemetry/instrumentation-mysql2@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.55.0.tgz#a0957590aa8d402d1debd10e42d7b5da359164ec" + integrity sha512-0cs8whQG55aIi20gnK8B7cco6OK6N+enNhW0p5284MvqJ5EPi+I1YlWsWXgzv/V2HFirEejkvKiI4Iw21OqDWg== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" - "@opentelemetry/sql-common" "^0.41.0" + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/semantic-conventions" "^1.33.0" + "@opentelemetry/sql-common" "^0.41.2" -"@opentelemetry/instrumentation-mysql@0.50.0": - version "0.50.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.50.0.tgz#25de9de05191cecf8b01df379544eba50fa6f548" - integrity sha512-duKAvMRI3vq6u9JwzIipY9zHfikN20bX05sL7GjDeLKr2qV0LQ4ADtKST7KStdGcQ+MTN5wghWbbVdLgNcB3rA== +"@opentelemetry/instrumentation-mysql@0.54.0": + version "0.54.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.54.0.tgz#6181ae097a2b5501049c518fe90393e1f136341d" + integrity sha512-bqC1YhnwAeWmRzy1/Xf9cDqxNG2d/JDkaxnqF5N6iJKN1eVWI+vg7NfDkf52/Nggp3tl1jcC++ptC61BD6738A== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" "@types/mysql" "2.15.27" -"@opentelemetry/instrumentation-nestjs-core@0.50.0": - version "0.50.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.50.0.tgz#f803bbeb6c972ac8f0685885cca7f6e5a4e09056" - integrity sha512-10u2Gjw260W8vdUem6pM7ENrb8i+UAyrgouhjN7HRdQYh9rcit51tRhgrI52fxTsRjrrBNIItHkX0YM8WnEU2w== +"@opentelemetry/instrumentation-nestjs-core@0.55.0": + version "0.55.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.55.0.tgz#820391be7ed2b699b49fef55b78619832ac0e0ae" + integrity sha512-JFLNhbbEGnnQrMKOYoXx0nNk5N9cPeghu4xP/oup40a7VaSeYruyOiFbg9nkbS4ZQiI8aMuRqUT3Mo4lQjKEKg== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-pg@0.57.0": - version "0.57.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.57.0.tgz#346cb613ccd1100221cef9692271468a3fe92eb0" - integrity sha512-dWLGE+r5lBgm2A8SaaSYDE3OKJ/kwwy5WLyGyzor8PLhUL9VnJRiY6qhp4njwhnljiLtzeffRtG2Mf/YyWLeTw== +"@opentelemetry/instrumentation-pg@0.61.0": + version "0.61.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.61.0.tgz#c755d00dba640e229fe50f817423dcf3376957ab" + integrity sha512-UeV7KeTnRSM7ECHa3YscoklhUtTQPs6V6qYpG283AB7xpnPGCUCUfECFT9jFg6/iZOQTt3FHkB1wGTJCNZEvPw== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" + "@opentelemetry/instrumentation" "^0.208.0" "@opentelemetry/semantic-conventions" "^1.34.0" - "@opentelemetry/sql-common" "^0.41.0" - "@types/pg" "8.15.5" + "@opentelemetry/sql-common" "^0.41.2" + "@types/pg" "8.15.6" "@types/pg-pool" "2.0.6" -"@opentelemetry/instrumentation-redis@0.53.0": - version "0.53.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.53.0.tgz#826cfeacebaf7ce571bb932ad410f23caf170b9c" - integrity sha512-WUHV8fr+8yo5RmzyU7D5BIE1zwiaNQcTyZPwtxlfr7px6NYYx7IIpSihJK7WA60npWynfxxK1T67RAVF0Gdfjg== +"@opentelemetry/instrumentation-redis@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.57.0.tgz#c6996eb8ace9cb16cf5be3db3a6b0fb599f47fab" + integrity sha512-bCxTHQFXzrU3eU1LZnOZQ3s5LURxQPDlU3/upBzlWY77qOI1GZuGofazj3jtzjctMJeBEJhNwIFEgRPBX1kp/Q== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/redis-common" "^0.38.0" + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/redis-common" "^0.38.2" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-tedious@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.23.0.tgz#a781de2cb33ff71ef65bbefba11c9fe2d79c4b32" - integrity sha512-3TMTk/9VtlRonVTaU4tCzbg4YqW+Iq/l5VnN2e5whP6JgEg/PKfrGbqQ+CxQWNLfLaQYIUgEZqAn5gk/inh1uQ== +"@opentelemetry/instrumentation-tedious@0.27.0": + version "0.27.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.27.0.tgz#f4ba662fd17edde80f1b14d0dc4c42c7fa4a3139" + integrity sha512-jRtyUJNZppPBjPae4ZjIQ2eqJbcRaRfJkr0lQLHFmOU/no5A6e9s1OHLd5XZyZoBJ/ymngZitanyRRA5cniseA== dependencies: - "@opentelemetry/instrumentation" "^0.204.0" - "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation" "^0.208.0" "@types/tedious" "^4.0.14" -"@opentelemetry/instrumentation-undici@0.15.0": - version "0.15.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.15.0.tgz#c8193a162d4abe61c2fd247912e0cb8c0c3bc10c" - integrity sha512-sNFGA/iCDlVkNjzTzPRcudmI11vT/WAfAguRdZY9IspCw02N4WSC72zTuQhSMheh2a1gdeM9my1imnKRvEEvEg== +"@opentelemetry/instrumentation-undici@0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.19.0.tgz#a9db59a7630261269239d17d2990d406e2ecddf8" + integrity sha512-Pst/RhR61A2OoZQZkn6OLpdVpXp6qn3Y92wXa6umfJe9rV640r4bc6SWvw4pPN6DiQqPu2c8gnSSZPDtC6JlpQ== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.204.0" - -"@opentelemetry/instrumentation@0.204.0", "@opentelemetry/instrumentation@^0.204.0": - version "0.204.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.204.0.tgz#587c104c02c9ccb38932ce508d9c81514ec7a7c4" - integrity sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g== - dependencies: - "@opentelemetry/api-logs" "0.204.0" - import-in-the-middle "^1.8.1" - require-in-the-middle "^7.1.1" - -"@opentelemetry/instrumentation@^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0": - version "0.57.2" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz#8924549d7941ba1b5c6f04d5529cf48330456d1d" - integrity sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg== - dependencies: - "@opentelemetry/api-logs" "0.57.2" - "@types/shimmer" "^1.2.0" - import-in-the-middle "^1.8.1" - require-in-the-middle "^7.1.1" - semver "^7.5.2" - shimmer "^1.2.1" - -"@opentelemetry/redis-common@^0.38.0": - version "0.38.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.0.tgz#87d2a792dcbcf466a41bb7dfb8a7cd094d643d0b" - integrity sha512-4Wc0AWURII2cfXVVoZ6vDqK+s5n4K5IssdrlVrvGsx6OEOKdghKtJZqXAHWFiZv4nTDLH2/2fldjIHY8clMOjQ== - -"@opentelemetry/resources@2.1.0", "@opentelemetry/resources@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.1.0.tgz#11772e732af4f27953cf55567a6630d8b4d8282d" - integrity sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw== + "@opentelemetry/instrumentation" "^0.208.0" + "@opentelemetry/semantic-conventions" "^1.24.0" + +"@opentelemetry/instrumentation@0.208.0", "@opentelemetry/instrumentation@>=0.52.0 <1", "@opentelemetry/instrumentation@^0.208.0": + version "0.208.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz#d764f8e4329dad50804e2e98f010170c14c4ce8f" + integrity sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA== + dependencies: + "@opentelemetry/api-logs" "0.208.0" + import-in-the-middle "^2.0.0" + require-in-the-middle "^8.0.0" + +"@opentelemetry/redis-common@^0.38.2": + version "0.38.2" + resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz#cefa4f3e79db1cd54f19e233b7dfb56621143955" + integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== + +"@opentelemetry/resources@2.2.0", "@opentelemetry/resources@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.2.0.tgz#b90a950ad98551295b76ea8a0e7efe45a179badf" + integrity sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A== dependencies: - "@opentelemetry/core" "2.1.0" + "@opentelemetry/core" "2.2.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-base@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.1.0.tgz#9d31474824e9ed215f94bf71260d5321f64d402a" - integrity sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ== +"@opentelemetry/sdk-trace-base@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz#ddef9a0afd01a623d8625a3529f2137b05e67d0b" + integrity sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw== dependencies: - "@opentelemetry/core" "2.1.0" - "@opentelemetry/resources" "2.1.0" + "@opentelemetry/core" "2.2.0" + "@opentelemetry/resources" "2.2.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.37.0": - version "1.37.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz#aa2b4fa0b910b66a050c5ddfcac1d262e91a321a" - integrity sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA== +"@opentelemetry/semantic-conventions@^1.24.0", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.36.0", "@opentelemetry/semantic-conventions@^1.37.0": + version "1.38.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz#8b5f415395a7ddb7c8e0c7932171deb9278df1a3" + integrity sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg== -"@opentelemetry/sql-common@^0.41.0": - version "0.41.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sql-common/-/sql-common-0.41.0.tgz#7ddef1ea7fb6338dcca8a9d2485c7dfd53c076b4" - integrity sha512-pmzXctVbEERbqSfiAgdes9Y63xjoOyXcD7B6IXBkVb+vbM7M9U98mn33nGXxPf4dfYR0M+vhcKRZmbSJ7HfqFA== +"@opentelemetry/sql-common@^0.41.2": + version "0.41.2" + resolved "https://registry.yarnpkg.com/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz#7f4a14166cfd6c9ffe89096db1cc75eaf6443b19" + integrity sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ== dependencies: "@opentelemetry/core" "^2.0.0" @@ -6456,12 +6432,12 @@ dependencies: "@prisma/debug" "6.15.0" -"@prisma/instrumentation@6.15.0": - version "6.15.0" - resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.15.0.tgz#40b066dc6b1ea621aa5ae0fd6d54319550b7d8c9" - integrity sha512-6TXaH6OmDkMOQvOxwLZ8XS51hU2v4A3vmE2pSijCIiGRJYyNeMcL6nMHQMyYdZRD8wl7LF3Wzc+AMPMV/9Oo7A== +"@prisma/instrumentation@6.19.0": + version "6.19.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-6.19.0.tgz#46d15adc8bc4a5a3167032eea6d0a7aa64fb7d93" + integrity sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg== dependencies: - "@opentelemetry/instrumentation" "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" + "@opentelemetry/instrumentation" ">=0.52.0 <1" "@protobuf-ts/plugin-framework@^2.0.7", "@protobuf-ts/plugin-framework@^2.9.4": version "2.9.4" @@ -8947,10 +8923,10 @@ dependencies: "@types/pg" "*" -"@types/pg@*", "@types/pg@8.15.5", "@types/pg@^8.6.5": - version "8.15.5" - resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.15.5.tgz#ef43e0f33b62dac95cae2f042888ec7980b30c09" - integrity sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ== +"@types/pg@*", "@types/pg@8.15.6", "@types/pg@^8.6.5": + version "8.15.6" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.15.6.tgz#4df7590b9ac557cbe5479e0074ec1540cbddad9b" + integrity sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ== dependencies: "@types/node" "*" pg-protocol "*" @@ -9097,11 +9073,6 @@ "@types/mime" "*" "@types/node" "*" -"@types/shimmer@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded" - integrity sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg== - "@types/sinon@^17.0.3": version "17.0.3" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.3.tgz#9aa7e62f0a323b9ead177ed23a36ea757141a5fa" @@ -19113,10 +19084,10 @@ import-fresh@^3.0.0, import-fresh@^3.2.1: parent-module "^1.0.0" resolve-from "^4.0.0" -import-in-the-middle@^1.14.2, import-in-the-middle@^1.8.1: - version "1.14.2" - resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-1.14.2.tgz#283661625a88ff7c0462bd2984f77715c3bc967c" - integrity sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw== +import-in-the-middle@^2, import-in-the-middle@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-2.0.0.tgz#295948cee94d0565314824c6bd75379d13e5b1a5" + integrity sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A== dependencies: acorn "^8.14.0" acorn-import-attributes "^1.9.5" @@ -26741,14 +26712,13 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== -require-in-the-middle@^7.1.1: - version "7.2.0" - resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-7.2.0.tgz#b539de8f00955444dc8aed95e17c69b0a4f10fcf" - integrity sha512-3TLx5TGyAY6AOqLBoXmHkNql0HIf2RGbuMgCDT2WO/uGVAPJs6h7Kl+bN6TIZGd9bWhWPwnDnTHGtW8Iu77sdw== +require-in-the-middle@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz#dbde2587f669398626d56b20c868ab87bf01cce4" + integrity sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ== dependencies: - debug "^4.1.1" + debug "^4.3.5" module-details-from-path "^1.0.3" - resolve "^1.22.1" require-package-name@^2.0.1: version "2.0.1" @@ -27487,7 +27457,7 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0, semve resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.0, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3, semver@^7.7.2: +semver@^7.0.0, semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.0, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.6.3, semver@^7.7.2: version "7.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== @@ -27773,11 +27743,6 @@ shikiji@^0.9.12: dependencies: shikiji-core "0.9.19" -shimmer@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337" - integrity sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw== - side-channel-list@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" From 5e7cd0687fc242201f7f1dbf36de5ae686e9ca54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Bignon?= Date: Mon, 24 Nov 2025 10:02:04 +0100 Subject: [PATCH 179/190] feat(node): Add tracing support for AzureOpenAI (#18281) This pull request adds the support to Azure OpenAI client in addition to the existing support of the vanilla OpenAI client. Fixes issue #18280 --- .../tracing/openai/scenario-azure-openai.mjs | 64 +++++++++++++++++++ .../suites/tracing/openai/test.ts | 47 ++++++++++++++ .../openai/v6/scenario-azure-openai.mjs | 64 +++++++++++++++++++ .../suites/tracing/openai/v6/test.ts | 58 +++++++++++++++++ .../tracing/openai/instrumentation.ts | 22 +++++-- 5 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/scenario-azure-openai.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-azure-openai.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/scenario-azure-openai.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-azure-openai.mjs new file mode 100644 index 000000000000..6d519ae8b313 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/scenario-azure-openai.mjs @@ -0,0 +1,64 @@ +import express from 'express'; +import { AzureOpenAI } from 'openai'; + +function startMockOpenAiServer() { + const app = express(); + app.use(express.json()); + + app.post('/azureopenai/deployments/:model/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.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 new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockOpenAiServer(); + + const client = new AzureOpenAI({ + baseURL: `http://localhost:${server.address().port}/azureopenai`, + apiKey: 'mock-api-key', + apiVersion: '2024-02-15-preview', + }); + + 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, + }); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(response)); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts index 116c3a6208fa..a0436d9e5a8b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/test.ts @@ -501,6 +501,53 @@ describe('OpenAI integration', () => { }); }); + createEsmAndCjsTests(__dirname, 'scenario-azure-openai.mjs', 'instrument.mjs', (createRunner, test) => { + test('it works with Azure OpenAI', async () => { + await createRunner() + // First the span that our mock express server is emitting, unrelated to this test + .expect({ + transaction: { + transaction: 'POST /azureopenai/deployments/:model/chat/completions', + }, + }) + .expect({ + transaction: { + transaction: 'chat gpt-3.5-turbo', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + '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.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }, + }, + }, + }) + .start() + .completed(); + }); + }); + createEsmAndCjsTests( __dirname, 'truncation/scenario-message-truncation-completions.mjs', diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-azure-openai.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-azure-openai.mjs new file mode 100644 index 000000000000..6d519ae8b313 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/scenario-azure-openai.mjs @@ -0,0 +1,64 @@ +import express from 'express'; +import { AzureOpenAI } from 'openai'; + +function startMockOpenAiServer() { + const app = express(); + app.use(express.json()); + + app.post('/azureopenai/deployments/:model/chat/completions', (req, res) => { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: req.body.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 new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockOpenAiServer(); + + const client = new AzureOpenAI({ + baseURL: `http://localhost:${server.address().port}/azureopenai`, + apiKey: 'mock-api-key', + apiVersion: '2024-02-15-preview', + }); + + 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, + }); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(response)); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts index 053f3066a1b0..4929325c6790 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v6/test.ts @@ -562,4 +562,62 @@ describe('OpenAI integration (V6)', () => { }, }, ); + + createEsmAndCjsTests( + __dirname, + 'scenario-azure-openai.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('it works with Azure OpenAI (v6)', async () => { + await createRunner() + // First the span that our mock express server is emitting, unrelated to this test + .expect({ + transaction: { + transaction: 'POST /azureopenai/deployments/:model/chat/completions', + }, + }) + .expect({ + transaction: { + transaction: 'chat gpt-3.5-turbo', + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'gen_ai.operation.name': 'chat', + 'sentry.op': 'gen_ai.chat', + 'sentry.origin': 'auto.ai.openai', + '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.response.finish_reasons': '["stop"]', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + 'openai.response.id': 'chatcmpl-mock123', + 'openai.response.model': 'gpt-3.5-turbo', + 'openai.response.timestamp': '2023-03-01T06:31:28.000Z', + 'openai.usage.completion_tokens': 15, + 'openai.usage.prompt_tokens': 10, + }, + op: 'gen_ai.chat', + origin: 'auto.ai.openai', + status: 'ok', + }, + }, + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + openai: '6.0.0', + express: 'latest', + }, + }, + ); }); diff --git a/packages/node/src/integrations/tracing/openai/instrumentation.ts b/packages/node/src/integrations/tracing/openai/instrumentation.ts index e0682185ff0a..b1a577f9a5f4 100644 --- a/packages/node/src/integrations/tracing/openai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/openai/instrumentation.ts @@ -25,6 +25,7 @@ export interface OpenAiIntegration extends Integration { interface PatchedModuleExports { [key: string]: unknown; OpenAI: abstract new (...args: unknown[]) => OpenAiClient; + AzureOpenAI?: abstract new (...args: unknown[]) => OpenAiClient; } /** @@ -56,10 +57,23 @@ export class SentryOpenAiInstrumentation extends InstrumentationBase Date: Mon, 24 Nov 2025 09:11:08 +0000 Subject: [PATCH 180/190] ref(react): Add more guarding against wildcards in lazy route transactions (#18155) Building on top of #17962 Added a few more checks to make sure non-resolved (wildcard) routes are not reported in lazy route pageloads / navigations. - Improved `patchSpanEnd` with a user-configurable wait timeout for potentially slow route resolution. Named this option as `lazyRouteTimeout` and it's defaulted as `idleTimeout` * 3. It may conditionally delay reporting (if the route resolution is still not done by the end of the timeout), but will prevent prematurely sent lazy-route transactions inside that window. - Added extra checks on `updateNavigationSpan` and `handleNavigation` for whether any wildcard still exists in a lazy-route, so they are still marked as open to full resolution. We keep track of pending lazy-route resolutions inside `pendingLazyRouteLoads` - Added a final attempt to update the transaction name with fully-resolved route when the pending resolution is done. Any of these should not affect the behaviour of non-lazy route usage --------- Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com> --- .../react-router-7-lazy-routes/src/index.tsx | 63 +- .../src/pages/Deep.tsx | 12 + .../src/pages/DelayedLazyRoute.tsx | 50 + .../src/pages/Index.tsx | 12 + .../src/pages/deep/Level1Routes.tsx | 11 + .../src/pages/deep/Level2Routes.tsx | 14 + .../src/pages/deep/Level3.tsx | 13 + .../tests/timeout-behaviour.test.ts | 126 +++ .../tests/transactions.test.ts | 295 ++++++ .../src/reactrouter-compat-utils/index.ts | 1 + .../instrumentation.tsx | 846 ++++++++++------ .../src/reactrouter-compat-utils/utils.ts | 5 + .../instrumentation.test.tsx | 943 +++++++++++++++++- .../reactrouter-compat-utils/utils.test.ts | 35 + 14 files changed, 2114 insertions(+), 312 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts 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 521048fd18f4..7787b60be398 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 @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/react'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { - Navigate, PatchRoutesOnNavigationFunction, RouterProvider, createBrowserRouter, @@ -12,6 +11,49 @@ import { useNavigationType, } from 'react-router-dom'; import Index from './pages/Index'; +import Deep from './pages/Deep'; + +function getRuntimeConfig(): { lazyRouteTimeout?: number; idleTimeout?: number } { + if (typeof window === 'undefined') { + return {}; + } + + try { + const url = new URL(window.location.href); + const timeoutParam = url.searchParams.get('timeout'); + const idleTimeoutParam = url.searchParams.get('idleTimeout'); + + let lazyRouteTimeout: number | undefined = undefined; + if (timeoutParam) { + if (timeoutParam === 'Infinity') { + lazyRouteTimeout = Infinity; + } else { + const parsed = parseInt(timeoutParam, 10); + if (!isNaN(parsed)) { + lazyRouteTimeout = parsed; + } + } + } + + let idleTimeout: number | undefined = undefined; + if (idleTimeoutParam) { + const parsed = parseInt(idleTimeoutParam, 10); + if (!isNaN(parsed)) { + idleTimeout = parsed; + } + } + + return { + lazyRouteTimeout, + idleTimeout, + }; + } catch (error) { + console.warn('Failed to read runtime config, falling back to defaults', error); + return {}; + } +} + +const runtimeConfig = getRuntimeConfig(); Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions @@ -25,6 +67,8 @@ Sentry.init({ matchRoutes, trackFetchStreamPerformance: true, enableAsyncRouteHandlers: true, + lazyRouteTimeout: runtimeConfig.lazyRouteTimeout, + idleTimeout: runtimeConfig.idleTimeout, }), ], // We recommend adjusting this value in production, or using tracesSampler @@ -66,8 +110,21 @@ const router = sentryCreateBrowserRouter( element: <>Hello World, }, { - path: '*', - element: , + path: '/delayed-lazy/:id', + lazy: async () => { + // Simulate slow lazy route loading (400ms delay) + await new Promise(resolve => setTimeout(resolve, 400)); + return { + Component: (await import('./pages/DelayedLazyRoute')).default, + }; + }, + }, + { + path: '/deep', + element: , + handle: { + lazyChildren: () => import('./pages/deep/Level1Routes').then(module => module.level2Routes), + }, }, ], { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx new file mode 100644 index 000000000000..c68f7b781e77 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/Deep.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; + +export default function Deep() { + return ( +
+

Deep Route Root

+

You are at the deep route root

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx new file mode 100644 index 000000000000..41e5ba5463be --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/DelayedLazyRoute.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Link, useParams, useLocation, useSearchParams } from 'react-router-dom'; + +const DelayedLazyRoute = () => { + const { id } = useParams<{ id: string }>(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const view = searchParams.get('view') || 'none'; + const source = searchParams.get('source') || 'none'; + + return ( +
+

Delayed Lazy Route

+

ID: {id}

+

{location.pathname}

+ +

{location.hash}

+

View: {view}

+

Source: {source}

+ + +
+ ); +}; + +export default DelayedLazyRoute; 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 3053aa57b887..21b965f571f3 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 @@ -19,6 +19,18 @@ const Index = () => { Navigate to Long Running Lazy Route +
+ + Navigate to Delayed Lazy Parameterized Route + +
+ + Navigate to Delayed Lazy with Query Param + +
+ + Navigate to Deep Nested Route (3 levels, 900ms total) + ); }; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx new file mode 100644 index 000000000000..0e0887b8850b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level1Routes.tsx @@ -0,0 +1,11 @@ +// Delay: 300ms before module loads +await new Promise(resolve => setTimeout(resolve, 300)); + +export const level2Routes = [ + { + path: 'level2', + handle: { + lazyChildren: () => import('./Level2Routes').then(module => module.level3Routes), + }, + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx new file mode 100644 index 000000000000..43671e1b7eee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level2Routes.tsx @@ -0,0 +1,14 @@ +// Delay: 300ms before module loads +await new Promise(resolve => setTimeout(resolve, 300)); + +export const level3Routes = [ + { + path: 'level3/:id', + lazy: async () => { + await new Promise(resolve => setTimeout(resolve, 300)); + return { + Component: (await import('./Level3')).default, + }; + }, + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx new file mode 100644 index 000000000000..e44ecc7da655 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/src/pages/deep/Level3.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +export default function Level3() { + const { id } = useParams(); + return ( +
+

Level 3 Deep Route

+

Deeply nested route loaded!

+

ID: {id}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts new file mode 100644 index 000000000000..281ebc88e52c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-lazy-routes/tests/timeout-behaviour.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('lazyRouteTimeout: Routes load within timeout window', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Route takes ~900ms, timeout allows 1050ms (50 + 1000) + // Routes will load in time → parameterized name + await page.goto('/?idleTimeout=50&timeout=1000'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + // Should get full parameterized route + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); +}); + +test('lazyRouteTimeout: Infinity timeout always waits for routes', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Infinity timeout → waits as long as possible (capped at finalTimeout to prevent indefinite hangs) + await page.goto('/?idleTimeout=50&timeout=Infinity'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + // Should wait for routes to load (up to finalTimeout) and get full route + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); +}); + +test('idleTimeout: Captures all activity with increased timeout', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // High idleTimeout (5000ms) ensures transaction captures all lazy loading activity + await page.goto('/?idleTimeout=5000'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + + // Transaction should wait for full idle timeout (5+ seconds) + const duration = event.timestamp! - event.start_timestamp; + expect(duration).toBeGreaterThan(5.0); + expect(duration).toBeLessThan(7.0); +}); + +test('idleTimeout: Finishes prematurely with low timeout', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Very low idleTimeout (50ms) and lazyRouteTimeout (100ms) + // Transaction finishes quickly, but still gets parameterized route name + await page.goto('/?idleTimeout=50&timeout=100'); + + const navigationLink = page.locator('id=navigation-to-deep'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const event = await transactionPromise; + + expect(event.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); + expect(event.transaction).toBe('/deep/level2/level3/:id'); + expect(event.contexts?.trace?.data?.['sentry.source']).toBe('route'); + + // Transaction should finish quickly (< 200ms) + const duration = event.timestamp! - event.start_timestamp; + expect(duration).toBeLessThan(0.2); +}); + +test('idleTimeout: Pageload on deeply nested route', async ({ page }) => { + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction.includes('deep') + ); + }); + + // Direct pageload to deeply nested route (not navigation) + await page.goto('/deep/level2/level3/12345'); + + const pageloadEvent = await pageloadPromise; + + expect(pageloadEvent.transaction).toBe('/deep/level2/level3/:id'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.idle_span_finish_reason']).toBe('idleTimeout'); +}); 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 e5b9f35042ed..ce8137d7f686 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 @@ -588,3 +588,298 @@ test('Creates separate transactions for rapid consecutive navigations', async ({ expect(secondSpanId).not.toBe(thirdSpanId); expect(firstSpanId).not.toBe(thirdSpanId); }); + +test('Creates pageload transaction with parameterized route for delayed lazy route', async ({ page }) => { + const pageloadPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + await page.goto('/delayed-lazy/123'); + + const pageloadEvent = await pageloadPromise; + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); + await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); + + expect(pageloadEvent.transaction).toBe('/delayed-lazy/:id'); + expect(pageloadEvent.contexts?.trace?.op).toBe('pageload'); + expect(pageloadEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); +}); + +test('Creates navigation transaction with parameterized route for delayed lazy route', async ({ page }) => { + await page.goto('/'); + + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const navigationLink = page.locator('id=navigation-to-delayed-lazy'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const navigationEvent = await navigationPromise; + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); + await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); + + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); +}); + +test('Creates navigation transaction when navigating with query parameters from home to route', async ({ page }) => { + await page.goto('/'); + + // Navigate from / to /delayed-lazy/123?source=homepage + // This should create a navigation transaction with the parameterized route name + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const navigationLink = page.locator('id=navigation-to-delayed-lazy-with-query'); + await expect(navigationLink).toBeVisible(); + await navigationLink.click(); + + const navigationEvent = await navigationPromise; + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-id')).toHaveText('ID: 123'); + await expect(page.locator('id=delayed-lazy-path')).toHaveText('/delayed-lazy/123'); + await expect(page.locator('id=delayed-lazy-search')).toHaveText('?source=homepage'); + await expect(page.locator('id=delayed-lazy-source')).toHaveText('Source: homepage'); + + // Verify the navigation transaction has the correct parameterized route name + // Query parameters should NOT affect the transaction name (still /delayed-lazy/:id) + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Creates separate navigation transaction when changing only query parameters on same route', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + // Wait for the page to fully load + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // Navigate from /delayed-lazy/123 to /delayed-lazy/123?view=detailed + // This is a query-only change on the same route + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const queryLink = page.locator('id=link-to-query-view-detailed'); + await expect(queryLink).toBeVisible(); + await queryLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify query param was updated + await expect(page.locator('id=delayed-lazy-search')).toHaveText('?view=detailed'); + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); + + // Query-only navigation should create a navigation transaction + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Creates separate navigation transactions for multiple query parameter changes', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // First query change: /delayed-lazy/123 -> /delayed-lazy/123?view=detailed + const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const firstQueryLink = page.locator('id=link-to-query-view-detailed'); + await expect(firstQueryLink).toBeVisible(); + await firstQueryLink.click(); + + const firstNavigationEvent = await firstNavigationPromise; + const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: detailed'); + + // Second query change: /delayed-lazy/123?view=detailed -> /delayed-lazy/123?view=list + const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' && + transactionEvent.contexts?.trace?.trace_id !== firstTraceId + ); + }); + + const secondQueryLink = page.locator('id=link-to-query-view-list'); + await expect(secondQueryLink).toBeVisible(); + await secondQueryLink.click(); + + const secondNavigationEvent = await secondNavigationPromise; + const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list'); + + // Both navigations should have created separate transactions + expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + + // Trace IDs should be different (separate transactions) + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(firstTraceId).not.toBe(secondTraceId); +}); + +test('Creates navigation transaction when changing only hash on same route', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // Navigate from /delayed-lazy/123 to /delayed-lazy/123#section1 + // This is a hash-only change on the same route + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const hashLink = page.locator('id=link-to-hash-section1'); + await expect(hashLink).toBeVisible(); + await hashLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify hash was updated + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); + + // Hash-only navigation should create a navigation transaction + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); + +test('Creates separate navigation transactions for multiple hash changes', async ({ page }) => { + await page.goto('/delayed-lazy/123'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + + // First hash change: /delayed-lazy/123 -> /delayed-lazy/123#section1 + const firstNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const firstHashLink = page.locator('id=link-to-hash-section1'); + await expect(firstHashLink).toBeVisible(); + await firstHashLink.click(); + + const firstNavigationEvent = await firstNavigationPromise; + const firstTraceId = firstNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section1'); + + // Second hash change: /delayed-lazy/123#section1 -> /delayed-lazy/123#section2 + const secondNavigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' && + transactionEvent.contexts?.trace?.trace_id !== firstTraceId + ); + }); + + const secondHashLink = page.locator('id=link-to-hash-section2'); + await expect(secondHashLink).toBeVisible(); + await secondHashLink.click(); + + const secondNavigationEvent = await secondNavigationPromise; + const secondTraceId = secondNavigationEvent.contexts?.trace?.trace_id; + + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#section2'); + + // Both navigations should have created separate transactions + expect(firstNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(firstNavigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(secondNavigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(secondNavigationEvent.contexts?.trace?.op).toBe('navigation'); + + // Trace IDs should be different (separate transactions) + expect(firstTraceId).toBeDefined(); + expect(secondTraceId).toBeDefined(); + expect(firstTraceId).not.toBe(secondTraceId); +}); + +test('Creates navigation transaction when changing both query and hash on same route', async ({ page }) => { + await page.goto('/delayed-lazy/123?view=list'); + + const delayedReady = page.locator('id=delayed-lazy-ready'); + await expect(delayedReady).toBeVisible(); + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: list'); + + // Navigate from /delayed-lazy/123?view=list to /delayed-lazy/123?view=grid#results + // This changes both query and hash + const navigationPromise = waitForTransaction('react-router-7-lazy-routes', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.transaction === '/delayed-lazy/:id' + ); + }); + + const queryAndHashLink = page.locator('id=link-to-query-and-hash'); + await expect(queryAndHashLink).toBeVisible(); + await queryAndHashLink.click(); + + const navigationEvent = await navigationPromise; + + // Verify both query and hash were updated + await expect(page.locator('id=delayed-lazy-search')).toHaveText('?view=grid'); + await expect(page.locator('id=delayed-lazy-hash')).toHaveText('#results'); + await expect(page.locator('id=delayed-lazy-view')).toHaveText('View: grid'); + + // Combined query + hash navigation should create a navigation transaction + expect(navigationEvent.transaction).toBe('/delayed-lazy/:id'); + expect(navigationEvent.contexts?.trace?.op).toBe('navigation'); + expect(navigationEvent.contexts?.trace?.data?.['sentry.source']).toBe('route'); + expect(navigationEvent.contexts?.trace?.status).toBe('ok'); +}); diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts index c2b56ec446fb..bb91ba8d3072 100644 --- a/packages/react/src/reactrouter-compat-utils/index.ts +++ b/packages/react/src/reactrouter-compat-utils/index.ts @@ -25,6 +25,7 @@ export { pathEndsWithWildcard, pathIsWildcardAndHasChildren, getNumberOfUrlSegments, + transactionNameHasWildcard, } from './utils'; // Lazy route exports diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 235b207ed9a0..6e19b9021ba5 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -41,59 +41,118 @@ import type { UseRoutes, } from '../types'; import { checkRouteForAsyncHandler } from './lazy-routes'; -import { initializeRouterUtils, resolveRouteNameAndSource } from './utils'; +import { initializeRouterUtils, resolveRouteNameAndSource, transactionNameHasWildcard } from './utils'; let _useEffect: UseEffect; let _useLocation: UseLocation; let _useNavigationType: UseNavigationType; let _createRoutesFromChildren: CreateRoutesFromChildren; let _matchRoutes: MatchRoutes; + let _enableAsyncRouteHandlers: boolean = false; +let _lazyRouteTimeout = 3000; const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); +// Prevents duplicate spans when router.subscribe fires multiple times +const activeNavigationSpans = new WeakMap< + Client, + { span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean } +>(); + +// Exported for testing only +export const allRoutes = new Set(); + +// Tracks lazy route loads to wait before finalizing span names +const pendingLazyRouteLoads = new WeakMap>>(); + /** - * Tracks last navigation per client to prevent duplicate spans in cross-usage scenarios. - * Entry persists until the navigation span ends, allowing cross-usage detection during delayed wrapper execution. + * Schedules a callback using requestAnimationFrame when available (browser), + * or falls back to setTimeout for SSR environments (Node.js, createMemoryRouter tests). */ -const LAST_NAVIGATION_PER_CLIENT = new WeakMap(); +function scheduleCallback(callback: () => void): number { + if (WINDOW?.requestAnimationFrame) { + return WINDOW.requestAnimationFrame(callback); + } + return setTimeout(callback, 0) as unknown as number; +} -export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void { - const existingChildren = parentRoute.children || []; +/** + * Cancels a scheduled callback, handling both RAF (browser) and timeout (SSR) IDs. + */ +function cancelScheduledCallback(id: number): void { + if (WINDOW?.cancelAnimationFrame) { + WINDOW.cancelAnimationFrame(id); + } else { + clearTimeout(id); + } +} - const newRoutes = resolvedRoutes.filter( - newRoute => - !existingChildren.some( - existing => - existing === newRoute || - (newRoute.path && existing.path === newRoute.path) || - (newRoute.id && existing.id === newRoute.id), - ), - ); +/** + * Computes location key for duplicate detection. Normalizes undefined/null to empty strings. + * Exported for testing. + */ +export function computeLocationKey(location: Location): string { + return `${location.pathname}${location.search || ''}${location.hash || ''}`; +} - if (newRoutes.length > 0) { - parentRoute.children = [...existingChildren, ...newRoutes]; - } +/** + * Checks if a route name is parameterized (contains route parameters like :id or wildcards like *) + * vs a raw URL path. + */ +function isParameterizedRoute(routeName: string): boolean { + return routeName.includes(':') || routeName.includes('*'); } /** - * Determines if a navigation should be handled based on router state. - * Only handles: - * - PUSH navigations (always) - * - POP navigations (only after initial pageload is complete) - * - When router state is 'idle' (not 'loading' or 'submitting') + * Determines if a navigation should be skipped as a duplicate, and if an existing span should be updated. + * Exported for testing. * - * During 'loading' or 'submitting', state.location may still have the old pathname, - * which would cause us to create a span for the wrong route. + * @returns An object with: + * - skip: boolean - Whether to skip creating a new span + * - shouldUpdate: boolean - Whether to update the existing span name (wildcard upgrade) */ -function shouldHandleNavigation( - state: { historyAction: string; navigation: { state: string } }, - isInitialPageloadComplete: boolean, -): boolean { - return ( - (state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete)) && - state.navigation.state === 'idle' - ); +export function shouldSkipNavigation( + trackedNav: + | { span: Span; routeName: string; pathname: string; locationKey: string; isPlaceholder?: boolean } + | undefined, + locationKey: string, + proposedName: string, + spanHasEnded: boolean, +): { skip: boolean; shouldUpdate: boolean } { + if (!trackedNav) { + return { skip: false, shouldUpdate: false }; + } + + // Check if this is a duplicate navigation (same location) + // 1. If it's a placeholder, it's always a duplicate (we're waiting for the real one) + // 2. If it's a real span, it's a duplicate only if it hasn't ended yet + const isDuplicate = trackedNav.locationKey === locationKey && (trackedNav.isPlaceholder || !spanHasEnded); + + if (isDuplicate) { + // Check if we should update the span name with a better route + // Allow updates if: + // 1. Current has wildcard and new doesn't (wildcard → parameterized upgrade) + // 2. Current is raw path and new is parameterized (raw → parameterized upgrade) + // 3. New name is different and more specific (longer, indicating nested routes resolved) + const currentHasWildcard = !!trackedNav.routeName && transactionNameHasWildcard(trackedNav.routeName); + const proposedHasWildcard = transactionNameHasWildcard(proposedName); + const currentIsParameterized = !!trackedNav.routeName && isParameterizedRoute(trackedNav.routeName); + const proposedIsParameterized = isParameterizedRoute(proposedName); + + const isWildcardUpgrade = currentHasWildcard && !proposedHasWildcard; + const isRawToParameterized = !currentIsParameterized && proposedIsParameterized; + const isMoreSpecific = + proposedName !== trackedNav.routeName && + proposedName.length > (trackedNav.routeName?.length || 0) && + !proposedHasWildcard; + + const shouldUpdate = !!(trackedNav.routeName && (isWildcardUpgrade || isRawToParameterized || isMoreSpecific)); + + return { skip: true, shouldUpdate }; + } + + return { skip: false, shouldUpdate: false }; } export interface ReactRouterOptions { @@ -116,13 +175,58 @@ export interface ReactRouterOptions { * @default false */ enableAsyncRouteHandlers?: boolean; + + /** + * Maximum time (in milliseconds) to wait for lazy routes to load before finalizing span names. + * + * - Set to `0` to not wait at all (immediate finalization) + * - Set to `Infinity` to wait as long as possible (capped at `finalTimeout` to prevent indefinite hangs) + * - Negative values will fall back to the default + * + * Defaults to 3× the configured `idleTimeout` (default: 3000ms). + * + * @default idleTimeout * 3 + */ + lazyRouteTimeout?: number; } type V6CompatibleVersion = '6' | '7'; -// Keeping as a global variable for cross-usage in multiple functions -// only exported for testing purposes -export const allRoutes = new Set(); +export function addResolvedRoutesToParent(resolvedRoutes: RouteObject[], parentRoute: RouteObject): void { + const existingChildren = parentRoute.children || []; + + const newRoutes = resolvedRoutes.filter( + newRoute => + !existingChildren.some( + existing => + existing === newRoute || + (newRoute.path && existing.path === newRoute.path) || + (newRoute.id && existing.id === newRoute.id), + ), + ); + + if (newRoutes.length > 0) { + parentRoute.children = [...existingChildren, ...newRoutes]; + } +} + +/** Registers a pending lazy route load promise for a span. */ +function trackLazyRouteLoad(span: Span, promise: Promise): void { + let promises = pendingLazyRouteLoads.get(span); + if (!promises) { + promises = new Set(); + pendingLazyRouteLoads.set(span, promises); + } + promises.add(promise); + + // Clean up when promise resolves/rejects + promise.finally(() => { + const currentPromises = pendingLazyRouteLoads.get(span); + if (currentPromises) { + currentPromises.delete(promise); + } + }); +} /** * Processes resolved routes by adding them to allRoutes and checking for nested async handlers. @@ -188,13 +292,14 @@ export function updateNavigationSpan( forceUpdate = false, matchRoutes: MatchRoutes, ): void { - // Check if this span has already been named to avoid multiple updates - // But allow updates if this is a forced update (e.g., when lazy routes are loaded) - const hasBeenNamed = - !forceUpdate && (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; + const spanJson = spanToJSON(activeRootSpan); + const currentName = spanJson.description; - if (!hasBeenNamed) { - // Get fresh branches for the current location with all loaded routes + const hasBeenNamed = (activeRootSpan as { __sentry_navigation_name_set__?: boolean })?.__sentry_navigation_name_set__; + const currentNameHasWildcard = currentName && transactionNameHasWildcard(currentName); + const shouldUpdate = !hasBeenNamed || forceUpdate || currentNameHasWildcard; + + if (shouldUpdate && !spanJson.timestamp) { const currentBranches = matchRoutes(allRoutes, location); const [name, source] = resolveRouteNameAndSource( location, @@ -204,22 +309,105 @@ export function updateNavigationSpan( '', ); - // Only update if we have a valid name and the span hasn't finished - const spanJson = spanToJSON(activeRootSpan); - if (name && !spanJson.timestamp) { + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + const isImprovement = + name && + (!currentName || // No current name - always set + (!hasBeenNamed && (currentSource !== 'route' || source === 'route')) || // Not finalized - allow unless downgrading route→url + (currentSource !== 'route' && source === 'route') || // URL → route upgrade + (currentSource === 'route' && source === 'route' && currentNameHasWildcard)); // Route → better route (only if current has wildcard) + if (isImprovement) { activeRootSpan.updateName(name); activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - // Mark this span as having its name set to prevent future updates - addNonEnumerableProperty( - activeRootSpan as { __sentry_navigation_name_set__?: boolean }, - '__sentry_navigation_name_set__', - true, - ); + // Only mark as finalized for non-wildcard route names (allows URL→route upgrades). + if (!transactionNameHasWildcard(name) && source === 'route') { + addNonEnumerableProperty( + activeRootSpan as { __sentry_navigation_name_set__?: boolean }, + '__sentry_navigation_name_set__', + true, + ); + } } } } +function setupRouterSubscription( + router: Router, + routes: RouteObject[], + version: V6CompatibleVersion, + basename: string | undefined, + activeRootSpan: Span | undefined, +): void { + let isInitialPageloadComplete = false; + let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).op === 'pageload'; + let hasSeenPopAfterPageload = false; + let scheduledNavigationHandler: number | null = null; + let lastHandledPathname: string | null = null; + + router.subscribe((state: RouterState) => { + if (!isInitialPageloadComplete) { + const currentRootSpan = getActiveRootSpan(); + const isCurrentlyInPageload = currentRootSpan && spanToJSON(currentRootSpan).op === 'pageload'; + + if (isCurrentlyInPageload) { + hasSeenPageloadSpan = true; + } else if (hasSeenPageloadSpan) { + if (state.historyAction === 'POP' && !hasSeenPopAfterPageload) { + hasSeenPopAfterPageload = true; + } else { + isInitialPageloadComplete = true; + } + } + } + + const shouldHandleNavigation = + state.historyAction === 'PUSH' || (state.historyAction === 'POP' && isInitialPageloadComplete); + + if (shouldHandleNavigation) { + // Include search and hash to allow query/hash-only navigations + // Use computeLocationKey() to ensure undefined/null values are normalized to empty strings + const currentLocationKey = computeLocationKey(state.location); + const navigationHandler = (): void => { + // Prevent multiple calls for the same location within the same navigation cycle + if (lastHandledPathname === currentLocationKey) { + return; + } + lastHandledPathname = currentLocationKey; + scheduledNavigationHandler = null; + handleNavigation({ + location: state.location, + routes, + navigationType: state.historyAction, + version, + basename, + allRoutes: Array.from(allRoutes), + }); + }; + + if (state.navigation.state !== 'idle') { + // Navigation in progress - reset if location changed + if (lastHandledPathname !== currentLocationKey) { + lastHandledPathname = null; + } + // Cancel any previously scheduled handler to avoid duplicates + if (scheduledNavigationHandler !== null) { + cancelScheduledCallback(scheduledNavigationHandler); + } + scheduledNavigationHandler = scheduleCallback(navigationHandler); + } else { + // Navigation completed - cancel scheduled handler if any, then call immediately + if (scheduledNavigationHandler !== null) { + cancelScheduledCallback(scheduledNavigationHandler); + scheduledNavigationHandler = null; + } + navigationHandler(); + // Don't reset - next navigation cycle resets to prevent duplicates within same cycle. + } + } + }); +} + /** * Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions. */ @@ -242,30 +430,17 @@ export function createV6CompatibleWrapCreateBrowserRouter< return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { addRoutesToAllRoutes(routes); - // Check for async handlers that might contain sub-route declarations (only if enabled) if (_enableAsyncRouteHandlers) { for (const route of routes) { checkRouteForAsyncHandler(route, processResolvedRoutes); } } - // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded const wrappedOpts = wrapPatchRoutesOnNavigation(opts); - const router = createRouterFunction(routes, wrappedOpts); const basename = opts?.basename; - 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. if (router.state.historyAction === 'POP' && activeRootSpan) { updatePageloadTransaction({ activeRootSpan, @@ -276,38 +451,7 @@ export function createV6CompatibleWrapCreateBrowserRouter< }); } - 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) - } - - if (shouldHandleNavigation(state, isInitialPageloadComplete)) { - handleNavigation({ - location: state.location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); - } - }); + setupRouterSubscription(router, routes, version, basename, activeRootSpan); return router; }; @@ -342,14 +486,12 @@ export function createV6CompatibleWrapCreateMemoryRouter< ): TRouter { addRoutesToAllRoutes(routes); - // Check for async handlers that might contain sub-route declarations (only if enabled) if (_enableAsyncRouteHandlers) { for (const route of routes) { checkRouteForAsyncHandler(route, processResolvedRoutes); } } - // Wrap patchRoutesOnNavigation to detect when lazy routes are loaded const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true); const router = createRouterFunction(routes, wrappedOpts); @@ -387,44 +529,7 @@ export function createV6CompatibleWrapCreateMemoryRouter< }); } - // 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) - } - - if (shouldHandleNavigation(state, isInitialPageloadComplete)) { - handleNavigation({ - location: state.location, - routes, - navigationType: state.historyAction, - version, - basename, - allRoutes: Array.from(allRoutes), - }); - } - }); + setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan); return router; }; @@ -449,6 +554,7 @@ export function createReactRouterV6CompatibleTracingIntegration( enableAsyncRouteHandlers = false, instrumentPageLoad = true, instrumentNavigation = true, + lazyRouteTimeout, } = options; return { @@ -456,6 +562,36 @@ export function createReactRouterV6CompatibleTracingIntegration( setup(client) { integration.setup(client); + const finalTimeout = options.finalTimeout ?? 30000; + const defaultMaxWait = (options.idleTimeout ?? 1000) * 3; + const configuredMaxWait = lazyRouteTimeout ?? defaultMaxWait; + + // Cap Infinity at finalTimeout to prevent indefinite hangs + if (configuredMaxWait === Infinity) { + _lazyRouteTimeout = finalTimeout; + DEBUG_BUILD && + debug.log( + '[React Router] lazyRouteTimeout set to Infinity, capping at finalTimeout:', + finalTimeout, + 'ms to prevent indefinite hangs', + ); + } else if (Number.isNaN(configuredMaxWait)) { + DEBUG_BUILD && + debug.warn('[React Router] lazyRouteTimeout must be a number, falling back to default:', defaultMaxWait); + _lazyRouteTimeout = defaultMaxWait; + } else if (configuredMaxWait < 0) { + DEBUG_BUILD && + debug.warn( + '[React Router] lazyRouteTimeout must be non-negative or Infinity, got:', + configuredMaxWait, + 'falling back to:', + defaultMaxWait, + ); + _lazyRouteTimeout = defaultMaxWait; + } else { + _lazyRouteTimeout = configuredMaxWait; + } + _useEffect = useEffect; _useLocation = useLocation; _useNavigationType = useNavigationType; @@ -530,6 +666,9 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio }); isMountRenderPass.current = false; } else { + // Note: Component-based routes don't support lazy route tracking via lazyRouteTimeout + // because React.lazy() loads happen at the component level, not the router level. + // Use createBrowserRouter with patchRoutesOnNavigation for lazy route tracking. handleNavigation({ location: normalizedLocation, routes, @@ -564,7 +703,8 @@ function wrapPatchRoutesOnNavigation( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const targetPath = (args as any)?.path; - // For browser router, wrap the patch function to update span during patching + const activeRootSpan = getActiveRootSpan(); + if (!isMemoryRouter) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const originalPatch = (args as any)?.patch; @@ -572,13 +712,13 @@ function wrapPatchRoutesOnNavigation( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (args as any).patch = (routeId: string, children: RouteObject[]) => { addRoutesToAllRoutes(children); - const activeRootSpan = getActiveRootSpan(); - if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { + const currentActiveRootSpan = getActiveRootSpan(); + if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { updateNavigationSpan( - activeRootSpan, + currentActiveRootSpan, { pathname: targetPath, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), - true, // forceUpdate = true since we're loading lazy routes + true, _matchRoutes, ); } @@ -587,102 +727,37 @@ function wrapPatchRoutesOnNavigation( } } - const result = await originalPatchRoutes(args); - - // Update navigation span after routes are patched - const activeRootSpan = getActiveRootSpan(); - if (activeRootSpan && (spanToJSON(activeRootSpan) as { op?: string }).op === 'navigation') { - // 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; + const lazyLoadPromise = (async () => { + const result = await originalPatchRoutes(args); + + const currentActiveRootSpan = getActiveRootSpan(); + if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') { + const pathname = isMemoryRouter ? targetPath : targetPath || WINDOW.location?.pathname; + + if (pathname) { + updateNavigationSpan( + currentActiveRootSpan, + { pathname, search: '', hash: '', state: null, key: 'default' }, + Array.from(allRoutes), + false, + _matchRoutes, + ); + } } - if (pathname) { - updateNavigationSpan( - activeRootSpan, - { pathname, search: '', hash: '', state: null, key: 'default' }, - Array.from(allRoutes), - false, // forceUpdate = false since this is after lazy routes are loaded - _matchRoutes, - ); - } + return result; + })(); + + if (activeRootSpan) { + trackLazyRouteLoad(activeRootSpan, lazyLoadPromise); } - return result; + return lazyLoadPromise; }, }; } -function getNavigationKey(location: Location): string { - return `${location.pathname}${location.search}${location.hash}`; -} - -function tryUpdateSpanName( - activeSpan: Span, - currentSpanName: string | undefined, - newName: string, - newSource: string, -): void { - // Check if the new name contains React Router parameter syntax (/:param/) - const isReactRouterParam = /\/:[a-zA-Z0-9_]+/.test(newName); - const isNewNameParameterized = newName !== currentSpanName && isReactRouterParam; - if (isNewNameParameterized) { - activeSpan.updateName(newName); - activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, newSource as 'route' | 'url' | 'custom'); - } -} - -function isDuplicateNavigation(client: Client, navigationKey: string): boolean { - const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client); - return lastKey === navigationKey; -} - -function createNavigationSpan(opts: { - client: Client; - name: string; - source: string; - version: string; - location: Location; - routes: RouteObject[]; - basename?: string; - allRoutes?: RouteObject[]; - navigationKey: string; -}): Span | undefined { - const { client, name, source, version, location, routes, basename, allRoutes, navigationKey } = opts; - - const navigationSpan = startBrowserTracingNavigationSpan(client, { - name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source as 'route' | 'url' | 'custom', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, - }, - }); - - if (navigationSpan) { - LAST_NAVIGATION_PER_CLIENT.set(client, navigationKey); - patchNavigationSpanEnd(navigationSpan, location, routes, basename, allRoutes); - - const unsubscribe = client.on('spanEnd', endedSpan => { - if (endedSpan === navigationSpan) { - // Clear key only if it's still our key (handles overlapping navigations) - const lastKey = LAST_NAVIGATION_PER_CLIENT.get(client); - if (lastKey === navigationKey) { - LAST_NAVIGATION_PER_CLIENT.delete(client); - } - unsubscribe(); // Prevent memory leak - } - }); - } - - return navigationSpan; -} - +// eslint-disable-next-line complexity export function handleNavigation(opts: { location: Location; routes: RouteObject[]; @@ -714,33 +789,84 @@ export function handleNavigation(opts: { basename, ); - const currentNavigationKey = getNavigationKey(location); - const isNavDuplicate = isDuplicateNavigation(client, currentNavigationKey); + const locationKey = computeLocationKey(location); + const trackedNav = activeNavigationSpans.get(client); + + // Determine if this navigation should be skipped as a duplicate + const trackedSpanHasEnded = + trackedNav && !trackedNav.isPlaceholder ? !!spanToJSON(trackedNav.span).timestamp : false; + const { skip, shouldUpdate } = shouldSkipNavigation(trackedNav, locationKey, name, trackedSpanHasEnded); + + if (skip) { + if (shouldUpdate && trackedNav) { + const oldName = trackedNav.routeName; + + if (trackedNav.isPlaceholder) { + // Update placeholder's route name - the real span will be created with this name + trackedNav.routeName = name; + DEBUG_BUILD && + debug.log( + `[Tracing] Updated placeholder navigation name from "${oldName}" to "${name}" (will apply to real span)`, + ); + } else { + // Update existing real span from wildcard to parameterized route name + trackedNav.span.updateName(name); + trackedNav.span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source as 'route' | 'url' | 'custom'); + addNonEnumerableProperty( + trackedNav.span as { __sentry_navigation_name_set__?: boolean }, + '__sentry_navigation_name_set__', + true, + ); + trackedNav.routeName = name; + DEBUG_BUILD && debug.log(`[Tracing] Updated navigation span name from "${oldName}" to "${name}"`); + } + } else { + DEBUG_BUILD && debug.log(`[Tracing] Skipping duplicate navigation for location: ${locationKey}`); + } + return; + } - if (isNavDuplicate) { - // Cross-usage duplicate - update existing span name if better - const activeSpan = getActiveSpan(); - const spanJson = activeSpan && spanToJSON(activeSpan); - const isAlreadyInNavigationSpan = spanJson?.op === 'navigation'; + // Create new navigation span (first navigation or legitimate new navigation) + // Reserve the spot in the map first to prevent race conditions + // Mark as placeholder to prevent concurrent handleNavigation calls from creating duplicates + const placeholderSpan = { end: () => {} } as unknown as Span; + const placeholderEntry = { + span: placeholderSpan, + routeName: name, + pathname: location.pathname, + locationKey, + isPlaceholder: true as const, + }; + activeNavigationSpans.set(client, placeholderEntry); + + let navigationSpan: Span | undefined; + try { + navigationSpan = startBrowserTracingNavigationSpan(client, { + name: placeholderEntry.routeName, // Use placeholder's routeName in case it was updated + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, + }, + }); + } catch (e) { + // If span creation fails, remove the placeholder so we don't block future navigations + activeNavigationSpans.delete(client); + throw e; + } - if (isAlreadyInNavigationSpan && activeSpan) { - tryUpdateSpanName(activeSpan, spanJson?.description, name, source); - } - } else { - // Not a cross-usage duplicate - create new span - // This handles: different routes, same route with different params (/user/2 → /user/3) - // startBrowserTracingNavigationSpan will end any active navigation span - createNavigationSpan({ - client, - name, - source, - version, - location, - routes, - basename, - allRoutes, - navigationKey: currentNavigationKey, + if (navigationSpan) { + // Update the map with the real span (isPlaceholder omitted, defaults to false) + activeNavigationSpans.set(client, { + span: navigationSpan, + routeName: placeholderEntry.routeName, // Use the (potentially updated) placeholder routeName + pathname: location.pathname, + locationKey, }); + patchSpanEnd(navigationSpan, location, routes, basename, allRoutes, 'navigation'); + } else { + // If no span was created, remove the placeholder + activeNavigationSpans.delete(client); } } } @@ -809,11 +935,93 @@ function updatePageloadTransaction({ activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); // Patch span.end() to ensure we update the name one last time before the span is sent - patchPageloadSpanEnd(activeRootSpan, location, routes, basename, allRoutes); + patchSpanEnd(activeRootSpan, location, routes, basename, allRoutes, 'pageload'); } } } +/** + * Determines if a span name should be updated during wildcard route resolution. + * + * Update conditions (in priority order): + * 1. No current name + allowNoCurrentName: true → always update (pageload spans) + * 2. Current name has wildcard + new is route without wildcard → upgrade (e.g., "/users/*" → "/users/:id") + * 3. Current source is not 'route' + new source is 'route' → upgrade (e.g., URL → parameterized route) + * + * @param currentName - The current span name (may be undefined) + * @param currentSource - The current span source ('route', 'url', or undefined) + * @param newName - The proposed new span name + * @param newSource - The proposed new span source + * @param allowNoCurrentName - If true, allow updates when there's no current name (for pageload spans) + * @returns true if the span name should be updated + */ +function shouldUpdateWildcardSpanName( + currentName: string | undefined, + currentSource: string | undefined, + newName: string, + newSource: string, + allowNoCurrentName = false, +): boolean { + if (!newName) { + return false; + } + + if (!currentName && allowNoCurrentName) { + return true; + } + + const hasWildcard = currentName && transactionNameHasWildcard(currentName); + + if (hasWildcard && newSource === 'route' && !transactionNameHasWildcard(newName)) { + return true; + } + + if (currentSource !== 'route' && newSource === 'route') { + return true; + } + + return false; +} + +function tryUpdateSpanNameBeforeEnd( + span: Span, + spanJson: ReturnType, + currentName: string | undefined, + location: Location, + routes: RouteObject[], + basename: string | undefined, + spanType: 'pageload' | 'navigation', + allRoutes: Set, +): void { + try { + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + + if (currentSource === 'route' && currentName && !transactionNameHasWildcard(currentName)) { + return; + } + + const currentAllRoutes = Array.from(allRoutes); + const routesToUse = currentAllRoutes.length > 0 ? currentAllRoutes : routes; + const branches = _matchRoutes(routesToUse, location, basename) as unknown as RouteMatch[]; + + if (!branches) { + return; + } + + const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, basename); + + const isImprovement = shouldUpdateWildcardSpanName(currentName, currentSource, name, source, true); + const spanNotEnded = spanType === 'pageload' || !spanJson.timestamp; + + if (isImprovement && spanNotEnded) { + span.updateName(name); + span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + } + } catch (error) { + DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + } +} + /** * Patches the span.end() method to update the transaction name one last time before the span is sent. * This handles cases where the span is cancelled early (e.g., document.hidden) before lazy routes have finished loading. @@ -833,71 +1041,93 @@ function patchSpanEnd( return; } + // Use the passed route context, or fall back to global Set + const allRoutesSet = _allRoutes ? new Set(_allRoutes) : allRoutes; + const originalEnd = span.end.bind(span); + let endCalled = false; span.end = function patchedEnd(...args) { - try { - // Only update if the span source is not already 'route' (i.e., it hasn't been parameterized yet) - const spanJson = spanToJSON(span); - const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; - if (currentSource !== 'route') { - // Last chance to update the transaction name with the latest route info - // Use the live global allRoutes Set to include any lazy routes loaded after patching - const currentAllRoutes = Array.from(allRoutes); - const branches = _matchRoutes( - currentAllRoutes.length > 0 ? currentAllRoutes : routes, - location, - basename, - ) as unknown as RouteMatch[]; + if (endCalled) { + return; + } + endCalled = true; + + // Capture timestamp immediately to avoid delay from async operations + // If no timestamp was provided, capture the current time now + const endTimestamp = args.length > 0 ? args[0] : Date.now() / 1000; + + const spanJson = spanToJSON(span); + const currentName = spanJson.description; + const currentSource = spanJson.data?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; + + // Helper to clean up activeNavigationSpans after span ends + const cleanupNavigationSpan = (): void => { + const client = getClient(); + if (client && spanType === 'navigation') { + const trackedNav = activeNavigationSpans.get(client); + if (trackedNav && trackedNav.span === span) { + activeNavigationSpans.delete(client); + } + } + }; + + const pendingPromises = pendingLazyRouteLoads.get(span); + // Wait for lazy routes if: + // 1. There are pending promises AND + // 2. Current name exists AND + // 3. Either the name has a wildcard OR the source is not 'route' (URL-based names) + const shouldWaitForLazyRoutes = + pendingPromises && + pendingPromises.size > 0 && + currentName && + (transactionNameHasWildcard(currentName) || currentSource !== 'route'); + + if (shouldWaitForLazyRoutes) { + if (_lazyRouteTimeout === 0) { + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet); + cleanupNavigationSpan(); + originalEnd(endTimestamp); + return; + } - if (branches) { - const [name, source] = resolveRouteNameAndSource( + const allSettled = Promise.allSettled(pendingPromises).then(() => {}); + const waitPromise = + _lazyRouteTimeout === Infinity + ? allSettled + : Promise.race([allSettled, new Promise(r => setTimeout(r, _lazyRouteTimeout))]); + + waitPromise + .then(() => { + const updatedSpanJson = spanToJSON(span); + tryUpdateSpanNameBeforeEnd( + span, + updatedSpanJson, + updatedSpanJson.description, location, - currentAllRoutes.length > 0 ? currentAllRoutes : routes, - currentAllRoutes.length > 0 ? currentAllRoutes : routes, - branches, + routes, basename, + spanType, + allRoutesSet, ); - - // Only update if we have a valid name - if (name && (spanType === 'pageload' || !spanJson.timestamp)) { - span.updateName(name); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - } - } - } - } catch (error) { - // Silently catch errors to ensure span.end() is always called - DEBUG_BUILD && debug.warn(`Error updating span details before ending: ${error}`); + cleanupNavigationSpan(); + originalEnd(endTimestamp); + }) + .catch(() => { + cleanupNavigationSpan(); + originalEnd(endTimestamp); + }); + return; } - return originalEnd(...args); + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutesSet); + cleanupNavigationSpan(); + originalEnd(endTimestamp); }; - // Mark this span as having its end() method patched to prevent duplicate patching addNonEnumerableProperty(span as unknown as Record, patchedPropertyName, true); } -function patchPageloadSpanEnd( - span: Span, - location: Location, - routes: RouteObject[], - basename: string | undefined, - _allRoutes: RouteObject[] | undefined, -): void { - patchSpanEnd(span, location, routes, basename, _allRoutes, 'pageload'); -} - -function patchNavigationSpanEnd( - span: Span, - location: Location, - routes: RouteObject[], - basename: string | undefined, - _allRoutes: RouteObject[] | undefined, -): void { - patchSpanEnd(span, location, routes, basename, _allRoutes, 'navigation'); -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createV6CompatibleWithSentryReactRouterRouting

, R extends React.FC

>( Routes: R, @@ -933,11 +1163,13 @@ export function createV6CompatibleWithSentryReactRouterRouting

{ return { ...(actual as any), startBrowserTracingNavigationSpan: vi.fn(), + startBrowserTracingPageLoadSpan: vi.fn(), browserTracingIntegration: vi.fn(() => ({ setup: vi.fn(), afterAllSetup: vi.fn(), @@ -49,6 +56,9 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({ getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })), getGlobalPathname: vi.fn(() => '/test'), routeIsDescendant: vi.fn(() => false), + transactionNameHasWildcard: vi.fn((name: string) => { + return name.includes('/*') || name === '*' || name.endsWith('*'); + }), })); vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({ @@ -370,3 +380,932 @@ describe('addRoutesToAllRoutes', () => { expect(firstCount).toBe(secondCount); }); }); + +describe('updateNavigationSpan with wildcard detection', () => { + const sampleLocation: Location = { + pathname: '/test', + search: '', + hash: '', + state: null, + key: 'default', + }; + + const sampleRoutes: RouteObject[] = [ + { path: '/', element:

Home
}, + { path: '/about', element:
About
}, + ]; + + const mockMatchRoutes = vi.fn(() => []); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call updateName when provided with valid routes', () => { + const testSpan = { ...mockSpan }; + updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, false, mockMatchRoutes); + + expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should handle forced updates', () => { + const testSpan = { ...mockSpan, __sentry_navigation_name_set__: true }; + updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, true, mockMatchRoutes); + + // Should update even though already named because forceUpdate=true + expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); + }); +}); + +describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should upgrade from URL source to route source (regression fix)', async () => { + // Setup: Current span has URL source and non-parameterized name + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/123', + data: { 'sentry.source': 'url' }, + } as any); + + // Target: Resolves to route source with parameterized name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + // Simulate patchSpanEnd calling tryUpdateSpanNameBeforeEnd + // by updating the span name during a navigation + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should upgrade from URL to route source + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should not downgrade from route source to URL source', async () => { + // Setup: Current span has route source with parameterized name (no wildcard) + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/:id', + data: { 'sentry.source': 'route' }, + } as any); + + // Target: Would resolve to URL source (downgrade attempt) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/456', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + __sentry_navigation_name_set__: true, // Mark as already named + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/456', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should not update because span is already named + // The early return in tryUpdateSpanNameBeforeEnd protects against downgrades + // This test verifies that route->url downgrades are blocked + expect(mockUpdateName).not.toHaveBeenCalled(); + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + + it('should upgrade wildcard names to specific routes', async () => { + // Setup: Current span has route source with wildcard + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Mock wildcard detection: current name has wildcard, new name doesn't + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name === '/users/*'; // Only the current name has wildcard + }); + + // Target: Resolves to specific parameterized route + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should upgrade from wildcard to specific + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should not downgrade from wildcard route to URL', async () => { + // Setup: Current span has route source with wildcard + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Mock wildcard detection: current name has wildcard, new name doesn't + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name === '/users/*'; // Only the current wildcard name returns true + }); + + // Target: After timeout, resolves to URL (lazy route didn't finish loading) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + __sentry_navigation_name_set__: true, // Mark span as already named/finalized + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/*', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/*' } }]), + ); + + // Should not update - keep wildcard route instead of downgrading to URL + // Wildcard routes are better than URLs for aggregation in performance monitoring + expect(mockUpdateName).not.toHaveBeenCalled(); + expect(mockSetAttribute).not.toHaveBeenCalled(); + }); + + it('should set name when no current name exists', async () => { + // Setup: Current span has no name (undefined) + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: undefined, + } as any); + + // Target: Resolves to route + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Should set initial name + expect(mockUpdateName).toHaveBeenCalledWith('/users/:id'); + expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route'); + }); + + it('should not update when same source and no improvement', async () => { + // Setup: Current span has URL source + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/123', + data: { 'sentry.source': 'url' }, + } as any); + + // Target: Resolves to same URL source (no improvement) + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/123', 'url']); + + const mockUpdateName = vi.fn(); + const mockSetAttribute = vi.fn(); + const testSpan = { + updateName: mockUpdateName, + setAttribute: mockSetAttribute, + end: vi.fn(), + } as unknown as Span; + + updateNavigationSpan( + testSpan, + { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, + [{ path: '/users/:id', element:
}], + false, + vi.fn(() => [{ route: { path: '/users/:id' } }]), + ); + + // Note: updateNavigationSpan always updates if not already named + // This test validates that the isImprovement logic works correctly in tryUpdateSpanNameBeforeEnd + // which is called during span.end() patching + expect(mockUpdateName).toHaveBeenCalled(); // Initial set is allowed + }); + + describe('computeLocationKey (pure function)', () => { + it('should include pathname, search, and hash in location key', () => { + const location: Location = { + pathname: '/search', + search: '?q=foo', + hash: '#results', + state: null, + key: 'test', + }; + + const result = computeLocationKey(location); + + expect(result).toBe('/search?q=foo#results'); + }); + + it('should differentiate locations with same pathname but different query', () => { + const loc1: Location = { pathname: '/search', search: '?q=foo', hash: '', state: null, key: 'k1' }; + const loc2: Location = { pathname: '/search', search: '?q=bar', hash: '', state: null, key: 'k2' }; + + const key1 = computeLocationKey(loc1); + const key2 = computeLocationKey(loc2); + + // Verifies that search params are included in the location key + expect(key1).not.toBe(key2); + expect(key1).toBe('/search?q=foo'); + expect(key2).toBe('/search?q=bar'); + }); + + it('should differentiate locations with same pathname but different hash', () => { + const loc1: Location = { pathname: '/page', search: '', hash: '#section1', state: null, key: 'k1' }; + const loc2: Location = { pathname: '/page', search: '', hash: '#section2', state: null, key: 'k2' }; + + const key1 = computeLocationKey(loc1); + const key2 = computeLocationKey(loc2); + + // Verifies that hash values are included in the location key + expect(key1).not.toBe(key2); + expect(key1).toBe('/page#section1'); + expect(key2).toBe('/page#section2'); + }); + + it('should produce same key for identical locations', () => { + const loc1: Location = { pathname: '/users', search: '?id=123', hash: '#profile', state: null, key: 'k1' }; + const loc2: Location = { pathname: '/users', search: '?id=123', hash: '#profile', state: null, key: 'k2' }; + + expect(computeLocationKey(loc1)).toBe(computeLocationKey(loc2)); + }); + + it('should normalize undefined/null search and hash to empty strings (partial location objects)', () => { + // When receives a string, React Router creates a partial location + // with search: undefined and hash: undefined. We must normalize these to empty strings + // to match the keys from full location objects (which have search: '' and hash: ''). + // This prevents duplicate navigation spans when using prop (common in modal routes). + const partialLocation: Location = { + pathname: '/users', + search: undefined as unknown as string, + hash: undefined as unknown as string, + state: null, + key: 'test1', + }; + + const fullLocation: Location = { + pathname: '/users', + search: '', + hash: '', + state: null, + key: 'test2', + }; + + const partialKey = computeLocationKey(partialLocation); + const fullKey = computeLocationKey(fullLocation); + + // Verifies that undefined values are normalized to empty strings, preventing + // '/usersundefinedundefined' !== '/users' mismatches + expect(partialKey).toBe('/users'); + expect(fullKey).toBe('/users'); + expect(partialKey).toBe(fullKey); + }); + + it('should normalize null search and hash to empty strings', () => { + const locationWithNulls: Location = { + pathname: '/products', + search: null as unknown as string, + hash: null as unknown as string, + state: null, + key: 'test3', + }; + + const locationWithEmptyStrings: Location = { + pathname: '/products', + search: '', + hash: '', + state: null, + key: 'test4', + }; + + expect(computeLocationKey(locationWithNulls)).toBe('/products'); + expect(computeLocationKey(locationWithEmptyStrings)).toBe('/products'); + expect(computeLocationKey(locationWithNulls)).toBe(computeLocationKey(locationWithEmptyStrings)); + }); + }); + + describe('shouldSkipNavigation (pure function - duplicate detection logic)', () => { + const mockSpan: Span = { updateName: vi.fn(), setAttribute: vi.fn(), end: vi.fn() } as unknown as Span; + + it('should not skip when no tracked navigation exists', () => { + const result = shouldSkipNavigation(undefined, '/users', '/users/:id', false); + + expect(result).toEqual({ skip: false, shouldUpdate: false }); + }); + + it('should skip placeholder navigations for same locationKey', () => { + const trackedNav = { + span: mockSpan, + routeName: '/search', + pathname: '/search', + locationKey: '/search?q=foo', + isPlaceholder: true, + }; + + const result = shouldSkipNavigation(trackedNav, '/search?q=foo', '/search', false); + + // Verifies that placeholder navigations for the same locationKey are skipped + expect(result.skip).toBe(true); + expect(result.shouldUpdate).toBe(false); + }); + + it('should NOT skip placeholder navigations for different locationKey (query change)', () => { + const trackedNav = { + span: mockSpan, + routeName: '/search', + pathname: '/search', + locationKey: '/search?q=foo', + isPlaceholder: true, + }; + + const result = shouldSkipNavigation(trackedNav, '/search?q=bar', '/search', false); + + // Verifies that different locationKeys allow new navigation even with same pathname + expect(result.skip).toBe(false); + expect(result.shouldUpdate).toBe(false); + }); + + it('should skip real span navigations for same locationKey when span has not ended', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/:id', + pathname: '/users/123', + locationKey: '/users/123?tab=profile', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123?tab=profile', '/users/:id', false); + + // Verifies that duplicate navigations are blocked when span hasn't ended + expect(result.skip).toBe(true); + }); + + it('should NOT skip real span navigations for different locationKey (query change)', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/:id', + pathname: '/users/123', + locationKey: '/users/123?tab=profile', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123?tab=settings', '/users/:id', false); + + // Verifies that different locationKeys allow new navigation even with same pathname + expect(result.skip).toBe(false); + }); + + it('should NOT skip when tracked span has ended', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/:id', + pathname: '/users/123', + locationKey: '/users/123', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/:id', true); + + // Allow new navigation when previous span has ended + expect(result.skip).toBe(false); + }); + + it('should set shouldUpdate=true for wildcard to parameterized upgrade', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/*', + pathname: '/users/123', + locationKey: '/users/123', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/:id', false); + + // Verifies that wildcard names are upgraded to parameterized routes + expect(result.skip).toBe(true); + expect(result.shouldUpdate).toBe(true); + }); + + it('should NOT set shouldUpdate=true when both names are wildcards', () => { + const trackedNav = { + span: mockSpan, + routeName: '/users/*', + pathname: '/users/123', + locationKey: '/users/123', + isPlaceholder: false, + }; + + const result = shouldSkipNavigation(trackedNav, '/users/123', '/users/*', false); + + expect(result.skip).toBe(true); + expect(result.shouldUpdate).toBe(false); + }); + }); + + describe('handleNavigation integration (verifies wiring to pure functions)', () => { + // Verifies that handleNavigation correctly uses computeLocationKey and shouldSkipNavigation + + let mockNavigationSpan: Span; + + beforeEach(async () => { + // Reset all mocks + vi.clearAllMocks(); + + // Import fresh modules to reset internal state + const coreModule = await import('@sentry/core'); + const browserModule = await import('@sentry/browser'); + const instrumentationModule = await import('../../src/reactrouter-compat-utils/instrumentation'); + + // Create a mock span with end() that captures callback + mockNavigationSpan = { + updateName: vi.fn(), + setAttribute: vi.fn(), + end: vi.fn(), + } as unknown as Span; + + // Mock getClient to return a client that's registered for instrumentation + const mockClient = { + addIntegration: vi.fn(), + emit: vi.fn(), + on: vi.fn(), + getOptions: vi.fn(() => ({})), + } as unknown as Client; + vi.mocked(coreModule.getClient).mockReturnValue(mockClient); + + // Mock startBrowserTracingPageLoadSpan to avoid pageload span creation during setup + vi.mocked(browserModule.startBrowserTracingPageLoadSpan).mockReturnValue(undefined); + + // Register client for instrumentation by adding it to the internal set + const integration = instrumentationModule.createReactRouterV6CompatibleTracingIntegration({ + useEffect: vi.fn(), + useLocation: vi.fn(), + useNavigationType: vi.fn(), + createRoutesFromChildren: vi.fn(), + matchRoutes: vi.fn(), + }); + integration.afterAllSetup(mockClient); + + // Mock startBrowserTracingNavigationSpan to return our mock span + vi.mocked(browserModule.startBrowserTracingNavigationSpan).mockReturnValue(mockNavigationSpan); + + // Mock spanToJSON to return different values for different calls + vi.mocked(coreModule.spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Mock getActiveRootSpan to return undefined (no pageload span) + vi.mocked(coreModule.getActiveSpan).mockReturnValue(undefined); + }); + + it('creates navigation span and uses computeLocationKey for tracking', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { resolveRouteNameAndSource } = await import('../../src/reactrouter-compat-utils/utils'); + + // Mock to return a specific route name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/search', 'route']); + + const location: Location = { + pathname: '/search', + search: '?q=foo', + hash: '#results', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/search', + pathnameBase: '/search', + route: { path: '/search', element:
}, + params: {}, + }, + ]; + + handleNavigation({ + location, + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that handleNavigation calls startBrowserTracingNavigationSpan + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith( + expect.objectContaining({ emit: expect.any(Function) }), // client + expect.objectContaining({ + name: '/search', + attributes: expect.objectContaining({ + 'sentry.op': 'navigation', + 'sentry.source': 'route', + }), + }), + ); + }); + + it('blocks duplicate navigation for exact same locationKey (pathname+query+hash)', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + + const location: Location = { + pathname: '/search', + search: '?q=foo', + hash: '#results', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/search', + pathnameBase: '/search', + route: { path: '/search', element:
}, + params: {}, + }, + ]; + + // First navigation - should create span + handleNavigation({ + location, + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second navigation - exact same location, should be blocked + handleNavigation({ + location: { ...location, key: 'test2' }, // Different key, same location + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that duplicate detection uses locationKey (not just pathname) + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); // Only first call + }); + + it('allows navigation for same pathname but different query string', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + + const location1: Location = { + pathname: '/search', + search: '?q=foo', + hash: '', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/search', + pathnameBase: '/search', + route: { path: '/search', element:
}, + params: {}, + }, + ]; + + // First navigation + handleNavigation({ + location: location1, + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second navigation - same pathname, different query + const location2: Location = { + pathname: '/search', + search: '?q=bar', + hash: '', + state: null, + key: 'test2', + }; + + handleNavigation({ + location: location2, + routes: [{ path: '/search', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that query params are included in locationKey for duplicate detection + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); // Both calls should create spans + }); + + it('allows navigation for same pathname but different hash', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + + const location1: Location = { + pathname: '/page', + search: '', + hash: '#section1', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/page', + pathnameBase: '/page', + route: { path: '/page', element:
}, + params: {}, + }, + ]; + + // First navigation + handleNavigation({ + location: location1, + routes: [{ path: '/page', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second navigation - same pathname, different hash + const location2: Location = { + pathname: '/page', + search: '', + hash: '#section2', + state: null, + key: 'test2', + }; + + handleNavigation({ + location: location2, + routes: [{ path: '/page', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that hash values are included in locationKey for duplicate detection + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2); // Both calls should create spans + }); + + it('updates wildcard span when better parameterized name becomes available', async () => { + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + const { transactionNameHasWildcard, resolveRouteNameAndSource } = await import( + '../../src/reactrouter-compat-utils/utils' + ); + + const location: Location = { + pathname: '/users/123', + search: '', + hash: '', + state: null, + key: 'test1', + }; + + const matches = [ + { + pathname: '/users/123', + pathnameBase: '/users', + route: { path: '/users/*', element:
}, + params: { '*': '123' }, + }, + ]; + + // First navigation - resolves to wildcard name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/*', 'route']); + // Mock transactionNameHasWildcard to return true for wildcards, false for parameterized + vi.mocked(transactionNameHasWildcard).mockImplementation((name: string) => { + return name.includes('/*') || name === '*' || name.endsWith('*'); + }); + + handleNavigation({ + location, + routes: [{ path: '/users/*', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + const firstSpan = mockNavigationSpan; + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // Mock spanToJSON to indicate span hasn't ended yet and has wildcard name + vi.mocked(spanToJSON).mockReturnValue({ + op: 'navigation', + description: '/users/*', + data: { 'sentry.source': 'route' }, + } as any); + + // Second navigation - same location but better parameterized name available + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users/:id', 'route']); + + handleNavigation({ + location: { ...location, key: 'test2' }, + routes: [{ path: '/users/:id', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that wildcard span names are upgraded when parameterized routes become available + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(vi.mocked(firstSpan.updateName)).toHaveBeenCalledWith('/users/:id'); + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); // No new span created + }); + + it('prevents duplicate spans when prop is a string (partial location)', async () => { + // This test verifies the fix for the bug where creates + // a partial location object with search: undefined and hash: undefined, which + // would result in a different locationKey ('/usersundefinedundefined' vs '/users') + // causing duplicate navigation spans. + const { handleNavigation } = await import('../../src/reactrouter-compat-utils/instrumentation'); + const { startBrowserTracingNavigationSpan } = await import('@sentry/browser'); + const { spanToJSON } = await import('@sentry/core'); + const { resolveRouteNameAndSource } = await import('../../src/reactrouter-compat-utils/utils'); + + // Mock resolveRouteNameAndSource to return consistent route name + vi.mocked(resolveRouteNameAndSource).mockReturnValue(['/users', 'route']); + + const matches = [ + { + pathname: '/users', + pathnameBase: '/users', + route: { path: '/users', element:
}, + params: {}, + }, + ]; + + // First call: Partial location (from ) + // React Router creates location with undefined search and hash + const partialLocation: Location = { + pathname: '/users', + search: undefined as unknown as string, + hash: undefined as unknown as string, + state: null, + key: 'test1', + }; + + handleNavigation({ + location: partialLocation, + routes: [{ path: '/users', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + + // Mock spanToJSON to indicate span hasn't ended yet + vi.mocked(spanToJSON).mockReturnValue({ op: 'navigation' } as any); + + // Second call: Full location (from router.state) + // React Router provides location with empty string search and hash + const fullLocation: Location = { + pathname: '/users', + search: '', + hash: '', + state: null, + key: 'test2', + }; + + handleNavigation({ + location: fullLocation, + routes: [{ path: '/users', element:
}], + navigationType: 'PUSH', + version: '6' as const, + matches: matches as any, + }); + + // Verifies that undefined values are normalized, preventing duplicate spans + // (without normalization, '/usersundefinedundefined' != '/users' would create 2 spans) + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + }); + }); + + describe('SSR-safe RAF fallback (scheduleCallback/cancelScheduledCallback)', () => { + // These tests verify that the RAF fallback works correctly in SSR environments + + it('uses requestAnimationFrame when available', () => { + // Save original RAF + const originalRAF = window.requestAnimationFrame; + const rafSpy = vi.fn((cb: () => void) => { + cb(); + return 123; + }); + window.requestAnimationFrame = rafSpy; + + try { + // Import module to trigger RAF usage + const scheduleCallback = (callback: () => void): number => { + if (window?.requestAnimationFrame) { + return window.requestAnimationFrame(callback); + } + return setTimeout(callback, 0) as unknown as number; + }; + + const mockCallback = vi.fn(); + scheduleCallback(mockCallback); + + // Verifies that requestAnimationFrame is used when available + expect(rafSpy).toHaveBeenCalled(); + expect(mockCallback).toHaveBeenCalled(); + } finally { + window.requestAnimationFrame = originalRAF; + } + }); + + it('falls back to setTimeout when requestAnimationFrame is unavailable (SSR)', () => { + // Simulate SSR by removing RAF + const originalRAF = window.requestAnimationFrame; + const originalCAF = window.cancelAnimationFrame; + // @ts-expect-error - Simulating SSR environment + delete window.requestAnimationFrame; + // @ts-expect-error - Simulating SSR environment + delete window.cancelAnimationFrame; + + try { + const timeoutSpy = vi.spyOn(global, 'setTimeout'); + + // Import module to trigger setTimeout fallback + const scheduleCallback = (callback: () => void): number => { + if (window?.requestAnimationFrame) { + return window.requestAnimationFrame(callback); + } + return setTimeout(callback, 0) as unknown as number; + }; + + const mockCallback = vi.fn(); + scheduleCallback(mockCallback); + + // Verifies that setTimeout is used when requestAnimationFrame is unavailable + expect(timeoutSpy).toHaveBeenCalledWith(mockCallback, 0); + } finally { + window.requestAnimationFrame = originalRAF; + window.cancelAnimationFrame = originalCAF; + } + }); + }); +}); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 9ff48e7450bc..438b026104bd 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -9,6 +9,7 @@ import { prefixWithSlash, rebuildRoutePathFromAllRoutes, resolveRouteNameAndSource, + transactionNameHasWildcard, } from '../../src/reactrouter-compat-utils'; import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types'; @@ -629,4 +630,38 @@ describe('reactrouter-compat-utils/utils', () => { expect(result).toEqual(['/unknown', 'url']); }); }); + + describe('transactionNameHasWildcard', () => { + it('should detect wildcard at the end of path', () => { + expect(transactionNameHasWildcard('/lazy/*')).toBe(true); + expect(transactionNameHasWildcard('/users/:id/*')).toBe(true); + expect(transactionNameHasWildcard('/products/:category/*')).toBe(true); + }); + + it('should detect standalone wildcard', () => { + expect(transactionNameHasWildcard('*')).toBe(true); + }); + + it('should detect wildcard in the middle of path', () => { + expect(transactionNameHasWildcard('/lazy/*/nested')).toBe(true); + expect(transactionNameHasWildcard('/a/*/b/*/c')).toBe(true); + }); + + it('should not detect wildcards in parameterized routes', () => { + expect(transactionNameHasWildcard('/users/:id')).toBe(false); + expect(transactionNameHasWildcard('/products/:category/:id')).toBe(false); + expect(transactionNameHasWildcard('/items/:itemId/details')).toBe(false); + }); + + it('should not detect wildcards in static routes', () => { + expect(transactionNameHasWildcard('/')).toBe(false); + expect(transactionNameHasWildcard('/about')).toBe(false); + expect(transactionNameHasWildcard('/users/profile')).toBe(false); + }); + + it('should handle edge cases', () => { + expect(transactionNameHasWildcard('')).toBe(false); + expect(transactionNameHasWildcard('/path/to/asterisk')).toBe(false); // 'asterisk' contains 'isk' but not '*' + }); + }); }); From 2ee464fc1a00c60e9438e6a5c27dc09e976e5e9c Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 24 Nov 2025 10:14:07 +0100 Subject: [PATCH 181/190] chore: Add external contributor to CHANGELOG.md (#18297) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18281 Co-authored-by: nicohrubec <29484629+nicohrubec@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 479b72fc2f08..ac6b755cbb24 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 @bignoncedric. Thank you for your contribution! + - feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239)) - Bump @opentelemetry/context-async-hooks from ^2.1.0 to ^2.2.0 - Bump @opentelemetry/core from ^2.1.0 to ^2.2.0 From b8127fbec3ab3412931eb643911384fa77f5dd9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20Kov=C3=A1cs?= <43071496+adam-kov@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:01:17 +0100 Subject: [PATCH 182/190] doc(sveltekit): Update documentation link for SvelteKit guide (#18298) Readme incorrectly pointed to NextJS docs --- packages/sveltekit/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sveltekit/README.md b/packages/sveltekit/README.md index a21adc43b836..a7a51e695255 100644 --- a/packages/sveltekit/README.md +++ b/packages/sveltekit/README.md @@ -25,7 +25,7 @@ functionality related to SvelteKit. ## Installation To get started installing the SDK, use the Sentry Next.js Wizard by running the following command in your terminal or -read the [Getting Started Docs](https://docs.sentry.io/platforms/javascript/guides/nextjs/): +read the [Getting Started Docs](https://docs.sentry.io/platforms/javascript/guides/sveltekit/): ```sh npx @sentry/wizard@latest -i sveltekit From 3d48cc66730723653893ebab7d1b3edab6e9ff3c Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 24 Nov 2025 11:11:51 +0100 Subject: [PATCH 183/190] chore: Add external contributor to CHANGELOG.md (#18300) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #18298 Co-authored-by: Lms24 <8420481+Lms24@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6b755cbb24..9bee97ac9189 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 @bignoncedric. Thank you for your contribution! +Work in this release was contributed by @bignoncedric and @adam-kov. Thank you for your contributions! - feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239)) - Bump @opentelemetry/context-async-hooks from ^2.1.0 to ^2.2.0 From 15256034ee8150a5b7dcb97d23eca1a5486f0cae Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 24 Nov 2025 14:59:08 +0100 Subject: [PATCH 184/190] feat(browserprofiling): Add `manual` mode and deprecate old profiling (#18189) Adds the `manual` mode for profiling and browser integration tests. - adds deprecation note for old option - adds some JSDoc comments to public-facing API to make the difference between Node and UI profiling better visible. Closes https://github.com/getsentry/sentry-javascript/issues/17279 --- .../suites/profiling/manualMode/subject.js | 76 ++++ .../suites/profiling/manualMode/test.ts | 93 +++++ .../suites/profiling/test-utils.ts | 2 +- packages/browser/src/client.ts | 1 - packages/browser/src/exports.ts | 1 + packages/browser/src/profiling/UIProfiler.ts | 150 ++++--- packages/browser/src/profiling/index.ts | 55 +++ packages/browser/src/profiling/integration.ts | 23 +- packages/browser/src/profiling/utils.ts | 16 +- .../browser/test/profiling/UIProfiler.test.ts | 367 ++++++++++++++---- packages/core/src/client.ts | 28 ++ packages/core/src/profiling.ts | 5 + .../core/src/types-hoist/browseroptions.ts | 3 +- 13 files changed, 681 insertions(+), 139 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts create mode 100644 packages/browser/src/profiling/index.ts diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js new file mode 100644 index 000000000000..906f14d06693 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/subject.js @@ -0,0 +1,76 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'manual', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +function fibonacci1(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +function fibonacci2(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +function notProfiledFib(n) { + if (n <= 1) { + return n; + } + return fibonacci1(n - 1) + fibonacci1(n - 2); +} + +// Adding setTimeout to ensure we cross the sampling interval to avoid flakes + +Sentry.uiProfiler.startProfiler(); + +fibonacci(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +largeSum(); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.uiProfiler.stopProfiler(); + +// --- + +notProfiledFib(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +// --- + +Sentry.uiProfiler.startProfiler(); + +fibonacci2(40); +await new Promise(resolve => setTimeout(resolve, 25)); + +Sentry.uiProfiler.stopProfiler(); + +const client = Sentry.getClient(); +await client?.flush(8000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts new file mode 100644 index 000000000000..2e4358563aa2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/manualMode/test.ts @@ -0,0 +1,93 @@ +import { expect } from '@playwright/test'; +import type { ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + countEnvelopes, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../utils/helpers'; +import { validateProfile, validateProfilePayloadMetadata } from '../test-utils'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Assert that no profile_chunk envelope is sent without policy header + const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 }); + expect(chunkCount).toBe(0); + }, +); + +sentryTest('sends profile_chunk envelopes in manual mode', async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + + // In manual mode we start and stop once -> expect exactly one chunk + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( + page, + 2, + { url, envelopeType: 'profile_chunk', timeout: 8000 }, + properFullEnvelopeRequestParser, + ); + + expect(profileChunkEnvelopes.length).toBe(2); + + // Validate the first chunk thoroughly + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload1 = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload1.profile).toBeDefined(); + + const profilerId1 = envelopeItemPayload1.profiler_id; + + validateProfilePayloadMetadata(envelopeItemPayload1); + + validateProfile(envelopeItemPayload1.profile, { + expectedFunctionNames: ['startJSSelfProfile', 'fibonacci', 'largeSum'], + minSampleDurationMs: 20, + isChunkFormat: true, + }); + + // only contains fibonacci + const functionNames1 = envelopeItemPayload1.profile.frames.map(frame => frame.function).filter(name => name !== ''); + expect(functionNames1).toEqual(expect.not.arrayContaining(['fibonacci1', 'fibonacci2', 'fibonacci3'])); + + // === PROFILE CHUNK 2 === + + const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0]; + const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; + const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; + + expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload2.profile).toBeDefined(); + + expect(envelopeItemPayload2.profiler_id).toBe(profilerId1); // same profiler id for the whole session + + validateProfilePayloadMetadata(envelopeItemPayload2); + + validateProfile(envelopeItemPayload2.profile, { + expectedFunctionNames: [ + 'startJSSelfProfile', + 'fibonacci1', // called by fibonacci2 + 'fibonacci2', + ], + isChunkFormat: true, + }); + + // does not contain notProfiledFib (called during unprofiled part) + const functionNames2 = envelopeItemPayload2.profile.frames.map(frame => frame.function).filter(name => name !== ''); + expect(functionNames2).toEqual(expect.not.arrayContaining(['notProfiledFib'])); +}); diff --git a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts index e150be2d56bc..39e6d2ca20b7 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/test-utils.ts @@ -90,7 +90,7 @@ export function validateProfile( } } - // Frames + // FRAMES expect(profile.frames.length).toBeGreaterThan(0); for (const frame of profile.frames) { expect(frame).toHaveProperty('function'); diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index ea55174f340c..65fcdf24734a 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -130,7 +130,6 @@ export class BrowserClient extends Client { // Flush logs and metrics when page becomes hidden (e.g., tab switch, navigation) // todo(v11): Remove the experimental flag - // eslint-disable-next-line deprecation/deprecation if (WINDOW.document && (sendClientReports || enableLogs || enableMetrics)) { WINDOW.document.addEventListener('visibilitychange', () => { if (WINDOW.document.visibilityState === 'hidden') { diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index 50223e4b9fd9..1b46687194da 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -78,6 +78,7 @@ export { export { WINDOW } from './helpers'; export { BrowserClient } from './client'; export { makeFetchTransport } from './transports/fetch'; +export { uiProfiler } from './profiling'; export { defaultStackParser, defaultStackLineParsers, diff --git a/packages/browser/src/profiling/UIProfiler.ts b/packages/browser/src/profiling/UIProfiler.ts index 731684996d62..fb059b836986 100644 --- a/packages/browser/src/profiling/UIProfiler.ts +++ b/packages/browser/src/profiling/UIProfiler.ts @@ -1,4 +1,4 @@ -import type { Client, ProfileChunk, Span } from '@sentry/core'; +import type { Client, ContinuousProfiler, ProfileChunk, Span } from '@sentry/core'; import { type ProfileChunkEnvelope, createEnvelope, @@ -9,67 +9,122 @@ import { getSdkMetadataForEnvelopeHeader, uuid4, } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from './../debug-build'; import type { JSSelfProfiler } from './jsSelfProfiling'; -import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from './utils'; +import { createProfileChunkPayload, shouldProfileSession, startJSSelfProfile, validateProfileChunk } from './utils'; const CHUNK_INTERVAL_MS = 60_000; // 1 minute // Maximum length for trace lifecycle profiling per root span (e.g. if spanEnd never fires) -const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes +const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes max per root span in trace mode /** - * Browser trace-lifecycle profiler (UI Profiling / Profiling V2): - * - Starts when the first sampled root span starts - * - Stops when the last sampled root span ends - * - While running, periodically stops and restarts the JS self-profiling API to collect chunks + * UIProfiler (Profiling V2): + * Supports two lifecycle modes: + * - 'manual': controlled explicitly via start()/stop() + * - 'trace': automatically runs while there are active sampled root spans * * Profiles are emitted as standalone `profile_chunk` envelopes either when: * - there are no more sampled root spans, or * - the 60s chunk timer elapses while profiling is running. */ -export class UIProfiler { +export class UIProfiler implements ContinuousProfiler { private _client: Client | undefined; private _profiler: JSSelfProfiler | undefined; private _chunkTimer: ReturnType | undefined; - // For keeping track of active root spans + + // Manual + Trace + private _profilerId: string | undefined; // one per Profiler session + private _isRunning: boolean; // current profiler instance active flag + private _sessionSampled: boolean; // sampling decision for entire session + private _lifecycleMode: 'manual' | 'trace' | undefined; + + // Trace-only private _activeRootSpanIds: Set; private _rootSpanTimeouts: Map>; - // ID for Profiler session - private _profilerId: string | undefined; - private _isRunning: boolean; - private _sessionSampled: boolean; public constructor() { this._client = undefined; this._profiler = undefined; this._chunkTimer = undefined; - this._activeRootSpanIds = new Set(); - this._rootSpanTimeouts = new Map>(); + this._profilerId = undefined; this._isRunning = false; this._sessionSampled = false; + this._lifecycleMode = undefined; + + this._activeRootSpanIds = new Set(); + this._rootSpanTimeouts = new Map(); } /** - * Initialize the profiler with client and session sampling decision computed by the integration. + * Initialize the profiler with client, session sampling and lifecycle mode. */ - public initialize(client: Client, sessionSampled: boolean): void { - // One Profiler ID per profiling session (user session) - this._profilerId = uuid4(); + public initialize(client: Client): void { + const lifecycleMode = (client.getOptions() as BrowserOptions).profileLifecycle; + const sessionSampled = shouldProfileSession(client.getOptions()); + + DEBUG_BUILD && debug.log(`[Profiling] Initializing profiler (lifecycle='${lifecycleMode}').`); - DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); + } + // One Profiler ID per profiling session (user session) + this._profilerId = uuid4(); this._client = client; this._sessionSampled = sessionSampled; + this._lifecycleMode = lifecycleMode; - this._setupTraceLifecycleListeners(client); + if (lifecycleMode === 'trace') { + this._setupTraceLifecycleListeners(client); + } } - /** - * Handle an already-active root span at integration setup time. - */ - public notifyRootSpanActive(rootSpan: Span): void { + /** Starts UI profiling (only effective in 'manual' mode and when sampled). */ + public start(): void { + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.warn( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.start()` are ignored in trace mode.', + ); + return; + } + + if (this._isRunning) { + DEBUG_BUILD && debug.warn('[Profiling] Profile session is already running, `uiProfiler.start()` is a no-op.'); + return; + } + if (!this._sessionSampled) { + DEBUG_BUILD && debug.warn('[Profiling] Session is not sampled, `uiProfiler.start()` is a no-op.'); + return; + } + + this._beginProfiling(); + } + + /** Stops UI profiling (only effective in 'manual' mode). */ + public stop(): void { + if (this._lifecycleMode === 'trace') { + DEBUG_BUILD && + debug.warn( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.stop()` are ignored in trace mode.', + ); + return; + } + + if (!this._isRunning) { + DEBUG_BUILD && debug.warn('[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.'); + return; + } + + this._endProfiling(); + } + + /** Handle an already-active root span at integration setup time (used only in trace mode). */ + public notifyRootSpanActive(rootSpan: Span): void { + if (this._lifecycleMode !== 'trace' || !this._sessionSampled) { return; } @@ -78,7 +133,7 @@ export class UIProfiler { return; } - this._activeRootSpanIds.add(spanId); + this._registerTraceRootSpan(spanId); const rootSpanCount = this._activeRootSpanIds.size; @@ -86,20 +141,20 @@ export class UIProfiler { DEBUG_BUILD && debug.log('[Profiling] Detected already active root span during setup. Active root spans now:', rootSpanCount); - this.start(); + this._beginProfiling(); } } /** - * Start profiling if not already running. + * Begin profiling if not already running. */ - public start(): void { + private _beginProfiling(): void { if (this._isRunning) { return; } this._isRunning = true; - DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profiler ID:', this._profilerId); // Expose profiler_id to match root spans with profiles getGlobalScope().setContext('profile', { profiler_id: this._profilerId }); @@ -107,7 +162,7 @@ export class UIProfiler { this._startProfilerInstance(); if (!this._profiler) { - DEBUG_BUILD && debug.log('[Profiling] Stopping trace lifecycle profiling.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler; stopping.'); this._resetProfilerInfo(); return; } @@ -115,15 +170,13 @@ export class UIProfiler { this._startPeriodicChunking(); } - /** - * Stop profiling; final chunk will be collected and sent. - */ - public stop(): void { + /** End profiling session; final chunk will be collected and sent. */ + private _endProfiling(): void { if (!this._isRunning) { return; } - this._isRunning = false; + if (this._chunkTimer) { clearTimeout(this._chunkTimer); this._chunkTimer = undefined; @@ -135,6 +188,12 @@ export class UIProfiler { this._collectCurrentChunk().catch(e => { DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); }); + + // Manual: Clear profiling context so spans outside start()/stop() aren't marked as profiled + // Trace: Profile context is kept for the whole session duration + if (this._lifecycleMode === 'manual') { + getGlobalScope().setContext('profile', {}); + } } /** Trace-mode: attach spanStart/spanEnd listeners. */ @@ -166,7 +225,7 @@ export class UIProfiler { debug.log( `[Profiling] Root span ${spanId} started. Profiling active while there are active root spans (count=${rootSpanCount}).`, ); - this.start(); + this._beginProfiling(); } }); @@ -189,13 +248,13 @@ export class UIProfiler { this._collectCurrentChunk().catch(e => { DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on last `spanEnd`:', e); }); - this.stop(); + this._endProfiling(); } }); } /** - * Resets profiling information from scope and resets running state + * Resets profiling information from scope and resets running state (used on failure) */ private _resetProfilerInfo(): void { this._isRunning = false; @@ -210,7 +269,7 @@ export class UIProfiler { this._rootSpanTimeouts.clear(); } - /** Register root span and schedule safeguard timeout (trace mode). */ + /** Keep track of root spans and schedule safeguard timeout (trace mode). */ private _registerTraceRootSpan(spanId: string): void { this._activeRootSpanIds.add(spanId); const timeout = setTimeout(() => this._onRootSpanTimeout(spanId), MAX_ROOT_SPAN_PROFILE_MS); @@ -222,11 +281,11 @@ export class UIProfiler { */ private _startProfilerInstance(): void { if (this._profiler?.stopped === false) { - return; + return; // already running } const profiler = startJSSelfProfile(); if (!profiler) { - DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in trace lifecycle.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler.'); return; } this._profiler = profiler; @@ -283,14 +342,13 @@ export class UIProfiler { this._activeRootSpanIds.delete(rootSpanId); - const rootSpanCount = this._activeRootSpanIds.size; - if (rootSpanCount === 0) { - this.stop(); + if (this._activeRootSpanIds.size === 0) { + this._endProfiling(); } } /** - * Stop the current profiler, convert and send a profile chunk. + * Stop current profiler instance, convert profile to chunk & send. */ private async _collectCurrentChunk(): Promise { const prevProfiler = this._profiler; diff --git a/packages/browser/src/profiling/index.ts b/packages/browser/src/profiling/index.ts new file mode 100644 index 000000000000..5847c070dd48 --- /dev/null +++ b/packages/browser/src/profiling/index.ts @@ -0,0 +1,55 @@ +import type { Profiler } from '@sentry/core'; +import { debug, getClient } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +/** + * Starts the Sentry UI profiler. + * This mode is exclusive with the transaction profiler and will only work if the profilesSampleRate is set to a falsy value. + * In UI profiling mode, the profiler will keep reporting profile chunks to Sentry until it is stopped, which allows for continuous profiling of the application. + */ +function startProfiler(): void { + const client = getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('No Sentry client available, profiling is not started'); + return; + } + + const integration = client.getIntegrationByName('BrowserProfiling'); + + if (!integration) { + DEBUG_BUILD && debug.warn('BrowserProfiling integration is not available'); + return; + } + + client.emit('startUIProfiler'); +} + +/** + * Stops the Sentry UI profiler. + * Calls to stop will stop the profiler and flush the currently collected profile data to Sentry. + */ +function stopProfiler(): void { + const client = getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('No Sentry client available, profiling is not started'); + return; + } + + const integration = client.getIntegrationByName('BrowserProfiling'); + if (!integration) { + DEBUG_BUILD && debug.warn('ProfilingIntegration is not available'); + return; + } + + client.emit('stopUIProfiler'); +} + +/** + * Profiler namespace for controlling the JS profiler in 'manual' mode. + * + * Requires the `browserProfilingIntegration` from the `@sentry/browser` package. + */ +export const uiProfiler: Profiler = { + startProfiler, + stopProfiler, +}; diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 7cd1886e636d..84cd33588320 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -14,7 +14,6 @@ import { getActiveProfilesCount, hasLegacyProfiling, isAutomatedPageLoadSpan, - shouldProfileSession, shouldProfileSpanLegacy, takeProfileFromGlobalCache, } from './utils'; @@ -26,12 +25,14 @@ const _browserProfilingIntegration = (() => { name: INTEGRATION_NAME, setup(client) { const options = client.getOptions() as BrowserOptions; + const profiler = new UIProfiler(); if (!hasLegacyProfiling(options) && !options.profileLifecycle) { // Set default lifecycle mode options.profileLifecycle = 'manual'; } + // eslint-disable-next-line deprecation/deprecation if (hasLegacyProfiling(options) && !options.profilesSampleRate) { DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no profiling options found.'); return; @@ -49,14 +50,15 @@ const _browserProfilingIntegration = (() => { // UI PROFILING (Profiling V2) if (!hasLegacyProfiling(options)) { - const sessionSampled = shouldProfileSession(options); - if (!sessionSampled) { - DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); - } - const lifecycleMode = options.profileLifecycle; - if (lifecycleMode === 'trace') { + // Registering hooks in all lifecycle modes to be able to notify users in case they want to start/stop the profiler manually in `trace` mode + client.on('startUIProfiler', () => profiler.start()); + client.on('stopUIProfiler', () => profiler.stop()); + + if (lifecycleMode === 'manual') { + profiler.initialize(client); + } else if (lifecycleMode === 'trace') { if (!hasSpansEnabled(options)) { DEBUG_BUILD && debug.warn( @@ -65,12 +67,11 @@ const _browserProfilingIntegration = (() => { return; } - const traceLifecycleProfiler = new UIProfiler(); - traceLifecycleProfiler.initialize(client, sessionSampled); + profiler.initialize(client); // If there is an active, sampled root span already, notify the profiler if (rootSpan) { - traceLifecycleProfiler.notifyRootSpanActive(rootSpan); + profiler.notifyRootSpanActive(rootSpan); } // In case rootSpan is created slightly after setup -> schedule microtask to re-check and notify. @@ -78,7 +79,7 @@ const _browserProfilingIntegration = (() => { const laterActiveSpan = getActiveSpan(); const laterRootSpan = laterActiveSpan && getRootSpan(laterActiveSpan); if (laterRootSpan) { - traceLifecycleProfiler.notifyRootSpanActive(laterRootSpan); + profiler.notifyRootSpanActive(laterRootSpan); } }, 0); } diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index ed794a40a98b..c50c76c84de4 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -651,8 +651,10 @@ export function shouldProfileSpanLegacy(span: Span): boolean { return false; } - // @ts-expect-error profilesSampleRate is not part of the browser options yet - const profilesSampleRate: number | boolean | undefined = options.profilesSampleRate; + // eslint-disable-next-line deprecation/deprecation + const profilesSampleRate = (options as BrowserOptions).profilesSampleRate as + | BrowserOptions['profilesSampleRate'] + | boolean; // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The // only valid values are booleans or numbers between 0 and 1.) @@ -688,18 +690,21 @@ export function shouldProfileSpanLegacy(span: Span): boolean { } /** - * Determine if a profile should be created for the current session (lifecycle profiling mode). + * Determine if a profile should be created for the current session. */ export function shouldProfileSession(options: BrowserOptions): boolean { // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (DEBUG_BUILD) { - debug.log('[Profiling] Profiling has been disabled for the duration of the current user session.'); + debug.log( + '[Profiling] Profiling has been disabled for the duration of the current user session as the JS Profiler could not be started.', + ); } return false; } - if (options.profileLifecycle !== 'trace') { + if (options.profileLifecycle !== 'trace' && options.profileLifecycle !== 'manual') { + DEBUG_BUILD && debug.warn('[Profiling] Session not sampled. Invalid `profileLifecycle` option.'); return false; } @@ -724,6 +729,7 @@ export function shouldProfileSession(options: BrowserOptions): boolean { * Checks if legacy profiling is configured. */ export function hasLegacyProfiling(options: BrowserOptions): boolean { + // eslint-disable-next-line deprecation/deprecation return typeof options.profilesSampleRate !== 'undefined'; } diff --git a/packages/browser/test/profiling/UIProfiler.test.ts b/packages/browser/test/profiling/UIProfiler.test.ts index f28880960256..6872e1e1beff 100644 --- a/packages/browser/test/profiling/UIProfiler.test.ts +++ b/packages/browser/test/profiling/UIProfiler.test.ts @@ -3,8 +3,20 @@ */ import * as Sentry from '@sentry/browser'; -import type { Span } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type Span, debug } from '@sentry/core'; +import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BrowserOptions } from '../../src/index'; + +function getBaseOptionsForTraceLifecycle(sendMock: Mock, enableTracing = true): BrowserOptions { + return { + dsn: 'https://public@o.ingest.sentry.io/1', + ...(enableTracing ? { tracesSampleRate: 1 } : {}), + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: sendMock }), + }; +} describe('Browser Profiling v2 trace lifecycle', () => { afterEach(async () => { @@ -48,12 +60,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { Sentry.init({ // tracing disabled - dsn: 'https://public@o.ingest.sentry.io/1', - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - // no tracesSampleRate/tracesSampler - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send, false), }); // warning is logged by our debug logger only when DEBUG_BUILD, so just assert no throw and no profiler @@ -79,12 +86,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanRef: any; @@ -112,12 +114,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanA: any; @@ -159,12 +156,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanRef: any; @@ -195,12 +187,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); let spanRef: any; @@ -255,12 +242,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { @@ -308,12 +290,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { @@ -375,12 +352,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpan({ name: 'root-for-context', parentSpan: null, forceTransaction: true }, () => { @@ -440,12 +412,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpan({ name: 'rootSpan-1', parentSpan: null, forceTransaction: true }, () => { @@ -499,12 +466,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { const send = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + ...getBaseOptionsForTraceLifecycle(send), }); Sentry.startSpan({ name: 'rootSpan-chunk-1', parentSpan: null, forceTransaction: true }, () => { @@ -563,12 +525,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { // Session 1 const send1 = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send1 }), + ...getBaseOptionsForTraceLifecycle(send1), }); Sentry.startSpan({ name: 'session-1-rootSpan', parentSpan: null, forceTransaction: true }, () => { @@ -598,12 +555,7 @@ describe('Browser Profiling v2 trace lifecycle', () => { // Session 2 (new init simulates new user session) const send2 = vi.fn().mockResolvedValue(undefined); Sentry.init({ - dsn: 'https://public@o.ingest.sentry.io/1', - tracesSampleRate: 1, - profileSessionSampleRate: 1, - profileLifecycle: 'trace', - integrations: [Sentry.browserProfilingIntegration()], - transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send2 }), + ...getBaseOptionsForTraceLifecycle(send2), }); Sentry.startSpan({ name: 'session-2-rootSpan', parentSpan: null, forceTransaction: true }, () => { @@ -628,4 +580,271 @@ describe('Browser Profiling v2 trace lifecycle', () => { } }); }); + + it('calling start and stop in trace lifecycle prints warnings', async () => { + const { stop } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const debugWarnSpy = vi.spyOn(debug, 'warn'); + + Sentry.init({ + ...getBaseOptionsForTraceLifecycle(send), + debug: true, + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.startProfiler(); + + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.start()` are ignored in trace mode.', + ); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + debugWarnSpy.mockClear(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(0); + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] `profileLifecycle` is set to "trace". Calls to `uiProfiler.stop()` are ignored in trace mode.', + ); + }); +}); + +function getBaseOptionsForManualLifecycle(sendMock: Mock, enableTracing = true): BrowserOptions { + return { + dsn: 'https://public@o.ingest.sentry.io/1', + ...(enableTracing ? { tracesSampleRate: 1 } : {}), + profileSessionSampleRate: 1, + profileLifecycle: 'manual', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: sendMock }), + }; +} + +describe('Browser Profiling v2 manual lifecycle', () => { + afterEach(async () => { + const client = Sentry.getClient(); + await client?.close(); + // reset profiler constructor + (window as any).Profiler = undefined; + vi.restoreAllMocks(); + }); + + function mockProfiler() { + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + const mockConstructor = vi.fn().mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => { + return new MockProfilerImpl(opts); + }); + + (window as any).Profiler = mockConstructor; + return { stop, mockConstructor }; + } + + it('starts and stops a profile session', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + const client = Sentry.getClient(); + expect(client).toBeDefined(); + + Sentry.uiProfiler.startProfiler(); + expect(mockConstructor).toHaveBeenCalledTimes(1); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + const envelopeHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeader?.type).toBe('profile_chunk'); + }); + + it('calling start and stop while profile session is running prints warnings', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const debugWarnSpy = vi.spyOn(debug, 'warn'); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + debug: true, + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.startProfiler(); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] Profile session is already running, `uiProfiler.start()` is a no-op.', + ); + + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + debugWarnSpy.mockClear(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(debugWarnSpy).toHaveBeenCalledWith('[Profiling] Profiler is not running, `uiProfiler.stop()` is a no-op.'); + }); + + it('profileSessionSampleRate is required', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const debugWarnSpy = vi.spyOn(debug, 'warn'); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + profileSessionSampleRate: undefined, + }); + + Sentry.uiProfiler.startProfiler(); + expect(debugWarnSpy).toHaveBeenCalledWith( + '[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got undefined of type "undefined".', + ); + expect(debugWarnSpy).toHaveBeenCalledWith('[Profiling] Session is not sampled, `uiProfiler.start()` is a no-op.'); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(mockConstructor).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + }); + + it('does not start profiler when profileSessionSampleRate is 0', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + profileSessionSampleRate: 0, + }); + + Sentry.uiProfiler.startProfiler(); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + expect(mockConstructor).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + }); + + describe('envelope', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + it('sends a profile_chunk envelope type', async () => { + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + const client = Sentry.getClient(); + + Sentry.uiProfiler.startProfiler(); + await new Promise(resolve => setTimeout(resolve, 10)); + Sentry.uiProfiler.stopProfiler(); + + await client?.flush(1000); + + expect(send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ + type: 'profile_chunk', + }); + + expect(send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[1]).toMatchObject({ + profiler_id: expect.any(String), + chunk_id: expect.any(String), + profile: expect.objectContaining({ + stacks: expect.any(Array), + }), + }); + }); + + it('reuses the same profiler_id while profiling across multiple stop/start calls', async () => { + mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + ...getBaseOptionsForManualLifecycle(send), + }); + + // 1. profiling cycle + Sentry.uiProfiler.startProfiler(); + Sentry.startSpan({ name: 'manual-span-1', parentSpan: null, forceTransaction: true }, () => {}); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + // Not profiled -> should not have profile context + Sentry.startSpan({ name: 'manual-span-between', parentSpan: null, forceTransaction: true }, () => {}); + + // 2. profiling cycle + Sentry.uiProfiler.startProfiler(); + Sentry.startSpan({ name: 'manual-span-2', parentSpan: null, forceTransaction: true }, () => {}); + Sentry.uiProfiler.stopProfiler(); + await Promise.resolve(); + + const client = Sentry.getClient(); + await client?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toBe(3); + + const firstProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + expect(typeof firstProfilerId).toBe('string'); + + // Middle transaction (not profiled) + expect(transactionEvents[1]?.contexts?.profile?.profiler_id).toBeUndefined(); + + const thirdProfilerId = transactionEvents[2]?.contexts?.profile?.profiler_id; + expect(typeof thirdProfilerId).toBe('string'); + expect(firstProfilerId).toBe(thirdProfilerId); // same profiler_id across session + }); + }); }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b7e0cab509c1..ef05750009c3 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -813,6 +813,24 @@ export abstract class Client { callback: (request: unknown, response: unknown, normalizedRequest: RequestEventData) => void, ): () => void; + /** + * A hook that is called when the UI Profiler should start profiling. + * + * This hook is called when running `Sentry.uiProfiler.startProfiler()`. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'startUIProfiler', callback: () => void): () => void; + + /** + * A hook that is called when the UI Profiler should stop profiling. + * + * This hook is called when running `Sentry.uiProfiler.stopProfiler()`. + * + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'stopUIProfiler', callback: () => void): () => void; + /** * Register a hook on this client. */ @@ -1029,6 +1047,16 @@ export abstract class Client { normalizedRequest: RequestEventData, ): void; + /** + * Emit a hook event for starting the UI Profiler. + */ + public emit(hook: 'startUIProfiler'): void; + + /** + * Emit a hook event for stopping the UI Profiler. + */ + public emit(hook: 'stopUIProfiler'): void; + /** * Emit a hook that was previously registered via `on()`. */ diff --git a/packages/core/src/profiling.ts b/packages/core/src/profiling.ts index 407c4a07c53c..e2e2c34e38cc 100644 --- a/packages/core/src/profiling.ts +++ b/packages/core/src/profiling.ts @@ -65,6 +65,11 @@ function stopProfiler(): void { integration._profiler.stop(); } +/** + * Profiler namespace for controlling the profiler in 'manual' mode. + * + * Requires the `nodeProfilingIntegration` from the `@sentry/profiling-node` package. + */ export const profiler: Profiler = { startProfiler, stopProfiler, diff --git a/packages/core/src/types-hoist/browseroptions.ts b/packages/core/src/types-hoist/browseroptions.ts index 18bbd46af09c..39b414d5140b 100644 --- a/packages/core/src/types-hoist/browseroptions.ts +++ b/packages/core/src/types-hoist/browseroptions.ts @@ -18,10 +18,11 @@ export type BrowserClientReplayOptions = { }; export type BrowserClientProfilingOptions = { - // todo: add deprecation warning for profilesSampleRate: @deprecated Use `profileSessionSampleRate` and `profileLifecycle` instead. /** * The sample rate for profiling * 1.0 will profile all transactions and 0 will profile none. + * + * @deprecated Use `profileSessionSampleRate` and `profileLifecycle` instead. */ profilesSampleRate?: number; From 6240191d1fef08423b9928846fc4a9a7aa7c8da5 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:53:32 +0100 Subject: [PATCH 185/190] feat(core): Use `maxValueLength` on error messages (#18301) It can happen that error messages are too long and exceed the maximum envelope size (mentioned in https://github.com/getsentry/sentry-javascript/issues/18219). `maxValueLength` now also checks for long error messages and truncates them. --- packages/core/src/utils/prepareEvent.ts | 13 ++++++++-- packages/core/test/lib/client.test.ts | 32 +++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index 9a4c4685e839..fd1cb62440f4 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -147,8 +147,17 @@ export function applyClientOptions(event: Event, options: ClientOptions): void { } const request = event.request; - if (request?.url) { - request.url = maxValueLength ? truncate(request.url, maxValueLength) : request.url; + if (request?.url && maxValueLength) { + request.url = truncate(request.url, maxValueLength); + } + + if (maxValueLength) { + event.exception?.values?.forEach(exception => { + if (exception.value) { + // Truncates error messages + exception.value = truncate(exception.value, maxValueLength); + } + }); } } diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 19ef8a95dff5..2a2d77171880 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -21,7 +21,6 @@ import type { ErrorEvent, Event, TransactionEvent } from '../../src/types-hoist/ import type { SpanJSON } from '../../src/types-hoist/span'; import * as debugLoggerModule from '../../src/utils/debug-logger'; import * as miscModule from '../../src/utils/misc'; -import * as stringModule from '../../src/utils/string'; import * as timeModule from '../../src/utils/time'; import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; import { AdHocIntegration, AsyncTestIntegration, TestIntegration } from '../mocks/integration'; @@ -37,7 +36,6 @@ const clientProcess = vi.spyOn(TestClient.prototype as any, '_process'); vi.spyOn(miscModule, 'uuid4').mockImplementation(() => '12312012123120121231201212312012'); vi.spyOn(debugLoggerModule, 'consoleSandbox').mockImplementation(cb => cb()); -vi.spyOn(stringModule, 'truncate').mockImplementation(str => str); vi.spyOn(timeModule, 'dateTimestampInSeconds').mockImplementation(() => 2020); describe('Client', () => { @@ -263,6 +261,36 @@ describe('Client', () => { ); }); + test('does not truncate exception values by default', () => { + const exceptionMessageLength = 10_000; + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + + client.captureException(new Error('a'.repeat(exceptionMessageLength))); + expect(TestClient.instance!.event).toEqual( + expect.objectContaining({ + exception: { + values: [{ type: 'Error', value: 'a'.repeat(exceptionMessageLength) }], + }, + }), + ); + }); + + test('truncates exception values according to `maxValueLength` option', () => { + const maxValueLength = 10; + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, maxValueLength }); + const client = new TestClient(options); + + client.captureException(new Error('a'.repeat(50))); + expect(TestClient.instance!.event).toEqual( + expect.objectContaining({ + exception: { + values: [{ type: 'Error', value: `${'a'.repeat(maxValueLength)}...` }], + }, + }), + ); + }); + test('sets the correct lastEventId', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); const client = new TestClient(options); From 4b92c64b75ffb85f31a516ca093ab77a5e55c15d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 24 Nov 2025 17:41:05 +0200 Subject: [PATCH 186/190] fix(nextjs): universal random tunnel path support (#18257) Summary for changelog: The `tunnelRoute: true` option didn't work well with Turbopack due to repeated runs of the config files leading to different tunnel URLs in client, server and edge runtimes, this PR fixes that while also fixing Sentry requests spans not being dropped by the sampler. When using Next.js with Turbopack and the Sentry tunnel route feature (`tunnelRoute: true`), several issues prevented events from being sent properly: ### 1. Tunnel Route Consistency (Turbopack) **Problem**: Random tunnel routes were generated separately for client and server builds in Turbopack. **Solution**: Implemented processs-level caching in `withSentryConfig.ts`: - Extract tunnel route resolution into `resolveTunnelRoute()` function - Use `process.env` to store the random tunnel value across server/client builds. ### 2. Filter Tunnel Request Spans **Problem**: Requests to the tunnel route (before rewrite) and to Sentry ingest URLs (after rewrite) were creating spans that polluted Sentry with internal instrumentation noise, spans were being created by the middleware and OTEL node.js fetch instrumentation. **Solution**: Implemented server-side span filtering: - Created `dropMiddlewareTunnelRequests()` utility to detect and drop tunnel-related spans - Filter spans originating from `Middleware.execute` (Next.js middleware) - Filter spans originating from `auto.http.otel.node_fetch` (Node.js fetch instrumentation) - Check both local tunnel paths and Sentry ingest URLs (using `isSentryRequestSpan` from `@sentry/opentelemetry`) - Mark matching spans with `TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION` to prevent them from being sent - I tried `beforeSampling` hook but it didn't work for some reason, so I stuck with the drop attribute. ---- The final issue was excluding the tunnel requests from the middleware/proxy, but there are many blockers for a solution: 1. The `config` must be statically analyzable, so we cannot expose `withSentryMiddlewareConfig` wrapper of any kind. 2. Warning the user doesn't help much because they can't do anything about it since the tunnel route is random. 3. Tested out writing a loader for turbopack/webpack to inject the tunnel into the matcher as an array but user existing matcher can match still. 4. Only way is to inject an exclusion match into the user existing matcher, if it is an array then we need to inject it into each single entry. I may explore this further later with a loader for both webpack/turbopack, and figure out a reliable way to inject the negative matchers into the user expressions. --- .../nextjs-16-tunnel/.gitignore | 46 ++++++ .../test-applications/nextjs-16-tunnel/.npmrc | 4 + .../nextjs-16-tunnel/app/favicon.ico | Bin 0 -> 25931 bytes .../nextjs-16-tunnel/app/global-error.tsx | 23 +++ .../nextjs-16-tunnel/app/layout.tsx | 7 + .../nextjs-16-tunnel/app/page.tsx | 3 + .../nextjs-16-tunnel/eslint.config.mjs | 19 +++ .../instrumentation-client.ts | 12 ++ .../nextjs-16-tunnel/instrumentation.ts | 13 ++ .../nextjs-16-tunnel/next.config.ts | 9 ++ .../nextjs-16-tunnel/package.json | 63 +++++++++ .../nextjs-16-tunnel/playwright.config.mjs | 29 ++++ .../nextjs-16-tunnel/proxy.ts | 11 ++ .../nextjs-16-tunnel/public/file.svg | 1 + .../nextjs-16-tunnel/public/globe.svg | 1 + .../nextjs-16-tunnel/public/next.svg | 1 + .../nextjs-16-tunnel/public/vercel.svg | 1 + .../nextjs-16-tunnel/public/window.svg | 1 + .../nextjs-16-tunnel/sentry.edge.config.ts | 11 ++ .../nextjs-16-tunnel/sentry.server.config.ts | 11 ++ .../nextjs-16-tunnel/start-event-proxy.mjs | 14 ++ .../tests/tunnel-route.test.ts | 132 ++++++++++++++++++ .../nextjs-16-tunnel/tsconfig.json | 27 ++++ .../utils/dropMiddlewareTunnelRequests.ts | 59 ++++++++ .../turbopack/constructTurbopackConfig.ts | 12 +- .../turbopack/generateValueInjectionRules.ts | 7 + .../nextjs/src/config/withSentryConfig.ts | 39 +++++- packages/nextjs/src/edge/index.ts | 24 ++++ packages/nextjs/src/server/index.ts | 3 + 29 files changed, 576 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/global-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/eslint.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/file.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/globe.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/next.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/vercel.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/window.svg create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tsconfig.json create mode 100644 packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore new file mode 100644 index 000000000000..ae044ec5ad53 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.gitignore @@ -0,0 +1,46 @@ +# 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 + +event-dumps + +# 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-tunnel/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/.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-tunnel/app/favicon.ico b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/global-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/global-error.tsx new file mode 100644 index 000000000000..20c175015b03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/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-tunnel/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/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-tunnel/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/page.tsx new file mode 100644 index 000000000000..f28a670096bf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/app/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

Next.js 16 Tunnel Route Test

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/eslint.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/eslint.config.mjs new file mode 100644 index 000000000000..60f7af38f6c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/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-tunnel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts new file mode 100644 index 000000000000..d40b790f18a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing + dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // No tunnel option - using tunnelRoute from withSentryConfig + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/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-tunnel/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts new file mode 100644 index 000000000000..cad68b926a58 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts @@ -0,0 +1,9 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = {}; + +export default withSentryConfig(nextConfig, { + silent: true, + tunnelRoute: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json new file mode 100644 index 000000000000..40389ad0888f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -0,0 +1,63 @@ +{ + "name": "nextjs-16-tunnel", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": " next dev", + "build": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ 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": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next dev --webpack", + "build-webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "start": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next start", + "lint": "eslint", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ 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 || *", + "ai": "^3.0.0", + "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": { + "variants": [ + { + "build-command": "pnpm test:build-webpack", + "label": "nextjs-16-tunnel (webpack)", + "assert-command": "pnpm test:assert-webpack" + }, + { + "build-command": "pnpm test:build", + "label": "nextjs-16-tunnel (turbopack)", + "assert-command": "pnpm test:assert" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs new file mode 100644 index 000000000000..797418b8cf7d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/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-tunnel/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts new file mode 100644 index 000000000000..28639f60bbe4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/proxy.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(_request: NextRequest) { + return NextResponse.next(); +} + +// Match all routes to test that tunnel requests are properly filtered +export const config = { + matcher: '/:path*', +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/file.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/file.svg new file mode 100644 index 000000000000..004145cddf3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/globe.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/globe.svg new file mode 100644 index 000000000000..567f17b0d7c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/next.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/vercel.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/vercel.svg new file mode 100644 index 000000000000..77053960334e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/window.svg b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/window.svg new file mode 100644 index 000000000000..b2b2a44f6ebc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts new file mode 100644 index 000000000000..8ba3a3bf2faa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing + dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // No tunnel option - using tunnelRoute from withSentryConfig + tracesSampleRate: 1.0, + sendDefaultPii: true, + // debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts new file mode 100644 index 000000000000..8ba3a3bf2faa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + // Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing + dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // No tunnel option - using tunnelRoute from withSentryConfig + tracesSampleRate: 1.0, + sendDefaultPii: true, + // debug: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/start-event-proxy.mjs new file mode 100644 index 000000000000..976073d3d2c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/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-tunnel', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/nextjs-16-tunnel-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts new file mode 100644 index 000000000000..a8bd7b4d925e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -0,0 +1,132 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Tunnel route should proxy pageload transaction to Sentry', async ({ page }) => { + // Wait for the pageload transaction to be sent through the tunnel + const pageloadTransactionPromise = waitForTransaction('nextjs-16-tunnel', async transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + // Navigate to the page + await page.goto('/'); + + const pageloadTransaction = await pageloadTransactionPromise; + + // Verify the pageload transaction was received successfully + expect(pageloadTransaction).toBeDefined(); + expect(pageloadTransaction.transaction).toBe('/'); + expect(pageloadTransaction.contexts?.trace?.op).toBe('pageload'); + expect(pageloadTransaction.contexts?.trace?.status).toBe('ok'); + expect(pageloadTransaction.type).toBe('transaction'); +}); + +test('Tunnel route should send multiple pageload transactions consistently', async ({ page }) => { + // This test verifies that the tunnel route remains consistent across multiple page loads + // (important for Turbopack which could generate different tunnel routes for client/server) + + // First pageload + const firstPageloadPromise = waitForTransaction('nextjs-16-tunnel', async transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + const firstPageload = await firstPageloadPromise; + + expect(firstPageload).toBeDefined(); + expect(firstPageload.transaction).toBe('/'); + expect(firstPageload.contexts?.trace?.op).toBe('pageload'); + expect(firstPageload.contexts?.trace?.status).toBe('ok'); + + // Second pageload (reload) + const secondPageloadPromise = waitForTransaction('nextjs-16-tunnel', async transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.reload(); + const secondPageload = await secondPageloadPromise; + + expect(secondPageload).toBeDefined(); + expect(secondPageload.transaction).toBe('/'); + expect(secondPageload.contexts?.trace?.op).toBe('pageload'); + expect(secondPageload.contexts?.trace?.status).toBe('ok'); +}); + +test('Tunnel requests should not create middleware or fetch spans', async ({ page }) => { + // This test verifies that our span filtering logic works correctly + // The proxy runs on all routes, so we'll get a middleware transaction for `/` + // But we should NOT get middleware or fetch transactions for the tunnel route itself + + const allTransactions: any[] = []; + + // Collect all transactions + const collectPromise = (async () => { + // Keep collecting for 3 seconds after pageload + const endTime = Date.now() + 3000; + while (Date.now() < endTime) { + try { + const tx = await Promise.race([ + waitForTransaction('nextjs-16-tunnel', () => true), + new Promise((_, reject) => setTimeout(() => reject(), 500)), + ]); + allTransactions.push(tx); + } catch { + // Timeout, continue collecting + } + } + })(); + + // Wait for pageload transaction + const pageloadPromise = waitForTransaction('nextjs-16-tunnel', async transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/'); + const pageloadTransaction = await pageloadPromise; + + // Trigger errors to force tunnel POST requests + await page + .evaluate(() => { + throw new Error('Test tunnel error 1'); + }) + .catch(() => { + // Expected to throw + }); + + await page + .evaluate(() => { + throw new Error('Test tunnel error 2'); + }) + .catch(() => { + // Expected to throw + }); + + // Wait for events to be sent through tunnel + await page.waitForTimeout(2000); + + // Continue collecting for a bit + await collectPromise; + + // We should have received the pageload transaction + expect(pageloadTransaction).toBeDefined(); + expect(pageloadTransaction.contexts?.trace?.op).toBe('pageload'); + + const middlewareTransactions = allTransactions.filter(tx => tx.contexts?.trace?.op === 'http.server.middleware'); + + // We WILL have a middleware transaction for GET / (the pageload) + // But we should NOT have middleware transactions for POST requests (tunnel route) + const postMiddlewareTransactions = middlewareTransactions.filter( + tx => tx.transaction?.includes('POST') || tx.contexts?.trace?.data?.['http.request.method'] === 'POST', + ); + + expect(postMiddlewareTransactions).toHaveLength(0); + + // We should NOT have any fetch transactions to Sentry ingest + const sentryFetchTransactions = allTransactions.filter( + tx => + tx.contexts?.trace?.op === 'http.client' && + (tx.contexts?.trace?.data?.['url.full']?.includes('sentry.io') || + tx.contexts?.trace?.data?.['url.full']?.includes('ingest')), + ); + + expect(sentryFetchTransactions).toHaveLength(0); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/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/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts new file mode 100644 index 000000000000..6f8b4eb96603 --- /dev/null +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -0,0 +1,59 @@ +import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; +import { type Span, type SpanAttributes, GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { isSentryRequestSpan } from '@sentry/opentelemetry'; +import { ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRewritesTunnelPath?: string; +}; + +/** + * Drops spans for tunnel requests from middleware or fetch instrumentation. + * This catches both: + * 1. Requests to the local tunnel route (before rewrite) + * 2. Requests to Sentry ingest (after rewrite) + */ +export function dropMiddlewareTunnelRequests(span: Span, attrs: SpanAttributes | undefined): void { + // Only filter middleware spans or HTTP fetch spans + const isMiddleware = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute'; + // The fetch span could be originating from rewrites re-writing a tunnel request + // So we want to filter it out + const isFetchSpan = attrs?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.http.otel.node_fetch'; + + // If the span is not a middleware span or a fetch span, return + if (!isMiddleware && !isFetchSpan) { + return; + } + + // Check if this is either a tunnel route request or a Sentry ingest request + const isTunnel = isTunnelRouteSpan(attrs || {}); + const isSentry = isSentryRequestSpan(span); + + if (isTunnel || isSentry) { + // Mark the span to be dropped + span.setAttribute(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true); + } +} + +/** + * Checks if a span's HTTP target matches the tunnel route. + */ +function isTunnelRouteSpan(spanAttributes: Record): boolean { + const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; + if (!tunnelPath) { + return false; + } + + // eslint-disable-next-line deprecation/deprecation + const httpTarget = spanAttributes[SEMATTRS_HTTP_TARGET]; + + if (typeof httpTarget === 'string') { + // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") + const pathname = httpTarget.split('?')[0] || ''; + + return pathname.startsWith(tunnelPath); + } + + return false; +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index e46d3f6bb5c7..b96b8e7f77ee 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -20,23 +20,31 @@ export function constructTurbopackConfig({ nextJsVersion, }: { userNextConfig: NextConfigObject; - userSentryOptions: SentryBuildOptions; + 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; + userSentryOptions?.sourcemaps?.disable !== true; const newConfig: TurbopackOptions = { ...userNextConfig.turbopack, ...(shouldEnableNativeDebugIds ? { debugIds: true } : {}), }; + const tunnelPath = + userSentryOptions?.tunnelRoute !== undefined && + userNextConfig.output !== 'export' && + typeof userSentryOptions.tunnelRoute === 'string' + ? `${userNextConfig.basePath ?? ''}${userSentryOptions.tunnelRoute}` + : undefined; + const valueInjectionRules = generateValueInjectionRules({ routeManifest, nextJsVersion, + tunnelPath, }); for (const { matcher, rule } of valueInjectionRules) { diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 58cf7cdd0a15..2cf96b5f5ad7 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -8,9 +8,11 @@ import type { JSONValue, TurbopackMatcherWithRule } from '../types'; export function generateValueInjectionRules({ routeManifest, nextJsVersion, + tunnelPath, }: { routeManifest?: RouteManifest; nextJsVersion?: string; + tunnelPath?: string; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; const isomorphicValues: Record = {}; @@ -26,6 +28,11 @@ export function generateValueInjectionRules({ clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } + // Inject tunnel route path for both client and server + if (tunnelPath) { + isomorphicValues._sentryRewritesTunnelPath = tunnelPath; + } + if (Object.keys(isomorphicValues).length > 0) { clientValues = { ...clientValues, ...isomorphicValues }; serverValues = { ...serverValues, ...isomorphicValues }; diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 7ac61d73aa73..892f4d6745fa 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -121,11 +121,10 @@ function getFinalConfigObject( ); } } else { - const resolvedTunnelRoute = - userSentryOptions.tunnelRoute === true ? generateRandomTunnelRoute() : userSentryOptions.tunnelRoute; - // Update the global options object to use the resolved value everywhere + const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; + setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); } } @@ -392,6 +391,13 @@ function getFinalConfigObject( */ function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { const originalRewrites = userNextConfig.rewrites; + // Allow overriding the tunnel destination for E2E tests via environment variable + const destinationOverride = process.env._SENTRY_TUNNEL_DESTINATION_OVERRIDE; + + // Make sure destinations are statically defined at build time + const destination = destinationOverride || 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0'; + const destinationWithRegion = + destinationOverride || 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0'; // This function doesn't take any arguments at the time of writing but we future-proof // here in case Next.js ever decides to pass some @@ -412,7 +418,7 @@ function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: s value: '(?\\d*)', }, ], - destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0', + destination, }; const tunnelRouteRewriteWithRegion = { @@ -436,7 +442,7 @@ function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: s value: '(?[a-z]{2})', }, ], - destination: 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0', + destination: destinationWithRegion, }; // Order of these is important, they get applied first to last. @@ -550,3 +556,26 @@ function getInstrumentationClientFileContents(): string | void { } } } + +/** + * Resolves the tunnel route based on the user's configuration and the environment. + * @param tunnelRoute - The user-provided tunnel route option + */ +function resolveTunnelRoute(tunnelRoute: string | true): string { + if (process.env.__SENTRY_TUNNEL_ROUTE__) { + // Reuse cached value from previous build (server/client) + return process.env.__SENTRY_TUNNEL_ROUTE__; + } + + const resolvedTunnelRoute = typeof tunnelRoute === 'string' ? tunnelRoute : generateRandomTunnelRoute(); + + // Cache for subsequent builds (only during build time) + // Turbopack runs the config twice, so we need a shared context to avoid generating a new tunnel route for each build. + // env works well here + // https://linear.app/getsentry/issue/JS-549/adblock-plus-blocking-requests-to-sentry-and-monitoring-tunnel + if (resolvedTunnelRoute) { + process.env.__SENTRY_TUNNEL_ROUTE__ = resolvedTunnelRoute; + } + + return resolvedTunnelRoute; +} diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 5fd92707b912..091adab98dee 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,5 +1,6 @@ import { context } from '@opentelemetry/api'; import { + type EventProcessor, applySdkMetadata, getCapturedScopesOnSpan, getCurrentScope, @@ -19,7 +20,9 @@ import { import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; +import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; import { flushSafelyWithTimeout } from '../common/utils/responseEnd'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; @@ -35,6 +38,7 @@ export type EdgeOptions = VercelEdgeOptions; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; + _sentryRewritesTunnelPath?: string; }; /** Inits the Sentry NextJS SDK on the Edge Runtime. */ @@ -70,6 +74,8 @@ export function init(options: VercelEdgeOptions = {}): void { const rootSpan = getRootSpan(span); const isRootSpan = span === rootSpan; + dropMiddlewareTunnelRequests(span, spanAttributes); + // Mark all spans generated by Next.js as 'auto' if (spanAttributes?.['next.span_type'] !== undefined) { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); @@ -137,6 +143,24 @@ export function init(options: VercelEdgeOptions = {}): void { } }); + getGlobalScope().addEventProcessor( + Object.assign( + (event => { + // Filter transactions that we explicitly want to drop. + if (event.type === 'transaction') { + if (event.contexts?.trace?.data?.[TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]) { + return null; + } + + return event; + } else { + return event; + } + }) satisfies EventProcessor, + { id: 'NextLowQualityTransactionsFilter' }, + ), + ); + try { // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js if (process.turbopack) { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index ce8ac7c56cea..caec9a9f1af1 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -38,6 +38,7 @@ import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, } from '../common/span-attributes-with-logic-attached'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; +import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; import { isBuild } from '../common/utils/isBuild'; import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetadata'; import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; @@ -169,6 +170,8 @@ export function init(options: NodeOptions): NodeClient | undefined { const rootSpan = getRootSpan(span); const isRootSpan = span === rootSpan; + dropMiddlewareTunnelRequests(span, spanAttributes); + // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. if (typeof spanAttributes?.[ATTR_NEXT_ROUTE] === 'string') { From 235c8651356fb66b6214ff5b88f16419c65f3478 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 24 Nov 2025 17:18:17 +0100 Subject: [PATCH 187/190] feat(core): Re-add `_experiments.enableLogs` option (#18299) We're re-introducing `_experiments.enableLogs`. The option stays deprecated and maybe we can actually remove it or type it as `undefined` in the next major to sunset it for good. Main motivation for re-adding: The flag was introduced in v9 while we already worked on v10 where we removed it again. Therefore, it had an unusually short lifespan. Some users didn't realize this when upgrading to v10 and were wondering where their logs went. --- .../suites/public-api/logger/init.js | 5 +++- .../public-api/logger/integration/init.js | 5 ++++ .../suites/winston/subject.ts | 6 +++- packages/core/src/client.ts | 5 ++++ packages/core/src/types-hoist/options.ts | 8 +++++ packages/core/test/lib/client.test.ts | 30 +++++++++++++++++++ 6 files changed, 57 insertions(+), 2 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/init.js b/dev-packages/browser-integration-tests/suites/public-api/logger/init.js index 8026df91ea46..1fa010f49659 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/init.js @@ -4,5 +4,8 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - enableLogs: true, + // purposefully testing against the experimental flag here + _experiments: { + enableLogs: true, + }, }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js index 809b78739e77..e26b03d7fc61 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/init.js @@ -5,5 +5,10 @@ window.Sentry = Sentry; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', enableLogs: true, + // Purposefully specifying the experimental flag here + // to ensure the top level option is used instead. + _experiments: { + enableLogs: false, + }, integrations: [Sentry.consoleLoggingIntegration()], }); diff --git a/dev-packages/node-core-integration-tests/suites/winston/subject.ts b/dev-packages/node-core-integration-tests/suites/winston/subject.ts index 3c31ddb63fa5..02ffcdb0f5cb 100644 --- a/dev-packages/node-core-integration-tests/suites/winston/subject.ts +++ b/dev-packages/node-core-integration-tests/suites/winston/subject.ts @@ -8,7 +8,11 @@ const client = Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0.0', environment: 'test', - enableLogs: true, + // Purposefully specifying the experimental flag here + // to ensure the top level option is still respected. + _experiments: { + enableLogs: true, + }, transport: loggingTransport, }); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index ef05750009c3..805d8e596528 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -239,6 +239,11 @@ export abstract class Client { }); } + // Backfill enableLogs option from _experiments.enableLogs + // TODO(v11): Remove or change default value + // eslint-disable-next-line deprecation/deprecation + this._options.enableLogs = this._options.enableLogs ?? this._options._experiments?.enableLogs; + // Setup log flushing with weight and timeout tracking if (this._options.enableLogs) { setupWeightBasedFlushing(this, 'afterCaptureLog', 'flushLogs', estimateLogSizeInBytes, _INTERNAL_flushLogsBuffer); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 59c4609f01c4..c33d0107df5f 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -306,6 +306,14 @@ export interface ClientOptions Metric | null; + + /** + * Determines if logs support should be enabled. + * + * @default false + * @deprecated Use the top level `enableLogs` option instead. + */ + enableLogs?: boolean; }; /** diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 2a2d77171880..a59a8bbb8780 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -2734,6 +2734,36 @@ describe('Client', () => { }); }); + describe('enableLogs', () => { + it('defaults to `undefined`', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); + const client = new TestClient(options); + expect(client.getOptions().enableLogs).toBeUndefined(); + }); + + it('can be set as a top-level option', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, enableLogs: true }); + const client = new TestClient(options); + expect(client.getOptions().enableLogs).toBe(true); + }); + + it('can be set as an experimental option', () => { + const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true } }); + const client = new TestClient(options); + expect(client.getOptions().enableLogs).toBe(true); + }); + + test('top-level option takes precedence over experimental option', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + enableLogs: true, + _experiments: { enableLogs: false }, + }); + const client = new TestClient(options); + expect(client.getOptions().enableLogs).toBe(true); + }); + }); + describe('log weight-based flushing', () => { beforeEach(() => { vi.useFakeTimers(); From 6ce620e983814a263eb036a3ee79f80e780a880a Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:31:48 +0100 Subject: [PATCH 188/190] fix(core): Always redact content of sensitive headers regardless of `sendDefaultPii` (#18311) In case an HTTP header is considered "sensitive" (could contain tokens), the value is already filtered within the SDK. --- Follow-up on this PR: - https://github.com/getsentry/sentry-javascript/pull/17475 --- packages/astro/src/server/middleware.ts | 5 +- packages/bun/src/integrations/bunserver.ts | 5 +- packages/cloudflare/src/request.ts | 3 +- packages/core/src/utils/request.ts | 39 ++++++---- packages/core/test/lib/utils/request.test.ts | 76 ++++++++----------- .../common/utils/addHeadersAsAttributes.ts | 7 +- .../http/httpServerSpansIntegration.ts | 3 +- .../runtime/hooks/wrapMiddlewareHandler.ts | 7 +- packages/remix/src/server/instrumentServer.ts | 5 +- .../sveltekit/src/server-common/handle.ts | 11 +-- 10 files changed, 68 insertions(+), 93 deletions(-) diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index a12c25ff6045..64fde266a3f8 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -219,10 +219,7 @@ async function instrumentRequestStartHttpServerSpan( // This is here for backwards compatibility, we used to set this here before method, url: stripUrlQueryAndFragment(ctx.url.href), - ...httpHeadersToSpanAttributes( - winterCGHeadersToDict(request.headers), - getClient()?.getOptions().sendDefaultPii ?? false, - ), + ...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)), }; if (parametrizedRoute) { diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 4a079f488474..73998e529349 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -3,7 +3,6 @@ import { captureException, continueTrace, defineIntegration, - getClient, httpHeadersToSpanAttributes, isURLObjectRelative, parseStringToURLObject, @@ -207,9 +206,7 @@ function wrapRequestHandler( routeName = route; } - const client = getClient(); - const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; - Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON(), sendDefaultPii)); + Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON())); isolationScope.setSDKProcessingMetadata({ normalizedRequest: { diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts index 5c97562d9fde..7908d3dcf48e 100644 --- a/packages/cloudflare/src/request.ts +++ b/packages/cloudflare/src/request.ts @@ -66,8 +66,7 @@ export function wrapRequestHandler( attributes['user_agent.original'] = userAgentHeader; } - const sendDefaultPii = options.sendDefaultPii ?? false; - Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), sendDefaultPii)); + Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers))); attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] = 'http.server'; diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index ffd60f3e8486..1d3985dd8479 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -129,7 +129,19 @@ function getAbsoluteUrl({ } // "-user" because otherwise it would match "user-agent" -const SENSITIVE_HEADER_SNIPPETS = ['auth', 'token', 'secret', 'cookie', '-user', 'password', 'key']; +const SENSITIVE_HEADER_SNIPPETS = [ + 'auth', + 'token', + 'secret', + 'cookie', + '-user', + 'password', + 'key', + 'jwt', + 'bearer', + 'sso', + 'saml', +]; /** * Converts incoming HTTP request headers to OpenTelemetry span attributes following semantic conventions. @@ -140,26 +152,25 @@ const SENSITIVE_HEADER_SNIPPETS = ['auth', 'token', 'secret', 'cookie', '-user', */ export function httpHeadersToSpanAttributes( headers: Record, - sendDefaultPii: boolean = false, ): Record { const spanAttributes: Record = {}; try { Object.entries(headers).forEach(([key, value]) => { - if (value !== undefined) { - const lowerCasedKey = key.toLowerCase(); - - if (!sendDefaultPii && SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet))) { - return; - } + if (value == null) { + return; + } - const normalizedKey = `http.request.header.${lowerCasedKey.replace(/-/g, '_')}`; + const lowerCasedKey = key.toLowerCase(); + const isSensitive = SENSITIVE_HEADER_SNIPPETS.some(snippet => lowerCasedKey.includes(snippet)); + const normalizedKey = `http.request.header.${lowerCasedKey.replace(/-/g, '_')}`; - if (Array.isArray(value)) { - spanAttributes[normalizedKey] = value.map(v => (v !== null && v !== undefined ? String(v) : v)).join(';'); - } else if (typeof value === 'string') { - spanAttributes[normalizedKey] = value; - } + if (isSensitive) { + spanAttributes[normalizedKey] = '[Filtered]'; + } else if (Array.isArray(value)) { + spanAttributes[normalizedKey] = value.map(v => (v != null ? String(v) : v)).join(';'); + } else if (typeof value === 'string') { + spanAttributes[normalizedKey] = value; } }); } catch { diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index b37ee860f43f..328aebf29209 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -613,61 +613,25 @@ describe('request utils', () => { }); describe('PII filtering', () => { - it('filters out sensitive headers when sendDefaultPii is false (default)', () => { - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'test-agent', - Authorization: 'Bearer secret-token', - Cookie: 'session=abc123', - 'X-API-Key': 'api-key-123', - 'X-Auth-Token': 'auth-token-456', - }; - - const result = httpHeadersToSpanAttributes(headers, false); - - expect(result).toEqual({ - 'http.request.header.content_type': 'application/json', - 'http.request.header.user_agent': 'test-agent', - // Sensitive headers should be filtered out - }); - }); - - it('includes sensitive headers when sendDefaultPii is true', () => { - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'test-agent', - Authorization: 'Bearer secret-token', - Cookie: 'session=abc123', - 'X-API-Key': 'api-key-123', - }; - - const result = httpHeadersToSpanAttributes(headers, true); - - expect(result).toEqual({ - 'http.request.header.content_type': 'application/json', - 'http.request.header.user_agent': 'test-agent', - 'http.request.header.authorization': 'Bearer secret-token', - 'http.request.header.cookie': 'session=abc123', - 'http.request.header.x_api_key': 'api-key-123', - }); - }); - it('filters sensitive headers case-insensitively', () => { const headers = { AUTHORIZATION: 'Bearer secret-token', Cookie: 'session=abc123', - 'x-api-key': 'key-123', + 'x-aPi-kEy': 'key-123', 'Content-Type': 'application/json', }; - const result = httpHeadersToSpanAttributes(headers, false); + const result = httpHeadersToSpanAttributes(headers); expect(result).toEqual({ 'http.request.header.content_type': 'application/json', + 'http.request.header.cookie': '[Filtered]', + 'http.request.header.x_api_key': '[Filtered]', + 'http.request.header.authorization': '[Filtered]', }); }); - it('filters comprehensive list of sensitive headers', () => { + it('always filters comprehensive list of sensitive headers', () => { const headers = { 'Content-Type': 'application/json', 'User-Agent': 'test-agent', @@ -692,15 +656,41 @@ describe('request utils', () => { 'X-Private-Key': 'private', 'X-Forwarded-user': 'user', 'X-Forwarded-authorization': 'auth', + 'x-jwt-token': 'jwt', + 'x-bearer-token': 'bearer', + 'x-sso-token': 'sso', + 'x-saml-token': 'saml', }; - const result = httpHeadersToSpanAttributes(headers, false); + const result = httpHeadersToSpanAttributes(headers); + // Sensitive headers are always included and redacted expect(result).toEqual({ 'http.request.header.content_type': 'application/json', 'http.request.header.user_agent': 'test-agent', 'http.request.header.accept': 'application/json', 'http.request.header.host': 'example.com', + 'http.request.header.authorization': '[Filtered]', + 'http.request.header.cookie': '[Filtered]', + 'http.request.header.set_cookie': '[Filtered]', + 'http.request.header.x_api_key': '[Filtered]', + 'http.request.header.x_auth_token': '[Filtered]', + 'http.request.header.x_secret': '[Filtered]', + 'http.request.header.x_secret_key': '[Filtered]', + 'http.request.header.www_authenticate': '[Filtered]', + 'http.request.header.proxy_authorization': '[Filtered]', + 'http.request.header.x_access_token': '[Filtered]', + 'http.request.header.x_csrf_token': '[Filtered]', + 'http.request.header.x_xsrf_token': '[Filtered]', + 'http.request.header.x_session_token': '[Filtered]', + 'http.request.header.x_password': '[Filtered]', + 'http.request.header.x_private_key': '[Filtered]', + 'http.request.header.x_forwarded_user': '[Filtered]', + 'http.request.header.x_forwarded_authorization': '[Filtered]', + 'http.request.header.x_jwt_token': '[Filtered]', + 'http.request.header.x_bearer_token': '[Filtered]', + 'http.request.header.x_sso_token': '[Filtered]', + 'http.request.header.x_saml_token': '[Filtered]', }); }); }); diff --git a/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts index 4e8cdb3fe7c9..ff025fc3ecc7 100644 --- a/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts +++ b/packages/nextjs/src/common/utils/addHeadersAsAttributes.ts @@ -1,5 +1,5 @@ import type { Span, WebFetchHeaders } from '@sentry/core'; -import { getClient, httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core'; +import { httpHeadersToSpanAttributes, winterCGHeadersToDict } from '@sentry/core'; /** * Extracts HTTP request headers as span attributes and optionally applies them to a span. @@ -12,15 +12,12 @@ export function addHeadersAsAttributes( return {}; } - const client = getClient(); - const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; - const headersDict: Record = headers instanceof Headers || (typeof headers === 'object' && 'get' in headers) ? winterCGHeadersToDict(headers as Headers) : headers; - const headerAttributes = httpHeadersToSpanAttributes(headersDict, sendDefaultPii); + const headerAttributes = httpHeadersToSpanAttributes(headersDict); if (span) { span.setAttributes(headerAttributes); diff --git a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts index c24c0c68d1da..34741e95c912 100644 --- a/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node-core/src/integrations/http/httpServerSpansIntegration.ts @@ -136,7 +136,6 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions const method = normalizedRequest.method || request.method?.toUpperCase() || 'GET'; const httpTargetWithoutQueryFragment = urlObj ? urlObj.pathname : stripUrlQueryAndFragment(fullUrl); const bestEffortTransactionName = `${method} ${httpTargetWithoutQueryFragment}`; - const shouldSendDefaultPii = client.getOptions().sendDefaultPii ?? false; // We use the plain tracer.startSpan here so we can pass the span kind const span = tracer.startSpan(bestEffortTransactionName, { @@ -158,7 +157,7 @@ const _httpServerSpansIntegration = ((options: HttpServerSpansIntegrationOptions 'http.flavor': httpVersion, 'net.transport': httpVersion?.toUpperCase() === 'QUIC' ? 'ip_udp' : 'ip_tcp', ...getRequestContentLengthAttribute(request), - ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}, shouldSendDefaultPii), + ...httpHeadersToSpanAttributes(normalizedRequest.headers || {}), }, }); diff --git a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts index a04b866cd774..4b41d6e8ab82 100644 --- a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts +++ b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts @@ -3,7 +3,6 @@ import { captureException, debug, flushIfServerless, - getClient, httpHeadersToSpanAttributes, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -171,13 +170,9 @@ function getSpanAttributes( attributes['http.route'] = event.path; } - // Extract and add HTTP headers as span attributes - const client = getClient(); - const sendDefaultPii = client?.getOptions().sendDefaultPii ?? false; - // Get headers from the Node.js request object const headers = event.node?.req?.headers || {}; - const headerAttributes = httpHeadersToSpanAttributes(headers, sendDefaultPii); + const headerAttributes = httpHeadersToSpanAttributes(headers); // Merge header attributes with existing attributes Object.assign(attributes, headerAttributes); diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index fda9b3f10b75..2416699cb2a6 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -310,10 +310,7 @@ function wrapRequestHandler ServerBuild | Promise [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', method: request.method, - ...httpHeadersToSpanAttributes( - winterCGHeadersToDict(request.headers), - clientOptions.sendDefaultPii ?? false, - ), + ...httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers)), }, }, async span => { diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 26872a0f6f24..3d9963bd1056 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -3,7 +3,6 @@ import { continueTrace, debug, flushIfServerless, - getClient, getCurrentScope, getDefaultIsolationScope, getIsolationScope, @@ -179,10 +178,7 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeName ? 'route' : 'url', 'sveltekit.tracing.original_name': originalName, - ...httpHeadersToSpanAttributes( - winterCGHeadersToDict(event.request.headers), - getClient()?.getOptions().sendDefaultPii ?? false, - ), + ...httpHeadersToSpanAttributes(winterCGHeadersToDict(event.request.headers)), }); } @@ -208,10 +204,7 @@ async function instrumentHandle( [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url', 'http.method': event.request.method, - ...httpHeadersToSpanAttributes( - winterCGHeadersToDict(event.request.headers), - getClient()?.getOptions().sendDefaultPii ?? false, - ), + ...httpHeadersToSpanAttributes(winterCGHeadersToDict(event.request.headers)), }, name: routeName, }, From 02aa2ea072fa956c805eeb6f463fb6ed763efa57 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 24 Nov 2025 18:45:17 +0100 Subject: [PATCH 189/190] meta(changelog): Update changelog for 10.27.0 --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bee97ac9189..58e2cf7bd830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @bignoncedric and @adam-kov. Thank you for your contributions! +## 10.27.0 + +### Important Changes -- feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239)) +- **feat(deps): Bump OpenTelemetry ([#18239](https://github.com/getsentry/sentry-javascript/pull/18239))** - Bump @opentelemetry/context-async-hooks from ^2.1.0 to ^2.2.0 - Bump @opentelemetry/core from ^2.1.0 to ^2.2.0 - Bump @opentelemetry/resources from ^2.1.0 to ^2.2.0 @@ -39,6 +41,49 @@ Work in this release was contributed by @bignoncedric and @adam-kov. Thank you f - Bump @opentelemetry/instrumentation-undici from 0.15.0 to 0.19.0 - Bump @prisma/instrumentation from 6.15.0 to 6.19.0 +- **feat(browserprofiling): Add `manual` mode and deprecate old profiling ([#18189](https://github.com/getsentry/sentry-javascript/pull/18189))** + + Adds the `manual` lifecycle mode for UI profiling (the default mode), allowing profiles to be captured manually with `Sentry.uiProfiler.startProfiler()` and `Sentry.uiProfiler.stopProfiler()`. + The previous transaction-based profiling is with `profilesSampleRate` is now deprecated in favor of the new UI Profiling with `profileSessionSampleRate`. + +### Other Changes + +- feat(core): Add `gibibyte` and `pebibyte` to `InformationUnit` type ([#18241](https://github.com/getsentry/sentry-javascript/pull/18241)) +- feat(core): Add scope attribute APIs ([#18165](https://github.com/getsentry/sentry-javascript/pull/18165)) +- feat(core): Re-add `_experiments.enableLogs` option ([#18299](https://github.com/getsentry/sentry-javascript/pull/18299)) +- feat(core): Use `maxValueLength` on error messages ([#18301](https://github.com/getsentry/sentry-javascript/pull/18301)) +- feat(deps): bump @sentry/bundler-plugin-core from 4.3.0 to 4.6.1 ([#18273](https://github.com/getsentry/sentry-javascript/pull/18273)) +- feat(deps): bump @sentry/cli from 2.56.0 to 2.58.2 ([#18271](https://github.com/getsentry/sentry-javascript/pull/18271)) +- feat(node): Add tracing support for AzureOpenAI ([#18281](https://github.com/getsentry/sentry-javascript/pull/18281)) +- feat(node): Fix local variables capturing for out-of-app frames ([#18245](https://github.com/getsentry/sentry-javascript/pull/18245)) +- fix(core): Add a PromiseBuffer for incoming events on the client ([#18120](https://github.com/getsentry/sentry-javascript/pull/18120)) +- fix(core): Always redact content of sensitive headers regardless of `sendDefaultPii` ([#18311](https://github.com/getsentry/sentry-javascript/pull/18311)) +- fix(metrics): Update return type of `beforeSendMetric` ([#18261](https://github.com/getsentry/sentry-javascript/pull/18261)) +- fix(nextjs): universal random tunnel path support ([#18257](https://github.com/getsentry/sentry-javascript/pull/18257)) +- ref(react): Add more guarding against wildcards in lazy route transactions ([#18155](https://github.com/getsentry/sentry-javascript/pull/18155)) +- chore(deps): bump glob from 11.0.1 to 11.1.0 in /packages/react-router ([#18243](https://github.com/getsentry/sentry-javascript/pull/18243)) + +
+ Internal Changes + - build(deps): bump hono from 4.9.7 to 4.10.3 in /dev-packages/e2e-tests/test-applications/cloudflare-hono ([#18038](https://github.com/getsentry/sentry-javascript/pull/18038)) + - chore: Add `bump_otel_instrumentations` cursor command ([#18253](https://github.com/getsentry/sentry-javascript/pull/18253)) + - chore: Add external contributor to CHANGELOG.md ([#18297](https://github.com/getsentry/sentry-javascript/pull/18297)) + - chore: Add external contributor to CHANGELOG.md ([#18300](https://github.com/getsentry/sentry-javascript/pull/18300)) + - chore: Do not update opentelemetry ([#18254](https://github.com/getsentry/sentry-javascript/pull/18254)) + - chore(angular): Add Angular 21 Support ([#18274](https://github.com/getsentry/sentry-javascript/pull/18274)) + - chore(deps): bump astro from 4.16.18 to 5.15.9 in /dev-packages/e2e-tests/test-applications/cloudflare-astro ([#18259](https://github.com/getsentry/sentry-javascript/pull/18259)) + - chore(dev-deps): Update some dev dependencies ([#17816](https://github.com/getsentry/sentry-javascript/pull/17816)) + - ci(deps): Bump actions/create-github-app-token from 2.1.1 to 2.1.4 ([#17825](https://github.com/getsentry/sentry-javascript/pull/17825)) + - ci(deps): bump actions/setup-node from 4 to 6 ([#18077](https://github.com/getsentry/sentry-javascript/pull/18077)) + - ci(deps): bump actions/upload-artifact from 4 to 5 ([#18075](https://github.com/getsentry/sentry-javascript/pull/18075)) + - ci(deps): bump github/codeql-action from 3 to 4 ([#18076](https://github.com/getsentry/sentry-javascript/pull/18076)) + - doc(sveltekit): Update documentation link for SvelteKit guide ([#18298](https://github.com/getsentry/sentry-javascript/pull/18298)) + - test(e2e): Fix astro config in test app ([#18282](https://github.com/getsentry/sentry-javascript/pull/18282)) + - test(nextjs): Remove debug logs from e2e test ([#18250](https://github.com/getsentry/sentry-javascript/pull/18250)) +
+ +Work in this release was contributed by @bignoncedric and @adam-kov. Thank you for your contributions! + ## 10.26.0 ### Important Changes From 0b0151d6be13a23c06eb957ace12085f760fae2c Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Mon, 24 Nov 2025 18:39:39 +0000 Subject: [PATCH 190/190] release: 10.27.0 --- .../browser-integration-tests/package.json | 4 ++-- .../bundle-analyzer-scenarios/package.json | 2 +- dev-packages/bundler-tests/package.json | 4 ++-- dev-packages/clear-cache-gh-action/package.json | 2 +- .../cloudflare-integration-tests/package.json | 6 +++--- dev-packages/e2e-tests/package.json | 2 +- .../external-contributor-gh-action/package.json | 2 +- .../node-core-integration-tests/package.json | 6 +++--- dev-packages/node-integration-tests/package.json | 10 +++++----- dev-packages/node-overhead-gh-action/package.json | 4 ++-- dev-packages/rollup-utils/package.json | 2 +- dev-packages/size-limit-gh-action/package.json | 2 +- dev-packages/test-utils/package.json | 4 ++-- lerna.json | 2 +- packages/angular/package.json | 6 +++--- packages/astro/package.json | 8 ++++---- packages/aws-serverless/package.json | 8 ++++---- packages/browser-utils/package.json | 4 ++-- packages/browser/package.json | 14 +++++++------- packages/bun/package.json | 6 +++--- packages/cloudflare/package.json | 4 ++-- packages/core/package.json | 2 +- packages/deno/package.json | 4 ++-- packages/ember/package.json | 6 +++--- packages/eslint-config-sdk/package.json | 6 +++--- packages/eslint-plugin-sdk/package.json | 2 +- packages/feedback/package.json | 4 ++-- packages/gatsby/package.json | 6 +++--- packages/google-cloud-serverless/package.json | 6 +++--- packages/integration-shims/package.json | 4 ++-- packages/nestjs/package.json | 6 +++--- packages/nextjs/package.json | 14 +++++++------- packages/node-core/package.json | 6 +++--- packages/node-native/package.json | 6 +++--- packages/node/package.json | 8 ++++---- packages/nuxt/package.json | 12 ++++++------ packages/opentelemetry/package.json | 4 ++-- packages/profiling-node/package.json | 6 +++--- packages/react-router/package.json | 10 +++++----- packages/react/package.json | 6 +++--- packages/remix/package.json | 8 ++++---- packages/replay-canvas/package.json | 6 +++--- packages/replay-internal/package.json | 8 ++++---- packages/replay-worker/package.json | 2 +- packages/solid/package.json | 6 +++--- packages/solidstart/package.json | 8 ++++---- packages/svelte/package.json | 6 +++--- packages/sveltekit/package.json | 10 +++++----- packages/tanstackstart-react/package.json | 10 +++++----- packages/tanstackstart/package.json | 2 +- packages/types/package.json | 4 ++-- packages/typescript/package.json | 2 +- packages/vercel-edge/package.json | 6 +++--- packages/vue/package.json | 6 +++--- packages/wasm/package.json | 6 +++--- 55 files changed, 155 insertions(+), 155 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 6350a0826572..e5ff62a58084 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "10.26.0", + "version": "10.27.0", "main": "index.js", "license": "MIT", "engines": { @@ -43,7 +43,7 @@ "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.53.2", "@sentry-internal/rrweb": "2.34.0", - "@sentry/browser": "10.26.0", + "@sentry/browser": "10.27.0", "@supabase/supabase-js": "2.49.3", "axios": "^1.12.2", "babel-loader": "^8.2.2", diff --git a/dev-packages/bundle-analyzer-scenarios/package.json b/dev-packages/bundle-analyzer-scenarios/package.json index 72928caf3708..518da1c1e8af 100644 --- a/dev-packages/bundle-analyzer-scenarios/package.json +++ b/dev-packages/bundle-analyzer-scenarios/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundle-analyzer-scenarios", - "version": "10.26.0", + "version": "10.27.0", "description": "Scenarios to test bundle analysis with", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/dev-packages/bundle-analyzer-scenarios", diff --git a/dev-packages/bundler-tests/package.json b/dev-packages/bundler-tests/package.json index 14bfa512f433..b2a307291c90 100644 --- a/dev-packages/bundler-tests/package.json +++ b/dev-packages/bundler-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundler-tests", - "version": "10.26.0", + "version": "10.27.0", "description": "Bundler tests for Sentry Browser SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bundler-tests", @@ -13,7 +13,7 @@ }, "dependencies": { "@rollup/plugin-node-resolve": "^15.2.3", - "@sentry/browser": "10.26.0", + "@sentry/browser": "10.27.0", "rollup": "^4.0.0", "vite": "^5.0.0", "vitest": "^3.2.4", diff --git a/dev-packages/clear-cache-gh-action/package.json b/dev-packages/clear-cache-gh-action/package.json index 22f0c16d2680..9592fba2a84d 100644 --- a/dev-packages/clear-cache-gh-action/package.json +++ b/dev-packages/clear-cache-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/clear-cache-gh-action", "description": "An internal Github Action to clear GitHub caches.", - "version": "10.26.0", + "version": "10.27.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index e4af74f7edfe..89c3abb7d5e8 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/cloudflare-integration-tests", - "version": "10.26.0", + "version": "10.27.0", "license": "MIT", "engines": { "node": ">=18" @@ -14,11 +14,11 @@ }, "dependencies": { "@langchain/langgraph": "^1.0.1", - "@sentry/cloudflare": "10.26.0" + "@sentry/cloudflare": "10.27.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250922.0", - "@sentry-internal/test-utils": "10.26.0", + "@sentry-internal/test-utils": "10.27.0", "eslint-plugin-regexp": "^1.15.0", "vitest": "^3.2.4", "wrangler": "4.22.0" diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 7712bbac10ee..be48359c7045 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/e2e-tests", - "version": "10.26.0", + "version": "10.27.0", "license": "MIT", "private": true, "scripts": { diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json index 9e99c2b66ae0..8c839a63b4aa 100644 --- a/dev-packages/external-contributor-gh-action/package.json +++ b/dev-packages/external-contributor-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/external-contributor-gh-action", "description": "An internal Github Action to add external contributors to the CHANGELOG.md file.", - "version": "10.26.0", + "version": "10.27.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 24ac2f57ea9e..a5be477c7334 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-core-integration-tests", - "version": "10.26.0", + "version": "10.27.0", "license": "MIT", "engines": { "node": ">=18" @@ -34,8 +34,8 @@ "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.26.0", - "@sentry/node-core": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/node-core": "10.27.0", "body-parser": "^1.20.3", "cors": "^2.8.5", "cron": "^3.1.6", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 1799e7c9b306..8a81e262d33f 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-integration-tests", - "version": "10.26.0", + "version": "10.27.0", "license": "MIT", "engines": { "node": ">=18" @@ -36,9 +36,9 @@ "@nestjs/core": "^11", "@nestjs/platform-express": "^11", "@prisma/client": "6.15.0", - "@sentry/aws-serverless": "10.26.0", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", + "@sentry/aws-serverless": "10.27.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", @@ -83,7 +83,7 @@ "yargs": "^16.2.0" }, "devDependencies": { - "@sentry-internal/test-utils": "10.26.0", + "@sentry-internal/test-utils": "10.27.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", diff --git a/dev-packages/node-overhead-gh-action/package.json b/dev-packages/node-overhead-gh-action/package.json index 498cefecf053..d044b8047bcc 100644 --- a/dev-packages/node-overhead-gh-action/package.json +++ b/dev-packages/node-overhead-gh-action/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-overhead-gh-action", - "version": "10.26.0", + "version": "10.27.0", "license": "MIT", "engines": { "node": ">=18" @@ -23,7 +23,7 @@ "fix": "eslint . --format stylish --fix" }, "dependencies": { - "@sentry/node": "10.26.0", + "@sentry/node": "10.27.0", "express": "^4.21.1", "mysql2": "^3.14.4" }, diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json index 62cec9c7f9f9..3c8e3a029d28 100644 --- a/dev-packages/rollup-utils/package.json +++ b/dev-packages/rollup-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/rollup-utils", - "version": "10.26.0", + "version": "10.27.0", "description": "Rollup utilities used at Sentry for the Sentry JavaScript SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/rollup-utils", diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index 5de80ac560c7..59d885937a9f 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/size-limit-gh-action", "description": "An internal Github Action to compare the current size of a PR against the one on develop.", - "version": "10.26.0", + "version": "10.27.0", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 7d0400748c94..3df8bcc441ea 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "10.26.0", + "version": "10.27.0", "name": "@sentry-internal/test-utils", "author": "Sentry", "license": "MIT", @@ -48,7 +48,7 @@ }, "devDependencies": { "@playwright/test": "~1.53.2", - "@sentry/core": "10.26.0", + "@sentry/core": "10.27.0", "eslint-plugin-regexp": "^1.15.0" }, "volta": { diff --git a/lerna.json b/lerna.json index 7363d9e257e2..9ad4c26bf2a7 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "10.26.0", + "version": "10.27.0", "npmClient": "yarn" } diff --git a/packages/angular/package.json b/packages/angular/package.json index fd378f4af2d8..72d29ca6b272 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Angular", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular", @@ -21,8 +21,8 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "10.26.0", - "@sentry/core": "10.26.0", + "@sentry/browser": "10.27.0", + "@sentry/core": "10.27.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/package.json b/packages/astro/package.json index 32a2cf0e3833..99d5eabae3e9 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/astro", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Astro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/astro", @@ -56,9 +56,9 @@ "astro": ">=3.x || >=4.0.0-beta || >=5.x" }, "dependencies": { - "@sentry/browser": "10.26.0", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", + "@sentry/browser": "10.27.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 8d6360e82d2a..2aaaa292cd93 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/aws-serverless", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for AWS Lambda and AWS Serverless Environments", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless", @@ -69,9 +69,9 @@ "@opentelemetry/instrumentation": "^0.208.0", "@opentelemetry/instrumentation-aws-sdk": "0.64.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", - "@sentry/node-core": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", + "@sentry/node-core": "10.27.0", "@types/aws-lambda": "^8.10.62" }, "devDependencies": { diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 117fec1f473c..37ac4a317bc8 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-utils", - "version": "10.26.0", + "version": "10.27.0", "description": "Browser Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser-utils", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.26.0" + "@sentry/core": "10.27.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser/package.json b/packages/browser/package.json index c7b21beb6766..2ecab4e6119e 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for browsers", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser", @@ -44,14 +44,14 @@ "access": "public" }, "dependencies": { - "@sentry-internal/browser-utils": "10.26.0", - "@sentry-internal/feedback": "10.26.0", - "@sentry-internal/replay": "10.26.0", - "@sentry-internal/replay-canvas": "10.26.0", - "@sentry/core": "10.26.0" + "@sentry-internal/browser-utils": "10.27.0", + "@sentry-internal/feedback": "10.27.0", + "@sentry-internal/replay": "10.27.0", + "@sentry-internal/replay-canvas": "10.27.0", + "@sentry/core": "10.27.0" }, "devDependencies": { - "@sentry-internal/integration-shims": "10.26.0", + "@sentry-internal/integration-shims": "10.27.0", "fake-indexeddb": "^6.2.4" }, "scripts": { diff --git a/packages/bun/package.json b/packages/bun/package.json index fcd538862c17..f1464af7a732 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bun", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for bun", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0" + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0" }, "devDependencies": { "bun-types": "^1.2.9" diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index ee8cba264e5f..163afddca271 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/cloudflare", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Cloudflare Workers and Pages", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare", @@ -50,7 +50,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.26.0" + "@sentry/core": "10.27.0" }, "peerDependencies": { "@cloudflare/workers-types": "^4.x" diff --git a/packages/core/package.json b/packages/core/package.json index 8ffc256e2f26..76ac4bc3e96f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/core", - "version": "10.26.0", + "version": "10.27.0", "description": "Base implementation for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/core", diff --git a/packages/deno/package.json b/packages/deno/package.json index 04ac8f592fd1..cc40ededd6e7 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/deno", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Deno", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", @@ -25,7 +25,7 @@ ], "dependencies": { "@opentelemetry/api": "^1.9.0", - "@sentry/core": "10.26.0" + "@sentry/core": "10.27.0" }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", diff --git a/packages/ember/package.json b/packages/ember/package.json index 69a111ab54a9..a2307bb11013 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/ember", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Ember.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/ember", @@ -32,8 +32,8 @@ "dependencies": { "@babel/core": "^7.27.7", "@embroider/macros": "^1.16.0", - "@sentry/browser": "10.26.0", - "@sentry/core": "10.26.0", + "@sentry/browser": "10.27.0", + "@sentry/core": "10.27.0", "ember-auto-import": "^2.7.2", "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^6.1.1", diff --git a/packages/eslint-config-sdk/package.json b/packages/eslint-config-sdk/package.json index d45a301e1590..8982ce90c026 100644 --- a/packages/eslint-config-sdk/package.json +++ b/packages/eslint-config-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-config-sdk", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK eslint config", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-config-sdk", @@ -22,8 +22,8 @@ "access": "public" }, "dependencies": { - "@sentry-internal/eslint-plugin-sdk": "10.26.0", - "@sentry-internal/typescript": "10.26.0", + "@sentry-internal/eslint-plugin-sdk": "10.27.0", + "@sentry-internal/typescript": "10.27.0", "@typescript-eslint/eslint-plugin": "^5.48.0", "@typescript-eslint/parser": "^5.48.0", "eslint-config-prettier": "^6.11.0", diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index bad2ae248475..2ee8a4be4925 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-plugin-sdk", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK eslint plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-plugin-sdk", diff --git a/packages/feedback/package.json b/packages/feedback/package.json index 7c9899ffd305..b2b17bad3033 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/feedback", - "version": "10.26.0", + "version": "10.27.0", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.26.0" + "@sentry/core": "10.27.0" }, "devDependencies": { "preact": "^10.19.4" diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 8236ee72e0e9..f46070324187 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/gatsby", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Gatsby.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/gatsby", @@ -45,8 +45,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.26.0", - "@sentry/react": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/react": "10.27.0", "@sentry/webpack-plugin": "^4.1.1" }, "peerDependencies": { diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index 6afb10e14c42..3495aff8de55 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/google-cloud-serverless", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Google Cloud Functions", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/google-cloud-serverless", @@ -48,8 +48,8 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", "@types/express": "^4.17.14" }, "devDependencies": { diff --git a/packages/integration-shims/package.json b/packages/integration-shims/package.json index 8569a5b837f6..437b12de6c33 100644 --- a/packages/integration-shims/package.json +++ b/packages/integration-shims/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/integration-shims", - "version": "10.26.0", + "version": "10.27.0", "description": "Shims for integrations in Sentry SDK.", "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -56,7 +56,7 @@ "url": "https://github.com/getsentry/sentry-javascript/issues" }, "dependencies": { - "@sentry/core": "10.26.0" + "@sentry/core": "10.27.0" }, "engines": { "node": ">=18" diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index ae39e2dc5d4f..2b4e04b097b9 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nestjs", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for NestJS", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nestjs", @@ -49,8 +49,8 @@ "@opentelemetry/instrumentation": "^0.208.0", "@opentelemetry/instrumentation-nestjs-core": "0.55.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0" + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 9afdfec16d7b..f633ee64b3bf 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nextjs", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Next.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nextjs", @@ -79,13 +79,13 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@rollup/plugin-commonjs": "28.0.1", - "@sentry-internal/browser-utils": "10.26.0", + "@sentry-internal/browser-utils": "10.27.0", "@sentry/bundler-plugin-core": "^4.6.1", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", - "@sentry/opentelemetry": "10.26.0", - "@sentry/react": "10.26.0", - "@sentry/vercel-edge": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", + "@sentry/opentelemetry": "10.27.0", + "@sentry/react": "10.27.0", + "@sentry/vercel-edge": "10.27.0", "@sentry/webpack-plugin": "^4.3.0", "resolve": "1.22.8", "rollup": "^4.35.0", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 1f845acfa16b..57100fc16cf3 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-core", - "version": "10.26.0", + "version": "10.27.0", "description": "Sentry Node-Core SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-core", @@ -67,8 +67,8 @@ }, "dependencies": { "@apm-js-collab/tracing-hooks": "^0.3.1", - "@sentry/core": "10.26.0", - "@sentry/opentelemetry": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/opentelemetry": "10.27.0", "import-in-the-middle": "^2" }, "devDependencies": { diff --git a/packages/node-native/package.json b/packages/node-native/package.json index 9a4c40818556..e193f26ae795 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node-native", - "version": "10.26.0", + "version": "10.27.0", "description": "Native Tools for the Official Sentry Node.js SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node-native", @@ -64,8 +64,8 @@ }, "dependencies": { "@sentry-internal/node-native-stacktrace": "^0.2.2", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0" + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/node/package.json b/packages/node/package.json index e43d7b04a0ee..95e0610c9828 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/node", - "version": "10.26.0", + "version": "10.27.0", "description": "Sentry Node SDK using OpenTelemetry for performance instrumentation", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/node", @@ -95,9 +95,9 @@ "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", "@prisma/instrumentation": "6.19.0", - "@sentry/core": "10.26.0", - "@sentry/node-core": "10.26.0", - "@sentry/opentelemetry": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/node-core": "10.27.0", + "@sentry/opentelemetry": "10.27.0", "import-in-the-middle": "^2", "minimatch": "^9.0.0" }, diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index e054d1b9938a..0ce5df7bb8d5 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/nuxt", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Nuxt", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/nuxt", @@ -49,13 +49,13 @@ }, "dependencies": { "@nuxt/kit": "^3.13.2", - "@sentry/browser": "10.26.0", - "@sentry/cloudflare": "10.26.0", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", + "@sentry/browser": "10.27.0", + "@sentry/cloudflare": "10.27.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", "@sentry/rollup-plugin": "^4.3.0", "@sentry/vite-plugin": "^4.3.0", - "@sentry/vue": "10.26.0" + "@sentry/vue": "10.27.0" }, "devDependencies": { "@nuxt/module-builder": "^0.8.4", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 86d73b8555b2..4a21c884155f 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/opentelemetry", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry utilities for OpenTelemetry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/opentelemetry", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "10.26.0" + "@sentry/core": "10.27.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/profiling-node/package.json b/packages/profiling-node/package.json index e897b8d5611e..f1082191997e 100644 --- a/packages/profiling-node/package.json +++ b/packages/profiling-node/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/profiling-node", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Node.js Profiling", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/profiling-node", @@ -63,8 +63,8 @@ }, "dependencies": { "@sentry-internal/node-cpu-profiler": "^2.2.0", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0" + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0" }, "devDependencies": { "@types/node": "^18.19.1" diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 51ce7bb94122..fe662890fa7a 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react-router", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for React Router (Framework)", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react-router", @@ -49,11 +49,11 @@ "@opentelemetry/core": "^2.2.0", "@opentelemetry/instrumentation": "^0.208.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/browser": "10.26.0", + "@sentry/browser": "10.27.0", "@sentry/cli": "^2.58.2", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", - "@sentry/react": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", + "@sentry/react": "10.27.0", "@sentry/vite-plugin": "^4.1.0", "glob": "11.1.0" }, diff --git a/packages/react/package.json b/packages/react/package.json index afd5f5f25bdd..06d56f58b6f5 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/react", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for React.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/react", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.26.0", - "@sentry/core": "10.26.0", + "@sentry/browser": "10.27.0", + "@sentry/core": "10.27.0", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { diff --git a/packages/remix/package.json b/packages/remix/package.json index 98bdd9a39c7c..7230064df963 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/remix", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Remix", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/remix", @@ -69,9 +69,9 @@ "@opentelemetry/semantic-conventions": "^1.37.0", "@remix-run/router": "1.x", "@sentry/cli": "^2.58.2", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", - "@sentry/react": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", + "@sentry/react": "10.27.0", "glob": "^10.3.4", "yargs": "^17.6.0" }, diff --git a/packages/replay-canvas/package.json b/packages/replay-canvas/package.json index 63ee871ddfdf..f66b691a42fa 100644 --- a/packages/replay-canvas/package.json +++ b/packages/replay-canvas/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-canvas", - "version": "10.26.0", + "version": "10.27.0", "description": "Replay canvas integration", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -69,8 +69,8 @@ "@sentry-internal/rrweb": "2.40.0" }, "dependencies": { - "@sentry-internal/replay": "10.26.0", - "@sentry/core": "10.26.0" + "@sentry-internal/replay": "10.27.0", + "@sentry/core": "10.27.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-internal/package.json b/packages/replay-internal/package.json index f1678cea2a15..1cccb4c2ef50 100644 --- a/packages/replay-internal/package.json +++ b/packages/replay-internal/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay", - "version": "10.26.0", + "version": "10.27.0", "description": "User replays for Sentry", "main": "build/npm/cjs/index.js", "module": "build/npm/esm/index.js", @@ -81,7 +81,7 @@ "homepage": "https://docs.sentry.io/platforms/javascript/session-replay/", "devDependencies": { "@babel/core": "^7.27.7", - "@sentry-internal/replay-worker": "10.26.0", + "@sentry-internal/replay-worker": "10.27.0", "@sentry-internal/rrweb": "2.40.0", "@sentry-internal/rrweb-snapshot": "2.40.0", "fflate": "0.8.2", @@ -89,8 +89,8 @@ "jsdom-worker": "^0.3.0" }, "dependencies": { - "@sentry-internal/browser-utils": "10.26.0", - "@sentry/core": "10.26.0" + "@sentry-internal/browser-utils": "10.27.0", + "@sentry/core": "10.27.0" }, "engines": { "node": ">=18" diff --git a/packages/replay-worker/package.json b/packages/replay-worker/package.json index e9a968061841..cdd3dd4059b1 100644 --- a/packages/replay-worker/package.json +++ b/packages/replay-worker/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/replay-worker", - "version": "10.26.0", + "version": "10.27.0", "description": "Worker for @sentry-internal/replay", "main": "build/esm/index.js", "module": "build/esm/index.js", diff --git a/packages/solid/package.json b/packages/solid/package.json index c75a0eadb4ce..9d6138147612 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solid", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Solid", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solid", @@ -54,8 +54,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.26.0", - "@sentry/core": "10.26.0" + "@sentry/browser": "10.27.0", + "@sentry/core": "10.27.0" }, "peerDependencies": { "@solidjs/router": "^0.13.4", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 8bb0e4e0471f..1069905423fa 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/solidstart", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Solid Start", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solidstart", @@ -66,9 +66,9 @@ } }, "dependencies": { - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", - "@sentry/solid": "10.26.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", + "@sentry/solid": "10.27.0", "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 6e9cbdf5b54a..83af1efee4a5 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/svelte", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Svelte", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/svelte", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.26.0", - "@sentry/core": "10.26.0", + "@sentry/browser": "10.27.0", + "@sentry/core": "10.27.0", "magic-string": "^0.30.0" }, "peerDependencies": { diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 7802db426793..a39ef877222e 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/sveltekit", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for SvelteKit", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/sveltekit", @@ -48,10 +48,10 @@ }, "dependencies": { "@babel/parser": "7.26.9", - "@sentry/cloudflare": "10.26.0", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", - "@sentry/svelte": "10.26.0", + "@sentry/cloudflare": "10.27.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", + "@sentry/svelte": "10.27.0", "@sentry/vite-plugin": "^4.1.0", "magic-string": "0.30.7", "recast": "0.23.11", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 03688bdd6aad..dac0c980b1f5 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart-react", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for TanStack Start React", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart-react", @@ -52,10 +52,10 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry-internal/browser-utils": "10.26.0", - "@sentry/core": "10.26.0", - "@sentry/node": "10.26.0", - "@sentry/react": "10.26.0" + "@sentry-internal/browser-utils": "10.27.0", + "@sentry/core": "10.27.0", + "@sentry/node": "10.27.0", + "@sentry/react": "10.27.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/tanstackstart/package.json b/packages/tanstackstart/package.json index 3918ecd9a3fa..6a99cfefdb99 100644 --- a/packages/tanstackstart/package.json +++ b/packages/tanstackstart/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/tanstackstart", - "version": "10.26.0", + "version": "10.27.0", "description": "Utilities for the Sentry TanStack Start SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/tanstackstart", diff --git a/packages/types/package.json b/packages/types/package.json index 2287d86014da..70cd501b4eb6 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/types", - "version": "10.26.0", + "version": "10.27.0", "description": "Types for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/types", @@ -57,7 +57,7 @@ "yalc:publish": "yalc publish --push --sig" }, "dependencies": { - "@sentry/core": "10.26.0" + "@sentry/core": "10.27.0" }, "volta": { "extends": "../../package.json" diff --git a/packages/typescript/package.json b/packages/typescript/package.json index ab02fe16f828..f1d5c80f4954 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/typescript", - "version": "10.26.0", + "version": "10.27.0", "description": "Typescript configuration used at Sentry", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/typescript", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 1efd9c1af34c..90d61f964f34 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vercel-edge", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for the Vercel Edge Runtime", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vercel-edge", @@ -41,14 +41,14 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/resources": "^2.2.0", - "@sentry/core": "10.26.0" + "@sentry/core": "10.27.0" }, "devDependencies": { "@edge-runtime/types": "3.0.1", "@opentelemetry/core": "^2.2.0", "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.37.0", - "@sentry/opentelemetry": "10.26.0" + "@sentry/opentelemetry": "10.27.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/vue/package.json b/packages/vue/package.json index abdb956018c6..6cb37663ebbf 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/vue", - "version": "10.26.0", + "version": "10.27.0", "description": "Official Sentry SDK for Vue.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/vue", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.26.0", - "@sentry/core": "10.26.0" + "@sentry/browser": "10.27.0", + "@sentry/core": "10.27.0" }, "peerDependencies": { "pinia": "2.x || 3.x", diff --git a/packages/wasm/package.json b/packages/wasm/package.json index 2e7883644276..a864eca972f7 100644 --- a/packages/wasm/package.json +++ b/packages/wasm/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/wasm", - "version": "10.26.0", + "version": "10.27.0", "description": "Support for WASM.", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/wasm", @@ -39,8 +39,8 @@ "access": "public" }, "dependencies": { - "@sentry/browser": "10.26.0", - "@sentry/core": "10.26.0" + "@sentry/browser": "10.27.0", + "@sentry/core": "10.27.0" }, "scripts": { "build": "run-p build:transpile build:bundle build:types",
+ logo +

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

+
+ Learn Solid + + + Learn TanStack + +