From 422443efdb127d6b6803c93700f360e2b482e0fc Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 11 Jul 2025 14:25:10 +0200 Subject: [PATCH 01/37] test: Exclude `node-core-integration-tests` from Node Unit tests CI job (#16930) Looks like node-core integration tests ran in our Node unit test job matrix. This is because the package is not excluded from the `test:pr` top-level NPM script. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e4aceff330a4..591424f91ec5 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "test": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests}\" test", "test:unit": "lerna run --ignore \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests}\" test:unit", "test:update-snapshots": "lerna run test:update-snapshots", - "test:pr": "nx affected -t test --exclude \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests}\"", + "test:pr": "nx affected -t test --exclude \"@sentry-internal/{browser-integration-tests,e2e-tests,integration-shims,node-integration-tests,node-core-integration-tests}\"", "test:pr:browser": "UNIT_TEST_ENV=browser ts-node ./scripts/ci-unit-tests.ts --affected", "test:pr:node": "UNIT_TEST_ENV=node ts-node ./scripts/ci-unit-tests.ts --affected", "test:ci:browser": "UNIT_TEST_ENV=browser ts-node ./scripts/ci-unit-tests.ts", From 7a0232dd1de58338f8c2d6fe0da821fbbf1d095c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 11 Jul 2025 15:18:20 +0200 Subject: [PATCH 02/37] feat(nextjs): Build app manifest (#16851) --- .../config/manifest/createRouteManifest.ts | 202 ++++++++++++++++++ packages/nextjs/src/config/manifest/types.ts | 32 +++ .../app/catchall/[[...path]]/page.tsx | 1 + .../manifest/suites/catchall/app/page.tsx | 1 + .../manifest/suites/catchall/catchall.test.ts | 33 +++ .../suites/dynamic/app/dynamic/[id]/page.tsx | 1 + .../dynamic/app/dynamic/static/page.tsx | 1 + .../manifest/suites/dynamic/app/page.tsx | 1 + .../suites/dynamic/app/static/nested/page.tsx | 1 + .../suites/dynamic/app/users/[id]/page.tsx | 1 + .../app/users/[id]/posts/[postId]/page.tsx | 1 + .../dynamic/app/users/[id]/settings/page.tsx | 1 + .../manifest/suites/dynamic/dynamic.test.ts | 84 ++++++++ .../app/javascript/component.tsx | 1 + .../file-extensions/app/javascript/page.js | 1 + .../file-extensions/app/jsx-route/page.jsx | 1 + .../suites/file-extensions/app/layout.tsx | 1 + .../suites/file-extensions/app/mixed/page.jsx | 1 + .../suites/file-extensions/app/mixed/page.ts | 1 + .../suites/file-extensions/app/page.tsx | 1 + .../file-extensions/app/precedence/page.js | 1 + .../file-extensions/app/precedence/page.tsx | 1 + .../file-extensions/app/typescript/other.ts | 1 + .../file-extensions/app/typescript/page.ts | 1 + .../file-extensions/file-extensions.test.ts | 21 ++ .../suites/route-groups/app/(auth)/layout.tsx | 1 + .../route-groups/app/(auth)/login/page.tsx | 1 + .../route-groups/app/(auth)/signup/page.tsx | 1 + .../app/(dashboard)/dashboard/[id]/page.tsx | 1 + .../app/(dashboard)/dashboard/page.tsx | 1 + .../route-groups/app/(dashboard)/layout.tsx | 1 + .../app/(dashboard)/settings/layout.tsx | 1 + .../app/(dashboard)/settings/profile/page.tsx | 1 + .../route-groups/app/(marketing)/layout.tsx | 1 + .../app/(marketing)/public/about/page.tsx | 1 + .../suites/route-groups/app/layout.tsx | 1 + .../manifest/suites/route-groups/app/page.tsx | 1 + .../suites/route-groups/route-groups.test.ts | 92 ++++++++ .../manifest/suites/static/app/page.tsx | 1 + .../suites/static/app/some/nested/page.tsx | 1 + .../manifest/suites/static/app/user/page.tsx | 1 + .../manifest/suites/static/app/users/page.tsx | 1 + .../manifest/suites/static/static.test.ts | 13 ++ 43 files changed, 513 insertions(+) create mode 100644 packages/nextjs/src/config/manifest/createRouteManifest.ts create mode 100644 packages/nextjs/src/config/manifest/types.ts create mode 100644 packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/static.test.ts diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts new file mode 100644 index 000000000000..4df71b389c8b --- /dev/null +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -0,0 +1,202 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { RouteInfo, RouteManifest } from './types'; + +export type CreateRouteManifestOptions = { + // For starters we only support app router + appDirPath?: string; + /** + * Whether to include route groups (e.g., (auth-layout)) in the final route paths. + * By default, route groups are stripped from paths following Next.js convention. + */ + includeRouteGroups?: boolean; +}; + +let manifestCache: RouteManifest | null = null; +let lastAppDirPath: string | null = null; +let lastIncludeRouteGroups: boolean | undefined = undefined; + +function isPageFile(filename: string): boolean { + return filename === 'page.tsx' || filename === 'page.jsx' || filename === 'page.ts' || filename === 'page.js'; +} + +function isRouteGroup(name: string): boolean { + return name.startsWith('(') && name.endsWith(')'); +} + +function normalizeRoutePath(routePath: string): string { + // Remove route group segments from the path + return routePath.replace(/\/\([^)]+\)/g, ''); +} + +function getDynamicRouteSegment(name: string): string { + if (name.startsWith('[[...') && name.endsWith(']]')) { + // Optional catchall: [[...param]] + const paramName = name.slice(5, -2); // Remove [[... and ]] + return `:${paramName}*?`; // Mark with ? as optional + } else if (name.startsWith('[...') && name.endsWith(']')) { + // Required catchall: [...param] + const paramName = name.slice(4, -1); // Remove [... and ] + return `:${paramName}*`; + } + // Regular dynamic: [param] + return `:${name.slice(1, -1)}`; +} + +function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } { + const segments = routePath.split('/').filter(Boolean); + const regexSegments: string[] = []; + const paramNames: string[] = []; + let hasOptionalCatchall = false; + + for (const segment of segments) { + if (segment.startsWith(':')) { + const paramName = segment.substring(1); + + if (paramName.endsWith('*?')) { + // Optional catchall: matches zero or more segments + const cleanParamName = paramName.slice(0, -2); + paramNames.push(cleanParamName); + // Handling this special case in pattern construction below + hasOptionalCatchall = true; + } else if (paramName.endsWith('*')) { + // Required catchall: matches one or more segments + const cleanParamName = paramName.slice(0, -1); + paramNames.push(cleanParamName); + regexSegments.push('(.+)'); + } else { + // Regular dynamic segment + paramNames.push(paramName); + regexSegments.push('([^/]+)'); + } + } else { + // Static segment - escape regex special characters including route group parentheses + regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + } + } + + let pattern: string; + if (hasOptionalCatchall) { + // For optional catchall, make the trailing slash and segments optional + // This allows matching both /catchall and /catchall/anything + const staticParts = regexSegments.join('/'); + pattern = `^/${staticParts}(?:/(.*))?$`; + } else { + pattern = `^/${regexSegments.join('/')}$`; + } + + return { regex: pattern, paramNames }; +} + +function scanAppDirectory( + dir: string, + basePath: string = '', + includeRouteGroups: boolean = false, +): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[] } { + const dynamicRoutes: RouteInfo[] = []; + const staticRoutes: RouteInfo[] = []; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const pageFile = entries.some(entry => isPageFile(entry.name)); + + if (pageFile) { + // Conditionally normalize the path based on includeRouteGroups option + const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/'); + const isDynamic = routePath.includes(':'); + + if (isDynamic) { + const { regex, paramNames } = buildRegexForDynamicRoute(routePath); + dynamicRoutes.push({ + path: routePath, + regex, + paramNames, + }); + } else { + staticRoutes.push({ + path: routePath, + }); + } + } + + for (const entry of entries) { + if (entry.isDirectory()) { + const fullPath = path.join(dir, entry.name); + let routeSegment: string; + + const isDynamic = entry.name.startsWith('[') && entry.name.endsWith(']'); + const isRouteGroupDir = isRouteGroup(entry.name); + + if (isRouteGroupDir) { + if (includeRouteGroups) { + routeSegment = entry.name; + } else { + routeSegment = ''; + } + } else if (isDynamic) { + routeSegment = getDynamicRouteSegment(entry.name); + } else { + routeSegment = entry.name; + } + + const newBasePath = routeSegment ? `${basePath}/${routeSegment}` : basePath; + const subRoutes = scanAppDirectory(fullPath, newBasePath, includeRouteGroups); + + dynamicRoutes.push(...subRoutes.dynamicRoutes); + staticRoutes.push(...subRoutes.staticRoutes); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Error building route manifest:', error); + } + + return { dynamicRoutes, staticRoutes }; +} + +/** + * Returns a route manifest for the given app directory + */ +export function createRouteManifest(options?: CreateRouteManifestOptions): RouteManifest { + let targetDir: string | undefined; + + if (options?.appDirPath) { + targetDir = options.appDirPath; + } else { + const projectDir = process.cwd(); + const maybeAppDirPath = path.join(projectDir, 'app'); + const maybeSrcAppDirPath = path.join(projectDir, 'src', 'app'); + + if (fs.existsSync(maybeAppDirPath) && fs.lstatSync(maybeAppDirPath).isDirectory()) { + targetDir = maybeAppDirPath; + } else if (fs.existsSync(maybeSrcAppDirPath) && fs.lstatSync(maybeSrcAppDirPath).isDirectory()) { + targetDir = maybeSrcAppDirPath; + } + } + + if (!targetDir) { + return { + dynamicRoutes: [], + staticRoutes: [], + }; + } + + // Check if we can use cached version + if (manifestCache && lastAppDirPath === targetDir && lastIncludeRouteGroups === options?.includeRouteGroups) { + return manifestCache; + } + + const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, '', options?.includeRouteGroups); + + const manifest: RouteManifest = { + dynamicRoutes, + staticRoutes, + }; + + // set cache + manifestCache = manifest; + lastAppDirPath = targetDir; + lastIncludeRouteGroups = options?.includeRouteGroups; + + return manifest; +} diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts new file mode 100644 index 000000000000..e3a26adfce2f --- /dev/null +++ b/packages/nextjs/src/config/manifest/types.ts @@ -0,0 +1,32 @@ +/** + * Information about a single route in the manifest + */ +export type RouteInfo = { + /** + * The parameterised route path, e.g. "/users/[id]" + */ + path: string; + /** + * (Optional) The regex pattern for dynamic routes + */ + regex?: string; + /** + * (Optional) The names of dynamic parameters in the route + */ + paramNames?: string[]; +}; + +/** + * The manifest containing all routes discovered in the app + */ +export type RouteManifest = { + /** + * List of all dynamic routes + */ + dynamicRoutes: RouteInfo[]; + + /** + * List of all static routes + */ + staticRoutes: RouteInfo[]; +}; diff --git a/packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx b/packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx new file mode 100644 index 000000000000..5d33b5d14573 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx @@ -0,0 +1 @@ +// beep diff --git a/packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx b/packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx new file mode 100644 index 000000000000..2145a5eea70d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx @@ -0,0 +1 @@ +// Ciao diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts new file mode 100644 index 000000000000..b1c417970ba4 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -0,0 +1,33 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('catchall', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should generate a manifest with catchall route', () => { + expect(manifest).toEqual({ + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/catchall/:path*?', + regex: '^/catchall(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }); + }); + + test('should generate correct pattern for catchall route', () => { + const catchallRoute = manifest.dynamicRoutes.find(route => route.path === '/catchall/:path*?'); + const regex = new RegExp(catchallRoute?.regex ?? ''); + expect(regex.test('/catchall/123')).toBe(true); + expect(regex.test('/catchall/abc')).toBe(true); + expect(regex.test('/catchall/123/456')).toBe(true); + expect(regex.test('/catchall/123/abc/789')).toBe(true); + expect(regex.test('/catchall/')).toBe(true); + expect(regex.test('/catchall')).toBe(true); + expect(regex.test('/123/catchall/123')).toBe(false); + expect(regex.test('/')).toBe(false); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx new file mode 100644 index 000000000000..5d33b5d14573 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx @@ -0,0 +1 @@ +// beep diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx new file mode 100644 index 000000000000..f0ba5f3c3b70 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx @@ -0,0 +1 @@ +// Static diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx new file mode 100644 index 000000000000..2145a5eea70d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx @@ -0,0 +1 @@ +// Ciao diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx new file mode 100644 index 000000000000..c3a94a1cb9e7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx @@ -0,0 +1 @@ +// Hola diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx new file mode 100644 index 000000000000..262ed4b5bade --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx @@ -0,0 +1 @@ +// User profile page diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx new file mode 100644 index 000000000000..1b8e79363a7f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx @@ -0,0 +1 @@ +// Post detail page diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx new file mode 100644 index 000000000000..2a09cffc75c4 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx @@ -0,0 +1 @@ +// User settings page diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts new file mode 100644 index 000000000000..fdcae299d7cf --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -0,0 +1,84 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('dynamic', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should generate a dynamic manifest', () => { + expect(manifest).toEqual({ + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should generate correct pattern for single dynamic route', () => { + const singleDynamic = manifest.dynamicRoutes.find(route => route.path === '/dynamic/:id'); + const regex = new RegExp(singleDynamic?.regex ?? ''); + expect(regex.test('/dynamic/123')).toBe(true); + expect(regex.test('/dynamic/abc')).toBe(true); + expect(regex.test('/dynamic/123/456')).toBe(false); + expect(regex.test('/dynamic123/123')).toBe(false); + expect(regex.test('/')).toBe(false); + }); + + test('should generate correct pattern for mixed static-dynamic route', () => { + const mixedRoute = manifest.dynamicRoutes.find(route => route.path === '/users/:id/settings'); + const regex = new RegExp(mixedRoute?.regex ?? ''); + + expect(regex.test('/users/123/settings')).toBe(true); + expect(regex.test('/users/john-doe/settings')).toBe(true); + expect(regex.test('/users/123/settings/extra')).toBe(false); + expect(regex.test('/users/123')).toBe(false); + expect(regex.test('/settings')).toBe(false); + }); + + test('should generate correct pattern for multiple dynamic segments', () => { + const multiDynamic = manifest.dynamicRoutes.find(route => route.path === '/users/:id/posts/:postId'); + const regex = new RegExp(multiDynamic?.regex ?? ''); + + expect(regex.test('/users/123/posts/456')).toBe(true); + expect(regex.test('/users/john/posts/my-post')).toBe(true); + expect(regex.test('/users/123/posts/456/comments')).toBe(false); + expect(regex.test('/users/123/posts')).toBe(false); + expect(regex.test('/users/123')).toBe(false); + + const match = '/users/123/posts/456'.match(regex); + expect(match).toBeTruthy(); + expect(match?.[1]).toBe('123'); + expect(match?.[2]).toBe('456'); + }); + + test('should handle special characters in dynamic segments', () => { + const userSettingsRoute = manifest.dynamicRoutes.find(route => route.path === '/users/:id/settings'); + expect(userSettingsRoute).toBeDefined(); + expect(userSettingsRoute?.regex).toBeDefined(); + + const regex = new RegExp(userSettingsRoute!.regex!); + expect(regex.test('/users/user-with-dashes/settings')).toBe(true); + expect(regex.test('/users/user_with_underscores/settings')).toBe(true); + expect(regex.test('/users/123/settings')).toBe(true); + expect(regex.test('/users/123/settings/extra')).toBe(false); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx new file mode 100644 index 000000000000..71f2fabe4ab9 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx @@ -0,0 +1 @@ +// Component file - should be ignored diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js new file mode 100644 index 000000000000..648c2fc1a572 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js @@ -0,0 +1 @@ +// JavaScript page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx new file mode 100644 index 000000000000..de9dad9da3f1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx @@ -0,0 +1 @@ +// JSX page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx new file mode 100644 index 000000000000..126ada0403af --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx @@ -0,0 +1 @@ +// Layout file - should be ignored diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx new file mode 100644 index 000000000000..de9dad9da3f1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx @@ -0,0 +1 @@ +// JSX page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts new file mode 100644 index 000000000000..9d0c3f668b0f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts @@ -0,0 +1 @@ +// TypeScript page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx new file mode 100644 index 000000000000..7c6102bf4455 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx @@ -0,0 +1 @@ +// Root page - TypeScript JSX diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js new file mode 100644 index 000000000000..c88b431881a8 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js @@ -0,0 +1 @@ +// JavaScript page - should be ignored if tsx exists diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx new file mode 100644 index 000000000000..b94cece5634c --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx @@ -0,0 +1 @@ +// TypeScript JSX page - should take precedence diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts new file mode 100644 index 000000000000..1838a98702b5 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts @@ -0,0 +1 @@ +// Other TypeScript file - should be ignored diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts new file mode 100644 index 000000000000..9d0c3f668b0f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts @@ -0,0 +1 @@ +// TypeScript page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts new file mode 100644 index 000000000000..2c898b1e8e96 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -0,0 +1,21 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('file-extensions', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should detect page files with all supported extensions', () => { + expect(manifest).toEqual({ + staticRoutes: [ + { path: '/' }, + { path: '/javascript' }, + { path: '/jsx-route' }, + { path: '/mixed' }, + { path: '/precedence' }, + { path: '/typescript' }, + ], + dynamicRoutes: [], + }); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx new file mode 100644 index 000000000000..c946d3fcf92f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx @@ -0,0 +1 @@ +// Auth layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx new file mode 100644 index 000000000000..ca3839e2b57a --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx @@ -0,0 +1 @@ +// Login page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx new file mode 100644 index 000000000000..9283a04ddf23 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx @@ -0,0 +1 @@ +// Signup page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx new file mode 100644 index 000000000000..f5c50b6ae225 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx @@ -0,0 +1 @@ +// Dynamic dashboard page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 000000000000..76e06b75c3d1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1 @@ +// Dashboard page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx new file mode 100644 index 000000000000..0277c5a9bfce --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx @@ -0,0 +1 @@ +// Dashboard layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx new file mode 100644 index 000000000000..80acdce1ca66 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx @@ -0,0 +1 @@ +// Settings layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx new file mode 100644 index 000000000000..f715804e06c7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx @@ -0,0 +1 @@ +// Settings profile page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx new file mode 100644 index 000000000000..3242dbd8f393 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx @@ -0,0 +1 @@ +// Marketing layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx new file mode 100644 index 000000000000..8088810f2e5a --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx @@ -0,0 +1 @@ +// About page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx new file mode 100644 index 000000000000..0490aba2e801 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx @@ -0,0 +1 @@ +// Root layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx new file mode 100644 index 000000000000..7a8d1d44737d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx @@ -0,0 +1 @@ +// Root page 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 new file mode 100644 index 000000000000..36ac9077df7e --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -0,0 +1,92 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('route-groups', () => { + const appDirPath = path.join(__dirname, 'app'); + + describe('default behavior (route groups stripped)', () => { + const manifest = createRouteManifest({ appDirPath }); + + test('should generate a manifest with route groups stripped', () => { + expect(manifest).toEqual({ + staticRoutes: [ + { path: '/' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ + { + path: '/dashboard/:id', + regex: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should handle dynamic routes within route groups', () => { + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.regex ?? ''); + expect(regex.test('/dashboard/123')).toBe(true); + expect(regex.test('/dashboard/abc')).toBe(true); + expect(regex.test('/dashboard/123/456')).toBe(false); + }); + }); + + describe('includeRouteGroups: true', () => { + const manifest = createRouteManifest({ appDirPath, includeRouteGroups: true }); + + test('should generate a manifest with route groups included', () => { + expect(manifest).toEqual({ + staticRoutes: [ + { path: '/' }, + { path: '/(auth)/login' }, + { path: '/(auth)/signup' }, + { path: '/(dashboard)/dashboard' }, + { path: '/(dashboard)/settings/profile' }, + { path: '/(marketing)/public/about' }, + ], + dynamicRoutes: [ + { + path: '/(dashboard)/dashboard/:id', + regex: '^/\\(dashboard\\)/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should handle dynamic routes within route groups with proper regex escaping', () => { + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/(dashboard)/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.regex ?? ''); + expect(regex.test('/(dashboard)/dashboard/123')).toBe(true); + expect(regex.test('/(dashboard)/dashboard/abc')).toBe(true); + expect(regex.test('/(dashboard)/dashboard/123/456')).toBe(false); + expect(regex.test('/dashboard/123')).toBe(false); // Should not match without route group + }); + + test('should properly extract parameter names from dynamic routes with route groups', () => { + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/(dashboard)/dashboard/:id')); + expect(dynamicRoute?.paramNames).toEqual(['id']); + }); + + test('should handle nested static routes within route groups', () => { + const nestedStaticRoute = manifest.staticRoutes.find(route => route.path === '/(dashboard)/settings/profile'); + expect(nestedStaticRoute).toBeDefined(); + }); + + test('should handle multiple route groups correctly', () => { + const authLogin = manifest.staticRoutes.find(route => route.path === '/(auth)/login'); + const authSignup = manifest.staticRoutes.find(route => route.path === '/(auth)/signup'); + const marketingPublic = manifest.staticRoutes.find(route => route.path === '/(marketing)/public/about'); + + expect(authLogin).toBeDefined(); + expect(authSignup).toBeDefined(); + expect(marketingPublic).toBeDefined(); + }); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/static/app/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/page.tsx new file mode 100644 index 000000000000..2145a5eea70d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/page.tsx @@ -0,0 +1 @@ +// Ciao diff --git a/packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx new file mode 100644 index 000000000000..c3a94a1cb9e7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx @@ -0,0 +1 @@ +// Hola diff --git a/packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx new file mode 100644 index 000000000000..5d33b5d14573 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx @@ -0,0 +1 @@ +// beep diff --git a/packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx new file mode 100644 index 000000000000..6723592cc451 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx @@ -0,0 +1 @@ +// boop diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts new file mode 100644 index 000000000000..a6f03f49b6fe --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -0,0 +1,13 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('static', () => { + test('should generate a static manifest', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + expect(manifest).toEqual({ + staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], + dynamicRoutes: [], + }); + }); +}); From 0fc803a7ff70f76df984f6778f64af7e49716eec Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 11 Jul 2025 10:04:24 -0400 Subject: [PATCH 03/37] ref(sveltekit): Use `debug` in sveltekit sdk (#16914) resolves https://github.com/getsentry/sentry-javascript/issues/16913 --------- Co-authored-by: Andrei Borza --- packages/sveltekit/src/server-common/handle.ts | 4 ++-- packages/sveltekit/src/server-common/utils.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 24ab005500de..aa2649a28a3a 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -1,11 +1,11 @@ import type { Span } from '@sentry/core'; import { continueTrace, + debug, getCurrentScope, getDefaultIsolationScope, getIsolationScope, getTraceMetaTags, - logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, setHttpStatus, @@ -112,7 +112,7 @@ async function instrumentHandle( if (getIsolationScope() !== getDefaultIsolationScope()) { getIsolationScope().setTransactionName(routeName); } else { - DEBUG_BUILD && logger.warn('Isolation scope is default isolation scope - skipping setting transactionName'); + DEBUG_BUILD && debug.warn('Isolation scope is default isolation scope - skipping setting transactionName'); } try { diff --git a/packages/sveltekit/src/server-common/utils.ts b/packages/sveltekit/src/server-common/utils.ts index 6f83615541f8..34e1575a70ea 100644 --- a/packages/sveltekit/src/server-common/utils.ts +++ b/packages/sveltekit/src/server-common/utils.ts @@ -1,4 +1,4 @@ -import { captureException, flush, logger, objectify } from '@sentry/core'; +import { captureException, debug, flush, objectify } from '@sentry/core'; import type { RequestEvent } from '@sveltejs/kit'; import { DEBUG_BUILD } from '../common/debug-build'; import { isHttpError, isRedirect } from '../common/utils'; @@ -26,11 +26,11 @@ export async function flushIfServerless(): Promise { if (!platformSupportsStreaming) { try { - DEBUG_BUILD && logger.log('Flushing events...'); + DEBUG_BUILD && debug.log('Flushing events...'); await flush(2000); - DEBUG_BUILD && logger.log('Done flushing events'); + DEBUG_BUILD && debug.log('Done flushing events'); } catch (e) { - DEBUG_BUILD && logger.log('Error while flushing events:\n', e); + DEBUG_BUILD && debug.log('Error while flushing events:\n', e); } } } From 9ec2bffd63c4d6c8e9c798d981e711e87404c1b4 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 11 Jul 2025 16:06:26 +0200 Subject: [PATCH 04/37] build: Disable side-effects for any `./debug-build.ts` file (#16929) - Closes #16846 --- dev-packages/rollup-utils/npmHelpers.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 6ef09192eef1..83053aaeea98 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -26,6 +26,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const packageDotJSON = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), './package.json'), { encoding: 'utf8' })); +const ignoreSideEffects = /[\\\/]debug-build\.ts$/; + export function makeBaseNPMConfig(options = {}) { const { entrypoints = ['src/index.ts'], @@ -83,6 +85,17 @@ export function makeBaseNPMConfig(options = {}) { interop: 'esModule', }, + treeshake: { + moduleSideEffects: (id, external) => { + if (external === false && ignoreSideEffects.test(id)) { + // Tell Rollup this module has no side effects, so it can be tree-shaken + return false; + } + + return true; + } + }, + plugins: [nodeResolvePlugin, sucrasePlugin, debugBuildStatementReplacePlugin, rrwebBuildPlugin, cleanupPlugin], // don't include imported modules from outside the package in the final output From baef98296933de90384600de3d98ac00d4585e5e Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 11 Jul 2025 16:09:41 +0200 Subject: [PATCH 05/37] fix(core): Remove side-effect from `tracing/errors.ts` (#16888) - Ref #16846 --- packages/core/src/tracing/errors.ts | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/core/src/tracing/errors.ts b/packages/core/src/tracing/errors.ts index f72cbbfe5349..a2d5af2560c5 100644 --- a/packages/core/src/tracing/errors.ts +++ b/packages/core/src/tracing/errors.ts @@ -20,24 +20,24 @@ export function registerSpanErrorInstrumentation(): void { return; } + /** + * If an error or unhandled promise occurs, we mark the active root span as failed + */ + function errorCallback(): void { + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan && getRootSpan(activeSpan); + if (rootSpan) { + const message = 'internal_error'; + DEBUG_BUILD && debug.log(`[Tracing] Root span: ${message} -> Global error occurred`); + rootSpan.setStatus({ code: SPAN_STATUS_ERROR, message }); + } + } + + // The function name will be lost when bundling but we need to be able to identify this listener later to maintain the + // node.js default exit behaviour + errorCallback.tag = 'sentry_tracingErrorCallback'; + errorsInstrumented = true; addGlobalErrorInstrumentationHandler(errorCallback); addGlobalUnhandledRejectionInstrumentationHandler(errorCallback); } - -/** - * If an error or unhandled promise occurs, we mark the active root span as failed - */ -function errorCallback(): void { - const activeSpan = getActiveSpan(); - const rootSpan = activeSpan && getRootSpan(activeSpan); - if (rootSpan) { - const message = 'internal_error'; - DEBUG_BUILD && debug.log(`[Tracing] Root span: ${message} -> Global error occurred`); - rootSpan.setStatus({ code: SPAN_STATUS_ERROR, message }); - } -} - -// The function name will be lost when bundling but we need to be able to identify this listener later to maintain the -// node.js default exit behaviour -errorCallback.tag = 'sentry_tracingErrorCallback'; From 101b4f236997091a9618ee3259030b5cf2b86a81 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 11 Jul 2025 11:24:33 -0400 Subject: [PATCH 06/37] feat(core): Prepend vercel ai attributes with `vercel.ai.X` (#16908) resolves https://linear.app/getsentry/issue/JS-663 Categorizing the attributes this way helps us differentiate between the ai attributes added by vercel compared to the otel semantic conventions. --- .../nextjs-15/tests/ai-test.test.ts | 20 +- .../suites/tracing/vercelai/test.ts | 238 +++++++++--------- packages/core/src/utils/vercel-ai.ts | 7 + 3 files changed, 136 insertions(+), 129 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts index c63716a34fad..9fd05f83c5f9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts @@ -31,34 +31,34 @@ test('should create AI spans with correct attributes', async ({ page }) => { // First AI call - should have telemetry enabled and record inputs/outputs (sendDefaultPii: true) /* const firstPipelineSpan = aiPipelineSpans[0]; - expect(firstPipelineSpan?.data?.['ai.model.id']).toBe('mock-model-id'); - expect(firstPipelineSpan?.data?.['ai.model.provider']).toBe('mock-provider'); - expect(firstPipelineSpan?.data?.['ai.prompt']).toContain('Where is the first span?'); + 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?.['ai.prompt']).toContain('Where is the second span?'); + 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?.['ai.response.finishReason']).toBe('tool-calls'); + 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?.['ai.toolCall.name']).toBe('getWeather'); - expect(toolSpan?.data?.['ai.toolCall.id']).toBe('call-1'); - expect(toolSpan?.data?.['ai.toolCall.args']).toContain('San Francisco'); - expect(toolSpan?.data?.['ai.toolCall.result']).toContain('Sunny, 72°F'); */ + 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?.['ai.prompt']) + .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); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 3566d40322de..f9b853aa4946 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -13,14 +13,14 @@ describe('Vercel AI integration', () => { // First span - no telemetry config, should enable telemetry but not record inputs/outputs when sendDefaultPii: false expect.objectContaining({ data: { - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText', - 'ai.pipeline.name': 'generateText', - 'ai.response.finishReason': 'stop', - 'ai.settings.maxRetries': 2, - 'ai.settings.maxSteps': 1, - 'ai.streaming': false, + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, 'gen_ai.response.model': 'mock-model-id', 'gen_ai.usage.input_tokens': 10, 'gen_ai.usage.output_tokens': 20, @@ -40,18 +40,18 @@ describe('Vercel AI integration', () => { 'sentry.origin': 'auto.vercelai.otel', 'sentry.op': 'gen_ai.generate_text', 'operation.name': 'ai.generateText.doGenerate', - 'ai.operationId': 'ai.generateText.doGenerate', - 'ai.model.provider': 'mock-provider', - 'ai.model.id': 'mock-model-id', - 'ai.settings.maxRetries': 2, + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, 'gen_ai.system': 'mock-provider', 'gen_ai.request.model': 'mock-model-id', - 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.streaming': false, - 'ai.response.finishReason': 'stop', - 'ai.response.model': 'mock-model-id', - 'ai.response.id': expect.any(String), - 'ai.response.timestamp': expect.any(String), + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, 'gen_ai.usage.output_tokens': 20, @@ -67,16 +67,16 @@ describe('Vercel AI integration', () => { // Third span - explicit telemetry enabled, should record inputs/outputs regardless of sendDefaultPii expect.objectContaining({ data: { - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText', - 'ai.pipeline.name': 'generateText', - 'ai.prompt': '{"prompt":"Where is the second span?"}', - 'ai.response.finishReason': 'stop', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'vercel.ai.response.finishReason': 'stop', 'gen_ai.response.text': expect.any(String), - 'ai.settings.maxRetries': 2, - 'ai.settings.maxSteps': 1, - 'ai.streaming': false, + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', 'gen_ai.response.model': 'mock-model-id', 'gen_ai.usage.input_tokens': 10, @@ -97,20 +97,20 @@ describe('Vercel AI integration', () => { 'sentry.origin': 'auto.vercelai.otel', 'sentry.op': 'gen_ai.generate_text', 'operation.name': 'ai.generateText.doGenerate', - 'ai.operationId': 'ai.generateText.doGenerate', - 'ai.model.provider': 'mock-provider', - 'ai.model.id': 'mock-model-id', - 'ai.settings.maxRetries': 2, + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, 'gen_ai.system': 'mock-provider', 'gen_ai.request.model': 'mock-model-id', - 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.streaming': false, - 'ai.response.finishReason': 'stop', - 'ai.response.model': 'mock-model-id', - 'ai.response.id': expect.any(String), + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), 'gen_ai.response.text': expect.any(String), - 'ai.response.timestamp': expect.any(String), - 'ai.prompt.format': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.prompt.format': expect.any(String), 'gen_ai.request.messages': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, @@ -127,14 +127,14 @@ describe('Vercel AI integration', () => { // Fifth span - tool call generateText span expect.objectContaining({ data: { - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText', - 'ai.pipeline.name': 'generateText', - 'ai.response.finishReason': 'tool-calls', - 'ai.settings.maxRetries': 2, - 'ai.settings.maxSteps': 1, - 'ai.streaming': false, + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, 'gen_ai.response.model': 'mock-model-id', 'gen_ai.usage.input_tokens': 15, 'gen_ai.usage.output_tokens': 25, @@ -151,16 +151,16 @@ describe('Vercel AI integration', () => { // Sixth span - tool call doGenerate span expect.objectContaining({ data: { - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText.doGenerate', - 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.response.finishReason': 'tool-calls', - 'ai.response.id': expect.any(String), - 'ai.response.model': 'mock-model-id', - 'ai.response.timestamp': expect.any(String), - 'ai.settings.maxRetries': 2, - 'ai.streaming': false, + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['tool-calls'], 'gen_ai.response.id': expect.any(String), @@ -181,7 +181,7 @@ describe('Vercel AI integration', () => { // Seventh span - tool call execution span expect.objectContaining({ data: { - 'ai.operationId': 'ai.toolCall', + 'vercel.ai.operationId': 'ai.toolCall', 'gen_ai.tool.call.id': 'call-1', 'gen_ai.tool.name': 'getWeather', 'gen_ai.tool.type': 'function', @@ -203,16 +203,16 @@ describe('Vercel AI integration', () => { // First span - no telemetry config, should enable telemetry AND record inputs/outputs when sendDefaultPii: true expect.objectContaining({ data: { - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText', - 'ai.pipeline.name': 'generateText', - 'ai.prompt': '{"prompt":"Where is the first span?"}', - 'ai.response.finishReason': 'stop', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the first span?"}', + 'vercel.ai.response.finishReason': 'stop', 'gen_ai.response.text': 'First span here!', - 'ai.settings.maxRetries': 2, - 'ai.settings.maxSteps': 1, - 'ai.streaming': false, + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, 'gen_ai.prompt': '{"prompt":"Where is the first span?"}', 'gen_ai.response.model': 'mock-model-id', 'gen_ai.usage.input_tokens': 10, @@ -230,19 +230,19 @@ describe('Vercel AI integration', () => { // Second span - doGenerate for first call, should also include input/output fields when sendDefaultPii: true expect.objectContaining({ data: { - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText.doGenerate', - 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.prompt.format': 'prompt', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.prompt.format': 'prompt', 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', - 'ai.response.finishReason': 'stop', - 'ai.response.id': expect.any(String), - 'ai.response.model': 'mock-model-id', + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', 'gen_ai.response.text': 'First span here!', - 'ai.response.timestamp': expect.any(String), - 'ai.settings.maxRetries': 2, - 'ai.streaming': false, + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.response.id': expect.any(String), @@ -263,16 +263,16 @@ describe('Vercel AI integration', () => { // Third span - explicitly enabled telemetry, should record inputs/outputs regardless of sendDefaultPii expect.objectContaining({ data: { - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText', - 'ai.pipeline.name': 'generateText', - 'ai.prompt': '{"prompt":"Where is the second span?"}', - 'ai.response.finishReason': 'stop', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'vercel.ai.response.finishReason': 'stop', 'gen_ai.response.text': expect.any(String), - 'ai.settings.maxRetries': 2, - 'ai.settings.maxSteps': 1, - 'ai.streaming': false, + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', 'gen_ai.response.model': 'mock-model-id', 'gen_ai.usage.input_tokens': 10, @@ -293,20 +293,20 @@ describe('Vercel AI integration', () => { 'sentry.origin': 'auto.vercelai.otel', 'sentry.op': 'gen_ai.generate_text', 'operation.name': 'ai.generateText.doGenerate', - 'ai.operationId': 'ai.generateText.doGenerate', - 'ai.model.provider': 'mock-provider', - 'ai.model.id': 'mock-model-id', - 'ai.settings.maxRetries': 2, + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, 'gen_ai.system': 'mock-provider', 'gen_ai.request.model': 'mock-model-id', - 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.streaming': false, - 'ai.response.finishReason': 'stop', - 'ai.response.model': 'mock-model-id', - 'ai.response.id': expect.any(String), + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), 'gen_ai.response.text': expect.any(String), - 'ai.response.timestamp': expect.any(String), - 'ai.prompt.format': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.prompt.format': expect.any(String), 'gen_ai.request.messages': expect.any(String), 'gen_ai.response.finish_reasons': ['stop'], 'gen_ai.usage.input_tokens': 10, @@ -323,17 +323,17 @@ describe('Vercel AI integration', () => { // Fifth span - tool call generateText span (should include prompts when sendDefaultPii: true) expect.objectContaining({ data: { - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText', - 'ai.pipeline.name': 'generateText', - 'ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', - 'ai.response.finishReason': 'tool-calls', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'vercel.ai.response.finishReason': 'tool-calls', 'gen_ai.response.text': 'Tool call completed!', 'gen_ai.response.tool_calls': expect.any(String), - 'ai.settings.maxRetries': 2, - 'ai.settings.maxSteps': 1, - 'ai.streaming': false, + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.settings.maxSteps': 1, + 'vercel.ai.streaming': false, 'gen_ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', 'gen_ai.response.model': 'mock-model-id', 'gen_ai.usage.input_tokens': 15, @@ -351,22 +351,22 @@ describe('Vercel AI integration', () => { // Sixth span - tool call doGenerate span (should include prompts when sendDefaultPii: true) expect.objectContaining({ data: { - 'ai.model.id': 'mock-model-id', - 'ai.model.provider': 'mock-provider', - 'ai.operationId': 'ai.generateText.doGenerate', - 'ai.pipeline.name': 'generateText.doGenerate', - 'ai.prompt.format': expect.any(String), + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.prompt.format': expect.any(String), 'gen_ai.request.messages': expect.any(String), - 'ai.prompt.toolChoice': expect.any(String), + 'vercel.ai.prompt.toolChoice': expect.any(String), 'gen_ai.request.available_tools': expect.any(Array), - 'ai.response.finishReason': 'tool-calls', - 'ai.response.id': expect.any(String), - 'ai.response.model': 'mock-model-id', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', 'gen_ai.response.text': 'Tool call completed!', - 'ai.response.timestamp': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), 'gen_ai.response.tool_calls': expect.any(String), - 'ai.settings.maxRetries': 2, - 'ai.streaming': false, + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, 'gen_ai.request.model': 'mock-model-id', 'gen_ai.response.finish_reasons': ['tool-calls'], 'gen_ai.response.id': expect.any(String), @@ -387,7 +387,7 @@ describe('Vercel AI integration', () => { // Seventh span - tool call execution span expect.objectContaining({ data: { - 'ai.operationId': 'ai.toolCall', + 'vercel.ai.operationId': 'ai.toolCall', 'gen_ai.tool.call.id': 'call-1', 'gen_ai.tool.name': 'getWeather', 'gen_ai.tool.input': expect.any(String), diff --git a/packages/core/src/utils/vercel-ai.ts b/packages/core/src/utils/vercel-ai.ts index 401c295c97c9..4717e2cf87c7 100644 --- a/packages/core/src/utils/vercel-ai.ts +++ b/packages/core/src/utils/vercel-ai.ts @@ -99,6 +99,13 @@ function processEndedVercelAiSpan(span: SpanJSON): void { renameAttributeKey(attributes, AI_TOOL_CALL_ARGS_ATTRIBUTE, 'gen_ai.tool.input'); renameAttributeKey(attributes, AI_TOOL_CALL_RESULT_ATTRIBUTE, 'gen_ai.tool.output'); + + // Change attributes namespaced with `ai.X` to `vercel.ai.X` + for (const key of Object.keys(attributes)) { + if (key.startsWith('ai.')) { + renameAttributeKey(attributes, key, `vercel.${key}`); + } + } } /** From 29124746e3839450cdc25cab10f8eba9f9302146 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 11 Jul 2025 17:40:11 +0200 Subject: [PATCH 07/37] feat(browser): Add `afterStartPageloadSpan` hook to improve spanId assignment on web vital spans (#16893) Our standalone web vital spans need to include the span id of the `pageload` span s.t. we can correctly assign the web vital value to the respective pageload span in the Sentry backend. In the previous implementation, we'd simply wait for a tick after tracking the web vitals, get the active root span and take its spanId if it was a pageload span. However, this relies on the assumption that the pageload span is indeed started immediately. By default this happens but users can always deactivate default behaviour and call `startBrowserTracingPageloadSpan` whenever they want (for example, in a custom `browserTracingIntegration`). Furthermore, this "wait for a tick" logic always bugged me and was only done because `getClient()` would not yet return the already initialized client. This change now makes the pageload spanId retrieval more robust by - adding and listening to a new SDK lifecycle hook: `afterStartPageloadSpan`. This callback fires as soon as the pageload span is actually started and available. - passing the `client` from the `browserTracingIntegration`'s `setup` hook into the web vital listening function so that we can remove the `setTimeout(_, 0)` logic. --- .../src/metrics/browserMetrics.ts | 8 ++- packages/browser-utils/src/metrics/cls.ts | 6 +- packages/browser-utils/src/metrics/lcp.ts | 6 +- packages/browser-utils/src/metrics/utils.ts | 55 ++++++++----------- .../src/tracing/browserTracingIntegration.ts | 9 ++- packages/core/src/client.ts | 11 ++++ 6 files changed, 52 insertions(+), 43 deletions(-) diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 18b274b855e0..e9fa822a431e 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines */ -import type { Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core'; +import type { Client, Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core'; import { browserPerformanceTimeOrigin, getActiveSpan, @@ -83,6 +83,7 @@ let _clsEntry: LayoutShift | undefined; interface StartTrackingWebVitalsOptions { recordClsStandaloneSpans: boolean; recordLcpStandaloneSpans: boolean; + client: Client; } /** @@ -94,6 +95,7 @@ interface StartTrackingWebVitalsOptions { export function startTrackingWebVitals({ recordClsStandaloneSpans, recordLcpStandaloneSpans, + client, }: StartTrackingWebVitalsOptions): () => void { const performance = getBrowserPerformanceAPI(); if (performance && browserPerformanceTimeOrigin()) { @@ -102,9 +104,9 @@ export function startTrackingWebVitals({ WINDOW.performance.mark('sentry-tracing-init'); } const fidCleanupCallback = _trackFID(); - const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan() : _trackLCP(); + const lcpCleanupCallback = recordLcpStandaloneSpans ? trackLcpAsStandaloneSpan(client) : _trackLCP(); const ttfbCleanupCallback = _trackTtfb(); - const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan() : _trackCLS(); + const clsCleanupCallback = recordClsStandaloneSpans ? trackClsAsStandaloneSpan(client) : _trackCLS(); return (): void => { fidCleanupCallback(); diff --git a/packages/browser-utils/src/metrics/cls.ts b/packages/browser-utils/src/metrics/cls.ts index 8839f038fb49..e300fd28d18b 100644 --- a/packages/browser-utils/src/metrics/cls.ts +++ b/packages/browser-utils/src/metrics/cls.ts @@ -1,4 +1,4 @@ -import type { SpanAttributes } from '@sentry/core'; +import type { Client, SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, getCurrentScope, @@ -24,7 +24,7 @@ import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, su * Once either of these events triggers, the CLS value is sent as a standalone span and we stop * measuring CLS. */ -export function trackClsAsStandaloneSpan(): void { +export function trackClsAsStandaloneSpan(client: Client): void { let standaloneCLsValue = 0; let standaloneClsEntry: LayoutShift | undefined; @@ -41,7 +41,7 @@ export function trackClsAsStandaloneSpan(): void { standaloneClsEntry = entry; }, true); - listenForWebVitalReportEvents((reportEvent, pageloadSpanId) => { + listenForWebVitalReportEvents(client, (reportEvent, pageloadSpanId) => { sendStandaloneClsSpan(standaloneCLsValue, standaloneClsEntry, pageloadSpanId, reportEvent); cleanupClsHandler(); }); diff --git a/packages/browser-utils/src/metrics/lcp.ts b/packages/browser-utils/src/metrics/lcp.ts index dc98b2f8f2b1..6864a233466c 100644 --- a/packages/browser-utils/src/metrics/lcp.ts +++ b/packages/browser-utils/src/metrics/lcp.ts @@ -1,4 +1,4 @@ -import type { SpanAttributes } from '@sentry/core'; +import type { Client, SpanAttributes } from '@sentry/core'; import { browserPerformanceTimeOrigin, getCurrentScope, @@ -24,7 +24,7 @@ import { listenForWebVitalReportEvents, msToSec, startStandaloneWebVitalSpan, su * Once either of these events triggers, the LCP value is sent as a standalone span and we stop * measuring LCP for subsequent routes. */ -export function trackLcpAsStandaloneSpan(): void { +export function trackLcpAsStandaloneSpan(client: Client): void { let standaloneLcpValue = 0; let standaloneLcpEntry: LargestContentfulPaint | undefined; @@ -41,7 +41,7 @@ export function trackLcpAsStandaloneSpan(): void { standaloneLcpEntry = entry; }, true); - listenForWebVitalReportEvents((reportEvent, pageloadSpanId) => { + listenForWebVitalReportEvents(client, (reportEvent, pageloadSpanId) => { _sendStandaloneLcpSpan(standaloneLcpValue, standaloneLcpEntry, pageloadSpanId, reportEvent); cleanupLcpHandler(); }); diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index ce3da0d4f16d..e56d0ee98d42 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -1,13 +1,13 @@ -import type { Integration, SentrySpan, Span, SpanAttributes, SpanTimeInput, StartSpanOptions } from '@sentry/core'; -import { - getActiveSpan, - getClient, - getCurrentScope, - getRootSpan, - spanToJSON, - startInactiveSpan, - withActiveSpan, +import type { + Client, + Integration, + SentrySpan, + Span, + SpanAttributes, + SpanTimeInput, + StartSpanOptions, } from '@sentry/core'; +import { getClient, getCurrentScope, spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core'; import { WINDOW } from '../types'; import { onHidden } from './web-vitals/lib/onHidden'; @@ -205,6 +205,7 @@ export function supportsWebVital(entryType: 'layout-shift' | 'largest-contentful * - pageloadSpanId: the span id of the pageload span. This is used to link the web vital span to the pageload span. */ export function listenForWebVitalReportEvents( + client: Client, collectorCallback: (event: WebVitalReportEvent, pageloadSpanId: string) => void, ) { let pageloadSpanId: string | undefined; @@ -218,32 +219,20 @@ export function listenForWebVitalReportEvents( } onHidden(() => { - if (!collected) { - _runCollectorCallbackOnce('pagehide'); - } + _runCollectorCallbackOnce('pagehide'); }); - setTimeout(() => { - const client = getClient(); - if (!client) { - return; + const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { + // we only want to collect LCP if we actually navigate. Redirects should be ignored. + if (!options?.isRedirect) { + _runCollectorCallbackOnce('navigation'); + unsubscribeStartNavigation?.(); + unsubscribeAfterStartPageLoadSpan?.(); } + }); - const unsubscribeStartNavigation = client.on('beforeStartNavigationSpan', (_, options) => { - // we only want to collect LCP if we actually navigate. Redirects should be ignored. - if (!options?.isRedirect) { - _runCollectorCallbackOnce('navigation'); - unsubscribeStartNavigation?.(); - } - }); - - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - const spanJSON = spanToJSON(rootSpan); - if (spanJSON.op === 'pageload') { - pageloadSpanId = rootSpan.spanContext().spanId; - } - } - }, 0); + const unsubscribeAfterStartPageLoadSpan = client.on('afterStartPageLoadSpan', span => { + pageloadSpanId = span.spanContext().spanId; + unsubscribeAfterStartPageLoadSpan?.(); + }); } diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index a1eb186d5c1e..f010030c47c3 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -440,6 +440,7 @@ export const browserTracingIntegration = ((_options: Partial { ) => void, ): () => void; + /** + * A hook for the browser tracing integrations to trigger after the pageload span was started. + * @returns {() => void} A function that, when executed, removes the registered callback. + */ + public on(hook: 'afterStartPageLoadSpan', callback: (span: Span) => void): () => void; + /** * A hook for triggering right before a navigation span is started. * @returns {() => void} A function that, when executed, removes the registered callback. @@ -791,6 +797,11 @@ export abstract class Client { traceOptions?: { sentryTrace?: string | undefined; baggage?: string | undefined }, ): void; + /** + * Emit a hook event for browser tracing integrations to trigger aafter the pageload span was started. + */ + public emit(hook: 'afterStartPageLoadSpan', span: Span): void; + /** * Emit a hook event for triggering right before a navigation span is started. */ From 369e5c0c99cd5d000221985f259537a62471b7cd Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Fri, 11 Jul 2025 12:52:57 -0400 Subject: [PATCH 08/37] ref(react): Use `debug` instead of `logger` (#16958) resolves https://github.com/getsentry/sentry-javascript/issues/16940 Migrates `@sentry/react` to use the `debug` utility instead of `logger` from `@sentry/core`. This change aligns with the ongoing effort to streamline logging utilities within the SDK, moving towards a more focused `debug` function. --- Before submitting a pull request, please take a look at our [Contributing](https://github.com/getsentry/sentry-javascript/blob/master/CONTRIBUTING.md) guidelines and verify: - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). --------- Co-authored-by: Cursor Agent --- packages/react/src/errorboundary.tsx | 4 ++-- packages/react/src/reactrouterv6-compat-utils.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx index 4874e285224c..e3f94b441ee6 100644 --- a/packages/react/src/errorboundary.tsx +++ b/packages/react/src/errorboundary.tsx @@ -1,7 +1,7 @@ import type { ReportDialogOptions } from '@sentry/browser'; import { getClient, showReportDialog, withScope } from '@sentry/browser'; import type { Scope } from '@sentry/core'; -import { logger } from '@sentry/core'; +import { debug } from '@sentry/core'; import * as React from 'react'; import { DEBUG_BUILD } from './debug-build'; import { captureReactException } from './error'; @@ -207,7 +207,7 @@ class ErrorBoundary extends React.Component { if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { DEBUG_BUILD && - logger.warn( + debug.warn( `reactRouterV${version}Instrumentation was unable to wrap the \`createRouter\` function because of one or more missing parameters.`, ); @@ -147,7 +147,7 @@ export function createV6CompatibleWrapCreateMemoryRouter< ): CreateRouterFunction { if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { DEBUG_BUILD && - logger.warn( + debug.warn( `reactRouterV${version}Instrumentation was unable to wrap the \`createMemoryRouter\` function because of one or more missing parameters.`, ); @@ -271,7 +271,7 @@ export function createReactRouterV6CompatibleTracingIntegration( export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, version: V6CompatibleVersion): UseRoutes { if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { DEBUG_BUILD && - logger.warn( + debug.warn( 'reactRouterV6Instrumentation was unable to wrap `useRoutes` because of one or more missing parameters.', ); @@ -632,7 +632,7 @@ export function createV6CompatibleWithSentryReactRouterRouting

Date: Mon, 14 Jul 2025 10:09:03 +0200 Subject: [PATCH 09/37] ref(core): Avoid side-effect of `vercelAiEventProcessor` (#16925) This is pretty small, but apparently `Object.assign()` also counts as side effect. Part of https://github.com/getsentry/sentry-javascript/issues/16846 --- packages/core/src/utils/vercel-ai.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/core/src/utils/vercel-ai.ts b/packages/core/src/utils/vercel-ai.ts index 4717e2cf87c7..b1a2feedb454 100644 --- a/packages/core/src/utils/vercel-ai.ts +++ b/packages/core/src/utils/vercel-ai.ts @@ -57,19 +57,15 @@ function onVercelAiSpanStart(span: Span): void { processGenerateSpan(span, name, attributes); } -const vercelAiEventProcessor = Object.assign( - (event: Event): Event => { - if (event.type === 'transaction' && event.spans) { - for (const span of event.spans) { - // this mutates spans in-place - processEndedVercelAiSpan(span); - } +function vercelAiEventProcessor(event: Event): Event { + if (event.type === 'transaction' && event.spans) { + for (const span of event.spans) { + // this mutates spans in-place + processEndedVercelAiSpan(span); } - return event; - }, - { id: 'VercelAiEventProcessor' }, -); - + } + return event; +} /** * Post-process spans emitted by the Vercel AI SDK. */ @@ -236,5 +232,5 @@ function processGenerateSpan(span: Span, name: string, attributes: SpanAttribute export function addVercelAiProcessors(client: Client): void { client.on('spanStart', onVercelAiSpanStart); // Note: We cannot do this on `spanEnd`, because the span cannot be mutated anymore at this point - client.addEventProcessor(vercelAiEventProcessor); + client.addEventProcessor(Object.assign(vercelAiEventProcessor, { id: 'VercelAiEventProcessor' })); } From 61feeb341628d12b923a7bdb2f92284c7c6a9b59 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 14 Jul 2025 10:09:31 +0200 Subject: [PATCH 10/37] ref(core): Keep client-logger map on carrier & avoid side-effect (#16923) Instead of having a side effect of writing on the GLOBAL_OBJ, we can use a global singleton instead. Part of https://github.com/getsentry/sentry-javascript/issues/16846 --- packages/core/src/carrier.ts | 7 +++++++ packages/core/src/logs/exports.ts | 20 ++++++++++++-------- packages/core/src/utils/worldwide.ts | 8 -------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts index 31827ad7b87e..b6d30082f847 100644 --- a/packages/core/src/carrier.ts +++ b/packages/core/src/carrier.ts @@ -1,6 +1,8 @@ import type { AsyncContextStack } from './asyncContext/stackStrategy'; import type { AsyncContextStrategy } from './asyncContext/types'; +import type { Client } from './client'; import type { Scope } from './scope'; +import type { SerializedLog } from './types-hoist/log'; import type { Logger } from './utils/logger'; import { SDK_VERSION } from './utils/version'; import { GLOBAL_OBJ } from './utils/worldwide'; @@ -27,6 +29,11 @@ export interface SentryCarrier { /** @deprecated Logger is no longer set. Instead, we keep enabled state in loggerSettings. */ logger?: Logger; loggerSettings?: { enabled: boolean }; + /** + * A map of Sentry clients to their log buffers. + * This is used to store logs that are sent to Sentry. + */ + clientToLogBufferMap?: WeakMap>; /** Overwrites TextEncoder used in `@sentry/core`, need for `react-native@0.73` and older */ encodePolyfill?: (input: string) => Uint8Array; diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/exports.ts index 8d8a2a292df8..641b1ab54651 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/exports.ts @@ -1,3 +1,4 @@ +import { getGlobalSingleton } from '../carrier'; import type { Client } from '../client'; import { _getTraceInfoFromScope } from '../client'; import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '../currentScopes'; @@ -9,15 +10,11 @@ import { isParameterizedString } from '../utils/is'; import { debug } from '../utils/logger'; import { _getSpanForScope } from '../utils/spanOnScope'; import { timestampInSeconds } from '../utils/time'; -import { GLOBAL_OBJ } from '../utils/worldwide'; import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants'; import { createLogEnvelope } from './envelope'; const MAX_LOG_BUFFER_SIZE = 100; -// The reference to the Client <> LogBuffer map is stored to ensure it's always the same -GLOBAL_OBJ._sentryClientToLogBufferMap = new WeakMap>(); - /** * Converts a log attribute to a serialized log attribute. * @@ -92,11 +89,13 @@ function setLogAttribute( * the stable Sentry SDK API and can be changed or removed without warning. */ export function _INTERNAL_captureSerializedLog(client: Client, serializedLog: SerializedLog): void { + const bufferMap = _getBufferMap(); + const logBuffer = _INTERNAL_getLogBuffer(client); if (logBuffer === undefined) { - GLOBAL_OBJ._sentryClientToLogBufferMap?.set(client, [serializedLog]); + bufferMap.set(client, [serializedLog]); } else { - GLOBAL_OBJ._sentryClientToLogBufferMap?.set(client, [...logBuffer, serializedLog]); + bufferMap.set(client, [...logBuffer, serializedLog]); if (logBuffer.length >= MAX_LOG_BUFFER_SIZE) { _INTERNAL_flushLogsBuffer(client, logBuffer); } @@ -217,7 +216,7 @@ export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array const envelope = createLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn()); // Clear the log buffer after envelopes have been constructed. - GLOBAL_OBJ._sentryClientToLogBufferMap?.set(client, []); + _getBufferMap().set(client, []); client.emit('flushLogs'); @@ -235,7 +234,7 @@ export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array * @returns The log buffer for the given client. */ export function _INTERNAL_getLogBuffer(client: Client): Array | undefined { - return GLOBAL_OBJ._sentryClientToLogBufferMap?.get(client); + return _getBufferMap().get(client); } /** @@ -251,3 +250,8 @@ function getMergedScopeData(currentScope: Scope): ScopeData { mergeScopeData(scopeData, currentScope.getScopeData()); return scopeData; } + +function _getBufferMap(): WeakMap> { + // The reference to the Client <> LogBuffer map is stored on the carrier to ensure it's always the same + return getGlobalSingleton('clientToLogBufferMap', () => new WeakMap>()); +} diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 0b7d763f3007..c6442d2308a9 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -13,8 +13,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Carrier } from '../carrier'; -import type { Client } from '../client'; -import type { SerializedLog } from '../types-hoist/log'; import type { Span } from '../types-hoist/span'; import type { SdkSource } from './env'; @@ -38,12 +36,6 @@ export type InternalGlobal = { id?: string; }; SENTRY_SDK_SOURCE?: SdkSource; - /** - * A map of Sentry clients to their log buffers. - * - * This is used to store logs that are sent to Sentry. - */ - _sentryClientToLogBufferMap?: WeakMap>; /** * Debug IDs are indirectly injected by Sentry CLI or bundler plugins to directly reference a particular source map * for resolving of a source file. The injected code will place an entry into the record for each loaded bundle/JS From 65162a2d2f002647df1dd1c4f2bccedf907b64af Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 14 Jul 2025 10:26:44 +0200 Subject: [PATCH 11/37] fix(core): Avoid prolonging idle span when starting standalone span (#16928) When we we start and send a web vital standalone span while an idlespan (e.g. pageload) was still running, we'd restart the idle span's child span timeout (i.e. prolong its duration potentially). Is is unintended because by definition the standalone span should not be associated with a potentially ongoing idle span and its tree. This can happen, for example when users hide the page while the pageload span is still active, causing web vitals to be reported. --- packages/core/src/tracing/idleSpan.ts | 8 ++++++- .../core/test/lib/tracing/idleSpan.test.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index e1e3585579a7..e9a7906f9b0d 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -17,6 +17,7 @@ import { import { timestampInSeconds } from '../utils/time'; import { freezeDscOnSpan, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; import { SentryNonRecordingSpan } from './sentryNonRecordingSpan'; +import { SentrySpan } from './sentrySpan'; import { SPAN_STATUS_ERROR } from './spanstatus'; import { startInactiveSpan } from './trace'; @@ -326,7 +327,12 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti // or if this is the idle span itself being started, // or if the started span has already been closed, // we don't care about it for activity - if (_finished || startedSpan === span || !!spanToJSON(startedSpan).timestamp) { + if ( + _finished || + startedSpan === span || + !!spanToJSON(startedSpan).timestamp || + (startedSpan instanceof SentrySpan && startedSpan.isStandaloneSpan()) + ) { return; } diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index 3eec7836a681..677428c941cd 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -586,6 +586,28 @@ describe('startIdleSpan', () => { expect(spanToJSON(idleSpan).status).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).timestamp).toBeDefined(); }); + + it("doesn't reset the timeout for standalone spans", () => { + const idleSpan = startIdleSpan({ name: 'idle span' }, { finalTimeout: 99_999 }); + expect(idleSpan).toBeDefined(); + + // Start any span to cancel idle timeout + startInactiveSpan({ name: 'span' }); + + // Wait some time + vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); + expect(spanToJSON(idleSpan).status).not.toEqual('deadline_exceeded'); + expect(spanToJSON(idleSpan).timestamp).toBeUndefined(); + + // new standalone span should not reset the timeout + const standaloneSpan = startInactiveSpan({ name: 'standalone span', experimental: { standalone: true } }); + expect(standaloneSpan).toBeDefined(); + + // Wait for timeout to exceed + vi.advanceTimersByTime(1001); + expect(spanToJSON(idleSpan).status).not.toEqual('deadline_exceeded'); + expect(spanToJSON(idleSpan).timestamp).toBeDefined(); + }); }); describe('disableAutoFinish', () => { From cebf51857f7945dd358c5afe81935fbe4cd6dafb Mon Sep 17 00:00:00 2001 From: Jan Papenbrock Date: Mon, 14 Jul 2025 14:12:39 +0200 Subject: [PATCH 12/37] docs(aws-serverless): Fix package homepage link (#16979) Link "homepage" on https://www.npmjs.com/package/@sentry/aws-serverless links to a 404 page, as it targets the old `serverless` URL. Is: https://github.com/getsentry/sentry-javascript/tree/master/packages/serverless Should be: https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless This is the fix. Not sure about the commit message, feel free to pick this up and adjust. This PR is similar to the one for GCP: https://github.com/getsentry/sentry-javascript/pull/14411 --- packages/aws-serverless/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 344458f327f2..65a11d713c41 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -3,7 +3,7 @@ "version": "9.38.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/serverless", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/aws-serverless", "author": "Sentry", "license": "MIT", "engines": { From bcb7422c54783348391bca4555252ee61f883ced Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 14 Jul 2025 09:08:27 -0400 Subject: [PATCH 13/37] ref(vercel-edge): Use `debug` in vercel edge sdk (#16912) resolves https://github.com/getsentry/sentry-javascript/issues/16911 --- packages/vercel-edge/src/sdk.ts | 26 ++++++++++--------- .../async-local-storage-context-manager.ts | 4 +-- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/vercel-edge/src/sdk.ts b/packages/vercel-edge/src/sdk.ts index bebce0935adf..12485a6c6579 100644 --- a/packages/vercel-edge/src/sdk.ts +++ b/packages/vercel-edge/src/sdk.ts @@ -10,6 +10,7 @@ import type { Client, Integration, Options } from '@sentry/core'; import { consoleIntegration, createStackParser, + debug, dedupeIntegration, functionToStringIntegration, getCurrentScope, @@ -18,7 +19,6 @@ import { hasSpansEnabled, inboundFiltersIntegration, linkedErrorsIntegration, - logger, nodeStackLineParser, requestDataIntegration, SDK_VERSION, @@ -135,14 +135,14 @@ function validateOpenTelemetrySetup(): void { for (const k of required) { if (!setup.includes(k)) { - logger.error( + debug.error( `You have to set up the ${k}. Without this, the OpenTelemetry & Sentry integration will not work properly.`, ); } } if (!setup.includes('SentrySampler')) { - logger.warn( + debug.warn( 'You have to set up the SentrySampler. Without this, the OpenTelemetry & Sentry integration may still work, but sample rates set for the Sentry SDK will not be respected. If you use a custom sampler, make sure to use `wrapSamplingDecision`.', ); } @@ -182,19 +182,21 @@ export function setupOtel(client: VercelEdgeClient): void { } /** - * Setup the OTEL logger to use our own logger. + * Setup the OTEL logger to use our own debug logger. */ function setupOpenTelemetryLogger(): void { - const otelLogger = new Proxy(logger as typeof logger & { verbose: (typeof logger)['debug'] }, { - get(target, prop, receiver) { - const actualProp = prop === 'verbose' ? 'debug' : prop; - return Reflect.get(target, actualProp, receiver); - }, - }); - // Disable diag, to ensure this works even if called multiple times diag.disable(); - diag.setLogger(otelLogger, DiagLogLevel.DEBUG); + diag.setLogger( + { + error: debug.error, + warn: debug.warn, + info: debug.log, + debug: debug.log, + verbose: debug.log, + }, + DiagLogLevel.DEBUG, + ); } /** 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 a6a5328565f5..3fd89f28af7c 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 @@ -27,7 +27,7 @@ import type { Context } from '@opentelemetry/api'; import { ROOT_CONTEXT } from '@opentelemetry/api'; -import { GLOBAL_OBJ, logger } from '@sentry/core'; +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'; @@ -42,7 +42,7 @@ export class AsyncLocalStorageContextManager extends AbstractAsyncHooksContextMa if (!MaybeGlobalAsyncLocalStorageConstructor) { DEBUG_BUILD && - logger.warn( + debug.warn( "Tried to register AsyncLocalStorage async context strategy in a runtime that doesn't support AsyncLocalStorage.", ); From e727db4b05fd84d9ce976fb5d07a6fc28a8b9561 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 14 Jul 2025 09:08:52 -0400 Subject: [PATCH 14/37] fix(core): Wrap `beforeSendLog` in `consoleSandbox` (#16968) resolves https://github.com/getsentry/sentry-javascript/issues/16920 This will prevent recursive calls to `beforeSendLog` when using console logging instrumentation which will cause crashes. --- packages/core/src/logs/exports.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/exports.ts index 641b1ab54651..b060721128ad 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/exports.ts @@ -7,7 +7,7 @@ import type { Scope, ScopeData } from '../scope'; import type { Log, SerializedLog, SerializedLogAttributeValue } from '../types-hoist/log'; import { mergeScopeData } from '../utils/applyScopeDataToEvent'; import { isParameterizedString } from '../utils/is'; -import { debug } from '../utils/logger'; +import { consoleSandbox, debug } from '../utils/logger'; import { _getSpanForScope } from '../utils/spanOnScope'; import { timestampInSeconds } from '../utils/time'; import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants'; @@ -168,7 +168,8 @@ export function _INTERNAL_captureLog( client.emit('beforeCaptureLog', processedLog); - const log = beforeSendLog ? beforeSendLog(processedLog) : processedLog; + // We need to wrap this in `consoleSandbox` to avoid recursive calls to `beforeSendLog` + const log = beforeSendLog ? consoleSandbox(() => beforeSendLog(processedLog)) : processedLog; if (!log) { client.recordDroppedEvent('before_send', 'log_item', 1); DEBUG_BUILD && debug.warn('beforeSendLog returned null, log will not be captured.'); From 17daa162f29ecf77938f236ee05fed423de55441 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 14 Jul 2025 09:09:35 -0400 Subject: [PATCH 15/37] ref(node-native): Use `debug` instead of `logger` (#16956) resolves https://github.com/getsentry/sentry-javascript/issues/16945 Co-authored-by: Cursor Agent --- packages/node-native/src/event-loop-block-integration.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 6b643e944adf..2980b93391e1 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -1,6 +1,6 @@ import { Worker } from 'node:worker_threads'; import type { Contexts, Event, EventHint, IntegrationFn } from '@sentry/core'; -import { defineIntegration, getFilenameToDebugIdMap, getIsolationScope, logger } from '@sentry/core'; +import { debug, defineIntegration, getFilenameToDebugIdMap, getIsolationScope } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { registerThread, threadPoll } from '@sentry-internal/node-native-stacktrace'; import type { ThreadBlockedIntegrationOptions, WorkerStartData } from './common'; @@ -9,7 +9,7 @@ import { POLL_RATIO } from './common'; const DEFAULT_THRESHOLD_MS = 1_000; function log(message: string, ...args: unknown[]): void { - logger.log(`[Sentry Block Event Loop] ${message}`, ...args); + debug.log(`[Sentry Block Event Loop] ${message}`, ...args); } /** @@ -103,7 +103,7 @@ async function _startWorker( } const options: WorkerStartData = { - debug: logger.isEnabled(), + debug: debug.isEnabled(), dsn, tunnel: initOptions.tunnel, environment: initOptions.environment || 'production', From 6f07e5a8ff69e19b75f6ddaf3d5d39ca5a63f404 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 14 Jul 2025 16:53:33 +0200 Subject: [PATCH 16/37] chore: Use volta when running E2E tests locally (#16983) To ensure we get the correct node version etc. --- dev-packages/e2e-tests/run.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/run.ts b/dev-packages/e2e-tests/run.ts index 7b6efb2dd13b..cb2685ec0489 100644 --- a/dev-packages/e2e-tests/run.ts +++ b/dev-packages/e2e-tests/run.ts @@ -84,10 +84,10 @@ async function run(): Promise { const cwd = tmpDirPath; console.log(`Building ${testAppPath} in ${tmpDirPath}...`); - await asyncExec('pnpm test:build', { env, cwd }); + await asyncExec('volta run pnpm test:build', { env, cwd }); console.log(`Testing ${testAppPath}...`); - await asyncExec('pnpm test:assert', { env, cwd }); + await asyncExec('volta run pnpm test:assert', { env, cwd }); // clean up (although this is tmp, still nice to do) await rm(tmpDirPath, { recursive: true }); From cc8cd7e6ef200c4dabfef5dff3118430988b4c7e Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 14 Jul 2025 17:28:16 +0200 Subject: [PATCH 17/37] chore: Add external contributor to CHANGELOG.md (#16980) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #16979 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 11e7473626be..ec94603f9608 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 @janpapenbrock. Thank you for your contribution! + ## 9.38.0 ### Important Changes From 676fc50d4869ebacb4e115d7266222c39c8bd014 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 14 Jul 2025 19:59:52 +0200 Subject: [PATCH 18/37] feat(node-native): Add option to disable event loop blocked detection (#16919) This PR updates `@sentry-internal/node-native-stacktrace` to v0.2.0 which supports disabling last time tracking per thread. The integration needed quite a few changes and I split it into more functions but all the tests still pass! This PR then adds `pauseEventLoopBlockDetection()`, `restartEventLoopBlockDetection()` which can be used to pause and restart detection and `disableBlockDetectionForCallback` which can be used like this: ```ts import { disableBlockDetectionForCallback } from '@sentry/node-native'; disableBlockDetectionForCallback(() => { someLongBlockingFn(); }); ``` --- .../thread-blocked-native/basic-disabled.mjs | 28 ++ .../suites/thread-blocked-native/long-work.js | 11 +- .../suites/thread-blocked-native/test.ts | 17 +- dev-packages/rollup-utils/npmHelpers.mjs | 2 +- packages/node-native/package.json | 2 +- .../src/event-loop-block-integration.ts | 267 ++++++++++++------ .../src/event-loop-block-watchdog.ts | 2 +- packages/node-native/src/index.ts | 7 +- yarn.lock | 8 +- 9 files changed, 253 insertions(+), 91 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/thread-blocked-native/basic-disabled.mjs diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/basic-disabled.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic-disabled.mjs new file mode 100644 index 000000000000..15a1f496de61 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic-disabled.mjs @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node'; +import { disableBlockDetectionForCallback, eventLoopBlockIntegration } from '@sentry/node-native'; +import { longWork, longWorkOther } from './long-work.js'; + +global._sentryDebugIds = { [new Error().stack]: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa' }; + +setTimeout(() => { + process.exit(); +}, 15000); + +Sentry.init({ + debug: true, + dsn: process.env.SENTRY_DSN, + release: '1.0', + integrations: [eventLoopBlockIntegration()], +}); + +setTimeout(() => { + disableBlockDetectionForCallback(() => { + // This wont be captured + longWork(); + }); + + setTimeout(() => { + // But this will be captured + longWorkOther(); + }, 2000); +}, 2000); diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/long-work.js b/dev-packages/node-integration-tests/suites/thread-blocked-native/long-work.js index 55f5358a10fe..fdf6b537126d 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/long-work.js +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/long-work.js @@ -1,7 +1,15 @@ const crypto = require('crypto'); const assert = require('assert'); -function longWork() { +function longWork(count = 100) { + for (let i = 0; i < count; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + assert.ok(hash); + } +} + +function longWorkOther() { for (let i = 0; i < 200; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); @@ -10,3 +18,4 @@ function longWork() { } exports.longWork = longWork; +exports.longWorkOther = longWorkOther; diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index 6798882015f1..d168b8ce75d5 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -3,7 +3,7 @@ import type { Event } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; -function EXCEPTION(thread_id = '0') { +function EXCEPTION(thread_id = '0', fn = 'longWork') { return { values: [ { @@ -24,7 +24,7 @@ function EXCEPTION(thread_id = '0') { colno: expect.any(Number), lineno: expect.any(Number), filename: expect.any(String), - function: 'longWork', + function: fn, in_app: true, }), ]), @@ -155,6 +155,19 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { expect(runner.childHasExited()).toBe(true); }); + test('can be disabled with disableBlockDetectionForCallback', async () => { + await createRunner(__dirname, 'basic-disabled.mjs') + .withMockSentryServer() + .expect({ + event: { + ...ANR_EVENT, + exception: EXCEPTION('0', 'longWorkOther'), + }, + }) + .start() + .completed(); + }); + test('worker thread', async () => { const instrument = join(__dirname, 'instrument.mjs'); await createRunner(__dirname, 'worker-main.mjs') diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 83053aaeea98..cff113d622d6 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -93,7 +93,7 @@ export function makeBaseNPMConfig(options = {}) { } return true; - } + }, }, plugins: [nodeResolvePlugin, sucrasePlugin, debugBuildStatementReplacePlugin, rrwebBuildPlugin, cleanupPlugin], diff --git a/packages/node-native/package.json b/packages/node-native/package.json index 28dd78d5e021..ec4e555d1d08 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -63,7 +63,7 @@ "build:tarball": "npm pack" }, "dependencies": { - "@sentry-internal/node-native-stacktrace": "^0.1.0", + "@sentry-internal/node-native-stacktrace": "^0.2.0", "@sentry/core": "9.38.0", "@sentry/node": "9.38.0" }, diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 2980b93391e1..14e60fbf0bc0 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -1,15 +1,26 @@ -import { Worker } from 'node:worker_threads'; -import type { Contexts, Event, EventHint, IntegrationFn } from '@sentry/core'; -import { debug, defineIntegration, getFilenameToDebugIdMap, getIsolationScope } from '@sentry/core'; +import { isPromise } from 'node:util/types'; +import { isMainThread, Worker } from 'node:worker_threads'; +import type { + Client, + ClientOptions, + Contexts, + DsnComponents, + Event, + EventHint, + Integration, + IntegrationFn, +} from '@sentry/core'; +import { debug, defineIntegration, getClient, getFilenameToDebugIdMap, getIsolationScope } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { registerThread, threadPoll } from '@sentry-internal/node-native-stacktrace'; import type { ThreadBlockedIntegrationOptions, WorkerStartData } from './common'; import { POLL_RATIO } from './common'; +const INTEGRATION_NAME = 'ThreadBlocked'; const DEFAULT_THRESHOLD_MS = 1_000; function log(message: string, ...args: unknown[]): void { - debug.log(`[Sentry Block Event Loop] ${message}`, ...args); + debug.log(`[Sentry Event Loop Blocked] ${message}`, ...args); } /** @@ -27,68 +38,61 @@ async function getContexts(client: NodeClient): Promise { return event?.contexts || {}; } -const INTEGRATION_NAME = 'ThreadBlocked'; +type IntegrationInternal = { start: () => void; stop: () => void }; -const _eventLoopBlockIntegration = ((options: Partial = {}) => { - return { - name: INTEGRATION_NAME, - afterAllSetup(client: NodeClient) { - registerThread(); - _startWorker(client, options).catch(err => { - log('Failed to start event loop block worker', err); - }); - }, - }; -}) satisfies IntegrationFn; +function poll(enabled: boolean, clientOptions: ClientOptions): void { + try { + const currentSession = getIsolationScope().getSession(); + // We need to copy the session object and remove the toJSON method so it can be sent to the worker + // serialized without making it a SerializedSession + const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; + // message the worker to tell it the main event loop is still running + threadPoll({ session, debugImages: getFilenameToDebugIdMap(clientOptions.stackParser) }, !enabled); + } catch (_) { + // we ignore all errors + } +} /** - * Monitors the Node.js event loop for blocking behavior and reports blocked events to Sentry. - * - * Uses a background worker thread to detect when the main thread is blocked for longer than - * the configured threshold (default: 1 second). - * - * When instrumenting via the `--import` flag, this integration will - * automatically monitor all worker threads as well. - * - * ```js - * // instrument.mjs - * import * as Sentry from '@sentry/node'; - * import { eventLoopBlockIntegration } from '@sentry/node-native'; - * - * Sentry.init({ - * dsn: '__YOUR_DSN__', - * integrations: [ - * eventLoopBlockIntegration({ - * threshold: 500, // Report blocks longer than 500ms - * }), - * ], - * }); - * ``` - * - * Start your application with: - * ```bash - * node --import instrument.mjs app.mjs - * ``` + * Starts polling */ -export const eventLoopBlockIntegration = defineIntegration(_eventLoopBlockIntegration); +function startPolling( + client: Client, + integrationOptions: Partial, +): IntegrationInternal | undefined { + registerThread(); + + let enabled = true; + + const initOptions = client.getOptions(); + const pollInterval = (integrationOptions.threshold || DEFAULT_THRESHOLD_MS) / POLL_RATIO; + + // unref so timer does not block exit + setInterval(() => poll(enabled, initOptions), pollInterval).unref(); + + return { + start: () => { + enabled = true; + }, + stop: () => { + enabled = false; + // poll immediately because the timer above might not get a chance to run + // before the event loop gets blocked + poll(enabled, initOptions); + }, + }; +} /** - * Starts the worker thread + * Starts the worker thread that will monitor the other threads. * - * @returns A function to stop the worker + * This function is only called in the main thread. */ -async function _startWorker( +async function startWorker( + dsn: DsnComponents, client: NodeClient, integrationOptions: Partial, -): Promise<() => void> { - const dsn = client.getDsn(); - - if (!dsn) { - return () => { - // - }; - } - +): Promise { const contexts = await getContexts(client); // These will not be accurate if sent later from the worker thread @@ -117,8 +121,6 @@ async function _startWorker( contexts, }; - const pollInterval = options.threshold / POLL_RATIO; - const worker = new Worker(new URL('./event-loop-block-watchdog.js', import.meta.url), { workerData: options, // We don't want any Node args like --import to be passed to the worker @@ -131,37 +133,142 @@ async function _startWorker( worker.terminate(); }); - const timer = setInterval(() => { - try { - const currentSession = getIsolationScope().getSession(); - // We need to copy the session object and remove the toJSON method so it can be sent to the worker - // serialized without making it a SerializedSession - const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; - // message the worker to tell it the main event loop is still running - threadPoll({ session, debugImages: getFilenameToDebugIdMap(initOptions.stackParser) }); - } catch (_) { - // - } - }, pollInterval); - // Timer should not block exit - timer.unref(); - worker.once('error', (err: Error) => { - clearInterval(timer); log('watchdog worker error', err); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + worker.terminate(); }); worker.once('exit', (code: number) => { - clearInterval(timer); log('watchdog worker exit', code); }); // Ensure this thread can't block app exit worker.unref(); +} - return () => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - worker.terminate(); - clearInterval(timer); - }; +const _eventLoopBlockIntegration = ((options: Partial = {}) => { + let polling: IntegrationInternal | undefined; + + return { + name: INTEGRATION_NAME, + async afterAllSetup(client: NodeClient): Promise { + const dsn = client.getDsn(); + + if (!dsn) { + log('No DSN configured, skipping starting integration'); + return; + } + + try { + polling = await startPolling(client, options); + + if (isMainThread) { + await startWorker(dsn, client, options); + } + } catch (err) { + log('Failed to start integration', err); + } + }, + start() { + polling?.start(); + }, + stop() { + polling?.stop(); + }, + } as Integration & IntegrationInternal; +}) satisfies IntegrationFn; + +/** + * Monitors the Node.js event loop for blocking behavior and reports blocked events to Sentry. + * + * Uses a background worker thread to detect when the main thread is blocked for longer than + * the configured threshold (default: 1 second). + * + * When instrumenting via the `--import` flag, this integration will + * automatically monitor all worker threads as well. + * + * ```js + * // instrument.mjs + * import * as Sentry from '@sentry/node'; + * import { eventLoopBlockIntegration } from '@sentry/node-native'; + * + * Sentry.init({ + * dsn: '__YOUR_DSN__', + * integrations: [ + * eventLoopBlockIntegration({ + * threshold: 500, // Report blocks longer than 500ms + * }), + * ], + * }); + * ``` + * + * Start your application with: + * ```bash + * node --import instrument.mjs app.mjs + * ``` + */ +export const eventLoopBlockIntegration = defineIntegration(_eventLoopBlockIntegration); + +export function disableBlockDetectionForCallback(callback: () => T): T; +export function disableBlockDetectionForCallback(callback: () => Promise): Promise; +/** + * Disables Event Loop Block detection for the current thread for the duration + * of the callback. + * + * This utility function allows you to disable block detection during operations that + * are expected to block the event loop, such as intensive computational tasks or + * synchronous I/O operations. + */ +export function disableBlockDetectionForCallback(callback: () => T | Promise): T | Promise { + const integration = getClient()?.getIntegrationByName(INTEGRATION_NAME) as IntegrationInternal | undefined; + + if (!integration) { + return callback(); + } + + integration.stop(); + + try { + const result = callback(); + if (isPromise(result)) { + return result.finally(() => integration.start()); + } + + integration.start(); + return result; + } catch (error) { + integration.start(); + throw error; + } +} + +/** + * Pauses the block detection integration. + * + * This function pauses event loop block detection for the current thread. + */ +export function pauseEventLoopBlockDetection(): void { + const integration = getClient()?.getIntegrationByName(INTEGRATION_NAME) as IntegrationInternal | undefined; + + if (!integration) { + return; + } + + integration.stop(); +} + +/** + * Restarts the block detection integration. + * + * This function restarts event loop block detection for the current thread. + */ +export function restartEventLoopBlockDetection(): void { + const integration = getClient()?.getIntegrationByName(INTEGRATION_NAME) as IntegrationInternal | undefined; + + if (!integration) { + return; + } + + integration.start(); } diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index 8909c00d1ea7..26b9bb683930 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -37,7 +37,7 @@ const triggeredThreads = new Set(); function log(...msg: unknown[]): void { if (debug) { // eslint-disable-next-line no-console - console.log('[Sentry Block Event Loop Watchdog]', ...msg); + console.log('[Sentry Event Loop Blocked Watchdog]', ...msg); } } diff --git a/packages/node-native/src/index.ts b/packages/node-native/src/index.ts index 454be4eb8ad2..2d7cab39ff10 100644 --- a/packages/node-native/src/index.ts +++ b/packages/node-native/src/index.ts @@ -1 +1,6 @@ -export { eventLoopBlockIntegration } from './event-loop-block-integration'; +export { + eventLoopBlockIntegration, + disableBlockDetectionForCallback, + pauseEventLoopBlockDetection, + restartEventLoopBlockDetection, +} from './event-loop-block-integration'; diff --git a/yarn.lock b/yarn.lock index df78dd913611..91f97624598b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6959,10 +6959,10 @@ detect-libc "^2.0.3" node-abi "^3.73.0" -"@sentry-internal/node-native-stacktrace@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.1.0.tgz#fa0eaf1e66245f463ca2294ff63da74c56d1a052" - integrity sha512-dWkxhDdjcRdEOTk1acrdBledqIroaYJrOSbecx5tJ/m9DiWZ1Oa4eNi/sI2SHLT+hKmsBBxrychf6+Iitz5Bzw== +"@sentry-internal/node-native-stacktrace@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.2.0.tgz#d759d9ba62101aea46829c436aec490d4a63f9f7" + integrity sha512-MPkjcXFUaBVxbpx8whvqQu7UncriCt3nUN7uA+ojgauHF2acvSp5nJCqKM2a4KInFWNiI1AxJ6tLE7EuBJ4WBQ== dependencies: detect-libc "^2.0.4" node-abi "^3.73.0" From 2426c9a145f4c42d3fa4874e8068bbd2895ff001 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 14 Jul 2025 14:30:00 -0400 Subject: [PATCH 19/37] ref(replay-internal): Use `debug` instead of `logger` (#16987) resolves https://github.com/getsentry/sentry-javascript/issues/16937 Also reaches into `packages/browser-utils/src/networkUtils.ts` to make a quick change. Please review `packages/replay-internal/src/util/logger.ts` carefully. --- .../suites/replay/logger/test.ts | 6 +-- packages/browser-utils/src/networkUtils.ts | 9 ++-- .../src/coreHandlers/handleGlobalEvent.ts | 4 +- .../coreHandlers/handleNetworkBreadcrumbs.ts | 4 +- .../src/coreHandlers/util/fetchUtils.ts | 14 +++--- .../src/coreHandlers/util/xhrUtils.ts | 12 ++--- .../EventBufferCompressionWorker.ts | 4 +- .../src/eventBuffer/EventBufferProxy.ts | 6 +-- .../src/eventBuffer/WorkerHandler.ts | 6 +-- .../replay-internal/src/eventBuffer/index.ts | 8 ++-- packages/replay-internal/src/replay.ts | 40 ++++++++-------- .../src/session/fetchSession.ts | 4 +- .../src/session/loadOrCreateSession.ts | 6 +-- packages/replay-internal/src/util/addEvent.ts | 6 +-- .../src/util/handleRecordingEmit.ts | 6 +-- packages/replay-internal/src/util/logger.ts | 46 +++++++++---------- .../src/util/sendReplayRequest.ts | 4 +- .../test/integration/flush.test.ts | 18 ++++---- .../test/unit/util/logger.test.ts | 24 +++++----- 19 files changed, 112 insertions(+), 115 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/logger/test.ts b/dev-packages/browser-integration-tests/suites/replay/logger/test.ts index b21247a88890..a3fd96643d1e 100644 --- a/dev-packages/browser-integration-tests/suites/replay/logger/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/logger/test.ts @@ -21,7 +21,7 @@ sentryTest('should output logger messages', async ({ getLocalTestUrl, page }) => await Promise.all([page.goto(url), reqPromise0]); expect(messages).toContain('Sentry Logger [log]: Integration installed: Replay'); - expect(messages).toContain('Sentry Logger [info]: [Replay] Creating new session'); - expect(messages).toContain('Sentry Logger [info]: [Replay] Starting replay in session mode'); - expect(messages).toContain('Sentry Logger [info]: [Replay] Using compression worker'); + expect(messages).toContain('Sentry Logger [log]: [Replay] Creating new session'); + expect(messages).toContain('Sentry Logger [log]: [Replay] Starting replay in session mode'); + expect(messages).toContain('Sentry Logger [log]: [Replay] Using compression worker'); }); diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index cbdd77640507..607434251872 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -1,5 +1,4 @@ -import type { Logger } from '@sentry/core'; -import { logger } from '@sentry/core'; +import { debug } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import type { NetworkMetaWarning } from './types'; @@ -16,7 +15,7 @@ export function serializeFormData(formData: FormData): string { } /** Get the string representation of a body. */ -export function getBodyString(body: unknown, _logger: Logger = logger): [string | undefined, NetworkMetaWarning?] { +export function getBodyString(body: unknown, _debug: typeof debug = debug): [string | undefined, NetworkMetaWarning?] { try { if (typeof body === 'string') { return [body]; @@ -34,11 +33,11 @@ export function getBodyString(body: unknown, _logger: Logger = logger): [string return [undefined]; } } catch (error) { - DEBUG_BUILD && _logger.error(error, 'Failed to serialize body', body); + DEBUG_BUILD && _debug.error(error, 'Failed to serialize body', body); return [undefined, 'BODY_PARSE_ERROR']; } - DEBUG_BUILD && _logger.info('Skipping network body because of body type', body); + DEBUG_BUILD && _debug.log('Skipping network body because of body type', body); return [undefined, 'UNPARSEABLE_BODY_TYPE']; } diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index 00f41903ac71..55559c0d4c01 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -3,7 +3,7 @@ import { DEBUG_BUILD } from '../debug-build'; import type { ReplayContainer } from '../types'; import { isErrorEvent, isFeedbackEvent, isReplayEvent, isTransactionEvent } from '../util/eventUtils'; import { isRrwebError } from '../util/isRrwebError'; -import { logger } from '../util/logger'; +import { debug } from '../util/logger'; import { resetReplayIdOnDynamicSamplingContext } from '../util/resetReplayIdOnDynamicSamplingContext'; import { addFeedbackBreadcrumb } from './util/addFeedbackBreadcrumb'; import { shouldSampleForBufferEvent } from './util/shouldSampleForBufferEvent'; @@ -52,7 +52,7 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even // Unless `captureExceptions` is enabled, we want to ignore errors coming from rrweb // As there can be a bunch of stuff going wrong in internals there, that we don't want to bubble up to users if (isRrwebError(event, hint) && !replay.getOptions()._experiments.captureExceptions) { - DEBUG_BUILD && logger.log('Ignoring error from rrweb internals', event); + DEBUG_BUILD && debug.log('Ignoring error from rrweb internals', event); return null; } diff --git a/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts b/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts index eab911b8d3f4..6a2e49bfa5b9 100644 --- a/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts +++ b/packages/replay-internal/src/coreHandlers/handleNetworkBreadcrumbs.ts @@ -3,7 +3,7 @@ import { getClient } from '@sentry/core'; import type { FetchHint, XhrHint } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; import type { ReplayContainer, ReplayNetworkOptions } from '../types'; -import { logger } from '../util/logger'; +import { debug } from '../util/logger'; import { captureFetchBreadcrumbToReplay, enrichFetchBreadcrumb } from './util/fetchUtils'; import { captureXhrBreadcrumbToReplay, enrichXhrBreadcrumb } from './util/xhrUtils'; @@ -79,7 +79,7 @@ export function beforeAddNetworkBreadcrumb( captureFetchBreadcrumbToReplay(breadcrumb, hint, options); } } catch (e) { - DEBUG_BUILD && logger.exception(e, 'Error when enriching network breadcrumb'); + DEBUG_BUILD && debug.exception(e, 'Error when enriching network breadcrumb'); } } diff --git a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts index c28b3acd6dc9..4de9d08805b3 100644 --- a/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/fetchUtils.ts @@ -8,7 +8,7 @@ import type { ReplayNetworkRequestData, ReplayNetworkRequestOrResponse, } from '../../types'; -import { logger } from '../../util/logger'; +import { debug } from '../../util/logger'; import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { buildNetworkRequestOrResponse, @@ -39,7 +39,7 @@ export async function captureFetchBreadcrumbToReplay( const result = makeNetworkReplayBreadcrumb('resource.fetch', data); addNetworkBreadcrumb(options.replay, result); } catch (error) { - DEBUG_BUILD && logger.exception(error, 'Failed to capture fetch breadcrumb'); + DEBUG_BUILD && debug.exception(error, 'Failed to capture fetch breadcrumb'); } } @@ -115,7 +115,7 @@ function _getRequestInfo( // We only want to transmit string or string-like bodies const requestBody = getFetchRequestArgBody(input); - const [bodyStr, warning] = getBodyString(requestBody, logger); + const [bodyStr, warning] = getBodyString(requestBody, debug); const data = buildNetworkRequestOrResponse(headers, requestBodySize, bodyStr); if (warning) { @@ -188,7 +188,7 @@ function getResponseData( return buildNetworkRequestOrResponse(headers, size, undefined); } catch (error) { - DEBUG_BUILD && logger.exception(error, 'Failed to serialize response body'); + DEBUG_BUILD && debug.exception(error, 'Failed to serialize response body'); // fallback return buildNetworkRequestOrResponse(headers, responseBodySize, undefined); } @@ -206,11 +206,11 @@ async function _parseFetchResponseBody(response: Response): Promise<[string | un return [text]; } catch (error) { if (error instanceof Error && error.message.indexOf('Timeout') > -1) { - DEBUG_BUILD && logger.warn('Parsing text body from response timed out'); + DEBUG_BUILD && debug.warn('Parsing text body from response timed out'); return [undefined, 'BODY_PARSE_TIMEOUT']; } - DEBUG_BUILD && logger.exception(error, 'Failed to get text body from response'); + DEBUG_BUILD && debug.exception(error, 'Failed to get text body from response'); return [undefined, 'BODY_PARSE_ERROR']; } } @@ -271,7 +271,7 @@ function _tryCloneResponse(response: Response): Response | void { return response.clone(); } catch (error) { // this can throw if the response was already consumed before - DEBUG_BUILD && logger.exception(error, 'Failed to clone response body'); + DEBUG_BUILD && debug.exception(error, 'Failed to clone response body'); } } diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index 815575ede1f8..bb7c631eddef 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -3,7 +3,7 @@ import type { NetworkMetaWarning, XhrHint } from '@sentry-internal/browser-utils import { getBodyString, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../../debug-build'; import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData } from '../../types'; -import { logger } from '../../util/logger'; +import { debug } from '../../util/logger'; import { addNetworkBreadcrumb } from './addNetworkBreadcrumb'; import { buildNetworkRequestOrResponse, @@ -32,7 +32,7 @@ export async function captureXhrBreadcrumbToReplay( const result = makeNetworkReplayBreadcrumb('resource.xhr', data); addNetworkBreadcrumb(options.replay, result); } catch (error) { - DEBUG_BUILD && logger.exception(error, 'Failed to capture xhr breadcrumb'); + DEBUG_BUILD && debug.exception(error, 'Failed to capture xhr breadcrumb'); } } @@ -106,7 +106,7 @@ function _prepareXhrData( : {}; const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); - const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input, logger) : [undefined]; + const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input, debug) : [undefined]; const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined]; const request = buildNetworkRequestOrResponse(networkRequestHeaders, requestBodySize, requestBody); @@ -156,7 +156,7 @@ function _getXhrResponseBody(xhr: XMLHttpRequest): [string | undefined, NetworkM errors.push(e); } - DEBUG_BUILD && logger.warn('Failed to get xhr response body', ...errors); + DEBUG_BUILD && debug.warn('Failed to get xhr response body', ...errors); return [undefined]; } @@ -193,11 +193,11 @@ export function _parseXhrResponse( return [undefined]; } } catch (error) { - DEBUG_BUILD && logger.exception(error, 'Failed to serialize body', body); + DEBUG_BUILD && debug.exception(error, 'Failed to serialize body', body); return [undefined, 'BODY_PARSE_ERROR']; } - DEBUG_BUILD && logger.info('Skipping network body because of body type', body); + DEBUG_BUILD && debug.log('Skipping network body because of body type', body); return [undefined, 'UNPARSEABLE_BODY_TYPE']; } diff --git a/packages/replay-internal/src/eventBuffer/EventBufferCompressionWorker.ts b/packages/replay-internal/src/eventBuffer/EventBufferCompressionWorker.ts index 593484bc8a21..48a4e219424e 100644 --- a/packages/replay-internal/src/eventBuffer/EventBufferCompressionWorker.ts +++ b/packages/replay-internal/src/eventBuffer/EventBufferCompressionWorker.ts @@ -2,7 +2,7 @@ import type { ReplayRecordingData } from '@sentry/core'; import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../constants'; import { DEBUG_BUILD } from '../debug-build'; import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; -import { logger } from '../util/logger'; +import { debug } from '../util/logger'; import { timestampToMs } from '../util/timestamp'; import { EventBufferSizeExceededError } from './error'; import { WorkerHandler } from './WorkerHandler'; @@ -91,7 +91,7 @@ export class EventBufferCompressionWorker implements EventBuffer { // We do not wait on this, as we assume the order of messages is consistent for the worker this._worker.postMessage('clear').then(null, e => { - DEBUG_BUILD && logger.exception(e, 'Sending "clear" message to worker failed', e); + DEBUG_BUILD && debug.exception(e, 'Sending "clear" message to worker failed', e); }); } diff --git a/packages/replay-internal/src/eventBuffer/EventBufferProxy.ts b/packages/replay-internal/src/eventBuffer/EventBufferProxy.ts index 465235ef02f6..9fb82882a484 100644 --- a/packages/replay-internal/src/eventBuffer/EventBufferProxy.ts +++ b/packages/replay-internal/src/eventBuffer/EventBufferProxy.ts @@ -1,7 +1,7 @@ import type { ReplayRecordingData } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; -import { logger } from '../util/logger'; +import { debug } from '../util/logger'; import { EventBufferArray } from './EventBufferArray'; import { EventBufferCompressionWorker } from './EventBufferCompressionWorker'; @@ -99,7 +99,7 @@ export class EventBufferProxy implements EventBuffer { } catch (error) { // If the worker fails to load, we fall back to the simple buffer. // Nothing more to do from our side here - DEBUG_BUILD && logger.exception(error, 'Failed to load the compression worker, falling back to simple buffer'); + DEBUG_BUILD && debug.exception(error, 'Failed to load the compression worker, falling back to simple buffer'); return; } @@ -130,7 +130,7 @@ export class EventBufferProxy implements EventBuffer { // Can now clear fallback buffer as it's no longer necessary this._fallback.clear(); } catch (error) { - DEBUG_BUILD && logger.exception(error, 'Failed to add events when switching buffers.'); + DEBUG_BUILD && debug.exception(error, 'Failed to add events when switching buffers.'); } } } diff --git a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts index 062ed384674b..69b77378eeee 100644 --- a/packages/replay-internal/src/eventBuffer/WorkerHandler.ts +++ b/packages/replay-internal/src/eventBuffer/WorkerHandler.ts @@ -1,6 +1,6 @@ import { DEBUG_BUILD } from '../debug-build'; import type { WorkerRequest, WorkerResponse } from '../types'; -import { logger } from '../util/logger'; +import { debug } from '../util/logger'; /** * Event buffer that uses a web worker to compress events. @@ -55,7 +55,7 @@ export class WorkerHandler { * Destroy the worker. */ public destroy(): void { - DEBUG_BUILD && logger.info('Destroying compression worker'); + DEBUG_BUILD && debug.log('Destroying compression worker'); this._worker.terminate(); } @@ -83,7 +83,7 @@ export class WorkerHandler { if (!response.success) { // TODO: Do some error handling, not sure what - DEBUG_BUILD && logger.error('Error in compression worker: ', response.response); + DEBUG_BUILD && debug.error('Error in compression worker: ', response.response); reject(new Error('Error in compression worker')); return; diff --git a/packages/replay-internal/src/eventBuffer/index.ts b/packages/replay-internal/src/eventBuffer/index.ts index d1e40903057d..d106caf9b113 100644 --- a/packages/replay-internal/src/eventBuffer/index.ts +++ b/packages/replay-internal/src/eventBuffer/index.ts @@ -1,7 +1,7 @@ import { getWorkerURL } from '@sentry-internal/replay-worker'; import { DEBUG_BUILD } from '../debug-build'; import type { EventBuffer, ReplayWorkerURL } from '../types'; -import { logger } from '../util/logger'; +import { debug } from '../util/logger'; import { EventBufferArray } from './EventBufferArray'; import { EventBufferProxy } from './EventBufferProxy'; @@ -32,7 +32,7 @@ export function createEventBuffer({ } } - DEBUG_BUILD && logger.info('Using simple buffer'); + DEBUG_BUILD && debug.log('Using simple buffer'); return new EventBufferArray(); } @@ -44,11 +44,11 @@ function _loadWorker(customWorkerUrl?: ReplayWorkerURL): EventBufferProxy | void return; } - DEBUG_BUILD && logger.info(`Using compression worker${customWorkerUrl ? ` from ${customWorkerUrl}` : ''}`); + DEBUG_BUILD && debug.log(`Using compression worker${customWorkerUrl ? ` from ${customWorkerUrl}` : ''}`); const worker = new Worker(workerUrl); return new EventBufferProxy(worker); } catch (error) { - DEBUG_BUILD && logger.exception(error, 'Failed to create compression worker'); + DEBUG_BUILD && debug.exception(error, 'Failed to create compression worker'); // Fall back to use simple event buffer array } } diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 6fc607e1c2d8..e2a49bd0a83b 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -51,7 +51,7 @@ import { getRecordingSamplingOptions } from './util/getRecordingSamplingOptions' import { getHandleRecordingEmit } from './util/handleRecordingEmit'; import { isExpired } from './util/isExpired'; import { isSessionExpired } from './util/isSessionExpired'; -import { logger } from './util/logger'; +import { debug } from './util/logger'; import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext'; import { closestElementOfNode } from './util/rrweb'; import { sendReplay } from './util/sendReplay'; @@ -228,10 +228,10 @@ export class ReplayContainer implements ReplayContainerInterface { this.clickDetector = new ClickDetector(this, slowClickConfig); } - // Configure replay logger w/ experimental options + // Configure replay debug logger w/ experimental options if (DEBUG_BUILD) { const experiments = options._experiments; - logger.setConfig({ + debug.setConfig({ captureExceptions: !!experiments.captureExceptions, traceInternals: !!experiments.traceInternals, }); @@ -304,7 +304,7 @@ export class ReplayContainer implements ReplayContainerInterface { /** A wrapper to conditionally capture exceptions. */ public handleException(error: unknown): void { - DEBUG_BUILD && logger.exception(error); + DEBUG_BUILD && debug.exception(error); if (this._options.onError) { this._options.onError(error); } @@ -333,7 +333,7 @@ export class ReplayContainer implements ReplayContainerInterface { if (!this.session) { // This should not happen, something wrong has occurred - DEBUG_BUILD && logger.exception(new Error('Unable to initialize and create session')); + DEBUG_BUILD && debug.exception(new Error('Unable to initialize and create session')); return; } @@ -347,7 +347,7 @@ export class ReplayContainer implements ReplayContainerInterface { // In this case, we still want to continue in `session` recording mode this.recordingMode = this.session.sampled === 'buffer' && this.session.segmentId === 0 ? 'buffer' : 'session'; - DEBUG_BUILD && logger.infoTick(`Starting replay in ${this.recordingMode} mode`); + DEBUG_BUILD && debug.infoTick(`Starting replay in ${this.recordingMode} mode`); this._initializeRecording(); } @@ -361,16 +361,16 @@ export class ReplayContainer implements ReplayContainerInterface { */ public start(): void { if (this._isEnabled && this.recordingMode === 'session') { - DEBUG_BUILD && logger.info('Recording is already in progress'); + DEBUG_BUILD && debug.log('Recording is already in progress'); return; } if (this._isEnabled && this.recordingMode === 'buffer') { - DEBUG_BUILD && logger.info('Buffering is in progress, call `flush()` to save the replay'); + DEBUG_BUILD && debug.log('Buffering is in progress, call `flush()` to save the replay'); return; } - DEBUG_BUILD && logger.infoTick('Starting replay in session mode'); + DEBUG_BUILD && debug.infoTick('Starting replay in session mode'); // Required as user activity is initially set in // constructor, so if `start()` is called after @@ -402,11 +402,11 @@ export class ReplayContainer implements ReplayContainerInterface { */ public startBuffering(): void { if (this._isEnabled) { - DEBUG_BUILD && logger.info('Buffering is in progress, call `flush()` to save the replay'); + DEBUG_BUILD && debug.log('Buffering is in progress, call `flush()` to save the replay'); return; } - DEBUG_BUILD && logger.infoTick('Starting replay in buffer mode'); + DEBUG_BUILD && debug.infoTick('Starting replay in buffer mode'); const session = loadOrCreateSession( { @@ -504,7 +504,7 @@ export class ReplayContainer implements ReplayContainerInterface { this._isEnabled = false; try { - DEBUG_BUILD && logger.info(`Stopping Replay${reason ? ` triggered by ${reason}` : ''}`); + DEBUG_BUILD && debug.log(`Stopping Replay${reason ? ` triggered by ${reason}` : ''}`); resetReplayIdOnDynamicSamplingContext(); @@ -543,7 +543,7 @@ export class ReplayContainer implements ReplayContainerInterface { this._isPaused = true; this.stopRecording(); - DEBUG_BUILD && logger.info('Pausing replay'); + DEBUG_BUILD && debug.log('Pausing replay'); } /** @@ -560,7 +560,7 @@ export class ReplayContainer implements ReplayContainerInterface { this._isPaused = false; this.startRecording(); - DEBUG_BUILD && logger.info('Resuming replay'); + DEBUG_BUILD && debug.log('Resuming replay'); } /** @@ -577,7 +577,7 @@ export class ReplayContainer implements ReplayContainerInterface { const activityTime = Date.now(); - DEBUG_BUILD && logger.info('Converting buffer to session'); + DEBUG_BUILD && debug.log('Converting buffer to session'); // Allow flush to complete before resuming as a session recording, otherwise // the checkout from `startRecording` may be included in the payload. @@ -1011,7 +1011,7 @@ export class ReplayContainer implements ReplayContainerInterface { // If the user has come back to the page within SESSION_IDLE_PAUSE_DURATION // ms, we will re-use the existing session, otherwise create a new // session - DEBUG_BUILD && logger.info('Document has become active, but session has expired'); + DEBUG_BUILD && debug.log('Document has become active, but session has expired'); return; } @@ -1138,7 +1138,7 @@ export class ReplayContainer implements ReplayContainerInterface { const replayId = this.getSessionId(); if (!this.session || !this.eventBuffer || !replayId) { - DEBUG_BUILD && logger.error('No session or eventBuffer found to flush.'); + DEBUG_BUILD && debug.error('No session or eventBuffer found to flush.'); return; } @@ -1231,7 +1231,7 @@ export class ReplayContainer implements ReplayContainerInterface { } if (!this.checkAndHandleExpiredSession()) { - DEBUG_BUILD && logger.error('Attempting to finish replay event after session expired.'); + DEBUG_BUILD && debug.error('Attempting to finish replay event after session expired.'); return; } @@ -1253,7 +1253,7 @@ export class ReplayContainer implements ReplayContainerInterface { const tooLong = duration > this._options.maxReplayDuration + 5_000; if (tooShort || tooLong) { DEBUG_BUILD && - logger.info( + debug.log( `Session duration (${Math.floor(duration / 1000)}s) is too ${ tooShort ? 'short' : 'long' }, not sending replay.`, @@ -1267,7 +1267,7 @@ export class ReplayContainer implements ReplayContainerInterface { const eventBuffer = this.eventBuffer; if (eventBuffer && this.session.segmentId === 0 && !eventBuffer.hasCheckout) { - DEBUG_BUILD && logger.info('Flushing initial segment without checkout.'); + DEBUG_BUILD && debug.log('Flushing initial segment without checkout.'); // TODO FN: Evaluate if we want to stop here, or remove this again? } diff --git a/packages/replay-internal/src/session/fetchSession.ts b/packages/replay-internal/src/session/fetchSession.ts index 031605bfde87..2347fcc66910 100644 --- a/packages/replay-internal/src/session/fetchSession.ts +++ b/packages/replay-internal/src/session/fetchSession.ts @@ -2,7 +2,7 @@ import { REPLAY_SESSION_KEY, WINDOW } from '../constants'; import { DEBUG_BUILD } from '../debug-build'; import type { Session } from '../types'; import { hasSessionStorage } from '../util/hasSessionStorage'; -import { logger } from '../util/logger'; +import { debug } from '../util/logger'; import { makeSession } from './Session'; /** @@ -23,7 +23,7 @@ export function fetchSession(): Session | null { const sessionObj = JSON.parse(sessionStringFromStorage) as Session; - DEBUG_BUILD && logger.infoTick('Loading existing session'); + DEBUG_BUILD && debug.infoTick('Loading existing session'); return makeSession(sessionObj); } catch { diff --git a/packages/replay-internal/src/session/loadOrCreateSession.ts b/packages/replay-internal/src/session/loadOrCreateSession.ts index d37c51590d54..9371725cf051 100644 --- a/packages/replay-internal/src/session/loadOrCreateSession.ts +++ b/packages/replay-internal/src/session/loadOrCreateSession.ts @@ -1,6 +1,6 @@ import { DEBUG_BUILD } from '../debug-build'; import type { Session, SessionOptions } from '../types'; -import { logger } from '../util/logger'; +import { debug } from '../util/logger'; import { createSession } from './createSession'; import { fetchSession } from './fetchSession'; import { shouldRefreshSession } from './shouldRefreshSession'; @@ -25,7 +25,7 @@ export function loadOrCreateSession( // No session exists yet, just create a new one if (!existingSession) { - DEBUG_BUILD && logger.infoTick('Creating new session'); + DEBUG_BUILD && debug.infoTick('Creating new session'); return createSession(sessionOptions, { previousSessionId }); } @@ -33,6 +33,6 @@ export function loadOrCreateSession( return existingSession; } - DEBUG_BUILD && logger.infoTick('Session in sessionStorage is expired, creating new one...'); + DEBUG_BUILD && debug.infoTick('Session in sessionStorage is expired, creating new one...'); return createSession(sessionOptions, { previousSessionId: existingSession.id }); } diff --git a/packages/replay-internal/src/util/addEvent.ts b/packages/replay-internal/src/util/addEvent.ts index 9a0b870c3f49..a133d9de6303 100644 --- a/packages/replay-internal/src/util/addEvent.ts +++ b/packages/replay-internal/src/util/addEvent.ts @@ -3,7 +3,7 @@ import { EventType } from '@sentry-internal/rrweb'; import { DEBUG_BUILD } from '../debug-build'; import { EventBufferSizeExceededError } from '../eventBuffer/error'; import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayFrameEvent, ReplayPluginOptions } from '../types'; -import { logger } from './logger'; +import { debug } from './logger'; import { timestampToMs } from './timestamp'; function isCustomEvent(event: RecordingEvent): event is ReplayFrameEvent { @@ -123,7 +123,7 @@ export function shouldAddEvent(replay: ReplayContainer, event: RecordingEvent): // Throw out events that are +60min from the initial timestamp if (timestampInMs > replay.getContext().initialTimestamp + replay.getOptions().maxReplayDuration) { DEBUG_BUILD && - logger.infoTick(`Skipping event with timestamp ${timestampInMs} because it is after maxReplayDuration`); + debug.infoTick(`Skipping event with timestamp ${timestampInMs} because it is after maxReplayDuration`); return false; } @@ -140,7 +140,7 @@ function maybeApplyCallback( } } catch (error) { DEBUG_BUILD && - logger.exception(error, 'An error occurred in the `beforeAddRecordingEvent` callback, skipping the event...'); + debug.exception(error, 'An error occurred in the `beforeAddRecordingEvent` callback, skipping the event...'); return null; } diff --git a/packages/replay-internal/src/util/handleRecordingEmit.ts b/packages/replay-internal/src/util/handleRecordingEmit.ts index cd0d379bb0fe..0ae87601637b 100644 --- a/packages/replay-internal/src/util/handleRecordingEmit.ts +++ b/packages/replay-internal/src/util/handleRecordingEmit.ts @@ -4,7 +4,7 @@ import { DEBUG_BUILD } from '../debug-build'; import { saveSession } from '../session/saveSession'; import type { RecordingEvent, ReplayContainer, ReplayOptionFrameEvent } from '../types'; import { addEventSync } from './addEvent'; -import { logger } from './logger'; +import { debug } from './logger'; type RecordingEmitCallback = (event: RecordingEvent, isCheckout?: boolean) => void; @@ -19,7 +19,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa return (event: RecordingEvent, _isCheckout?: boolean) => { // If this is false, it means session is expired, create and a new session and wait for checkout if (!replay.checkAndHandleExpiredSession()) { - DEBUG_BUILD && logger.warn('Received replay event after session expired.'); + DEBUG_BUILD && debug.warn('Received replay event after session expired.'); return; } @@ -76,7 +76,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa const earliestEvent = replay.eventBuffer.getEarliestTimestamp(); if (earliestEvent) { DEBUG_BUILD && - logger.info(`Updating session start time to earliest event in buffer to ${new Date(earliestEvent)}`); + debug.log(`Updating session start time to earliest event in buffer to ${new Date(earliestEvent)}`); session.started = earliestEvent; diff --git a/packages/replay-internal/src/util/logger.ts b/packages/replay-internal/src/util/logger.ts index 46da5b40ad70..89ded3c69468 100644 --- a/packages/replay-internal/src/util/logger.ts +++ b/packages/replay-internal/src/util/logger.ts @@ -1,28 +1,28 @@ -import type { ConsoleLevel, Logger, SeverityLevel } from '@sentry/core'; -import { addBreadcrumb, captureException, logger as coreLogger, severityLevelFromString } from '@sentry/core'; +import type { ConsoleLevel, SeverityLevel } from '@sentry/core'; +import { addBreadcrumb, captureException, debug as coreDebug, severityLevelFromString } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -type ReplayConsoleLevels = Extract; -const CONSOLE_LEVELS: readonly ReplayConsoleLevels[] = ['info', 'warn', 'error', 'log'] as const; +type ReplayConsoleLevels = Extract; +const CONSOLE_LEVELS: readonly ReplayConsoleLevels[] = ['log', 'warn', 'error'] as const; const PREFIX = '[Replay] '; -type LoggerMethod = (...args: unknown[]) => void; - interface LoggerConfig { captureExceptions: boolean; traceInternals: boolean; } -interface ReplayLogger extends Logger { +type CoreDebugLogger = typeof coreDebug; + +interface ReplayDebugLogger extends CoreDebugLogger { /** - * Calls `logger.info` but saves breadcrumb in the next tick due to race + * Calls `debug.log` but saves breadcrumb in the next tick due to race * conditions before replay is initialized. */ - infoTick: LoggerMethod; + infoTick: CoreDebugLogger['log']; /** * Captures exceptions (`Error`) if "capture internal exceptions" is enabled */ - exception: LoggerMethod; + exception: CoreDebugLogger['error']; /** * Configures the logger with additional debugging behavior */ @@ -43,11 +43,11 @@ function _addBreadcrumb(message: unknown, level: SeverityLevel = 'info'): void { ); } -function makeReplayLogger(): ReplayLogger { +function makeReplayDebugLogger(): ReplayDebugLogger { let _capture = false; let _trace = false; - const _logger: Partial = { + const _debug: Partial = { exception: () => undefined, infoTick: () => undefined, setConfig: (opts: Partial) => { @@ -58,20 +58,20 @@ function makeReplayLogger(): ReplayLogger { if (DEBUG_BUILD) { CONSOLE_LEVELS.forEach(name => { - _logger[name] = (...args: unknown[]) => { - coreLogger[name](PREFIX, ...args); + _debug[name] = (...args: unknown[]) => { + coreDebug[name](PREFIX, ...args); if (_trace) { _addBreadcrumb(args.join(''), severityLevelFromString(name)); } }; }); - _logger.exception = (error: unknown, ...message: unknown[]) => { - if (message.length && _logger.error) { - _logger.error(...message); + _debug.exception = (error: unknown, ...message: unknown[]) => { + if (message.length && _debug.error) { + _debug.error(...message); } - coreLogger.error(PREFIX, error); + coreDebug.error(PREFIX, error); if (_capture) { captureException(error); @@ -82,8 +82,8 @@ function makeReplayLogger(): ReplayLogger { } }; - _logger.infoTick = (...args: unknown[]) => { - coreLogger.info(PREFIX, ...args); + _debug.infoTick = (...args: unknown[]) => { + coreDebug.log(PREFIX, ...args); if (_trace) { // Wait a tick here to avoid race conditions for some initial logs // which may be added before replay is initialized @@ -92,11 +92,11 @@ function makeReplayLogger(): ReplayLogger { }; } else { CONSOLE_LEVELS.forEach(name => { - _logger[name] = () => undefined; + _debug[name] = () => undefined; }); } - return _logger as ReplayLogger; + return _debug as ReplayDebugLogger; } -export const logger = makeReplayLogger(); +export const debug = makeReplayDebugLogger(); diff --git a/packages/replay-internal/src/util/sendReplayRequest.ts b/packages/replay-internal/src/util/sendReplayRequest.ts index 64694a5c9c39..5edb94f721f9 100644 --- a/packages/replay-internal/src/util/sendReplayRequest.ts +++ b/packages/replay-internal/src/util/sendReplayRequest.ts @@ -4,7 +4,7 @@ import { REPLAY_EVENT_NAME, UNABLE_TO_SEND_REPLAY } from '../constants'; import { DEBUG_BUILD } from '../debug-build'; import type { SendReplayData } from '../types'; import { createReplayEnvelope } from './createReplayEnvelope'; -import { logger } from './logger'; +import { debug } from './logger'; import { prepareRecordingData } from './prepareRecordingData'; import { prepareReplayEvent } from './prepareReplayEvent'; @@ -54,7 +54,7 @@ export async function sendReplayRequest({ if (!replayEvent) { // Taken from baseclient's `_processEvent` method, where this is handled for errors/transactions client.recordDroppedEvent('event_processor', 'replay'); - DEBUG_BUILD && logger.info('An event processor returned `null`, will not send event.'); + DEBUG_BUILD && debug.log('An event processor returned `null`, will not send event.'); return resolvedSyncPromise({}); } diff --git a/packages/replay-internal/test/integration/flush.test.ts b/packages/replay-internal/test/integration/flush.test.ts index f3a1f4e34f52..d9c45278855b 100644 --- a/packages/replay-internal/test/integration/flush.test.ts +++ b/packages/replay-internal/test/integration/flush.test.ts @@ -14,7 +14,7 @@ import { clearSession } from '../../src/session/clearSession'; import type { EventBuffer } from '../../src/types'; import { createPerformanceEntries } from '../../src/util/createPerformanceEntries'; import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; -import { logger } from '../../src/util/logger'; +import { debug } from '../../src/util/logger'; import * as SendReplay from '../../src/util/sendReplay'; import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; import type { DomHandler } from '../types'; @@ -332,7 +332,7 @@ describe('Integration | flush', () => { }); it('logs warning if flushing initial segment without checkout', async () => { - logger.setConfig({ traceInternals: true }); + debug.setConfig({ traceInternals: true }); sessionStorage.clear(); clearSession(replay); @@ -398,20 +398,20 @@ describe('Integration | flush', () => { type: 'default', category: 'console', data: { logger: 'replay' }, - level: 'info', + level: 'log', message: '[Replay] Flushing initial segment without checkout.', }, }, }, ]); - logger.setConfig({ traceInternals: false }); + debug.setConfig({ traceInternals: false }); }); it('logs warning if adding event that is after maxReplayDuration', async () => { - logger.setConfig({ traceInternals: true }); + debug.setConfig({ traceInternals: true }); - const spyLogger = vi.spyOn(SentryUtils.logger, 'info'); + const spyDebugLogger = vi.spyOn(SentryUtils.debug, 'log'); sessionStorage.clear(); clearSession(replay); @@ -436,15 +436,15 @@ describe('Integration | flush', () => { expect(mockFlush).toHaveBeenCalledTimes(0); expect(mockSendReplay).toHaveBeenCalledTimes(0); - expect(spyLogger).toHaveBeenLastCalledWith( + expect(spyDebugLogger).toHaveBeenLastCalledWith( '[Replay] ', `Skipping event with timestamp ${ BASE_TIMESTAMP + MAX_REPLAY_DURATION + 100 } because it is after maxReplayDuration`, ); - logger.setConfig({ traceInternals: false }); - spyLogger.mockRestore(); + debug.setConfig({ traceInternals: false }); + spyDebugLogger.mockRestore(); }); /** diff --git a/packages/replay-internal/test/unit/util/logger.test.ts b/packages/replay-internal/test/unit/util/logger.test.ts index 7d44ee34381b..a52512dabed8 100644 --- a/packages/replay-internal/test/unit/util/logger.test.ts +++ b/packages/replay-internal/test/unit/util/logger.test.ts @@ -1,14 +1,13 @@ import * as SentryCore from '@sentry/core'; -import { logger as coreLogger } from '@sentry/core'; +import { debug as coreDebugLogger } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { logger } from '../../../src/util/logger'; +import { debug } from '../../../src/util/logger'; const mockCaptureException = vi.spyOn(SentryCore, 'captureException'); const mockAddBreadcrumb = vi.spyOn(SentryCore, 'addBreadcrumb'); -const mockLogError = vi.spyOn(coreLogger, 'error'); -vi.spyOn(coreLogger, 'info'); -vi.spyOn(coreLogger, 'log'); -vi.spyOn(coreLogger, 'warn'); +const mockLogError = vi.spyOn(coreDebugLogger, 'error'); +vi.spyOn(coreDebugLogger, 'log'); +vi.spyOn(coreDebugLogger, 'warn'); describe('logger', () => { beforeEach(() => { @@ -22,20 +21,19 @@ describe('logger', () => { [true, true], ])('with options: captureExceptions:%s, traceInternals:%s', (captureExceptions, traceInternals) => { beforeEach(() => { - logger.setConfig({ + debug.setConfig({ captureExceptions, traceInternals, }); }); it.each([ - ['info', 'info', 'info message'], ['log', 'log', 'log message'], ['warn', 'warning', 'warn message'], ['error', 'error', 'error message'], - ])('%s', (fn, level, message) => { - logger[fn](message); - expect(coreLogger[fn]).toHaveBeenCalledWith('[Replay] ', message); + ] as const)('%s', (fn, level, message) => { + debug[fn](message); + expect(coreDebugLogger[fn]).toHaveBeenCalledWith('[Replay] ', message); if (traceInternals) { expect(mockAddBreadcrumb).toHaveBeenLastCalledWith( @@ -52,7 +50,7 @@ describe('logger', () => { it('logs exceptions with a message', () => { const err = new Error('An error'); - logger.exception(err, 'a message'); + debug.exception(err, 'a message'); if (captureExceptions) { expect(mockCaptureException).toHaveBeenCalledWith(err); } @@ -75,7 +73,7 @@ describe('logger', () => { it('logs exceptions without a message', () => { const err = new Error('An error'); - logger.exception(err); + debug.exception(err); if (captureExceptions) { expect(mockCaptureException).toHaveBeenCalledWith(err); expect(mockAddBreadcrumb).not.toHaveBeenCalled(); From 47f85b95886e543cd1dff58aa9456486a0a1a8a5 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 15 Jul 2025 10:27:55 +0200 Subject: [PATCH 20/37] feat(node): Drop 401-404 and 3xx status code spans by default (#16972) In addition to 404, we now also drop 401, 402, 403 and 3xx (e.g. 301 MOVED_PERMANTENTLY) spans by default. These are usually not helpful. Noticed this in some react-router E2E test where 301 spans were messing with the test, but these should not even be captured really. --------- Co-authored-by: Andrei Borza --- .../with-redirect/page.tsx | 11 ---- .../tests/generation-functions.test.ts | 17 ----- .../app/route-handlers/[param]/route.ts | 2 +- .../tests/route-handlers.test.ts | 2 +- .../test-applications/node-express/src/app.ts | 4 +- .../node-express/tests/trpc.test.ts | 6 +- .../tracing/scenario-filterStatusCode.mjs | 12 ++++ .../suites/express-v5/tracing/scenario.mjs | 12 ++++ .../suites/express-v5/tracing/test.ts | 62 ++++++++++-------- .../tracing/scenario-filterStatusCode.mjs | 12 ++++ .../suites/express/tracing/scenario.mjs | 12 ++++ .../suites/express/tracing/test.ts | 64 +++++++++++-------- .../node-core/src/integrations/http/index.ts | 7 +- packages/node/src/integrations/http/index.ts | 7 +- .../server/instrumentation/action.test.ts | 19 +----- .../server/instrumentation/loader.test.ts | 19 +----- 16 files changed, 142 insertions(+), 126 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/with-redirect/page.tsx diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/with-redirect/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/with-redirect/page.tsx deleted file mode 100644 index f1f37d7a32c6..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/app/generation-functions/with-redirect/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { redirect } from 'next/navigation'; - -export const dynamic = 'force-dynamic'; - -export default function PageWithRedirect() { - return

Hello World!

; -} - -export async function generateMetadata() { - redirect('/'); -} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts index 084824824225..384e1e35055d 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/generation-functions.test.ts @@ -108,20 +108,3 @@ test('Should send a transaction and an error event for a faulty generateViewport expect(errorEvent.transaction).toBe('Page.generateViewport (/generation-functions)'); }); - -test('Should send a transaction event with correct status for a generateMetadata() function invocation with redirect()', async ({ - page, -}) => { - const testTitle = 'redirect-foobar'; - - const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { - return ( - transactionEvent.contexts?.trace?.data?.['http.target'] === - `/generation-functions/with-redirect?metadataTitle=${testTitle}` - ); - }); - - await page.goto(`/generation-functions/with-redirect?metadataTitle=${testTitle}`); - - expect((await transactionPromise).contexts?.trace?.status).toBe('ok'); -}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/route.ts index df5361852508..13535427a3b8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/route.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/route-handlers/[param]/route.ts @@ -5,5 +5,5 @@ export async function GET() { } export async function POST() { - return NextResponse.json({ name: 'John Doe' }, { status: 403 }); + return NextResponse.json({ name: 'John Doe' }, { status: 400 }); } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts index 02f2b6dc4f24..946f9e20911a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts @@ -28,7 +28,7 @@ test('Should create a transaction for route handlers and correctly set span stat const routehandlerTransaction = await routehandlerTransactionPromise; - expect(routehandlerTransaction.contexts?.trace?.status).toBe('permission_denied'); + expect(routehandlerTransaction.contexts?.trace?.status).toBe('invalid_argument'); expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server'); }); diff --git a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts index 35b21a97b9aa..9f7b0055b66d 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/src/app.ts @@ -143,8 +143,8 @@ export const appRouter = t.router({ .mutation(() => { throw new Error('I crashed in a trpc handler'); }), - unauthorized: procedure.mutation(() => { - throw new TRPCError({ code: 'UNAUTHORIZED', cause: new Error('Unauthorized') }); + badRequest: procedure.mutation(() => { + throw new TRPCError({ code: 'BAD_REQUEST', cause: new Error('Bad Request') }); }), }); diff --git a/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts b/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts index 3cb458f81175..e27789b7e4c5 100644 --- a/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-express/tests/trpc.test.ts @@ -109,12 +109,12 @@ test('Should record transaction and error for a trpc handler that returns a stat const transactionEventPromise = waitForTransaction('node-express', transactionEvent => { return ( transactionEvent.transaction === 'POST /trpc' && - !!transactionEvent.spans?.find(span => span.description === 'trpc/unauthorized') + !!transactionEvent.spans?.find(span => span.description === 'trpc/badRequest') ); }); const errorEventPromise = waitForError('node-express', errorEvent => { - return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Unauthorized')); + return !!errorEvent?.exception?.values?.some(exception => exception.value?.includes('Bad Request')); }); const trpcClient = createTRPCProxyClient({ @@ -125,7 +125,7 @@ test('Should record transaction and error for a trpc handler that returns a stat ], }); - await expect(trpcClient.unauthorized.mutate()).rejects.toBeDefined(); + await expect(trpcClient.badRequest.mutate()).rejects.toBeDefined(); await expect(transactionEventPromise).resolves.toBeDefined(); await expect(errorEventPromise).resolves.toBeDefined(); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs index f2e20014f48f..c53a72951970 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario-filterStatusCode.mjs @@ -8,6 +8,18 @@ app.get('/', (_req, res) => { res.send({ response: 'response 0' }); }); +app.get('/401', (_req, res) => { + res.status(401).send({ response: 'response 401' }); +}); + +app.get('/402', (_req, res) => { + res.status(402).send({ response: 'response 402' }); +}); + +app.get('/403', (_req, res) => { + res.status(403).send({ response: 'response 403' }); +}); + app.get('/499', (_req, res) => { res.status(499).send({ response: 'response 499' }); }); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs index fe3e190a4bdd..b0aebcbe8a79 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/scenario.mjs @@ -15,6 +15,18 @@ app.get('/', (_req, res) => { res.send({ response: 'response 0' }); }); +app.get('/401', (_req, res) => { + res.status(401).send({ response: 'response 401' }); +}); + +app.get('/402', (_req, res) => { + res.status(402).send({ response: 'response 402' }); +}); + +app.get('/403', (_req, res) => { + res.status(403).send({ response: 'response 403' }); +}); + app.get('/test/express', (_req, res) => { res.send({ response: 'response 1' }); }); diff --git a/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts b/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts index 4618f801a087..5ed3878572b8 100644 --- a/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express-v5/tracing/test.ts @@ -89,16 +89,16 @@ describe('express v5 tracing', () => { await runner.completed(); }); - test('ignores 404 routes by default', async () => { + test.each(['/401', '/402', '/403', '/does-not-exist'])('ignores %s route by default', async (url: string) => { const runner = createRunner() .expect({ - // No transaction is sent for the 404 route + // No transaction is sent for the 401, 402, 403, 404 routes transaction: { transaction: 'GET /', }, }) .start(); - runner.makeRequest('get', '/does-not-exist', { expectError: true }); + runner.makeRequest('get', url, { expectError: true }); runner.makeRequest('get', '/'); await runner.completed(); }); @@ -284,33 +284,41 @@ describe('express v5 tracing', () => { 'scenario-filterStatusCode.mjs', 'instrument-filterStatusCode.mjs', (createRunner, test) => { - // We opt-out of the default 404 filtering in order to test how 404 spans are handled - test('handles 404 route correctly', async () => { - const runner = createRunner() - .expect({ - transaction: { - transaction: 'GET /does-not-exist', - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'http.response.status_code': 404, - url: expect.stringMatching(/\/does-not-exist$/), - 'http.method': 'GET', - 'http.url': expect.stringMatching(/\/does-not-exist$/), - 'http.target': '/does-not-exist', + // We opt-out of the default [401, 404] fitering in order to test how these spans are handled + test.each([ + { status_code: 401, url: '/401', status: 'unauthenticated' }, + { status_code: 402, url: '/402', status: 'invalid_argument' }, + { status_code: 403, url: '/403', status: 'permission_denied' }, + { status_code: 404, url: '/does-not-exist', status: 'not_found' }, + ])( + 'handles %s route correctly', + async ({ status_code, url, status }: { status_code: number; url: string; status: string }) => { + const runner = createRunner() + .expect({ + transaction: { + transaction: `GET ${url}`, + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'http.response.status_code': status_code, + url: expect.stringMatching(url), + 'http.method': 'GET', + 'http.url': expect.stringMatching(url), + 'http.target': url, + }, + op: 'http.server', + status, }, - op: 'http.server', - status: 'not_found', }, }, - }, - }) - .start(); - runner.makeRequest('get', '/does-not-exist', { expectError: true }); - await runner.completed(); - }); + }) + .start(); + runner.makeRequest('get', url, { expectError: true }); + await runner.completed(); + }, + ); test('filters defined status codes', async () => { const runner = createRunner() diff --git a/dev-packages/node-integration-tests/suites/express/tracing/scenario-filterStatusCode.mjs b/dev-packages/node-integration-tests/suites/express/tracing/scenario-filterStatusCode.mjs index f2e20014f48f..c53a72951970 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/scenario-filterStatusCode.mjs +++ b/dev-packages/node-integration-tests/suites/express/tracing/scenario-filterStatusCode.mjs @@ -8,6 +8,18 @@ app.get('/', (_req, res) => { res.send({ response: 'response 0' }); }); +app.get('/401', (_req, res) => { + res.status(401).send({ response: 'response 401' }); +}); + +app.get('/402', (_req, res) => { + res.status(402).send({ response: 'response 402' }); +}); + +app.get('/403', (_req, res) => { + res.status(403).send({ response: 'response 403' }); +}); + app.get('/499', (_req, res) => { res.status(499).send({ response: 'response 499' }); }); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs b/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs index 8b48ee0dbc44..4e32a052908d 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/express/tracing/scenario.mjs @@ -15,6 +15,18 @@ app.get('/', (_req, res) => { res.send({ response: 'response 0' }); }); +app.get('/401', (_req, res) => { + res.status(401).send({ response: 'response 401' }); +}); + +app.get('/402', (_req, res) => { + res.status(402).send({ response: 'response 402' }); +}); + +app.get('/403', (_req, res) => { + res.status(403).send({ response: 'response 403' }); +}); + app.get('/test/express', (_req, res) => { res.send({ response: 'response 1' }); }); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/test.ts b/dev-packages/node-integration-tests/suites/express/tracing/test.ts index 63706c0e5cb2..f5a772dbf096 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/express/tracing/test.ts @@ -90,16 +90,16 @@ describe('express tracing', () => { await runner.completed(); }); - test('ignores 404 routes by default', async () => { + test.each(['/401', '/402', '/403', '/does-not-exist'])('ignores %s route by default', async (url: string) => { const runner = createRunner() .expect({ - // No transaction is sent for the 404 route + // No transaction is sent for the 401, 402, 403, 404 routes transaction: { transaction: 'GET /', }, }) .start(); - runner.makeRequest('get', '/does-not-exist', { expectError: true }); + runner.makeRequest('get', url, { expectError: true }); runner.makeRequest('get', '/'); await runner.completed(); }); @@ -315,34 +315,42 @@ describe('express tracing', () => { 'scenario-filterStatusCode.mjs', 'instrument-filterStatusCode.mjs', (createRunner, test) => { - // We opt-out of the default 404 filtering in order to test how 404 spans are handled - test('handles 404 route correctly', async () => { - const runner = createRunner() - .expect({ - transaction: { - // FIXME: This is incorrect, sadly :( - transaction: 'GET /', - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { - 'http.response.status_code': 404, - url: expect.stringMatching(/\/does-not-exist$/), - 'http.method': 'GET', - 'http.url': expect.stringMatching(/\/does-not-exist$/), - 'http.target': '/does-not-exist', + // We opt-out of the default [401, 404] filtering in order to test how these spans are handled + test.each([ + { status_code: 401, url: '/401', status: 'unauthenticated' }, + { status_code: 402, url: '/402', status: 'invalid_argument' }, + { status_code: 403, url: '/403', status: 'permission_denied' }, + { status_code: 404, url: '/does-not-exist', status: 'not_found' }, + ])( + 'handles %s route correctly', + async ({ status_code, url, status }: { status_code: number; url: string; status: string }) => { + const runner = createRunner() + .expect({ + transaction: { + // TODO(v10): This is incorrect on OpenTelemetry v1 but can be fixed in v2 + transaction: `GET ${status_code === 404 ? '/' : url}`, + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + 'http.response.status_code': status_code, + url: expect.stringMatching(url), + 'http.method': 'GET', + 'http.url': expect.stringMatching(url), + 'http.target': url, + }, + op: 'http.server', + status, }, - op: 'http.server', - status: 'not_found', }, }, - }, - }) - .start(); - runner.makeRequest('get', '/does-not-exist', { expectError: true }); - await runner.completed(); - }); + }) + .start(); + runner.makeRequest('get', url, { expectError: true }); + await runner.completed(); + }, + ); test('filters defined status codes', async () => { const runner = createRunner() diff --git a/packages/node-core/src/integrations/http/index.ts b/packages/node-core/src/integrations/http/index.ts index 8bd69c22a8e7..7e574712990d 100644 --- a/packages/node-core/src/integrations/http/index.ts +++ b/packages/node-core/src/integrations/http/index.ts @@ -57,7 +57,7 @@ interface HttpOptions { * By default, spans with 404 status code are ignored. * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. * - * @default `[404]` + * @default `[[401, 404], [300, 399]]` */ dropSpansForIncomingRequestStatusCodes?: (number | [number, number])[]; @@ -105,7 +105,10 @@ const instrumentSentryHttp = generateInstrumentOnce { - const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [404]; + const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [ + [401, 404], + [300, 399], + ]; return { name: INTEGRATION_NAME, diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts index e46e830f9d16..e56842be85cb 100644 --- a/packages/node/src/integrations/http/index.ts +++ b/packages/node/src/integrations/http/index.ts @@ -79,7 +79,7 @@ interface HttpOptions { * By default, spans with 404 status code are ignored. * Expects an array of status codes or a range of status codes, e.g. [[300,399], 404] would ignore 3xx and 404 status codes. * - * @default `[404]` + * @default `[[401, 404], [300, 399]]` */ dropSpansForIncomingRequestStatusCodes?: (number | [number, number])[]; @@ -184,7 +184,10 @@ export function _shouldInstrumentSpans(options: HttpOptions, clientOptions: Part * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span. */ export const httpIntegration = defineIntegration((options: HttpOptions = {}) => { - const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [404]; + const dropSpansForIncomingRequestStatusCodes = options.dropSpansForIncomingRequestStatusCodes ?? [ + [401, 404], + [300, 399], + ]; return { name: INTEGRATION_NAME, diff --git a/packages/remix/test/integration/test/server/instrumentation/action.test.ts b/packages/remix/test/integration/test/server/instrumentation/action.test.ts index 3681e43807b8..bca38429ee29 100644 --- a/packages/remix/test/integration/test/server/instrumentation/action.test.ts +++ b/packages/remix/test/integration/test/server/instrumentation/action.test.ts @@ -152,28 +152,15 @@ describe('Remix API Actions', () => { const envelopes = await env.getMultipleEnvelopeRequest({ url, - count: 3, + count: 2, method: 'post', envelopeType: ['transaction', 'event'], }); - const [transaction_1, transaction_2] = envelopes.filter(envelope => envelope[1].type === 'transaction'); + const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction'); const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); - assertSentryTransaction(transaction_1[2], { - contexts: { - trace: { - op: 'http.server', - status: 'ok', - data: { - 'http.response.status_code': 302, - }, - }, - }, - transaction: `POST action-json-response/:id`, - }); - - assertSentryTransaction(transaction_2[2], { + assertSentryTransaction(transaction[2], { contexts: { trace: { op: 'http.server', diff --git a/packages/remix/test/integration/test/server/instrumentation/loader.test.ts b/packages/remix/test/integration/test/server/instrumentation/loader.test.ts index eef2a9683813..c90c23d135c8 100644 --- a/packages/remix/test/integration/test/server/instrumentation/loader.test.ts +++ b/packages/remix/test/integration/test/server/instrumentation/loader.test.ts @@ -123,27 +123,14 @@ describe('Remix API Loaders', () => { const envelopes = await env.getMultipleEnvelopeRequest({ url, - count: 3, + count: 2, envelopeType: ['transaction', 'event'], }); - const [transaction_1, transaction_2] = envelopes.filter(envelope => envelope[1].type === 'transaction'); + const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction'); const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); - assertSentryTransaction(transaction_1[2], { - contexts: { - trace: { - op: 'http.server', - status: 'ok', - data: { - 'http.response.status_code': 302, - }, - }, - }, - transaction: `GET loader-json-response/:id`, - }); - - assertSentryTransaction(transaction_2[2], { + assertSentryTransaction(transaction[2], { contexts: { trace: { op: 'http.server', From 16d95cdca3d91f83d46017ec794f8612b241c21c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 15 Jul 2025 10:34:52 +0200 Subject: [PATCH 21/37] feat(nextjs): Inject manifest into client for webpack builds (#16857) --- packages/nextjs/src/config/types.ts | 18 ++++++++++++++++++ packages/nextjs/src/config/webpack.ts | 6 +++++- packages/nextjs/src/config/withSentryConfig.ts | 14 +++++++++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 3ac3698789b0..c635aa88c21a 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -467,6 +467,24 @@ export type SentryBuildOptions = { */ suppressOnRouterTransitionStartWarning?: boolean; + /** + * Disables automatic injection of the route manifest into the client bundle. + * + * The route manifest is a build-time generated mapping of your Next.js App Router + * routes that enables Sentry to group transactions by parameterized route names + * (e.g., `/users/:id` instead of `/users/123`, `/users/456`, etc.). + * + * **Disable this option if:** + * - You want to minimize client bundle size + * - You're experiencing build issues related to route scanning + * - You're using custom routing that the scanner can't detect + * - You prefer raw URLs in transaction names + * - You're only using Pages Router (this feature is only supported in the App Router) + * + * @default false + */ + disableManifestInjection?: boolean; + /** * Contains a set of experimental flags that might change in future releases. These flags enable * features that are still in development and may be modified, renamed, or removed without notice. diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index e0faf2bd285d..77db5120cda3 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { sync as resolveSync } from 'resolve'; import type { VercelCronsConfig } from '../common/types'; +import type { RouteManifest } from './manifest/types'; // Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our // circular dependency check thinks this file is importing from itself. See https://github.com/pahen/madge/issues/306. import type { @@ -43,6 +44,7 @@ export function constructWebpackConfigFunction( userNextConfig: NextConfigObject = {}, userSentryOptions: SentryBuildOptions = {}, releaseName: string | undefined, + routeManifest: RouteManifest | undefined, ): WebpackConfigFunction { // Will be called by nextjs and passed its default webpack configuration and context data about the build (whether // we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that @@ -88,7 +90,7 @@ export function constructWebpackConfigFunction( const newConfig = setUpModuleRules(rawNewConfig); // Add a loader which will inject code that sets global values - addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext, releaseName); + addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext, releaseName, routeManifest); addOtelWarningIgnoreRule(newConfig); @@ -686,6 +688,7 @@ function addValueInjectionLoader( userSentryOptions: SentryBuildOptions, buildContext: BuildContext, releaseName: string | undefined, + routeManifest: RouteManifest | undefined, ): void { const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; @@ -727,6 +730,7 @@ function addValueInjectionLoader( _sentryExperimentalThirdPartyOriginStackFrames: userSentryOptions._experimental?.thirdPartyOriginStackFrames ? 'true' : undefined, + _sentryRouteManifest: JSON.stringify(routeManifest), }; if (buildContext.isServer) { diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 84bd0a01cb5e..4e231a3227d6 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -5,6 +5,8 @@ import { getSentryRelease } from '@sentry/node'; import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { createRouteManifest } from './manifest/createRouteManifest'; +import type { RouteManifest } from './manifest/types'; import type { ExportedNextConfig as NextConfig, NextConfigFunction, @@ -141,6 +143,11 @@ function getFinalConfigObject( } } + let routeManifest: RouteManifest | undefined; + if (!userSentryOptions.disableManifestInjection) { + routeManifest = createRouteManifest(); + } + setUpBuildTimeVariables(incomingUserNextConfigObject, userSentryOptions, releaseName); const nextJsVersion = getNextjsVersion(); @@ -300,7 +307,12 @@ function getFinalConfigObject( ], }, }), - webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName), + webpack: constructWebpackConfigFunction( + incomingUserNextConfigObject, + userSentryOptions, + releaseName, + routeManifest, + ), }; } From a3b45012461e50681ea853220e58401a476a0551 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 15 Jul 2025 10:46:21 +0200 Subject: [PATCH 22/37] ref(nextjs): Allow `rollup@^4.35.0` dependency range (#17010) Allows rollup minor and patch versions above 4.35.0 so that rollup dependencies specifying a similar version range can be better deduped. --- packages/nextjs/package.json | 2 +- yarn.lock | 128 ----------------------------------- 2 files changed, 1 insertion(+), 129 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index c16153611af3..1861da31a475 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -88,7 +88,7 @@ "@sentry/webpack-plugin": "^3.5.0", "chalk": "3.0.0", "resolve": "1.22.8", - "rollup": "4.35.0", + "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 91f97624598b..333cac9f5d06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6747,131 +6747,66 @@ estree-walker "^2.0.2" picomatch "^4.0.2" -"@rollup/rollup-android-arm-eabi@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.35.0.tgz#e1d7700735f7e8de561ef7d1fa0362082a180c43" - integrity sha512-uYQ2WfPaqz5QtVgMxfN6NpLD+no0MYHDBywl7itPYd3K5TjjSghNKmX8ic9S8NU8w81NVhJv/XojcHptRly7qQ== - "@rollup/rollup-android-arm-eabi@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz#f768e3b2b0e6b55c595d7a053652c06413713983" integrity sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w== -"@rollup/rollup-android-arm64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.35.0.tgz#fa6cdfb1fc9e2c8e227a7f35d524d8f7f90cf4db" - integrity sha512-FtKddj9XZudurLhdJnBl9fl6BwCJ3ky8riCXjEw3/UIbjmIY58ppWwPEvU3fNu+W7FUsAsB1CdH+7EQE6CXAPA== - "@rollup/rollup-android-arm64@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz#40379fd5501cfdfd7d8f86dfa1d3ce8d3a609493" integrity sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ== -"@rollup/rollup-darwin-arm64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.35.0.tgz#6da5a1ddc4f11d4a7ae85ab443824cb6bf614e30" - integrity sha512-Uk+GjOJR6CY844/q6r5DR/6lkPFOw0hjfOIzVx22THJXMxktXG6CbejseJFznU8vHcEBLpiXKY3/6xc+cBm65Q== - "@rollup/rollup-darwin-arm64@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz#972c227bc89fe8a38a3f0c493e1966900e4e1ff7" integrity sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg== -"@rollup/rollup-darwin-x64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.35.0.tgz#25b74ce2d8d3f9ea8e119b01384d44a1c0a0d3ae" - integrity sha512-3IrHjfAS6Vkp+5bISNQnPogRAW5GAV1n+bNCrDwXmfMHbPl5EhTmWtfmwlJxFRUCBZ+tZ/OxDyU08aF6NI/N5Q== - "@rollup/rollup-darwin-x64@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz#96c919dcb87a5aa7dec5f7f77d90de881e578fdd" integrity sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw== -"@rollup/rollup-freebsd-arm64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.35.0.tgz#be3d39e3441df5d6e187c83d158c60656c82e203" - integrity sha512-sxjoD/6F9cDLSELuLNnY0fOrM9WA0KrM0vWm57XhrIMf5FGiN8D0l7fn+bpUeBSU7dCgPV2oX4zHAsAXyHFGcQ== - "@rollup/rollup-freebsd-arm64@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz#d199d8eaef830179c0c95b7a6e5455e893d1102c" integrity sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA== -"@rollup/rollup-freebsd-x64@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.35.0.tgz#cd932d3ec679711efd65ca25821fb318e25b7ce4" - integrity sha512-2mpHCeRuD1u/2kruUiHSsnjWtHjqVbzhBkNVQ1aVD63CcexKVcQGwJ2g5VphOd84GvxfSvnnlEyBtQCE5hxVVw== - "@rollup/rollup-freebsd-x64@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz#cab01f9e06ca756c1fabe87d64825ae016af4713" integrity sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw== -"@rollup/rollup-linux-arm-gnueabihf@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.35.0.tgz#d300b74c6f805474225632f185daaeae760ac2bb" - integrity sha512-mrA0v3QMy6ZSvEuLs0dMxcO2LnaCONs1Z73GUDBHWbY8tFFocM6yl7YyMu7rz4zS81NDSqhrUuolyZXGi8TEqg== - "@rollup/rollup-linux-arm-gnueabihf@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz#f6f1c42036dba0e58dc2315305429beff0d02c78" integrity sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ== -"@rollup/rollup-linux-arm-musleabihf@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.35.0.tgz#2caac622380f314c41934ed1e68ceaf6cc380cc3" - integrity sha512-DnYhhzcvTAKNexIql8pFajr0PiDGrIsBYPRvCKlA5ixSS3uwo/CWNZxB09jhIapEIg945KOzcYEAGGSmTSpk7A== - "@rollup/rollup-linux-arm-musleabihf@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz#1157e98e740facf858993fb51431dce3a4a96239" integrity sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw== -"@rollup/rollup-linux-arm64-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.35.0.tgz#1ec841650b038cc15c194c26326483fd7ebff3e3" - integrity sha512-uagpnH2M2g2b5iLsCTZ35CL1FgyuzzJQ8L9VtlJ+FckBXroTwNOaD0z0/UF+k5K3aNQjbm8LIVpxykUOQt1m/A== - "@rollup/rollup-linux-arm64-gnu@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz#b39db73f8a4c22e7db31a4f3fd45170105f33265" integrity sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ== -"@rollup/rollup-linux-arm64-musl@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.35.0.tgz#2fc70a446d986e27f6101ea74e81746987f69150" - integrity sha512-XQxVOCd6VJeHQA/7YcqyV0/88N6ysSVzRjJ9I9UA/xXpEsjvAgDTgH3wQYz5bmr7SPtVK2TsP2fQ2N9L4ukoUg== - "@rollup/rollup-linux-arm64-musl@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz#4043398049fe4449c1485312d1ae9ad8af4056dd" integrity sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g== -"@rollup/rollup-linux-loongarch64-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.35.0.tgz#561bd045cd9ce9e08c95f42e7a8688af8c93d764" - integrity sha512-5pMT5PzfgwcXEwOaSrqVsz/LvjDZt+vQ8RT/70yhPU06PTuq8WaHhfT1LW+cdD7mW6i/J5/XIkX/1tCAkh1W6g== - "@rollup/rollup-linux-loongarch64-gnu@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz#855a80e7e86490da15a85dcce247dbc25265bc08" integrity sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew== -"@rollup/rollup-linux-powerpc64le-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.35.0.tgz#45d849a0b33813f33fe5eba9f99e0ff15ab5caad" - integrity sha512-c+zkcvbhbXF98f4CtEIP1EBA/lCic5xB0lToneZYvMeKu5Kamq3O8gqrxiYYLzlZH6E3Aq+TSW86E4ay8iD8EA== - "@rollup/rollup-linux-powerpc64le-gnu@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz#8cf843cb7ab1d42e1dda680937cf0a2db6d59047" integrity sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA== -"@rollup/rollup-linux-riscv64-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.35.0.tgz#78dde3e6fcf5b5733a97d0a67482d768aa1e83a5" - integrity sha512-s91fuAHdOwH/Tad2tzTtPX7UZyytHIRR6V4+2IGlV0Cej5rkG0R61SX4l4y9sh0JBibMiploZx3oHKPnQBKe4g== - "@rollup/rollup-linux-riscv64-gnu@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz#287c085472976c8711f16700326f736a527f2f38" @@ -6882,61 +6817,31 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz#095ad5e53a54ba475979f1b3226b92440c95c892" integrity sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg== -"@rollup/rollup-linux-s390x-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.35.0.tgz#2e34835020f9e03dfb411473a5c2a0e8a9c5037b" - integrity sha512-hQRkPQPLYJZYGP+Hj4fR9dDBMIM7zrzJDWFEMPdTnTy95Ljnv0/4w/ixFw3pTBMEuuEuoqtBINYND4M7ujcuQw== - "@rollup/rollup-linux-s390x-gnu@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz#a3dec8281d8f2aef1703e48ebc65d29fe847933c" integrity sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw== -"@rollup/rollup-linux-x64-gnu@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.35.0.tgz#4f9774beddc6f4274df57ac99862eb23040de461" - integrity sha512-Pim1T8rXOri+0HmV4CdKSGrqcBWX0d1HoPnQ0uw0bdp1aP5SdQVNBy8LjYncvnLgu3fnnCt17xjWGd4cqh8/hA== - "@rollup/rollup-linux-x64-gnu@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz#4b211e6fd57edd6a134740f4f8e8ea61972ff2c5" integrity sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw== -"@rollup/rollup-linux-x64-musl@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.35.0.tgz#dfcff2c1aed518b3d23ccffb49afb349d74fb608" - integrity sha512-QysqXzYiDvQWfUiTm8XmJNO2zm9yC9P/2Gkrwg2dH9cxotQzunBHYr6jk4SujCTqnfGxduOmQcI7c2ryuW8XVg== - "@rollup/rollup-linux-x64-musl@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz#3ecbf8e21b4157e57bb15dc6837b6db851f9a336" integrity sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g== -"@rollup/rollup-win32-arm64-msvc@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.35.0.tgz#b0b37e2d77041e3aa772f519291309abf4c03a84" - integrity sha512-OUOlGqPkVJCdJETKOCEf1mw848ZyJ5w50/rZ/3IBQVdLfR5jk/6Sr5m3iO2tdPgwo0x7VcncYuOvMhBWZq8ayg== - "@rollup/rollup-win32-arm64-msvc@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz#d4aae38465b2ad200557b53c8c817266a3ddbfd0" integrity sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg== -"@rollup/rollup-win32-ia32-msvc@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.35.0.tgz#5b5a40e44a743ddc0e06b8e1b3982f856dc9ce0a" - integrity sha512-2/lsgejMrtwQe44glq7AFFHLfJBPafpsTa6JvP2NGef/ifOa4KBoglVf7AKN7EV9o32evBPRqfg96fEHzWo5kw== - "@rollup/rollup-win32-ia32-msvc@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz#0258e8ca052abd48b23fd6113360fa0cd1ec3e23" integrity sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A== -"@rollup/rollup-win32-x64-msvc@4.35.0": - version "4.35.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.35.0.tgz#05f25dbc9981bee1ae6e713daab10397044a46ca" - integrity sha512-PIQeY5XDkrOysbQblSW7v3l1MDZzkTEzAfTPkj5VAu3FW8fS4ynyLg2sINp0fp3SjZ8xkRYpLqoKcYqAkhU1dw== - "@rollup/rollup-win32-x64-msvc@4.44.1": version "4.44.1" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz#1c982f6a5044ffc2a35cd754a0951bdcb44d5ba0" @@ -8439,11 +8344,6 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/estree@1.0.6": - version "1.0.6" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" - integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== - "@types/estree@^0.0.51": version "0.0.51" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" @@ -27096,34 +26996,6 @@ rollup-pluginutils@^2.8.2: dependencies: estree-walker "^0.6.1" -rollup@4.35.0: - version "4.35.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.35.0.tgz#76c95dba17a579df4c00c3955aed32aa5d4dc66d" - integrity sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg== - dependencies: - "@types/estree" "1.0.6" - optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.35.0" - "@rollup/rollup-android-arm64" "4.35.0" - "@rollup/rollup-darwin-arm64" "4.35.0" - "@rollup/rollup-darwin-x64" "4.35.0" - "@rollup/rollup-freebsd-arm64" "4.35.0" - "@rollup/rollup-freebsd-x64" "4.35.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.35.0" - "@rollup/rollup-linux-arm-musleabihf" "4.35.0" - "@rollup/rollup-linux-arm64-gnu" "4.35.0" - "@rollup/rollup-linux-arm64-musl" "4.35.0" - "@rollup/rollup-linux-loongarch64-gnu" "4.35.0" - "@rollup/rollup-linux-powerpc64le-gnu" "4.35.0" - "@rollup/rollup-linux-riscv64-gnu" "4.35.0" - "@rollup/rollup-linux-s390x-gnu" "4.35.0" - "@rollup/rollup-linux-x64-gnu" "4.35.0" - "@rollup/rollup-linux-x64-musl" "4.35.0" - "@rollup/rollup-win32-arm64-msvc" "4.35.0" - "@rollup/rollup-win32-ia32-msvc" "4.35.0" - "@rollup/rollup-win32-x64-msvc" "4.35.0" - fsevents "~2.3.2" - rollup@^2.70.0, rollup@^2.79.1: version "2.79.2" resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.2.tgz#f150e4a5db4b121a21a747d762f701e5e9f49090" From 50120cb64d9d084c159fb8ee0ead6f0bab083e32 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 15 Jul 2025 10:48:39 +0200 Subject: [PATCH 23/37] ref(core): Avoid keeping span-FF map on GLOBAL_OBJ (#16924) Instead, we can just check the required things directly on the span. This is likely a bit more expensive than checking a map, but I would not assume this operation should be happening too excessively, so IMHO this should be fine. This is also part of https://github.com/getsentry/sentry-javascript/issues/16846 @aliu39 do you see any problems with this change? --- packages/core/src/utils/featureFlags.ts | 35 +++++++++++++------------ packages/core/src/utils/worldwide.ts | 5 ---- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/core/src/utils/featureFlags.ts b/packages/core/src/utils/featureFlags.ts index c79cfa9c9b3b..c0cef4bde2e2 100644 --- a/packages/core/src/utils/featureFlags.ts +++ b/packages/core/src/utils/featureFlags.ts @@ -1,10 +1,8 @@ import { getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; import { type Event } from '../types-hoist/event'; -import { type Span } from '../types-hoist/span'; import { debug } from '../utils/logger'; -import { GLOBAL_OBJ } from '../utils/worldwide'; -import { getActiveSpan } from './spanUtils'; +import { getActiveSpan, spanToJSON } from './spanUtils'; /** * Ordered LRU cache for storing feature flags in the scope context. The name @@ -24,9 +22,6 @@ export const _INTERNAL_FLAG_BUFFER_SIZE = 100; */ export const _INTERNAL_MAX_FLAGS_PER_SPAN = 10; -// Global map of spans to feature flag buffers. Populated by feature flag integrations. -GLOBAL_OBJ._spanToFlagBufferMap = new WeakMap>(); - const SPAN_FLAG_ATTRIBUTE_PREFIX = 'flag.evaluation.'; /** @@ -133,20 +128,26 @@ export function _INTERNAL_addFeatureFlagToActiveSpan( value: unknown, maxFlagsPerSpan: number = _INTERNAL_MAX_FLAGS_PER_SPAN, ): void { - const spanFlagMap = GLOBAL_OBJ._spanToFlagBufferMap; - if (!spanFlagMap || typeof value !== 'boolean') { + if (typeof value !== 'boolean') { return; } const span = getActiveSpan(); - if (span) { - const flags = spanFlagMap.get(span) || new Set(); - if (flags.has(name)) { - span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); - } else if (flags.size < maxFlagsPerSpan) { - flags.add(name); - span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); - } - spanFlagMap.set(span, flags); + if (!span) { + return; + } + + const attributes = spanToJSON(span).data; + + // If the flag already exists, always update it + if (`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}` in attributes) { + span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); + return; + } + + // Else, add the flag to the span if we have not reached the max number of flags + const numOfAddedFlags = Object.keys(attributes).filter(key => key.startsWith(SPAN_FLAG_ATTRIBUTE_PREFIX)).length; + if (numOfAddedFlags < maxFlagsPerSpan) { + span.setAttribute(`${SPAN_FLAG_ATTRIBUTE_PREFIX}${name}`, value); } } diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index c6442d2308a9..e2f1ad5fc2b2 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -13,7 +13,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Carrier } from '../carrier'; -import type { Span } from '../types-hoist/span'; import type { SdkSource } from './env'; /** Internal global with common properties and Sentry extensions */ @@ -49,10 +48,6 @@ export type InternalGlobal = { */ _sentryModuleMetadata?: Record; _sentryEsmLoaderHookRegistered?: boolean; - /** - * A map of spans to evaluated feature flags. Populated by feature flag integrations. - */ - _spanToFlagBufferMap?: WeakMap>; } & Carrier; /** Get's the global object for the current JavaScript runtime */ From e67ba064e4d5d7762088c98c911608b8b25851ca Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 15 Jul 2025 10:50:45 +0200 Subject: [PATCH 24/37] feat(nextjs): Inject manifest into client for turbopack builds (#16902) --- .../turbopack/constructTurbopackConfig.ts | 78 +++ packages/nextjs/src/config/turbopack/index.ts | 1 + packages/nextjs/src/config/types.ts | 36 ++ .../nextjs/src/config/withSentryConfig.ts | 23 +- .../constructTurbopackConfig.test.ts | 443 ++++++++++++++++++ 5 files changed, 574 insertions(+), 7 deletions(-) create mode 100644 packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts create mode 100644 packages/nextjs/src/config/turbopack/index.ts create mode 100644 packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts new file mode 100644 index 000000000000..4e5c404ec56c --- /dev/null +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -0,0 +1,78 @@ +import { logger } from '@sentry/core'; +import * as chalk from 'chalk'; +import * as path from 'path'; +import type { RouteManifest } from '../manifest/types'; +import type { NextConfigObject, TurbopackOptions, TurbopackRuleConfigItemOrShortcut } from '../types'; + +/** + * 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. + * @returns The Turbopack config object. + */ +export function constructTurbopackConfig({ + userNextConfig, + routeManifest, +}: { + userNextConfig: NextConfigObject; + routeManifest?: RouteManifest; +}): TurbopackOptions { + const newConfig: TurbopackOptions = { + ...userNextConfig.turbopack, + }; + + if (routeManifest) { + newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { + matcher: '**/instrumentation-client.*', + rule: { + loaders: [ + { + loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), + options: { + values: { + _sentryRouteManifest: JSON.stringify(routeManifest), + }, + }, + }, + ], + }, + }); + } + + return newConfig; +} + +/** + * Safely add a Turbopack rule to the existing rules. + * + * @param existingRules - The existing rules. + * @param matcher - The matcher for the rule. + * @param rule - The rule to add. + * @returns The updated rules object. + */ +export function safelyAddTurbopackRule( + existingRules: TurbopackOptions['rules'], + { matcher, rule }: { matcher: string; rule: TurbopackRuleConfigItemOrShortcut }, +): TurbopackOptions['rules'] { + if (!existingRules) { + return { + [matcher]: rule, + }; + } + + // If the rule already exists, we don't want to mess with it. + if (existingRules[matcher]) { + logger.info( + `${chalk.cyan( + 'info', + )} - Turbopack rule already exists for ${matcher}. Please remove it from your Next.js config in order for Sentry to work properly.`, + ); + return existingRules; + } + + return { + ...existingRules, + [matcher]: rule, + }; +} diff --git a/packages/nextjs/src/config/turbopack/index.ts b/packages/nextjs/src/config/turbopack/index.ts new file mode 100644 index 000000000000..06fc8bf09293 --- /dev/null +++ b/packages/nextjs/src/config/turbopack/index.ts @@ -0,0 +1 @@ +export * from './constructTurbopackConfig'; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index c635aa88c21a..81ee686ce205 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -51,6 +51,7 @@ export type NextConfigObject = { // https://nextjs.org/docs/pages/api-reference/next-config-js/env env?: Record; serverExternalPackages?: string[]; // next >= v15.0.0 + turbopack?: TurbopackOptions; }; export type SentryBuildOptions = { @@ -607,3 +608,38 @@ export type EnhancedGlobal = typeof GLOBAL_OBJ & { SENTRY_RELEASE?: { id: string }; SENTRY_RELEASES?: { [key: string]: { id: string } }; }; + +type JSONValue = string | number | boolean | JSONValue[] | { [k: string]: JSONValue }; + +type TurbopackLoaderItem = + | string + | { + loader: string; + // At the moment, Turbopack options must be JSON-serializable, so restrict values. + options: Record; + }; + +type TurbopackRuleCondition = { + path: string | RegExp; +}; + +export type TurbopackRuleConfigItemOrShortcut = TurbopackLoaderItem[] | TurbopackRuleConfigItem; + +type TurbopackRuleConfigItemOptions = { + loaders: TurbopackLoaderItem[]; + as?: string; +}; + +type TurbopackRuleConfigItem = + | TurbopackRuleConfigItemOptions + | { [condition: string]: TurbopackRuleConfigItem } + | false; + +export interface TurbopackOptions { + resolveAlias?: Record>; + resolveExtensions?: string[]; + rules?: Record; + conditions?: Record; + moduleIds?: 'named' | 'deterministic'; + root?: string; +} diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 4e231a3227d6..5ba768f3d0b7 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { createRouteManifest } from './manifest/createRouteManifest'; import type { RouteManifest } from './manifest/types'; +import { constructTurbopackConfig } from './turbopack'; import type { ExportedNextConfig as NextConfig, NextConfigFunction, @@ -251,6 +252,8 @@ function getFinalConfigObject( } let nextMajor: number | undefined; + const isTurbopack = process.env.TURBOPACK; + let isTurbopackSupported = false; if (nextJsVersion) { const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); nextMajor = major; @@ -262,6 +265,7 @@ function getFinalConfigObject( (major === 15 && minor > 3) || (major === 15 && minor === 3 && patch === 0 && prerelease === undefined) || (major === 15 && minor === 3 && patch > 0)); + isTurbopackSupported = isSupportedVersion; const isSupportedCanary = major !== undefined && minor !== undefined && @@ -274,7 +278,7 @@ function getFinalConfigObject( parseInt(prerelease.split('.')[1] || '', 10) >= 28; const supportsClientInstrumentation = isSupportedCanary || isSupportedVersion; - if (!supportsClientInstrumentation && process.env.TURBOPACK) { + if (!supportsClientInstrumentation && isTurbopack) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.warn( @@ -307,12 +311,17 @@ function getFinalConfigObject( ], }, }), - webpack: constructWebpackConfigFunction( - incomingUserNextConfigObject, - userSentryOptions, - releaseName, - routeManifest, - ), + webpack: !isTurbopack + ? constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName, routeManifest) + : undefined, + ...(isTurbopackSupported && isTurbopack + ? { + turbopack: constructTurbopackConfig({ + userNextConfig: incomingUserNextConfigObject, + routeManifest, + }), + } + : {}), }; } diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts new file mode 100644 index 000000000000..813d3c0f8894 --- /dev/null +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -0,0 +1,443 @@ +import * as path from 'path'; +import { describe, expect, it, vi } from 'vitest'; +import type { RouteManifest } from '../../../src/config/manifest/types'; +import { + constructTurbopackConfig, + safelyAddTurbopackRule, +} from '../../../src/config/turbopack/constructTurbopackConfig'; +import type { NextConfigObject } from '../../../src/config/types'; + +// Mock path.resolve to return a predictable loader path +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + resolve: vi.fn().mockReturnValue('/mocked/path/to/valueInjectionLoader.js'), + }; +}); + +describe('constructTurbopackConfig', () => { + const mockRouteManifest: RouteManifest = { + dynamicRoutes: [{ path: '/users/[id]', regex: '/users/([^/]+)', paramNames: ['id'] }], + staticRoutes: [ + { path: '/users', regex: '/users' }, + { path: '/api/health', regex: '/api/health' }, + ], + }; + + describe('without existing turbopack config', () => { + it('should create a basic turbopack config when no manifest is provided', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + }); + + expect(result).toEqual({}); + }); + + it('should create turbopack config with instrumentation rule when manifest is provided', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should call path.resolve with correct arguments', () => { + const userNextConfig: NextConfigObject = {}; + const pathResolveSpy = vi.spyOn(path, 'resolve'); + + constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); + }); + + it('should handle Windows-style paths correctly', () => { + // Mock path.resolve to return a Windows-style path + const windowsLoaderPath = 'C:\\my\\project\\dist\\config\\loaders\\valueInjectionLoader.js'; + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockReturnValue(windowsLoaderPath); + + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result.rules).toBeDefined(); + expect(result.rules!['**/instrumentation-client.*']).toBeDefined(); + + const rule = result.rules!['**/instrumentation-client.*']; + expect(rule).toHaveProperty('loaders'); + + const ruleWithLoaders = rule as { loaders: Array<{ loader: string; options: any }> }; + expect(ruleWithLoaders.loaders).toBeDefined(); + expect(ruleWithLoaders.loaders).toHaveLength(1); + + const loader = ruleWithLoaders.loaders[0]!; + expect(loader).toHaveProperty('loader'); + expect(loader).toHaveProperty('options'); + expect(loader.options).toHaveProperty('values'); + expect(loader.options.values).toHaveProperty('_sentryRouteManifest'); + expect(loader.loader).toBe(windowsLoaderPath); + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); + + // Restore the original mock behavior + pathResolveSpy.mockReturnValue('/mocked/path/to/valueInjectionLoader.js'); + }); + }); + + describe('with existing turbopack config', () => { + it('should preserve existing turbopack config when no manifest is provided', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }); + }); + + it('should merge manifest rule with existing turbopack config', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should not override existing instrumentation rule', () => { + const existingRule = { + loaders: [ + { + loader: '/existing/loader.js', + options: { custom: 'value' }, + }, + ], + }; + + const userNextConfig: NextConfigObject = { + turbopack: { + rules: { + '**/instrumentation-client.*': existingRule, + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': existingRule, + }, + }); + }); + }); + + describe('with edge cases', () => { + it('should handle empty route manifest', () => { + const userNextConfig: NextConfigObject = {}; + const emptyManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [] }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: emptyManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(emptyManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle complex route manifest', () => { + const userNextConfig: NextConfigObject = {}; + const complexManifest: RouteManifest = { + dynamicRoutes: [ + { path: '/users/[id]/posts/[postId]', regex: '/users/([^/]+)/posts/([^/]+)', paramNames: ['id', 'postId'] }, + { path: '/api/[...params]', regex: '/api/(.+)', paramNames: ['params'] }, + ], + staticRoutes: [], + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: complexManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(complexManifest), + }, + }, + }, + ], + }, + }, + }); + }); + }); +}); + +describe('safelyAddTurbopackRule', () => { + const mockRule = { + loaders: [ + { + loader: '/test/loader.js', + options: { test: 'value' }, + }, + ], + }; + + describe('with undefined/null existingRules', () => { + it('should create new rules object when existingRules is undefined', () => { + const result = safelyAddTurbopackRule(undefined, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.test.js': mockRule, + }); + }); + + it('should create new rules object when existingRules is null', () => { + const result = safelyAddTurbopackRule(null as any, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.test.js': mockRule, + }); + }); + }); + + describe('with existing rules', () => { + it('should add new rule to existing rules object', () => { + const existingRules = { + '*.css': ['css-loader'], + '*.scss': ['sass-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.scss': ['sass-loader'], + '*.test.js': mockRule, + }); + }); + + it('should not override existing rule with same matcher', () => { + const existingRule = { + loaders: [ + { + loader: '/existing/loader.js', + options: { existing: 'option' }, + }, + ], + }; + + const existingRules = { + '*.css': ['css-loader'], + '*.test.js': existingRule, + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': existingRule, + }); + }); + + it('should handle empty rules object', () => { + const existingRules = {}; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.test.js': mockRule, + }); + }); + }); + + describe('with different rule formats', () => { + it('should handle string array rule (shortcut format)', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: ['jest-loader', 'babel-loader'], + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': ['jest-loader', 'babel-loader'], + }); + }); + + it('should handle complex rule with conditions', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const complexRule = { + loaders: [ + { + loader: '/test/loader.js', + options: { test: 'value' }, + }, + ], + as: 'javascript/auto', + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: complexRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': complexRule, + }); + }); + + it('should handle disabled rule (false)', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: false, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': false, + }); + }); + }); + + describe('immutable', () => { + it('should not mutate original existingRules object', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': mockRule, + }); + }); + }); +}); From 4c1079e0c1185ae7de612a0d0b2f91da9d9c5ccc Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 15 Jul 2025 11:00:29 +0200 Subject: [PATCH 25/37] fix(react-router): Ensure that all browser spans have `source=route` (#16984) This updates the browser tracing implementation in react router to ensure that we always have parametrized routes. In the previous implementation, if the parametrized route was the same as the "initial" one, we would just do nothing and the source remains URL. We would also not set the origin of the spans. this is not correct, because we actually ensured this is parametrized (there are simply no parameters in, but still!), so the source should still be `route` in this case. --- .../performance/navigation.client.test.ts | 4 +- .../tests/performance/pageload.client.test.ts | 26 ++++---- .../performance/navigation.client.test.ts | 4 +- .../tests/performance/pageload.client.test.ts | 26 ++++---- .../tests/performance/pageload.client.test.ts | 12 ++-- .../tests/performance/pageload.client.test.ts | 12 ++-- .../performance/navigation.client.test.ts | 4 +- .../tests/performance/pageload.client.test.ts | 26 ++++---- .../react-router/src/client/hydratedRouter.ts | 65 +++++++++++-------- .../test/client/hydratedRouter.test.ts | 10 +-- 10 files changed, 101 insertions(+), 88 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts index 57e3e764d6a8..24e27e89539e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/navigation.client.test.ts @@ -22,7 +22,7 @@ test.describe('client - navigation performance', () => { data: { 'sentry.origin': 'auto.navigation.react-router', 'sentry.op': 'navigation', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'navigation', origin: 'auto.navigation.react-router', @@ -33,7 +33,7 @@ test.describe('client - navigation performance', () => { timestamp: expect.any(Number), transaction: '/performance/ssr', type: 'transaction', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, platform: 'javascript', request: { url: expect.stringContaining('/performance/ssr'), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts index b18ae44e0e71..465d000dcd31 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/'; + return transactionEvent.transaction === '/performance'; }); await page.goto(`/performance`); @@ -18,20 +18,20 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), start_timestamp: expect.any(Number), timestamp: expect.any(Number), - transaction: '/performance/', + transaction: '/performance', type: 'transaction', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, measurements: expect.any(Object), platform: 'javascript', request: { @@ -68,12 +68,12 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), @@ -105,7 +105,7 @@ test.describe('client - pageload performance', () => { test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/static/'; + return transactionEvent.transaction === '/performance/static'; }); await page.goto(`/performance/static`); @@ -113,18 +113,18 @@ test.describe('client - pageload performance', () => { const transaction = await txPromise; expect(transaction).toMatchObject({ - transaction: '/performance/static/', + transaction: '/performance/static', contexts: { trace: { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts index 57e3e764d6a8..24e27e89539e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/navigation.client.test.ts @@ -22,7 +22,7 @@ test.describe('client - navigation performance', () => { data: { 'sentry.origin': 'auto.navigation.react-router', 'sentry.op': 'navigation', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'navigation', origin: 'auto.navigation.react-router', @@ -33,7 +33,7 @@ test.describe('client - navigation performance', () => { timestamp: expect.any(Number), transaction: '/performance/ssr', type: 'transaction', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, platform: 'javascript', request: { url: expect.stringContaining('/performance/ssr'), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts index b18ae44e0e71..465d000dcd31 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/'; + return transactionEvent.transaction === '/performance'; }); await page.goto(`/performance`); @@ -18,20 +18,20 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), start_timestamp: expect.any(Number), timestamp: expect.any(Number), - transaction: '/performance/', + transaction: '/performance', type: 'transaction', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, measurements: expect.any(Object), platform: 'javascript', request: { @@ -68,12 +68,12 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), @@ -105,7 +105,7 @@ test.describe('client - pageload performance', () => { test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/static/'; + return transactionEvent.transaction === '/performance/static'; }); await page.goto(`/performance/static`); @@ -113,18 +113,18 @@ test.describe('client - pageload performance', () => { const transaction = await txPromise; expect(transaction).toMatchObject({ - transaction: '/performance/static/', + transaction: '/performance/static', contexts: { trace: { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts index a02942693a79..1118bde2669c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/performance/pageload.client.test.ts @@ -18,12 +18,12 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), @@ -31,7 +31,7 @@ test.describe('client - pageload performance', () => { timestamp: expect.any(Number), transaction: '/performance', type: 'transaction', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, measurements: expect.any(Object), platform: 'javascript', request: { @@ -68,12 +68,12 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts index a02942693a79..1118bde2669c 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/performance/pageload.client.test.ts @@ -18,12 +18,12 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), @@ -31,7 +31,7 @@ test.describe('client - pageload performance', () => { timestamp: expect.any(Number), transaction: '/performance', type: 'transaction', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, measurements: expect.any(Object), platform: 'javascript', request: { @@ -68,12 +68,12 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts index 57e3e764d6a8..24e27e89539e 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts @@ -22,7 +22,7 @@ test.describe('client - navigation performance', () => { data: { 'sentry.origin': 'auto.navigation.react-router', 'sentry.op': 'navigation', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'navigation', origin: 'auto.navigation.react-router', @@ -33,7 +33,7 @@ test.describe('client - navigation performance', () => { timestamp: expect.any(Number), transaction: '/performance/ssr', type: 'transaction', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, platform: 'javascript', request: { url: expect.stringContaining('/performance/ssr'), diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts index b18ae44e0e71..465d000dcd31 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/pageload.client.test.ts @@ -5,7 +5,7 @@ import { APP_NAME } from '../constants'; test.describe('client - pageload performance', () => { test('should send pageload transaction', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/'; + return transactionEvent.transaction === '/performance'; }); await page.goto(`/performance`); @@ -18,20 +18,20 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), start_timestamp: expect.any(Number), timestamp: expect.any(Number), - transaction: '/performance/', + transaction: '/performance', type: 'transaction', - transaction_info: { source: 'url' }, + transaction_info: { source: 'route' }, measurements: expect.any(Object), platform: 'javascript', request: { @@ -68,12 +68,12 @@ test.describe('client - pageload performance', () => { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, spans: expect.any(Array), @@ -105,7 +105,7 @@ test.describe('client - pageload performance', () => { test('should send pageload transaction for prerendered pages', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === '/performance/static/'; + return transactionEvent.transaction === '/performance/static'; }); await page.goto(`/performance/static`); @@ -113,18 +113,18 @@ test.describe('client - pageload performance', () => { const transaction = await txPromise; expect(transaction).toMatchObject({ - transaction: '/performance/static/', + transaction: '/performance/static', contexts: { trace: { span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.pageload.browser', + 'sentry.origin': 'auto.pageload.react-router', 'sentry.op': 'pageload', - 'sentry.source': 'url', + 'sentry.source': 'route', }, op: 'pageload', - origin: 'auto.pageload.browser', + origin: 'auto.pageload.react-router', }, }, }); diff --git a/packages/react-router/src/client/hydratedRouter.ts b/packages/react-router/src/client/hydratedRouter.ts index e5ec2d65d5ef..7cb53d87487b 100644 --- a/packages/react-router/src/client/hydratedRouter.ts +++ b/packages/react-router/src/client/hydratedRouter.ts @@ -36,43 +36,56 @@ export function instrumentHydratedRouter(): void { // The first time we hit the router, we try to update the pageload transaction // todo: update pageload tx here const pageloadSpan = getActiveRootSpan(); - const pageloadName = pageloadSpan ? spanToJSON(pageloadSpan).description : undefined; - const parameterizePageloadRoute = getParameterizedRoute(router.state); - if ( - pageloadName && - normalizePathname(router.state.location.pathname) === normalizePathname(pageloadName) && // this event is for the currently active pageload - normalizePathname(parameterizePageloadRoute) !== normalizePathname(pageloadName) // route is not parameterized yet - ) { - pageloadSpan?.updateName(parameterizePageloadRoute); - pageloadSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); - } - // Patching navigate for creating accurate navigation transactions - if (typeof router.navigate === 'function') { - const originalNav = router.navigate.bind(router); - router.navigate = function sentryPatchedNavigate(...args) { - maybeCreateNavigationTransaction( - String(args[0]) || '', // will be updated anyway - 'url', // this also will be updated once we have the parameterized route - ); - return originalNav(...args); - }; + if (pageloadSpan) { + const pageloadName = spanToJSON(pageloadSpan).description; + const parameterizePageloadRoute = getParameterizedRoute(router.state); + if ( + pageloadName && + // this event is for the currently active pageload + normalizePathname(router.state.location.pathname) === normalizePathname(pageloadName) + ) { + pageloadSpan.updateName(parameterizePageloadRoute); + pageloadSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react-router', + }); + } + + // Patching navigate for creating accurate navigation transactions + if (typeof router.navigate === 'function') { + const originalNav = router.navigate.bind(router); + router.navigate = function sentryPatchedNavigate(...args) { + maybeCreateNavigationTransaction( + String(args[0]) || '', // will be updated anyway + 'url', // this also will be updated once we have the parameterized route + ); + return originalNav(...args); + }; + } } // Subscribe to router state changes to update navigation transactions with parameterized routes router.subscribe(newState => { const navigationSpan = getActiveRootSpan(); - const navigationSpanName = navigationSpan ? spanToJSON(navigationSpan).description : undefined; + + if (!navigationSpan) { + return; + } + + const navigationSpanName = spanToJSON(navigationSpan).description; const parameterizedNavRoute = getParameterizedRoute(newState); if ( - navigationSpanName && // we have an active pageload tx + navigationSpanName && newState.navigation.state === 'idle' && // navigation has completed - normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName) && // this event is for the currently active navigation - normalizePathname(parameterizedNavRoute) !== normalizePathname(navigationSpanName) // route is not parameterized yet + normalizePathname(newState.location.pathname) === normalizePathname(navigationSpanName) // this event is for the currently active navigation ) { - navigationSpan?.updateName(parameterizedNavRoute); - navigationSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + navigationSpan.updateName(parameterizedNavRoute); + navigationSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react-router', + }); } }); return true; diff --git a/packages/react-router/test/client/hydratedRouter.test.ts b/packages/react-router/test/client/hydratedRouter.test.ts index 98ed1a241d93..3e798e829566 100644 --- a/packages/react-router/test/client/hydratedRouter.test.ts +++ b/packages/react-router/test/client/hydratedRouter.test.ts @@ -39,8 +39,8 @@ describe('instrumentHydratedRouter', () => { }; (globalThis as any).__reactRouterDataRouter = mockRouter; - mockPageloadSpan = { updateName: vi.fn(), setAttribute: vi.fn() }; - mockNavigationSpan = { updateName: vi.fn(), setAttribute: vi.fn() }; + mockPageloadSpan = { updateName: vi.fn(), setAttributes: vi.fn() }; + mockNavigationSpan = { updateName: vi.fn(), setAttributes: vi.fn() }; (core.getActiveSpan as any).mockReturnValue(mockPageloadSpan); (core.getRootSpan as any).mockImplementation((span: any) => span); @@ -66,7 +66,7 @@ describe('instrumentHydratedRouter', () => { it('updates pageload transaction name if needed', () => { instrumentHydratedRouter(); expect(mockPageloadSpan.updateName).toHaveBeenCalled(); - expect(mockPageloadSpan.setAttribute).toHaveBeenCalled(); + expect(mockPageloadSpan.setAttributes).toHaveBeenCalled(); }); it('creates navigation transaction on navigate', () => { @@ -89,7 +89,7 @@ describe('instrumentHydratedRouter', () => { (core.getActiveSpan as any).mockReturnValue(mockNavigationSpan); callback(newState); expect(mockNavigationSpan.updateName).toHaveBeenCalled(); - expect(mockNavigationSpan.setAttribute).toHaveBeenCalled(); + expect(mockNavigationSpan.setAttributes).toHaveBeenCalled(); }); it('does not update navigation transaction on state change to loading', () => { @@ -106,6 +106,6 @@ describe('instrumentHydratedRouter', () => { (core.getActiveSpan as any).mockReturnValue(mockNavigationSpan); callback(newState); expect(mockNavigationSpan.updateName).not.toHaveBeenCalled(); - expect(mockNavigationSpan.setAttribute).not.toHaveBeenCalled(); + expect(mockNavigationSpan.setAttributes).not.toHaveBeenCalled(); }); }); From d23207cb5b1efb573f9414b5caf169b274bfed52 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 15 Jul 2025 11:10:09 +0200 Subject: [PATCH 26/37] feat(nextjs): Client-side parameterized routes (#16934) --- .../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 | 7 + .../tests/client/parameterized-routes.test.ts | 189 +++++ .../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 | 7 + .../tests/parameterized-routes.test.ts | 189 +++++ .../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 | 7 + .../nextjs-15/playwright.config.mjs | 14 +- .../tests/parameterized-routes.test.ts | 189 +++++ ...client-app-routing-instrumentation.test.ts | 22 +- .../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 | 7 + .../nextjs-turbo/next.config.js | 6 +- .../nextjs-turbo/package.json | 2 +- .../app-router/parameterized-routes.test.ts | 189 +++++ .../appRouterRoutingInstrumentation.ts | 34 +- .../src/client/routing/parameterization.ts | 170 +++++ .../test/client/parameterization.test.ts | 647 ++++++++++++++++++ 27 files changed, 1678 insertions(+), 37 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts create mode 100644 packages/nextjs/src/client/routing/parameterization.ts create mode 100644 packages/nextjs/test/client/parameterization.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/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-13/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/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-13/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/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-13/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts new file mode 100644 index 000000000000..b53cda3ac968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/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-13', 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-13', 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-13', 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-13', 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-14/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/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-14/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/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-14/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/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-14/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..2a5e2910050a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/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-14', 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-14', 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-14', 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-14', 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-15/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/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-15/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/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-15/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/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-15/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs index 8448829443d6..c675d003853a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs @@ -5,15 +5,9 @@ if (!testEnv) { throw new Error('No test env defined'); } -const config = getPlaywrightConfig( - { - startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', - port: 3030, - }, - { - // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize - workers: '100%', - }, -); +const config = getPlaywrightConfig({ + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + port: 3030, +}); export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..fb93e77aaf8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/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-15', 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-15', 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-15', 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-15', 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-app-dir/tests/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts index 8069a1d1395b..a685f969eeda 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -6,7 +6,7 @@ test('Creates a pageload transaction for app router routes', async ({ page }) => const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && + transactionEvent?.transaction === `/server-component/parameter/:parameter` && transactionEvent.contexts?.trace?.op === 'pageload' ); }); @@ -21,7 +21,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && + transactionEvent?.transaction === `/server-component/parameter/:parameter` && transactionEvent.contexts?.trace?.op === 'pageload' ); }); @@ -32,7 +32,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) const clientNavigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === '/server-component/parameter/foo/bar/baz' && + transactionEvent?.transaction === '/server-component/parameter/:parameters*' && transactionEvent.contexts?.trace?.op === 'navigation' ); }); @@ -59,7 +59,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) test('Creates a navigation transaction for `router.push()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent?.transaction === `/navigation/:param/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' ); @@ -75,7 +75,7 @@ test('Creates a navigation transaction for `router.push()`', async ({ page }) => test('Creates a navigation transaction for `router.replace()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-replace` && + transactionEvent?.transaction === `/navigation/:param/router-replace` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace' ); @@ -91,7 +91,7 @@ test('Creates a navigation transaction for `router.replace()`', async ({ page }) test('Creates a navigation transaction for `router.back()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/1337/router-back` && + transactionEvent?.transaction === `/navigation/:param/router-back` && transactionEvent.contexts?.trace?.op === 'navigation' ); }); @@ -116,7 +116,7 @@ test('Creates a navigation transaction for `router.back()`', async ({ page }) => test('Creates a navigation transaction for `router.forward()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent?.transaction === `/navigation/:param/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && (transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward' || transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') @@ -137,7 +137,7 @@ test('Creates a navigation transaction for `router.forward()`', async ({ page }) test('Creates a navigation transaction for ``', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/link` && + transactionEvent?.transaction === `/navigation/:param/link` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' ); @@ -152,7 +152,7 @@ test('Creates a navigation transaction for ``', async ({ page }) => { test('Creates a navigation transaction for ``', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/link-replace` && + transactionEvent?.transaction === `/navigation/:param/link-replace` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace' ); @@ -168,7 +168,7 @@ test('Creates a navigation transaction for ``', async ({ page }) test('Creates a navigation transaction for browser-back', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/browser-back` && + transactionEvent?.transaction === `/navigation/:param/browser-back` && transactionEvent.contexts?.trace?.op === 'navigation' && (transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' || transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') @@ -187,7 +187,7 @@ test('Creates a navigation transaction for browser-back', async ({ page }) => { test('Creates a navigation transaction for browser-forward', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent?.transaction === `/navigation/:param/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && (transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' || transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/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-turbo/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/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-turbo/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/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-turbo/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js index a0d2b254bc42..55a7b9361b3a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js @@ -1,11 +1,7 @@ const { withSentryConfig } = require('@sentry/nextjs'); /** @type {import('next').NextConfig} */ -const nextConfig = { - experimental: { - turbo: {}, // Enables Turbopack for builds - }, -}; +const nextConfig = {}; module.exports = withSentryConfig(nextConfig, { silent: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json index 76d544bb823a..9102de60706b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json @@ -17,7 +17,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.3.0-canary.40", + "next": "^15.3.5", "react": "rc", "react-dom": "rc", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts new file mode 100644 index 000000000000..0a2f1dfc0c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/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-turbo', 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-turbo', 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-turbo', 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-turbo', 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/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index a793a73a4488..425daeb3e558 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -7,6 +7,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react'; +import { maybeParameterizeRoute } from './parameterization'; export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction'; @@ -34,15 +35,16 @@ const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: und /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { + const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname); const origin = browserPerformanceTimeOrigin(); startBrowserTracingPageLoadSpan(client, { - name: WINDOW.location.pathname, + name: parameterizedPathname ?? WINDOW.location.pathname, // pageload should always start at timeOrigin (and needs to be in s, not ms) startTime: origin ? origin / 1000 : undefined, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', }, }); } @@ -85,7 +87,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 pathname = new URL(href, WINDOW.location.href).pathname; + const unparameterizedPathname = new URL(href, WINDOW.location.href).pathname; + const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); + const pathname = parameterizedPathname ?? unparameterizedPathname; if (navigationRoutingMode === 'router-patch') { navigationRoutingMode = 'transition-start-hook'; @@ -96,6 +100,7 @@ export function appRouterInstrumentNavigation(client: Client): void { currentNavigationSpan.updateName(pathname); currentNavigationSpan.setAttributes({ 'navigation.type': `router.${navigationType}`, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', }); currentRouterPatchingNavigationSpanRef.current = undefined; } else { @@ -104,7 +109,7 @@ export function appRouterInstrumentNavigation(client: Client): void { attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', 'navigation.type': `router.${navigationType}`, }, }); @@ -112,15 +117,19 @@ export function appRouterInstrumentNavigation(client: Client): void { }; WINDOW.addEventListener('popstate', () => { + const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname); if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) { - currentRouterPatchingNavigationSpanRef.current.updateName(WINDOW.location.pathname); - currentRouterPatchingNavigationSpanRef.current.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + currentRouterPatchingNavigationSpanRef.current.updateName(parameterizedPathname ?? WINDOW.location.pathname); + currentRouterPatchingNavigationSpanRef.current.setAttribute( + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + parameterizedPathname ? 'route' : 'url', + ); } else { currentRouterPatchingNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, { - name: WINDOW.location.pathname, + name: parameterizedPathname ?? WINDOW.location.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', 'navigation.type': 'browser.popstate', }, }); @@ -209,9 +218,14 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe transactionAttributes['navigation.type'] = 'router.forward'; } + const parameterizedPathname = maybeParameterizeRoute(transactionName); + currentNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, { - name: transactionName, - attributes: transactionAttributes, + name: parameterizedPathname ?? transactionName, + attributes: { + ...transactionAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', + }, }); return target.apply(thisArg, argArray); diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts new file mode 100644 index 000000000000..8ce98044a588 --- /dev/null +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -0,0 +1,170 @@ +import { GLOBAL_OBJ, logger } from '@sentry/core'; +import { DEBUG_BUILD } from '../../common/debug-build'; +import type { RouteManifest } from '../../config/manifest/types'; + +const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest: RouteManifest | undefined; +}; + +// Some performance caches +let cachedManifest: RouteManifest | null = null; +let cachedManifestString: string | undefined = undefined; +const compiledRegexCache: Map = new Map(); +const routeResultCache: Map = new Map(); + +/** + * Calculate the specificity score for a route path. + * Lower scores indicate more specific routes. + */ +function getRouteSpecificity(routePath: string): number { + const segments = routePath.split('/').filter(Boolean); + let score = 0; + + for (const segment of segments) { + if (segment.startsWith(':')) { + const paramName = segment.substring(1); + if (paramName.endsWith('*?')) { + // Optional catch-all: [[...param]] + score += 1000; + } else if (paramName.endsWith('*')) { + // Required catch-all: [...param] + score += 100; + } else { + // Regular dynamic segment: [param] + score += 10; + } + } + // Static segments add 0 to score as they are most specific + } + + return score; +} + +/** + * Get compiled regex from cache or create and cache it. + */ +function getCompiledRegex(regexString: string): RegExp | null { + if (compiledRegexCache.has(regexString)) { + return compiledRegexCache.get(regexString) ?? null; + } + + try { + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- regex patterns are from build-time route manifest, not user input + const regex = new RegExp(regexString); + compiledRegexCache.set(regexString, regex); + return regex; + } catch (error) { + DEBUG_BUILD && logger.warn('Could not compile regex', { regexString, error }); + // Cache the failure to avoid repeated attempts by storing undefined + return null; + } +} + +/** + * Get and cache the route manifest from the global object. + * @returns The parsed route manifest or null if not available/invalid. + */ +function getManifest(): RouteManifest | null { + if ( + !globalWithInjectedManifest?._sentryRouteManifest || + typeof globalWithInjectedManifest._sentryRouteManifest !== 'string' + ) { + return null; + } + + const currentManifestString = globalWithInjectedManifest._sentryRouteManifest; + + // Return cached manifest if the string hasn't changed + if (cachedManifest && cachedManifestString === currentManifestString) { + return cachedManifest; + } + + // Clear caches when manifest changes + compiledRegexCache.clear(); + routeResultCache.clear(); + + let manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [], + }; + + // Shallow check if the manifest is actually what we expect it to be + try { + manifest = JSON.parse(currentManifestString); + if (!Array.isArray(manifest.staticRoutes) || !Array.isArray(manifest.dynamicRoutes)) { + return null; + } + // Cache the successfully parsed manifest + cachedManifest = manifest; + cachedManifestString = currentManifestString; + return manifest; + } catch (error) { + // Something went wrong while parsing the manifest, so we'll fallback to no parameterization + DEBUG_BUILD && logger.warn('Could not extract route manifest'); + return null; + } +} + +/** + * Find matching routes from static and dynamic route collections. + * @param route - The route to match against. + * @param staticRoutes - Array of static route objects. + * @param dynamicRoutes - Array of dynamic route objects. + * @returns Array of matching route paths. + */ +function findMatchingRoutes( + route: string, + staticRoutes: RouteManifest['staticRoutes'], + dynamicRoutes: RouteManifest['dynamicRoutes'], +): string[] { + const matches: string[] = []; + + // Static path: no parameterization needed, return empty array + if (staticRoutes.some(r => r.path === route)) { + return matches; + } + + // Dynamic path: find the route pattern that matches the concrete route + for (const dynamicRoute of dynamicRoutes) { + if (dynamicRoute.regex) { + const regex = getCompiledRegex(dynamicRoute.regex); + if (regex?.test(route)) { + matches.push(dynamicRoute.path); + } + } + } + + return matches; +} + +/** + * Parameterize a route using the route manifest. + * + * @param route - The route to parameterize. + * @returns The parameterized route or undefined if no parameterization is needed. + */ +export const maybeParameterizeRoute = (route: string): string | undefined => { + const manifest = getManifest(); + if (!manifest) { + return undefined; + } + + // Check route result cache after manifest validation + if (routeResultCache.has(route)) { + return routeResultCache.get(route); + } + + const { staticRoutes, dynamicRoutes } = manifest; + if (!Array.isArray(staticRoutes) || !Array.isArray(dynamicRoutes)) { + return undefined; + } + + const matches = findMatchingRoutes(route, staticRoutes, dynamicRoutes); + + // We can always do the `sort()` call, it will short-circuit when it has one array item + const result = matches.sort((a, b) => getRouteSpecificity(a) - getRouteSpecificity(b))[0]; + + routeResultCache.set(route, result); + + return result; +}; diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts new file mode 100644 index 000000000000..e9f484e71827 --- /dev/null +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -0,0 +1,647 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { maybeParameterizeRoute } from '../../src/client/routing/parameterization'; +import type { RouteManifest } from '../../src/config/manifest/types'; + +const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest: string | undefined; +}; + +describe('maybeParameterizeRoute', () => { + const originalManifest = globalWithInjectedManifest._sentryRouteManifest; + + afterEach(() => { + globalWithInjectedManifest._sentryRouteManifest = originalManifest; + }); + + describe('when no manifest is available', () => { + it('should return undefined', () => { + globalWithInjectedManifest._sentryRouteManifest = undefined; + + expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); + expect(maybeParameterizeRoute('/posts/456/comments')).toBeUndefined(); + expect(maybeParameterizeRoute('/')).toBeUndefined(); + }); + }); + + describe('when manifest has static routes', () => { + it('should return undefined for static routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/some/nested')).toBeUndefined(); + expect(maybeParameterizeRoute('/user')).toBeUndefined(); + expect(maybeParameterizeRoute('/users')).toBeUndefined(); + }); + }); + + describe('when manifest has dynamic routes', () => { + it('should return parameterized routes for matching dynamic routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/dynamic/123')).toBe('/dynamic/:id'); + expect(maybeParameterizeRoute('/dynamic/abc')).toBe('/dynamic/:id'); + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/john-doe')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/123/posts/456')).toBe('/users/:id/posts/:postId'); + expect(maybeParameterizeRoute('/users/john/posts/my-post')).toBe('/users/:id/posts/:postId'); + expect(maybeParameterizeRoute('/users/123/settings')).toBe('/users/:id/settings'); + expect(maybeParameterizeRoute('/users/john-doe/settings')).toBe('/users/:id/settings'); + }); + + it('should return undefined for static routes even when dynamic routes exist', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/dynamic/static')).toBeUndefined(); + expect(maybeParameterizeRoute('/static/nested')).toBeUndefined(); + }); + + it('should handle catchall routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/catchall/:path*?', + regex: '^/catchall(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/catchall/123')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/abc')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/123/456')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/123/abc/789')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall')).toBe('/catchall/:path*?'); + }); + + it('should handle route groups when included', () => { + const manifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/(auth)/login' }, + { path: '/(auth)/signup' }, + { path: '/(dashboard)/dashboard' }, + { path: '/(dashboard)/settings/profile' }, + { path: '/(marketing)/public/about' }, + ], + dynamicRoutes: [ + { + path: '/(dashboard)/dashboard/:id', + regex: '^/\\(dashboard\\)/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/(auth)/login')).toBeUndefined(); + expect(maybeParameterizeRoute('/(auth)/signup')).toBeUndefined(); + expect(maybeParameterizeRoute('/(dashboard)/dashboard')).toBeUndefined(); + expect(maybeParameterizeRoute('/(dashboard)/settings/profile')).toBeUndefined(); + expect(maybeParameterizeRoute('/(marketing)/public/about')).toBeUndefined(); + expect(maybeParameterizeRoute('/(dashboard)/dashboard/123')).toBe('/(dashboard)/dashboard/:id'); + }); + + it('should handle route groups when stripped (default behavior)', () => { + const manifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ + { + path: '/dashboard/:id', + regex: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/login')).toBeUndefined(); + expect(maybeParameterizeRoute('/signup')).toBeUndefined(); + expect(maybeParameterizeRoute('/dashboard')).toBeUndefined(); + expect(maybeParameterizeRoute('/settings/profile')).toBeUndefined(); + expect(maybeParameterizeRoute('/public/about')).toBeUndefined(); + expect(maybeParameterizeRoute('/dashboard/123')).toBe('/dashboard/:id'); + }); + + it('should handle routes with special characters', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/users/user-with-dashes/settings')).toBe('/users/:id/settings'); + expect(maybeParameterizeRoute('/users/user_with_underscores/settings')).toBe('/users/:id/settings'); + expect(maybeParameterizeRoute('/users/123/settings')).toBe('/users/:id/settings'); + }); + + it('should return the first matching dynamic route', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:slug', + regex: '^/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/about')).toBe('/:slug'); + }); + + it('should return undefined for dynamic routes without regex', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); + }); + + it('should handle invalid regex patterns gracefully', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id', + regex: '[invalid-regex', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); + }); + }); + + describe('when route does not match any pattern', () => { + it('should return undefined for unknown routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [ + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/unknown')).toBeUndefined(); + expect(maybeParameterizeRoute('/posts/123')).toBeUndefined(); + expect(maybeParameterizeRoute('/users/123/extra')).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('should handle empty route', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('')).toBeUndefined(); + }); + + it('should handle root route', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/')).toBeUndefined(); + }); + + it('should handle complex nested dynamic routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/api/v1/users/:id/posts/:postId/comments/:commentId', + regex: '^/api/v1/users/([^/]+)/posts/([^/]+)/comments/([^/]+)$', + paramNames: ['id', 'postId', 'commentId'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/api/v1/users/123/posts/456/comments/789')).toBe( + '/api/v1/users/:id/posts/:postId/comments/:commentId', + ); + }); + }); + + describe('realistic Next.js App Router patterns', () => { + it.each([ + ['/', undefined], + ['/some/nested', undefined], + ['/user', undefined], + ['/users', undefined], + ['/dynamic/static', undefined], + ['/static/nested', undefined], + ['/login', undefined], + ['/signup', undefined], + ['/dashboard', undefined], + ['/settings/profile', undefined], + ['/public/about', undefined], + + ['/dynamic/123', '/dynamic/:id'], + ['/dynamic/abc', '/dynamic/:id'], + ['/users/123', '/users/:id'], + ['/users/john-doe', '/users/:id'], + ['/users/123/posts/456', '/users/:id/posts/:postId'], + ['/users/john/posts/my-post', '/users/:id/posts/:postId'], + ['/users/123/settings', '/users/:id/settings'], + ['/users/user-with-dashes/settings', '/users/:id/settings'], + ['/dashboard/123', '/dashboard/:id'], + + ['/catchall/123', '/catchall/:path*?'], + ['/catchall/abc', '/catchall/:path*?'], + ['/catchall/123/456', '/catchall/:path*?'], + ['/catchall/123/abc/789', '/catchall/:path*?'], + ['/catchall/', '/catchall/:path*?'], + ['/catchall', '/catchall/:path*?'], + + ['/unknown-route', undefined], + ['/api/unknown', undefined], + ['/posts/123', undefined], + ])('should handle route "%s" and return %s', (inputRoute, expectedRoute) => { + const manifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/some/nested' }, + { path: '/user' }, + { path: '/users' }, + { path: '/dynamic/static' }, + { path: '/static/nested' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + { + path: '/dashboard/:id', + regex: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/catchall/:path*?', + regex: '^/catchall(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + if (expectedRoute === undefined) { + expect(maybeParameterizeRoute(inputRoute)).toBeUndefined(); + } else { + expect(maybeParameterizeRoute(inputRoute)).toBe(expectedRoute); + } + }); + }); + + describe('route specificity and precedence', () => { + it('should prefer more specific routes over catch-all routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:parameter', + regex: '^/([^/]+)$', + paramNames: ['parameter'], + }, + { + path: '/:parameters*', + regex: '^/(.+)$', + paramNames: ['parameters'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Single segment should match the specific route, not the catch-all + expect(maybeParameterizeRoute('/123')).toBe('/:parameter'); + expect(maybeParameterizeRoute('/abc')).toBe('/:parameter'); + expect(maybeParameterizeRoute('/user-id')).toBe('/:parameter'); + + // Multiple segments should match the catch-all + expect(maybeParameterizeRoute('/123/456')).toBe('/:parameters*'); + expect(maybeParameterizeRoute('/users/123/posts')).toBe('/:parameters*'); + }); + + it('should prefer regular dynamic routes over optional catch-all routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:parameter', + regex: '^/([^/]+)$', + paramNames: ['parameter'], + }, + { + path: '/:parameters*?', + regex: '^(?:/(.*))?$', + paramNames: ['parameters'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Single segment should match the specific route, not the optional catch-all + expect(maybeParameterizeRoute('/123')).toBe('/:parameter'); + expect(maybeParameterizeRoute('/test')).toBe('/:parameter'); + }); + + it('should handle multiple levels of specificity correctly', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/static' }], + dynamicRoutes: [ + { + path: '/:param', + regex: '^/([^/]+)$', + paramNames: ['param'], + }, + { + path: '/:catch*', + regex: '^/(.+)$', + paramNames: ['catch'], + }, + { + path: '/:optional*?', + regex: '^(?:/(.*))?$', + paramNames: ['optional'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Static route should take precedence (no parameterization) + expect(maybeParameterizeRoute('/static')).toBeUndefined(); + + // Single segment should match regular dynamic route + expect(maybeParameterizeRoute('/dynamic')).toBe('/:param'); + + // Multiple segments should match required catch-all over optional catch-all + expect(maybeParameterizeRoute('/path/to/resource')).toBe('/:catch*'); + }); + + it('should handle real-world Next.js app directory structure', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/contact' }], + dynamicRoutes: [ + { + path: '/blog/:slug', + regex: '^/blog/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/:segments*', + regex: '^/(.+)$', + paramNames: ['segments'], + }, + { + path: '/:catch*?', + regex: '^(?:/(.*))?$', + paramNames: ['catch'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Static routes should not be parameterized + expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/about')).toBeUndefined(); + expect(maybeParameterizeRoute('/contact')).toBeUndefined(); + + // Specific dynamic routes should take precedence over catch-all + expect(maybeParameterizeRoute('/blog/my-post')).toBe('/blog/:slug'); + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/john/posts/456')).toBe('/users/:id/posts/:postId'); + + // Unmatched multi-segment paths should match required catch-all + expect(maybeParameterizeRoute('/api/v1/data')).toBe('/:segments*'); + expect(maybeParameterizeRoute('/some/deep/nested/path')).toBe('/:segments*'); + }); + + it('should prefer routes with more static segments', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/api/users/:id', + regex: '^/api/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/api/:resource/:id', + regex: '^/api/([^/]+)/([^/]+)$', + paramNames: ['resource', 'id'], + }, + { + path: '/:segments*', + regex: '^/(.+)$', + paramNames: ['segments'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // More specific route with static segments should win + expect(maybeParameterizeRoute('/api/users/123')).toBe('/api/users/:id'); + + // Less specific but still targeted route should win over catch-all + expect(maybeParameterizeRoute('/api/posts/456')).toBe('/api/:resource/:id'); + + // Unmatched patterns should fall back to catch-all + expect(maybeParameterizeRoute('/some/other/path')).toBe('/:segments*'); + }); + + it('should handle complex nested catch-all scenarios', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/docs/:slug', + regex: '^/docs/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/docs/:sections*', + regex: '^/docs/(.+)$', + paramNames: ['sections'], + }, + { + path: '/files/:path*?', + regex: '^/files(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Single segment should match specific route + expect(maybeParameterizeRoute('/docs/introduction')).toBe('/docs/:slug'); + + // Multiple segments should match catch-all + expect(maybeParameterizeRoute('/docs/api/reference')).toBe('/docs/:sections*'); + expect(maybeParameterizeRoute('/docs/guide/getting-started/installation')).toBe('/docs/:sections*'); + + // Optional catch-all should match both empty and filled cases + expect(maybeParameterizeRoute('/files')).toBe('/files/:path*?'); + expect(maybeParameterizeRoute('/files/documents')).toBe('/files/:path*?'); + expect(maybeParameterizeRoute('/files/images/avatar.png')).toBe('/files/:path*?'); + }); + + it('should correctly order routes by specificity score', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + // These routes are intentionally in non-specificity order + { + path: '/:optional*?', // Specificity: 1000 (least specific) + regex: '^(?:/(.*))?$', + paramNames: ['optional'], + }, + { + path: '/:catchall*', // Specificity: 100 + regex: '^/(.+)$', + paramNames: ['catchall'], + }, + { + path: '/api/:endpoint/:id', // Specificity: 20 (2 dynamic segments) + regex: '^/api/([^/]+)/([^/]+)$', + paramNames: ['endpoint', 'id'], + }, + { + path: '/users/:id', // Specificity: 10 (1 dynamic segment) + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/api/users/:id', // Specificity: 10 (1 dynamic segment) + regex: '^/api/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Most specific route should win despite order in manifest + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/api/users/456')).toBe('/api/users/:id'); + + // More general dynamic route should win over catch-all + expect(maybeParameterizeRoute('/api/posts/789')).toBe('/api/:endpoint/:id'); + + // Catch-all should be used when no more specific routes match + expect(maybeParameterizeRoute('/some/random/path')).toBe('/:catchall*'); + }); + }); +}); From fa210ad306c622084ca2af55c2ebc71c4a27e2b1 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 15 Jul 2025 11:35:07 +0200 Subject: [PATCH 27/37] ref(node): Add `sentry.parent_span_already_sent` attribute (#16870) This adds a new attribute to spans that we send from the Node SDK because their parent span was already sent. This can be used by us or customers to debug why certain things show up as transactions/root spans in product. We could possibly also use this in the trace view to give users helpful pointers. --- packages/opentelemetry/src/spanExporter.ts | 8 ++++++++ .../test/integration/transactions.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index 6430f0f23da5..3328b64c8230 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -191,6 +191,14 @@ export class SentrySpanExporter { sentSpans.add(span); const transactionEvent = createTransactionForOtelSpan(span); + // Add an attribute to the transaction event to indicate that this transaction is an orphaned transaction + if (root.parentNode && this._sentSpans.has(root.parentNode.id)) { + const traceData = transactionEvent.contexts?.trace?.data; + if (traceData) { + traceData['sentry.parent_span_already_sent'] = true; + } + } + // We'll recursively add all the child spans to this array const spans = transactionEvent.spans || []; diff --git a/packages/opentelemetry/test/integration/transactions.test.ts b/packages/opentelemetry/test/integration/transactions.test.ts index 9bc1847b422b..c3cc9b0e8b7b 100644 --- a/packages/opentelemetry/test/integration/transactions.test.ts +++ b/packages/opentelemetry/test/integration/transactions.test.ts @@ -612,6 +612,20 @@ describe('Integration | Transactions', () => { expect(transactions).toHaveLength(2); expect(transactions[0]?.spans).toHaveLength(1); + expect(transactions[0]?.transaction).toBe('test name'); + expect(transactions[0]?.contexts?.trace?.data).toEqual({ + 'sentry.origin': 'manual', + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + }); + + expect(transactions[1]?.transaction).toBe('inner span 2'); + expect(transactions[1]?.contexts?.trace?.data).toEqual({ + 'sentry.parent_span_already_sent': true, + 'sentry.origin': 'manual', + 'sentry.source': 'custom', + }); + const finishedSpans: any = exporter['_finishedSpanBuckets'].flatMap(bucket => bucket ? Array.from(bucket.spans) : [], ); From 10d5454dce0e1f0993070f92f99379b7b2f378a7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 15 Jul 2025 14:22:28 +0200 Subject: [PATCH 28/37] feat(nextjs): Add `disableSentryWebpackConfig` flag (#17013) --- packages/nextjs/src/config/types.ts | 12 ++ .../nextjs/src/config/withSentryConfig.ts | 7 +- .../test/config/withSentryConfig.test.ts | 124 ++++++++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 81ee686ce205..b29fbb6881af 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -486,6 +486,18 @@ export type SentryBuildOptions = { */ disableManifestInjection?: boolean; + /** + * Disables automatic injection of Sentry's Webpack configuration. + * + * By default, the Sentry Next.js SDK injects its own Webpack configuration to enable features such as + * source map upload and automatic instrumentation. Set this option to `true` if you want to prevent + * the SDK from modifying your Webpack config (for example, if you want to handle Sentry integration manually + * or if you are on an older version of Next.js while using Turbopack). + * + * @default false + */ + disableSentryWebpackConfig?: boolean; + /** * Contains a set of experimental flags that might change in future releases. These flags enable * features that are still in development and may be modified, renamed, or removed without notice. diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 5ba768f3d0b7..85ade8e682de 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -311,9 +311,10 @@ function getFinalConfigObject( ], }, }), - webpack: !isTurbopack - ? constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName, routeManifest) - : undefined, + webpack: + isTurbopack || userSentryOptions.disableSentryWebpackConfig + ? incomingUserNextConfigObject.webpack // just return the original webpack config + : constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName, routeManifest), ...(isTurbopackSupported && isTurbopack ? { turbopack: constructTurbopackConfig({ diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index f1f1ae13f9fa..3b872d810c49 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -145,6 +145,130 @@ describe('withSentryConfig', () => { }); }); + describe('webpack configuration behavior', () => { + const originalTurbopack = process.env.TURBOPACK; + + afterEach(() => { + vi.restoreAllMocks(); + process.env.TURBOPACK = originalTurbopack; + }); + + it('uses constructed webpack function when Turbopack is disabled and disableSentryWebpackConfig is false/undefined', () => { + delete process.env.TURBOPACK; + + // default behavior + const finalConfigUndefined = materializeFinalNextConfig(exportedNextConfig); + expect(finalConfigUndefined.webpack).toBeInstanceOf(Function); + + const sentryOptions = { + disableSentryWebpackConfig: false, + }; + const finalConfigFalse = materializeFinalNextConfig(exportedNextConfig, undefined, sentryOptions); + expect(finalConfigFalse.webpack).toBeInstanceOf(Function); + }); + + it('preserves original webpack config when disableSentryWebpackConfig is true (regardless of Turbopack)', () => { + const originalWebpackFunction = vi.fn(); + const configWithWebpack = { + ...exportedNextConfig, + webpack: originalWebpackFunction, + }; + + const sentryOptions = { + disableSentryWebpackConfig: true, + }; + + delete process.env.TURBOPACK; + const finalConfigWithoutTurbopack = materializeFinalNextConfig(configWithWebpack, undefined, sentryOptions); + expect(finalConfigWithoutTurbopack.webpack).toBe(originalWebpackFunction); + + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + const finalConfigWithTurbopack = materializeFinalNextConfig(configWithWebpack, undefined, sentryOptions); + expect(finalConfigWithTurbopack.webpack).toBe(originalWebpackFunction); + }); + + it('preserves original webpack config when Turbopack is enabled (ignores disableSentryWebpackConfig flag)', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const originalWebpackFunction = vi.fn(); + const configWithWebpack = { + ...exportedNextConfig, + webpack: originalWebpackFunction, + }; + + const sentryOptionsWithFalse = { + disableSentryWebpackConfig: false, + }; + const finalConfigWithFalse = materializeFinalNextConfig(configWithWebpack, undefined, sentryOptionsWithFalse); + expect(finalConfigWithFalse.webpack).toBe(originalWebpackFunction); + + const finalConfigWithUndefined = materializeFinalNextConfig(configWithWebpack); + expect(finalConfigWithUndefined.webpack).toBe(originalWebpackFunction); + + const sentryOptionsWithTrue = { + disableSentryWebpackConfig: true, + }; + const finalConfigWithTrue = materializeFinalNextConfig(configWithWebpack, undefined, sentryOptionsWithTrue); + expect(finalConfigWithTrue.webpack).toBe(originalWebpackFunction); + }); + + it('preserves original webpack config when Turbopack is enabled and disableSentryWebpackConfig is true', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const sentryOptions = { + disableSentryWebpackConfig: true, + }; + + const originalWebpackFunction = vi.fn(); + const configWithWebpack = { + ...exportedNextConfig, + webpack: originalWebpackFunction, + }; + + const finalConfig = materializeFinalNextConfig(configWithWebpack, undefined, sentryOptions); + + expect(finalConfig.webpack).toBe(originalWebpackFunction); + }); + + it('preserves undefined webpack when Turbopack is enabled, disableSentryWebpackConfig is true, and no original webpack config exists', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const sentryOptions = { + disableSentryWebpackConfig: true, + }; + + const configWithoutWebpack = { + ...exportedNextConfig, + }; + delete configWithoutWebpack.webpack; + + const finalConfig = materializeFinalNextConfig(configWithoutWebpack, undefined, sentryOptions); + + expect(finalConfig.webpack).toBeUndefined(); + }); + + it('includes turbopack config when Turbopack is supported and enabled', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.3.0'); + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeDefined(); + }); + + it('does not include turbopack config when Turbopack is not enabled', () => { + delete process.env.TURBOPACK; + + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.turbopack).toBeUndefined(); + }); + }); + describe('release injection behavior', () => { afterEach(() => { vi.restoreAllMocks(); From 90c23855b42233508175b88a164c8b34eb46655f Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:41:28 +0200 Subject: [PATCH 29/37] fix(node-core): Apply correct SDK metadata (#17014) While adding the SDK to the release-registry I noticed that we never apply the correct metadata to node-core. https://github.com/getsentry/sentry-javascript/commit/64d056a0ddc3fd04fbb2b17a7bf9d3a30fbbaac2 failing tests show the node-core SDK getting incorrect `sentry.javascript.node` metadata (from `NodeClient`) https://github.com/getsentry/sentry-javascript/commit/88a975aef0e09ee1b1b0c0701b83c128518b81e5 applies correct metadata to both SDKs I was debating changing the underlying `NodeClient` to not automatically set `sentry.javascript.node` but wasn't sure of the implications so decided not to. --- packages/node-core/src/sdk/index.ts | 3 +++ packages/node-core/test/sdk/init.test.ts | 20 +++++++++++++++++++- packages/node/src/sdk/index.ts | 4 +++- packages/node/test/sdk/init.test.ts | 20 +++++++++++++++++++- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index 47c4256c5c2f..9ec30a892927 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -1,5 +1,6 @@ import type { Integration, Options } from '@sentry/core'; import { + applySdkMetadata, consoleIntegration, consoleSandbox, functionToStringIntegration, @@ -120,6 +121,8 @@ function _init( ); } + applySdkMetadata(options, 'node-core'); + const client = new NodeClient(options); // The client is on the current scope, from where it generally is inherited getCurrentScope().setClient(client); diff --git a/packages/node-core/test/sdk/init.test.ts b/packages/node-core/test/sdk/init.test.ts index dc523a843b92..02115fa26ff7 100644 --- a/packages/node-core/test/sdk/init.test.ts +++ b/packages/node-core/test/sdk/init.test.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { logger } from '@sentry/core'; +import { logger, SDK_VERSION } from '@sentry/core'; import * as SentryOpentelemetry from '@sentry/opentelemetry'; import { type Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getClient } from '../../src/'; @@ -31,6 +31,24 @@ describe('init()', () => { vi.clearAllMocks(); }); + describe('metadata', () => { + it('has the correct metadata', () => { + init({ dsn: PUBLIC_DSN }); + + const client = getClient(); + + expect(client?.getSdkMetadata()).toEqual( + expect.objectContaining({ + sdk: { + name: 'sentry.javascript.node-core', + version: SDK_VERSION, + packages: [{ name: 'npm:@sentry/node-core', version: SDK_VERSION }], + }, + }), + ); + }); + }); + describe('integrations', () => { it("doesn't install default integrations if told not to", () => { init({ dsn: PUBLIC_DSN, defaultIntegrations: false }); diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 7afa959b2ce8..6942c6500f84 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -1,5 +1,5 @@ import type { Integration, Options } from '@sentry/core'; -import { hasSpansEnabled } from '@sentry/core'; +import { applySdkMetadata, hasSpansEnabled } from '@sentry/core'; import type { NodeClient } from '@sentry/node-core'; import { getDefaultIntegrations as getNodeCoreDefaultIntegrations, @@ -50,6 +50,8 @@ function _init( options: NodeOptions | undefined = {}, getDefaultIntegrationsImpl: (options: Options) => Integration[], ): NodeClient | undefined { + applySdkMetadata(options, 'node'); + const client = initNodeCore({ ...options, // Only use Node SDK defaults if none provided diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 67bf2cdbde65..1aa11387f5c3 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { logger } from '@sentry/core'; +import { logger, SDK_VERSION } from '@sentry/core'; import * as SentryOpentelemetry from '@sentry/opentelemetry'; import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getClient, NodeClient, validateOpenTelemetrySetup } from '../../src/'; @@ -38,6 +38,24 @@ describe('init()', () => { vi.clearAllMocks(); }); + describe('metadata', () => { + it('has the correct metadata', () => { + init({ dsn: PUBLIC_DSN }); + + const client = getClient(); + + expect(client?.getSdkMetadata()).toEqual( + expect.objectContaining({ + sdk: { + name: 'sentry.javascript.node', + version: SDK_VERSION, + packages: [{ name: 'npm:@sentry/node', version: SDK_VERSION }], + }, + }), + ); + }); + }); + describe('integrations', () => { it("doesn't install default integrations if told not to", () => { init({ dsn: PUBLIC_DSN, defaultIntegrations: false }); From 6e2eaed1908f85fa42180eaa614e5d34758084e1 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Tue, 15 Jul 2025 14:45:59 +0200 Subject: [PATCH 30/37] feat(react-router): Ensure http.server route handling is consistent (#16986) This updates react router http.server handling to ensure: * That we always have a correct route/url source * Streamlines how we get rid of the weird `*` transaction name by just using `processEvent` in the integration. This means we now always add the server integration, it just does less in non-matching node versions. * Ensure we always have a good span origin * Ensure the name of the spans is consistent with the target (e.g. containing the .data suffix) - we can revisit this later but for now this is consistent I'd say. --- .../instrument.mjs | 5 - .../performance/performance.server.test.ts | 94 +++++++++++++--- .../instrument.mjs | 3 +- .../performance/performance.server.test.ts | 100 +++++++++++++++--- .../react-router-7-framework/instrument.mjs | 1 - .../performance/performance.server.test.ts | 8 +- .../src/server/instrumentation/reactRouter.ts | 17 ++- .../server/integration/reactRouterServer.ts | 27 ++++- packages/react-router/src/server/sdk.ts | 48 ++------- .../src/server/wrapSentryHandleRequest.ts | 33 +++--- .../src/server/wrapServerAction.ts | 21 ++-- .../src/server/wrapServerLoader.ts | 21 ++-- .../instrumentation/reactRouterServer.test.ts | 5 +- .../integration/reactRouterServer.test.ts | 65 ++++++++++++ packages/react-router/test/server/sdk.test.ts | 58 +--------- .../server/wrapSentryHandleRequest.test.ts | 32 ++++-- .../test/server/wrapServerAction.test.ts | 4 +- .../test/server/wrapServerLoader.test.ts | 4 +- 18 files changed, 351 insertions(+), 195 deletions(-) create mode 100644 packages/react-router/test/server/integration/reactRouterServer.test.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs index a43afcba814f..c16240141b6d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/instrument.mjs @@ -5,9 +5,4 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, tunnel: `http://localhost:3031/`, // proxy server - integrations: function (integrations) { - return integrations.filter(integration => { - return integration.name !== 'ReactRouterServer'; - }); - }, }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts index abca82a6d938..a53b85228f00 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/performance/performance.server.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; -test.describe('servery - performance', () => { +test.describe('server - performance', () => { test('should send server transaction on pageload', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { return transactionEvent.transaction === 'GET /performance'; @@ -19,11 +19,11 @@ test.describe('servery - performance', () => { trace_id: expect.any(String), data: { 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.react-router.request-handler', 'sentry.source': 'route', }, op: 'http.server', - origin: 'auto.http.otel.http', + origin: 'auto.http.react-router.request-handler', }, }, spans: expect.any(Array), @@ -70,11 +70,11 @@ test.describe('servery - performance', () => { trace_id: expect.any(String), data: { 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.react-router.request-handler', 'sentry.source': 'route', }, op: 'http.server', - origin: 'auto.http.otel.http', + origin: 'auto.http.react-router.request-handler', }, }, spans: expect.any(Array), @@ -107,21 +107,52 @@ test.describe('servery - performance', () => { test('should instrument wrapped server loader', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - console.log(110, transactionEvent.transaction); - return transactionEvent.transaction === 'GET /performance/server-loader'; + return transactionEvent.transaction === 'GET /performance/server-loader.data'; }); await page.goto(`/performance`); - await page.waitForTimeout(500); await page.getByRole('link', { name: 'Server Loader' }).click(); const transaction = await txPromise; - expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({ + expect(transaction).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'http.server', + origin: 'auto.http.react-router.loader', + parent_span_id: expect.any(String), + status: 'ok', + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/performance/server-loader.data', + 'http.url': 'http://localhost:3030/performance/server-loader.data', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react-router.loader', + 'sentry.source': 'url', + url: 'http://localhost:3030/performance/server-loader.data', + }), + }, + }), + transaction: 'GET /performance/server-loader.data', + type: 'transaction', + transaction_info: { source: 'url' }, + platform: 'node', + }), + ); + // ensure we do not have a stray, bogus route attribute + expect(transaction.contexts?.trace?.data?.['http.route']).not.toBeDefined(); + + expect(transaction?.spans).toContainEqual({ span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.http.react-router', + 'sentry.origin': 'auto.http.react-router.loader', 'sentry.op': 'function.react-router.loader', }, description: 'Executing Server Loader', @@ -130,13 +161,13 @@ test.describe('servery - performance', () => { timestamp: expect.any(Number), status: 'ok', op: 'function.react-router.loader', - origin: 'auto.http.react-router', + origin: 'auto.http.react-router.loader', }); }); test('should instrument a wrapped server action', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'POST /performance/server-action'; + return transactionEvent.transaction === 'POST /performance/server-action.data'; }); await page.goto(`/performance/server-action`); @@ -144,11 +175,44 @@ test.describe('servery - performance', () => { const transaction = await txPromise; - expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({ + expect(transaction).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'http.server', + origin: 'auto.http.react-router.action', + parent_span_id: expect.any(String), + status: 'ok', + data: expect.objectContaining({ + 'http.method': 'POST', + 'http.response.status_code': 200, + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/performance/server-action.data', + 'http.url': 'http://localhost:3030/performance/server-action.data', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react-router.action', + 'sentry.source': 'url', + url: 'http://localhost:3030/performance/server-action.data', + }), + }, + }), + transaction: 'POST /performance/server-action.data', + type: 'transaction', + transaction_info: { source: 'url' }, + platform: 'node', + }), + ); + // ensure we do not have a stray, bogus route attribute + expect(transaction.contexts?.trace?.data?.['http.route']).not.toBeDefined(); + + expect(transaction?.spans).toContainEqual({ span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.http.react-router', + 'sentry.origin': 'auto.http.react-router.action', 'sentry.op': 'function.react-router.action', }, description: 'Executing Server Action', @@ -157,7 +221,7 @@ test.describe('servery - performance', () => { timestamp: expect.any(Number), status: 'ok', op: 'function.react-router.action', - origin: 'auto.http.react-router', + origin: 'auto.http.react-router.action', }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/instrument.mjs index 70768dd2a6b4..48e4b7b61ff3 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/instrument.mjs @@ -1,9 +1,8 @@ import * as Sentry from '@sentry/react-router'; Sentry.init({ - // todo: grab from env dsn: 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, - tunnel: `http://localhost:3031/`, // proxy server + tunnel: `http://localhost:3031/`, // proxy server, }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts index b747719b5ff2..dd55a2ed6625 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/performance/performance.server.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; import { APP_NAME } from '../constants'; -test.describe('servery - performance', () => { +test.describe('server - performance', () => { test('should send server transaction on pageload', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { return transactionEvent.transaction === 'GET /performance'; @@ -19,11 +19,11 @@ test.describe('servery - performance', () => { trace_id: expect.any(String), data: { 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.react-router.request-handler', 'sentry.source': 'route', }, op: 'http.server', - origin: 'auto.http.otel.http', + origin: 'auto.http.react-router.request-handler', }, }, spans: expect.any(Array), @@ -70,11 +70,11 @@ test.describe('servery - performance', () => { trace_id: expect.any(String), data: { 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.react-router.request-handler', 'sentry.source': 'route', }, op: 'http.server', - origin: 'auto.http.otel.http', + origin: 'auto.http.react-router.request-handler', }, }, spans: expect.any(Array), @@ -110,26 +110,59 @@ test.describe('servery - performance', () => { return transactionEvent.transaction === 'GET /performance/server-loader.data'; }); - await page.goto(`/performance`); // initial ssr pageloads do not contain .data requests - await page.waitForTimeout(500); // quick breather before navigation + await page.goto('/performance'); // initial ssr pageloads do not contain .data requests await page.getByRole('link', { name: 'Server Loader' }).click(); // this will actually trigger a .data request const transaction = await txPromise; - expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({ + expect(transaction).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'http.server', + origin: 'auto.http.react-router.server', + parent_span_id: expect.any(String), + status: 'ok', + data: expect.objectContaining({ + 'http.method': 'GET', + 'http.response.status_code': 200, + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/performance/server-loader.data', + 'http.url': 'http://localhost:3030/performance/server-loader.data', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react-router.server', + 'sentry.source': 'url', + url: 'http://localhost:3030/performance/server-loader.data', + }), + }, + }), + transaction: 'GET /performance/server-loader.data', + type: 'transaction', + transaction_info: { source: 'url' }, + platform: 'node', + }), + ); + + // ensure we do not have a stray, bogus route attribute + expect(transaction.contexts?.trace?.data?.['http.route']).not.toBeDefined(); + + expect(transaction.spans).toContainEqual({ span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.http.react-router', 'sentry.op': 'function.react-router.loader', + 'sentry.origin': 'auto.http.react-router.server', }, description: 'Executing Server Loader', + op: 'function.react-router.loader', + origin: 'auto.http.react-router.server', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.loader', - origin: 'auto.http.react-router', + timestamp: expect.any(Number), }); }); @@ -143,20 +176,53 @@ test.describe('servery - performance', () => { const transaction = await txPromise; - expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({ + expect(transaction).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + op: 'http.server', + origin: 'auto.http.react-router.server', + parent_span_id: expect.any(String), + status: 'ok', + data: expect.objectContaining({ + 'http.method': 'POST', + 'http.response.status_code': 200, + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/performance/server-action.data', + 'http.url': 'http://localhost:3030/performance/server-action.data', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.react-router.server', + 'sentry.source': 'url', + url: 'http://localhost:3030/performance/server-action.data', + }), + }, + }), + transaction: 'POST /performance/server-action.data', + type: 'transaction', + transaction_info: { source: 'url' }, + platform: 'node', + }), + ); + // ensure we do not have a stray, bogus route attribute + expect(transaction.contexts?.trace?.data?.['http.route']).not.toBeDefined(); + + expect(transaction.spans).toContainEqual({ span_id: expect.any(String), trace_id: expect.any(String), data: { - 'sentry.origin': 'auto.http.react-router', 'sentry.op': 'function.react-router.action', + 'sentry.origin': 'auto.http.react-router.server', }, description: 'Executing Server Action', + op: 'function.react-router.action', + origin: 'auto.http.react-router.server', parent_span_id: expect.any(String), start_timestamp: expect.any(Number), - timestamp: expect.any(Number), status: 'ok', - op: 'function.react-router.action', - origin: 'auto.http.react-router', + timestamp: expect.any(Number), }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs index 70768dd2a6b4..c16240141b6d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/instrument.mjs @@ -1,7 +1,6 @@ import * as Sentry from '@sentry/react-router'; Sentry.init({ - // todo: grab from env dsn: 'https://username@domain/123', environment: 'qa', // dynamic sampling bias to keep transactions tracesSampleRate: 1.0, diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts index 6b80e1e95fd4..f3f5e26a6154 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts @@ -19,11 +19,11 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.react-router.request-handler', 'sentry.source': 'route', }, op: 'http.server', - origin: 'auto.http.otel.http', + origin: 'auto.http.react-router.request-handler', }, }, spans: expect.any(Array), @@ -70,11 +70,11 @@ test.describe('server - performance', () => { trace_id: expect.any(String), data: { 'sentry.op': 'http.server', - 'sentry.origin': 'auto.http.otel.http', + 'sentry.origin': 'auto.http.react-router.request-handler', 'sentry.source': 'route', }, op: 'http.server', - origin: 'auto.http.otel.http', + origin: 'auto.http.react-router.request-handler', }, }, spans: expect.any(Array), diff --git a/packages/react-router/src/server/instrumentation/reactRouter.ts b/packages/react-router/src/server/instrumentation/reactRouter.ts index 5bfc0b62e352..f369e22ce66e 100644 --- a/packages/react-router/src/server/instrumentation/reactRouter.ts +++ b/packages/react-router/src/server/instrumentation/reactRouter.ts @@ -1,5 +1,6 @@ import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import { getActiveSpan, getRootSpan, @@ -8,11 +9,13 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanToJSON, startSpan, + updateSpanName, } from '@sentry/core'; import type * as reactRouter from 'react-router'; import { DEBUG_BUILD } from '../../common/debug-build'; -import { getOpName, getSpanName, isDataRequest, SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './util'; +import { getOpName, getSpanName, isDataRequest } from './util'; type ReactRouterModuleExports = typeof reactRouter; @@ -81,19 +84,23 @@ export class ReactRouterInstrumentation extends InstrumentationBase { return { name: INTEGRATION_NAME, setupOnce() { - instrumentReactRouterServer(); + if ( + (NODE_VERSION.major === 20 && NODE_VERSION.minor < 19) || // https://nodejs.org/en/blog/release/v20.19.0 + (NODE_VERSION.major === 22 && NODE_VERSION.minor < 12) // https://nodejs.org/en/blog/release/v22.12.0 + ) { + instrumentReactRouterServer(); + } + }, + processEvent(event) { + // Express generates bogus `*` routes for data loaders, which we want to remove here + // we cannot do this earlier because some OTEL instrumentation adds this at some unexpected point + if ( + event.type === 'transaction' && + event.contexts?.trace?.data && + event.contexts.trace.data[ATTR_HTTP_ROUTE] === '*' && + // This means the name has been adjusted before, but the http.route remains, so we need to remove it + event.transaction !== 'GET *' && + event.transaction !== 'POST *' + ) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete event.contexts.trace.data[ATTR_HTTP_ROUTE]; + } + + return event; }, }; }); diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts index 07ea80e867ea..9d8a22862a11 100644 --- a/packages/react-router/src/server/sdk.ts +++ b/packages/react-router/src/server/sdk.ts @@ -1,10 +1,8 @@ -import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import type { EventProcessor, Integration } from '@sentry/core'; -import { applySdkMetadata, getGlobalScope, logger, setTag } from '@sentry/core'; +import type { Integration } from '@sentry/core'; +import { applySdkMetadata, logger, setTag } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk, NODE_VERSION } from '@sentry/node'; +import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util'; import { lowQualityTransactionsFilterIntegration } from './integration/lowQualityTransactionsFilterIntegration'; import { reactRouterServerIntegration } from './integration/reactRouterServer'; @@ -13,16 +11,11 @@ import { reactRouterServerIntegration } from './integration/reactRouterServer'; * @param options The options for the SDK. */ export function getDefaultReactRouterServerIntegrations(options: NodeOptions): Integration[] { - const integrations = [...getNodeDefaultIntegrations(options), lowQualityTransactionsFilterIntegration(options)]; - - if ( - (NODE_VERSION.major === 20 && NODE_VERSION.minor < 19) || // https://nodejs.org/en/blog/release/v20.19.0 - (NODE_VERSION.major === 22 && NODE_VERSION.minor < 12) // https://nodejs.org/en/blog/release/v22.12.0 - ) { - integrations.push(reactRouterServerIntegration()); - } - - return integrations; + return [ + ...getNodeDefaultIntegrations(options), + lowQualityTransactionsFilterIntegration(options), + reactRouterServerIntegration(), + ]; } /** @@ -42,31 +35,6 @@ export function init(options: NodeOptions): NodeClient | undefined { setTag('runtime', 'node'); - // Overwrite the transaction name for instrumented data loaders because the trace data gets overwritten at a later point. - // We only update the tx in case SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE got set in our instrumentation before. - getGlobalScope().addEventProcessor( - Object.assign( - (event => { - const overwrite = event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]; - if ( - event.type === 'transaction' && - (event.transaction === 'GET *' || event.transaction === 'POST *') && - event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE] === '*' && - overwrite - ) { - event.transaction = overwrite; - event.contexts.trace.data[ATTR_HTTP_ROUTE] = 'url'; - } - - // always yeet this attribute into the void, as this should not reach the server - delete event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]; - - return event; - }) satisfies EventProcessor, - { id: 'ReactRouterTransactionEnhancer' }, - ), - ); - DEBUG_BUILD && logger.log('SDK successfully initialized'); return client; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 40a336a40fbd..df7d65109338 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -5,7 +5,7 @@ import { getActiveSpan, getRootSpan, getTraceMetaTags, - SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import type { AppLoadContext, EntryContext } from 'react-router'; @@ -37,26 +37,25 @@ export function wrapSentryHandleRequest(originalHandle: OriginalHandleRequest): const parameterizedPath = routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; - if (parameterizedPath) { - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - const routeName = `/${parameterizedPath}`; + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - // The express instrumentation writes on the rpcMetadata and that ends up stomping on the `http.route` attribute. - const rpcMetadata = getRPCMetadata(context.active()); + if (parameterizedPath && rootSpan) { + const routeName = `/${parameterizedPath}`; - if (rpcMetadata?.type === RPCType.HTTP) { - rpcMetadata.route = routeName; - } + // The express instrumentation writes on the rpcMetadata and that ends up stomping on the `http.route` attribute. + const rpcMetadata = getRPCMetadata(context.active()); - // The span exporter picks up the `http.route` (ATTR_HTTP_ROUTE) attribute to set the transaction name - rootSpan.setAttributes({ - [ATTR_HTTP_ROUTE]: routeName, - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME]: `${request.method} ${routeName}`, - }); + if (rpcMetadata?.type === RPCType.HTTP) { + rpcMetadata.route = routeName; } + + // The span exporter picks up the `http.route` (ATTR_HTTP_ROUTE) attribute to set the transaction name + rootSpan.setAttributes({ + [ATTR_HTTP_ROUTE]: routeName, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.request-handler', + }); } return originalHandle(request, responseStatusCode, responseHeaders, routerContext, loadContext); diff --git a/packages/react-router/src/server/wrapServerAction.ts b/packages/react-router/src/server/wrapServerAction.ts index 9da0e8d351f8..7dc8851e2171 100644 --- a/packages/react-router/src/server/wrapServerAction.ts +++ b/packages/react-router/src/server/wrapServerAction.ts @@ -1,16 +1,16 @@ +import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes } from '@sentry/core'; import { getActiveSpan, getRootSpan, - parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, startSpan, + updateSpanName, } from '@sentry/core'; import type { ActionFunctionArgs } from 'react-router'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util'; type SpanOptions = { name?: string; @@ -42,13 +42,18 @@ export function wrapServerAction(options: SpanOptions = {}, actionFn: (args: const active = getActiveSpan(); if (active) { const root = getRootSpan(active); - // coming from auto.http.otel.http - if (spanToJSON(root).description === 'POST') { - const url = parseStringToURLObject(args.request.url); - if (url?.pathname) { + const spanData = spanToJSON(root); + if (spanData.origin === 'auto.http.otel.http') { + // eslint-disable-next-line deprecation/deprecation + const target = spanData.data[SEMATTRS_HTTP_TARGET]; + + if (target) { + // We cannot rely on the regular span name inferral here, as the express instrumentation sets `*` as the route + // So we force this to be a more sensible name here + updateSpanName(root, `${args.request.method} ${target}`); root.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]: `${args.request.method} ${url.pathname}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.action', }); } } @@ -59,7 +64,7 @@ export function wrapServerAction(options: SpanOptions = {}, actionFn: (args: name, ...options, attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.action', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', ...options.attributes, }, diff --git a/packages/react-router/src/server/wrapServerLoader.ts b/packages/react-router/src/server/wrapServerLoader.ts index dda64a1a9204..3d32f0c9d159 100644 --- a/packages/react-router/src/server/wrapServerLoader.ts +++ b/packages/react-router/src/server/wrapServerLoader.ts @@ -1,16 +1,16 @@ +import { SEMATTRS_HTTP_TARGET } from '@opentelemetry/semantic-conventions'; import type { SpanAttributes } from '@sentry/core'; import { getActiveSpan, getRootSpan, - parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, startSpan, + updateSpanName, } from '@sentry/core'; import type { LoaderFunctionArgs } from 'react-router'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util'; type SpanOptions = { name?: string; @@ -40,16 +40,21 @@ export function wrapServerLoader(options: SpanOptions = {}, loaderFn: (args: return async function (args: LoaderFunctionArgs) { const name = options.name || 'Executing Server Loader'; const active = getActiveSpan(); + if (active) { const root = getRootSpan(active); - // coming from auto.http.otel.http - if (spanToJSON(root).description === 'GET') { - const url = parseStringToURLObject(args.request.url); + const spanData = spanToJSON(root); + if (spanData.origin === 'auto.http.otel.http') { + // eslint-disable-next-line deprecation/deprecation + const target = spanData.data[SEMATTRS_HTTP_TARGET]; - if (url?.pathname) { + if (target) { + // We cannot rely on the regular span name inferral here, as the express instrumentation sets `*` as the route + // So we force this to be a more sensible name here + updateSpanName(root, `${args.request.method} ${target}`); root.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE]: `${args.request.method} ${url.pathname}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.loader', }); } } @@ -59,7 +64,7 @@ export function wrapServerLoader(options: SpanOptions = {}, loaderFn: (args: name, ...options, attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.loader', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', ...options.attributes, }, diff --git a/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts index ddcb856c68b9..d89fdf624294 100644 --- a/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts +++ b/packages/react-router/test/server/instrumentation/reactRouterServer.test.ts @@ -1,4 +1,4 @@ -import type { Span } from '@sentry/core'; +import type { Span, SpanJSON } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ReactRouterInstrumentation } from '../../../src/server/instrumentation/reactRouter'; @@ -8,6 +8,8 @@ vi.mock('@sentry/core', async () => { return { getActiveSpan: vi.fn(), getRootSpan: vi.fn(), + spanToJSON: vi.fn(), + updateSpanName: vi.fn(), logger: { debug: vi.fn(), }, @@ -90,6 +92,7 @@ describe('ReactRouterInstrumentation', () => { vi.spyOn(Util, 'isDataRequest').mockReturnValue(true); vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue(mockSpan as Span); 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}`); vi.spyOn(SentryCore, 'startSpan').mockImplementation((_opts, fn) => fn(mockSpan as Span)); diff --git a/packages/react-router/test/server/integration/reactRouterServer.test.ts b/packages/react-router/test/server/integration/reactRouterServer.test.ts new file mode 100644 index 000000000000..a5eac42643e5 --- /dev/null +++ b/packages/react-router/test/server/integration/reactRouterServer.test.ts @@ -0,0 +1,65 @@ +import * as SentryNode from '@sentry/node'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ReactRouterInstrumentation } from '../../../src/server/instrumentation/reactRouter'; +import { reactRouterServerIntegration } from '../../../src/server/integration/reactRouterServer'; + +vi.mock('../../../src/server/instrumentation/reactRouter', () => { + return { + ReactRouterInstrumentation: vi.fn(), + }; +}); + +vi.mock('@sentry/node', () => { + return { + generateInstrumentOnce: vi.fn((_name: string, callback: () => any) => { + return Object.assign(callback, { id: 'test' }); + }), + NODE_VERSION: { + major: 0, + minor: 0, + patch: 0, + }, + }; +}); + +describe('reactRouterServerIntegration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sets up ReactRouterInstrumentation for Node 20.18', () => { + vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 20, minor: 18, patch: 0 }); + + const integration = reactRouterServerIntegration(); + integration.setupOnce!(); + + expect(ReactRouterInstrumentation).toHaveBeenCalled(); + }); + + it('sets up ReactRouterInstrumentationfor Node.js 22.11', () => { + vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 22, minor: 11, patch: 0 }); + + const integration = reactRouterServerIntegration(); + integration.setupOnce!(); + + expect(ReactRouterInstrumentation).toHaveBeenCalled(); + }); + + it('does not set up ReactRouterInstrumentation for Node.js 20.19', () => { + vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 20, minor: 19, patch: 0 }); + + const integration = reactRouterServerIntegration(); + integration.setupOnce!(); + + expect(ReactRouterInstrumentation).not.toHaveBeenCalled(); + }); + + it('does not set up ReactRouterInstrumentation for Node.js 22.12', () => { + vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 22, minor: 12, patch: 0 }); + + const integration = reactRouterServerIntegration(); + integration.setupOnce!(); + + expect(ReactRouterInstrumentation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts index 861144e3f62b..6e1879f8e24b 100644 --- a/packages/react-router/test/server/sdk.test.ts +++ b/packages/react-router/test/server/sdk.test.ts @@ -72,27 +72,7 @@ describe('React Router server SDK', () => { expect(filterIntegration).toBeDefined(); }); - it('adds reactRouterServer integration for Node.js 20.18', () => { - vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 20, minor: 18, patch: 0 }); - - reactRouterInit({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }); - - expect(nodeInit).toHaveBeenCalledTimes(1); - const initOptions = nodeInit.mock.calls[0]?.[0]; - const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; - - const reactRouterServerIntegration = defaultIntegrations.find( - integration => integration.name === 'ReactRouterServer', - ); - - expect(reactRouterServerIntegration).toBeDefined(); - }); - - it('adds reactRouterServer integration for Node.js 22.11', () => { - vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 22, minor: 11, patch: 0 }); - + it('adds reactRouterServer integration by default', () => { reactRouterInit({ dsn: 'https://public@dsn.ingest.sentry.io/1337', }); @@ -107,41 +87,5 @@ describe('React Router server SDK', () => { expect(reactRouterServerIntegration).toBeDefined(); }); - - it('does not add reactRouterServer integration for Node.js 20.19', () => { - vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 20, minor: 19, patch: 0 }); - - reactRouterInit({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }); - - expect(nodeInit).toHaveBeenCalledTimes(1); - const initOptions = nodeInit.mock.calls[0]?.[0]; - const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; - - const reactRouterServerIntegration = defaultIntegrations.find( - integration => integration.name === 'ReactRouterServer', - ); - - expect(reactRouterServerIntegration).toBeUndefined(); - }); - - it('does not add reactRouterServer integration for Node.js 22.12', () => { - vi.spyOn(SentryNode, 'NODE_VERSION', 'get').mockReturnValue({ major: 22, minor: 12, patch: 0 }); - - reactRouterInit({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }); - - expect(nodeInit).toHaveBeenCalledTimes(1); - const initOptions = nodeInit.mock.calls[0]?.[0]; - const defaultIntegrations = initOptions?.defaultIntegrations as Integration[]; - - const reactRouterServerIntegration = defaultIntegrations.find( - integration => integration.name === 'ReactRouterServer', - ); - - expect(reactRouterServerIntegration).toBeUndefined(); - }); }); }); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index 61a92e7b6546..40dce7c83702 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -1,6 +1,12 @@ import { RPCType } from '@opentelemetry/core'; import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import { getActiveSpan, getRootSpan, getTraceMetaTags, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + getActiveSpan, + getRootSpan, + getTraceMetaTags, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; import { PassThrough } from 'stream'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { getMetaTagTransformer, wrapSentryHandleRequest } from '../../src/server/wrapSentryHandleRequest'; @@ -12,7 +18,7 @@ vi.mock('@opentelemetry/core', () => ({ vi.mock('@sentry/core', () => ({ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE: 'sentry.source', - SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME: 'sentry.custom-span-name', + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN: 'sentry.origin', getActiveSpan: vi.fn(), getRootSpan: vi.fn(), getTraceMetaTags: vi.fn(), @@ -49,7 +55,7 @@ describe('wrapSentryHandleRequest', () => { const originalHandler = vi.fn().mockResolvedValue('test'); const wrappedHandler = wrapSentryHandleRequest(originalHandler); - const mockActiveSpan = { setAttribute: vi.fn() }; + const mockActiveSpan = {}; const mockRootSpan = { setAttributes: vi.fn() }; const mockRpcMetadata = { type: RPCType.HTTP, route: '/some-path' }; @@ -66,17 +72,21 @@ describe('wrapSentryHandleRequest', () => { await wrappedHandler(new Request('https://nacho.queso'), 200, new Headers(), routerContext, {} as any); - expect(getActiveSpan).toHaveBeenCalled(); - expect(getRootSpan).toHaveBeenCalledWith(mockActiveSpan); expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ [ATTR_HTTP_ROUTE]: '/some-path', - 'sentry.custom-span-name': 'GET /some-path', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.request-handler', }); expect(mockRpcMetadata.route).toBe('/some-path'); }); test('should not set span attributes when parameterized path does not exist', async () => { + const mockActiveSpan = {}; + const mockRootSpan = { setAttributes: vi.fn() }; + + (getActiveSpan as unknown as ReturnType).mockReturnValue(mockActiveSpan); + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); + const originalHandler = vi.fn().mockResolvedValue('test'); const wrappedHandler = wrapSentryHandleRequest(originalHandler); @@ -88,15 +98,20 @@ describe('wrapSentryHandleRequest', () => { await wrappedHandler(new Request('https://guapo.chulo'), 200, new Headers(), routerContext, {} as any); - expect(getActiveSpan).not.toHaveBeenCalled(); + expect(mockRootSpan.setAttributes).not.toHaveBeenCalled(); }); test('should not set span attributes when active span does not exist', async () => { const originalHandler = vi.fn().mockResolvedValue('test'); const wrappedHandler = wrapSentryHandleRequest(originalHandler); + const mockRpcMetadata = { type: RPCType.HTTP, route: '/some-path' }; + (getActiveSpan as unknown as ReturnType).mockReturnValue(null); + const getRPCMetadata = vi.fn().mockReturnValue(mockRpcMetadata); + vi.mocked(vi.importActual('@opentelemetry/core')).getRPCMetadata = getRPCMetadata; + const routerContext = { staticHandlerContext: { matches: [{ route: { path: 'some-path' } }], @@ -105,8 +120,7 @@ describe('wrapSentryHandleRequest', () => { await wrappedHandler(new Request('https://tio.pepe'), 200, new Headers(), routerContext, {} as any); - expect(getActiveSpan).toHaveBeenCalled(); - expect(getRootSpan).not.toHaveBeenCalled(); + expect(getRPCMetadata).not.toHaveBeenCalled(); }); }); diff --git a/packages/react-router/test/server/wrapServerAction.test.ts b/packages/react-router/test/server/wrapServerAction.test.ts index 931e4c72b446..14933fe87e4f 100644 --- a/packages/react-router/test/server/wrapServerAction.test.ts +++ b/packages/react-router/test/server/wrapServerAction.test.ts @@ -20,7 +20,7 @@ describe('wrapServerAction', () => { { name: 'Executing Server Action', attributes: { - [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.action', [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', }, }, @@ -48,7 +48,7 @@ describe('wrapServerAction', () => { { name: 'Custom Action', attributes: { - [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.action', [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.action', 'sentry.custom': 'value', }, diff --git a/packages/react-router/test/server/wrapServerLoader.test.ts b/packages/react-router/test/server/wrapServerLoader.test.ts index 53fce752286b..67b7d512bcbe 100644 --- a/packages/react-router/test/server/wrapServerLoader.test.ts +++ b/packages/react-router/test/server/wrapServerLoader.test.ts @@ -20,7 +20,7 @@ describe('wrapServerLoader', () => { { name: 'Executing Server Loader', attributes: { - [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.loader', [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', }, }, @@ -48,7 +48,7 @@ describe('wrapServerLoader', () => { { name: 'Custom Loader', attributes: { - [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router', + [core.SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react-router.loader', [core.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.react-router.loader', 'sentry.custom': 'value', }, From b0623d8e4edf321840eeb4923b5607ca59a5dc90 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:46:10 +0200 Subject: [PATCH 31/37] chore(node-core): Add node-core to release-registry in .craft.yml (#17016) Counter-part in sentry-release-registry: https://github.com/getsentry/sentry-release-registry/pull/200 --- .craft.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.craft.yml b/.craft.yml index 6d2076434180..f9c56070d9b2 100644 --- a/.craft.yml +++ b/.craft.yml @@ -207,6 +207,8 @@ targets: onlyIfPresent: /^sentry-nuxt-\d.*\.tgz$/ 'npm:@sentry/node': onlyIfPresent: /^sentry-node-\d.*\.tgz$/ + 'npm:@sentry/node-core': + onlyIfPresent: /^sentry-node-core-\d.*\.tgz$/ 'npm:@sentry/react': onlyIfPresent: /^sentry-react-\d.*\.tgz$/ 'npm:@sentry/react-router': From d8faaa25146af0aae03fdf8682034ec47044f6c2 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 15 Jul 2025 09:50:18 -0400 Subject: [PATCH 32/37] ref(solidstart): Use `debug` instead of `logger` (#16936) resolves https://github.com/getsentry/sentry-javascript/issues/16935 --- packages/solidstart/src/config/withSentry.ts | 4 ++-- packages/solidstart/src/server/utils.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/solidstart/src/config/withSentry.ts b/packages/solidstart/src/config/withSentry.ts index a18e5841d958..0c4352182fcb 100644 --- a/packages/solidstart/src/config/withSentry.ts +++ b/packages/solidstart/src/config/withSentry.ts @@ -1,4 +1,4 @@ -import { logger } from '@sentry/core'; +import { debug } from '@sentry/core'; import type { Nitro } from 'nitropack'; import { addSentryPluginToVite } from '../vite/sentrySolidStartVite'; import type { SentrySolidStartPluginOptions } from '../vite/types'; @@ -55,7 +55,7 @@ export function withSentry( await addDynamicImportEntryFileWrapper({ nitro, rollupConfig: config, sentryPluginOptions }); sentrySolidStartPluginOptions.debug && - logger.log( + debug.log( 'Wrapping the server entry file with a dynamic `import()`, so Sentry can be preloaded before the server initializes.', ); } else { diff --git a/packages/solidstart/src/server/utils.ts b/packages/solidstart/src/server/utils.ts index 956a0a3c4653..e4c70fef633b 100644 --- a/packages/solidstart/src/server/utils.ts +++ b/packages/solidstart/src/server/utils.ts @@ -1,5 +1,5 @@ import type { EventProcessor, Options } from '@sentry/core'; -import { logger } from '@sentry/core'; +import { debug } from '@sentry/core'; import { flush, getGlobalScope } from '@sentry/node'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -9,11 +9,11 @@ export async function flushIfServerless(): Promise { if (isServerless) { try { - DEBUG_BUILD && logger.log('Flushing events...'); + DEBUG_BUILD && debug.log('Flushing events...'); await flush(2000); - DEBUG_BUILD && logger.log('Done flushing events'); + DEBUG_BUILD && debug.log('Done flushing events'); } catch (e) { - DEBUG_BUILD && logger.log('Error while flushing events:\n', e); + DEBUG_BUILD && debug.log('Error while flushing events:\n', e); } } } @@ -46,7 +46,7 @@ export function filterLowQualityTransactions(options: Options): void { } // Filter out transactions for build assets if (event.transaction?.match(/^GET \/_build\//)) { - options.debug && logger.log('SolidStartLowQualityTransactionsFilter filtered transaction', event.transaction); + options.debug && debug.log('SolidStartLowQualityTransactionsFilter filtered transaction', event.transaction); return null; } return event; From 92e55a9949e9e4cc5fa6dcbb2e41d92147347078 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 15 Jul 2025 09:50:28 -0400 Subject: [PATCH 33/37] ref(pino-transport): Use `debug` instead of `logger` (#16957) resolves https://github.com/getsentry/sentry-javascript/issues/16942 --------- Co-authored-by: Cursor Agent --- packages/pino-transport/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pino-transport/src/index.ts b/packages/pino-transport/src/index.ts index 34cfe9ecde86..7bff4dc35327 100644 --- a/packages/pino-transport/src/index.ts +++ b/packages/pino-transport/src/index.ts @@ -1,5 +1,5 @@ import type { LogSeverityLevel } from '@sentry/core'; -import { _INTERNAL_captureLog, isPrimitive, logger, normalize } from '@sentry/core'; +import { _INTERNAL_captureLog, debug, isPrimitive, normalize } from '@sentry/core'; import type buildType from 'pino-abstract-transport'; import * as pinoAbstractTransport from 'pino-abstract-transport'; import { DEBUG_BUILD } from './debug-build'; @@ -95,7 +95,7 @@ interface PinoSourceConfig { * the stable Sentry SDK API and can be changed or removed without warning. */ export function createSentryPinoTransport(options?: SentryPinoTransportOptions): ReturnType { - DEBUG_BUILD && logger.log('Initializing Sentry Pino transport'); + DEBUG_BUILD && debug.log('Initializing Sentry Pino transport'); const capturedLogLevels = new Set(options?.logLevels ?? DEFAULT_CAPTURED_LEVELS); return build( From e07cdfd1a8bae8477552d52253bf53eaa99f6cae Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 15 Jul 2025 09:50:37 -0400 Subject: [PATCH 34/37] ref(profiling-node): Use `debug` instead of `logger` (#16959) resolves https://github.com/getsentry/sentry-javascript/issues/16941 --------- Co-authored-by: Cursor Agent --- packages/profiling-node/src/integration.ts | 88 +++++++++---------- .../profiling-node/src/spanProfileUtils.ts | 18 ++-- packages/profiling-node/src/utils.ts | 14 +-- .../profiling-node/test/integration.test.ts | 6 +- 4 files changed, 62 insertions(+), 64 deletions(-) diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 67ad0c0ed2e3..fb0cdbe6195c 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -2,12 +2,12 @@ import type { Event, IntegrationFn, Profile, ProfileChunk, ProfilingIntegration, Span } from '@sentry/core'; import { consoleSandbox, + debug, defineIntegration, getCurrentScope, getGlobalScope, getIsolationScope, getRootSpan, - logger, LRUMap, spanToJSON, uuid4, @@ -75,7 +75,7 @@ class ContinuousProfiler { this._legacyProfilerMode = 'profilesSampleRate' in options || 'profilesSampler' in options ? 'span' : 'continuous'; - DEBUG_BUILD && logger.log(`[Profiling] Profiling mode is ${this._legacyProfilerMode}.`); + DEBUG_BUILD && debug.log(`[Profiling] Profiling mode is ${this._legacyProfilerMode}.`); switch (this._legacyProfilerMode) { case 'span': { @@ -88,7 +88,7 @@ class ContinuousProfiler { } default: { DEBUG_BUILD && - logger.warn( + debug.warn( `[Profiling] Unknown profiler mode: ${this._legacyProfilerMode}, profiler was not initialized`, ); break; @@ -100,7 +100,7 @@ class ContinuousProfiler { case 'current': { this._setupSpanChunkInstrumentation(); - DEBUG_BUILD && logger.log(`[Profiling] Profiling mode is ${this._profileLifecycle}.`); + DEBUG_BUILD && debug.log(`[Profiling] Profiling mode is ${this._profileLifecycle}.`); switch (this._profileLifecycle) { case 'trace': { @@ -113,14 +113,14 @@ class ContinuousProfiler { } default: { DEBUG_BUILD && - logger.warn(`[Profiling] Unknown profiler mode: ${this._profileLifecycle}, profiler was not initialized`); + debug.warn(`[Profiling] Unknown profiler mode: ${this._profileLifecycle}, profiler was not initialized`); break; } } break; } default: { - DEBUG_BUILD && logger.warn(`[Profiling] Unknown profiler mode: ${this._mode}, profiler was not initialized`); + DEBUG_BUILD && debug.warn(`[Profiling] Unknown profiler mode: ${this._mode}, profiler was not initialized`); break; } } @@ -142,17 +142,17 @@ class ContinuousProfiler { } if (!this._client) { - DEBUG_BUILD && logger.log('[Profiling] Failed to start, sentry client was never attached to the profiler.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to start, sentry client was never attached to the profiler.'); return; } if (this._mode !== 'legacy') { - DEBUG_BUILD && logger.log('[Profiling] Continuous profiling is not supported in the current mode.'); + DEBUG_BUILD && debug.log('[Profiling] Continuous profiling is not supported in the current mode.'); return; } if (this._legacyProfilerMode === 'span') { - DEBUG_BUILD && logger.log('[Profiling] Calls to profiler.start() are not supported in span profiling mode.'); + DEBUG_BUILD && debug.log('[Profiling] Calls to profiler.start() are not supported in span profiling mode.'); return; } @@ -176,17 +176,17 @@ class ContinuousProfiler { } if (!this._client) { - DEBUG_BUILD && logger.log('[Profiling] Failed to stop, sentry client was never attached to the profiler.'); + DEBUG_BUILD && debug.log('[Profiling] Failed to stop, sentry client was never attached to the profiler.'); return; } if (this._mode !== 'legacy') { - DEBUG_BUILD && logger.log('[Profiling] Continuous profiling is not supported in the current mode.'); + DEBUG_BUILD && debug.log('[Profiling] Continuous profiling is not supported in the current mode.'); return; } if (this._legacyProfilerMode === 'span') { - DEBUG_BUILD && logger.log('[Profiling] Calls to profiler.stop() are not supported in span profiling mode.'); + DEBUG_BUILD && debug.log('[Profiling] Calls to profiler.stop() are not supported in span profiling mode.'); return; } @@ -196,25 +196,25 @@ class ContinuousProfiler { private _startProfiler(): void { if (this._mode !== 'current') { - DEBUG_BUILD && logger.log('[Profiling] Continuous profiling is not supported in the current mode.'); + DEBUG_BUILD && debug.log('[Profiling] Continuous profiling is not supported in the current mode.'); return; } if (this._chunkData !== undefined) { - DEBUG_BUILD && logger.log('[Profiling] Profile session already running, no-op.'); + DEBUG_BUILD && debug.log('[Profiling] Profile session already running, no-op.'); return; } if (this._mode === 'current') { if (!this._sampled) { - DEBUG_BUILD && logger.log('[Profiling] Profile session not sampled, no-op.'); + DEBUG_BUILD && debug.log('[Profiling] Profile session not sampled, no-op.'); return; } } if (this._profileLifecycle === 'trace') { DEBUG_BUILD && - logger.log( + debug.log( '[Profiling] You are using the trace profile lifecycle, manual calls to profiler.startProfiler() and profiler.stopProfiler() will be ignored.', ); return; @@ -225,20 +225,20 @@ class ContinuousProfiler { private _stopProfiler(): void { if (this._mode !== 'current') { - DEBUG_BUILD && logger.log('[Profiling] Continuous profiling is not supported in the current mode.'); + DEBUG_BUILD && debug.log('[Profiling] Continuous profiling is not supported in the current mode.'); return; } if (this._profileLifecycle === 'trace') { DEBUG_BUILD && - logger.log( + debug.log( '[Profiling] You are using the trace profile lifecycle, manual calls to profiler.startProfiler() and profiler.stopProfiler() will be ignored.', ); return; } if (!this._chunkData) { - DEBUG_BUILD && logger.log('[Profiling] No profile session running, no-op.'); + DEBUG_BUILD && debug.log('[Profiling] No profile session running, no-op.'); return; } @@ -251,7 +251,7 @@ class ContinuousProfiler { private _startTraceLifecycleProfiling(): void { if (!this._client) { DEBUG_BUILD && - logger.log( + debug.log( '[Profiling] Failed to start trace lifecycle profiling, sentry client was never attached to the profiler.', ); return; @@ -276,7 +276,7 @@ class ContinuousProfiler { private _setupAutomaticSpanProfiling(): void { if (!this._client) { DEBUG_BUILD && - logger.log( + debug.log( '[Profiling] Failed to setup automatic span profiling, sentry client was never attached to the profiler.', ); return; @@ -307,7 +307,7 @@ class ContinuousProfiler { // Enqueue a timeout to prevent profiles from running over max duration. const timeout = global.setTimeout(() => { DEBUG_BUILD && - logger.log( + debug.log( '[Profiling] max profile duration elapsed, stopping profiling for:', spanToJSON(span).description, ); @@ -371,7 +371,7 @@ class ContinuousProfiler { const cpuProfile = takeFromProfileQueue(profile_id); if (!cpuProfile) { - DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + DEBUG_BUILD && debug.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); continue; } @@ -406,13 +406,13 @@ class ContinuousProfiler { // The client is not attached to the profiler if the user has not enabled continuous profiling. // In this case, calling start() and stop() is a noop action.The reason this exists is because // it makes the types easier to work with and avoids users having to do null checks. - DEBUG_BUILD && logger.log('[Profiling] Profiler was never attached to the client.'); + DEBUG_BUILD && debug.log('[Profiling] Profiler was never attached to the client.'); return; } if (this._chunkData) { DEBUG_BUILD && - logger.log( + debug.log( `[Profiling] Chunk with chunk_id ${this._chunkData.id} is still running, current chunk will be stopped a new chunk will be started.`, ); this._stopChunkProfiling(); @@ -426,26 +426,26 @@ class ContinuousProfiler { */ private _stopChunkProfiling(): void { if (!this._chunkData) { - DEBUG_BUILD && logger.log('[Profiling] No chunk data found, no-op.'); + DEBUG_BUILD && debug.log('[Profiling] No chunk data found, no-op.'); return; } if (this._chunkData?.timer) { global.clearTimeout(this._chunkData.timer); this._chunkData.timer = undefined; - DEBUG_BUILD && logger.log(`[Profiling] Stopping profiling chunk: ${this._chunkData.id}`); + DEBUG_BUILD && debug.log(`[Profiling] Stopping profiling chunk: ${this._chunkData.id}`); } if (!this._client) { DEBUG_BUILD && - logger.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.'); + debug.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.'); this._resetChunkData(); return; } if (!this._chunkData?.id) { DEBUG_BUILD && - logger.log(`[Profiling] Failed to collect profile for: ${this._chunkData?.id}, the chunk_id is missing.`); + debug.log(`[Profiling] Failed to collect profile for: ${this._chunkData?.id}, the chunk_id is missing.`); this._resetChunkData(); return; } @@ -453,22 +453,22 @@ class ContinuousProfiler { const profile = CpuProfilerBindings.stopProfiling(this._chunkData.id, ProfileFormat.CHUNK); if (!profile) { - DEBUG_BUILD && logger.log(`[Profiling] Failed to collect profile for: ${this._chunkData.id}`); + DEBUG_BUILD && debug.log(`[Profiling] Failed to collect profile for: ${this._chunkData.id}`); this._resetChunkData(); return; } if (!this._profilerId) { DEBUG_BUILD && - logger.log('[Profiling] Profile chunk does not contain a valid profiler_id, this is a bug in the SDK'); + debug.log('[Profiling] Profile chunk does not contain a valid profiler_id, this is a bug in the SDK'); this._resetChunkData(); return; } if (profile) { - DEBUG_BUILD && logger.log(`[Profiling] Sending profile chunk ${this._chunkData.id}.`); + DEBUG_BUILD && debug.log(`[Profiling] Sending profile chunk ${this._chunkData.id}.`); } - DEBUG_BUILD && logger.log(`[Profiling] Profile chunk ${this._chunkData.id} sent to Sentry.`); + DEBUG_BUILD && debug.log(`[Profiling] Profile chunk ${this._chunkData.id} sent to Sentry.`); const chunk = createProfilingChunkEvent( this._client, this._client.getOptions(), @@ -482,7 +482,7 @@ class ContinuousProfiler { ); if (!chunk) { - DEBUG_BUILD && logger.log(`[Profiling] Failed to create profile chunk for: ${this._chunkData.id}`); + DEBUG_BUILD && debug.log(`[Profiling] Failed to create profile chunk for: ${this._chunkData.id}`); this._resetChunkData(); return; } @@ -502,13 +502,13 @@ class ContinuousProfiler { private _flush(chunk: ProfileChunk): void { if (!this._client) { DEBUG_BUILD && - logger.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.'); + debug.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.'); return; } const transport = this._client.getTransport(); if (!transport) { - DEBUG_BUILD && logger.log('[Profiling] No transport available to send profile chunk.'); + DEBUG_BUILD && debug.log('[Profiling] No transport available to send profile chunk.'); return; } @@ -518,7 +518,7 @@ class ContinuousProfiler { const envelope = makeProfileChunkEnvelope('node', chunk, metadata?.sdk, tunnel, dsn); transport.send(envelope).then(null, reason => { - DEBUG_BUILD && logger.error('Error while sending profile chunk envelope:', reason); + DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); }); } @@ -528,7 +528,7 @@ class ContinuousProfiler { */ private _startChunkProfiling(): void { if (this._chunkData) { - DEBUG_BUILD && logger.log('[Profiling] Chunk is already running, no-op.'); + DEBUG_BUILD && debug.log('[Profiling] Chunk is already running, no-op.'); return; } @@ -537,12 +537,12 @@ class ContinuousProfiler { const chunk = this._initializeChunk(traceId); CpuProfilerBindings.startProfiling(chunk.id); - DEBUG_BUILD && logger.log(`[Profiling] starting profiling chunk: ${chunk.id}`); + DEBUG_BUILD && debug.log(`[Profiling] starting profiling chunk: ${chunk.id}`); chunk.timer = global.setTimeout(() => { - DEBUG_BUILD && logger.log(`[Profiling] Stopping profiling chunk: ${chunk.id}`); + DEBUG_BUILD && debug.log(`[Profiling] Stopping profiling chunk: ${chunk.id}`); this._stopChunkProfiling(); - DEBUG_BUILD && logger.log('[Profiling] Starting new profiling chunk.'); + DEBUG_BUILD && debug.log('[Profiling] Starting new profiling chunk.'); setImmediate(this._restartChunkProfiling.bind(this)); }, CHUNK_INTERVAL_MS); @@ -557,9 +557,7 @@ class ContinuousProfiler { private _setupSpanChunkInstrumentation(): void { if (!this._client) { DEBUG_BUILD && - logger.log( - '[Profiling] Failed to initialize span profiling, sentry client was never attached to the profiler.', - ); + debug.log('[Profiling] Failed to initialize span profiling, sentry client was never attached to the profiler.'); return; } @@ -648,7 +646,7 @@ export const _nodeProfilingIntegration = ((): ProfilingIntegration = name: 'ProfilingIntegration', _profiler: new ContinuousProfiler(), setup(client: NodeClient) { - DEBUG_BUILD && logger.log('[Profiling] Profiling integration setup.'); + DEBUG_BUILD && debug.log('[Profiling] Profiling integration setup.'); this._profiler.initialize(client); return; }, diff --git a/packages/profiling-node/src/spanProfileUtils.ts b/packages/profiling-node/src/spanProfileUtils.ts index 3cde8acde52d..0f3365bbd261 100644 --- a/packages/profiling-node/src/spanProfileUtils.ts +++ b/packages/profiling-node/src/spanProfileUtils.ts @@ -1,6 +1,6 @@ /* eslint-disable deprecation/deprecation */ import type { CustomSamplingContext, Span } from '@sentry/core'; -import { logger, spanIsSampled, spanToJSON, uuid4 } from '@sentry/core'; +import { debug, spanIsSampled, spanToJSON, uuid4 } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { type RawThreadCpuProfile, CpuProfilerBindings } from '@sentry-internal/node-cpu-profiler'; import { DEBUG_BUILD } from './debug-build'; @@ -26,13 +26,13 @@ export function maybeProfileSpan( // Client and options are required for profiling if (!client) { - DEBUG_BUILD && logger.log('[Profiling] Profiling disabled, no client found.'); + DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no client found.'); return; } const options = client.getOptions(); if (!options) { - DEBUG_BUILD && logger.log('[Profiling] Profiling disabled, no options found.'); + DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no options found.'); return; } @@ -56,14 +56,14 @@ export function maybeProfileSpan( // 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.) if (!isValidSampleRate(profilesSampleRate)) { - DEBUG_BUILD && logger.warn('[Profiling] Discarding profile because of invalid sample rate.'); + DEBUG_BUILD && debug.warn('[Profiling] Discarding profile because of invalid sample rate.'); return; } // if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped if (!profilesSampleRate) { DEBUG_BUILD && - logger.log( + debug.log( `[Profiling] Discarding profile because ${ typeof profilesSampler === 'function' ? 'profileSampler returned 0 or false' @@ -79,7 +79,7 @@ export function maybeProfileSpan( // Check if we should sample this profile if (!sampled) { DEBUG_BUILD && - logger.log( + debug.log( `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number( profilesSampleRate, )})`, @@ -89,7 +89,7 @@ export function maybeProfileSpan( const profile_id = uuid4(); CpuProfilerBindings.startProfiling(profile_id); - DEBUG_BUILD && logger.log(`[Profiling] started profiling transaction: ${spanToJSON(span).description}`); + DEBUG_BUILD && debug.log(`[Profiling] started profiling transaction: ${spanToJSON(span).description}`); // set transaction context - do this regardless if profiling fails down the line // so that we can still see the profile_id in the transaction context @@ -109,12 +109,12 @@ export function stopSpanProfile(span: Span, profile_id: string | undefined): Raw } const profile = CpuProfilerBindings.stopProfiling(profile_id, 0); - DEBUG_BUILD && logger.log(`[Profiling] stopped profiling of transaction: ${spanToJSON(span).description}`); + DEBUG_BUILD && debug.log(`[Profiling] stopped profiling of transaction: ${spanToJSON(span).description}`); // In case of an overlapping span, stopProfiling may return null and silently ignore the overlapping profile. if (!profile) { DEBUG_BUILD && - logger.log( + debug.log( `[Profiling] profiler returned null profile for: ${spanToJSON(span).description}`, 'this may indicate an overlapping span or a call to stopProfiling with a profile title that was never started', ); diff --git a/packages/profiling-node/src/utils.ts b/packages/profiling-node/src/utils.ts index e5b5fb9a8f6e..e17642c08148 100644 --- a/packages/profiling-node/src/utils.ts +++ b/packages/profiling-node/src/utils.ts @@ -16,10 +16,10 @@ import type { } from '@sentry/core'; import { createEnvelope, + debug, dsnToString, forEachEnvelopeItem, getDebugImagesForResources, - logger, uuid4, } from '@sentry/core'; import type { RawChunkCpuProfile, RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; @@ -133,7 +133,7 @@ function createProfilePayload( // All profiles and transactions are rejected if this is the case and we want to // warn users that this is happening if they enable debug flag if (trace_id?.length !== 32) { - DEBUG_BUILD && logger.log(`[Profiling] Invalid traceId: ${trace_id} on profiled event`); + DEBUG_BUILD && debug.log(`[Profiling] Invalid traceId: ${trace_id} on profiled event`); } const enrichedThreadProfile = enrichWithThreadInformation(cpuProfile); @@ -206,7 +206,7 @@ function createProfileChunkPayload( // All profiles and transactions are rejected if this is the case and we want to // warn users that this is happening if they enable debug flag if (trace_id?.length !== 32) { - DEBUG_BUILD && logger.log(`[Profiling] Invalid traceId: ${trace_id} on profiled event`); + DEBUG_BUILD && debug.log(`[Profiling] Invalid traceId: ${trace_id} on profiled event`); } const enrichedThreadProfile = enrichWithThreadInformation(cpuProfile); @@ -265,7 +265,7 @@ export function isValidSampleRate(rate: unknown): boolean { // we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck if ((typeof rate !== 'number' && typeof rate !== 'boolean') || (typeof rate === 'number' && isNaN(rate))) { DEBUG_BUILD && - logger.warn( + debug.warn( `[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify( rate, )} of type ${JSON.stringify(typeof rate)}.`, @@ -280,7 +280,7 @@ export function isValidSampleRate(rate: unknown): boolean { // in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false if (rate < 0 || rate > 1) { - DEBUG_BUILD && logger.warn(`[Profiling] Invalid sample rate. Sample rate must be between 0 and 1. Got ${rate}.`); + DEBUG_BUILD && debug.warn(`[Profiling] Invalid sample rate. Sample rate must be between 0 and 1. Got ${rate}.`); return false; } return true; @@ -297,7 +297,7 @@ export function isValidProfile(profile: RawThreadCpuProfile): profile is RawThre // Log a warning if the profile has less than 2 samples so users can know why // they are not seeing any profiling data and we cant avoid the back and forth // of asking them to provide us with a dump of the profile data. - logger.log('[Profiling] Discarding profile because it contains less than 2 samples'); + debug.log('[Profiling] Discarding profile because it contains less than 2 samples'); return false; } @@ -319,7 +319,7 @@ export function isValidProfileChunk(profile: RawChunkCpuProfile): profile is Raw // Log a warning if the profile has less than 2 samples so users can know why // they are not seeing any profiling data and we cant avoid the back and forth // of asking them to provide us with a dump of the profile data. - logger.log('[Profiling] Discarding profile chunk because it contains less than 2 samples'); + debug.log('[Profiling] Discarding profile chunk because it contains less than 2 samples'); return false; } diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts index 828d08f9fe44..a0ff14d4602e 100644 --- a/packages/profiling-node/test/integration.test.ts +++ b/packages/profiling-node/test/integration.test.ts @@ -1,5 +1,5 @@ import type { ProfileChunk, ProfilingIntegration, Transport } from '@sentry/core'; -import { createEnvelope, getMainCarrier, GLOBAL_OBJ, logger } from '@sentry/core'; +import { createEnvelope, debug, getMainCarrier, GLOBAL_OBJ } from '@sentry/core'; import * as Sentry from '@sentry/node'; import type { NodeClientOptions } from '@sentry/node/build/types/types'; import { CpuProfilerBindings } from '@sentry-internal/node-cpu-profiler'; @@ -127,7 +127,7 @@ describe('ProfilingIntegration', () => { }); it('logger warns user if there are insufficient samples and discards the profile', async () => { - const logSpy = vi.spyOn(logger, 'log'); + const logSpy = vi.spyOn(debug, 'log'); const [client, transport] = makeLegacySpanProfilingClient(); Sentry.setCurrentClient(client); @@ -166,7 +166,7 @@ describe('ProfilingIntegration', () => { }); it('logger warns user if traceId is invalid', async () => { - const logSpy = vi.spyOn(logger, 'log'); + const logSpy = vi.spyOn(debug, 'log'); const [client, transport] = makeLegacySpanProfilingClient(); Sentry.setCurrentClient(client); From 5f45a48e551999fd34c8180f4f0f37d0dab07f8d Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 15 Jul 2025 09:50:47 -0400 Subject: [PATCH 35/37] ref(remix): Use `debug` instead of `logger` (#16988) resolves https://github.com/getsentry/sentry-javascript/issues/16938 --- packages/remix/src/client/index.ts | 4 ++-- packages/remix/src/client/performance.tsx | 4 ++-- packages/remix/src/server/errors.ts | 6 +++--- packages/remix/src/server/instrumentServer.ts | 12 ++++++------ packages/remix/src/server/sdk.ts | 4 ++-- packages/remix/src/utils/utils.ts | 4 ++-- .../test/integration/test/server/utils/helpers.ts | 6 +++--- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/remix/src/client/index.ts b/packages/remix/src/client/index.ts index fc77db42ad3d..6eb908457432 100644 --- a/packages/remix/src/client/index.ts +++ b/packages/remix/src/client/index.ts @@ -1,4 +1,4 @@ -import { logger } from '@sentry/core'; +import { debug } from '@sentry/core'; import { DEBUG_BUILD } from '../utils/debug-build'; export * from '@sentry/react'; @@ -17,7 +17,7 @@ export { browserTracingIntegration } from './browserTracingIntegration'; */ export async function captureRemixServerException(err: unknown, name: string, request: Request): Promise { DEBUG_BUILD && - logger.warn( + debug.warn( '`captureRemixServerException` is a server-only function and should not be called in the browser. ' + 'This function is a no-op in the browser environment.', ); diff --git a/packages/remix/src/client/performance.tsx b/packages/remix/src/client/performance.tsx index 66927deb4c9a..18b923c8b6d4 100644 --- a/packages/remix/src/client/performance.tsx +++ b/packages/remix/src/client/performance.tsx @@ -1,10 +1,10 @@ import type { Client, StartSpanOptions } from '@sentry/core'; import { + debug, getActiveSpan, getCurrentScope, getRootSpan, isNodeEnv, - logger, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; @@ -119,7 +119,7 @@ export function withSentry

, R extends React.Co if (!_useEffect || !_useLocation || !_useMatches) { DEBUG_BUILD && !isNodeEnv() && - logger.warn('Remix SDK was unable to wrap your root because of one or more missing parameters.'); + debug.warn('Remix SDK was unable to wrap your root because of one or more missing parameters.'); // @ts-expect-error Setting more specific React Component typing for `R` generic above // will break advanced type inference done by react router params diff --git a/packages/remix/src/server/errors.ts b/packages/remix/src/server/errors.ts index 0e26242a0164..d21e9d57d42c 100644 --- a/packages/remix/src/server/errors.ts +++ b/packages/remix/src/server/errors.ts @@ -11,9 +11,9 @@ import type { RequestEventData, Span } from '@sentry/core'; import { addExceptionMechanism, captureException, + debug, getClient, handleCallbackErrors, - logger, objectify, winterCGRequestToRequestData, } from '@sentry/core'; @@ -46,7 +46,7 @@ export async function captureRemixServerException(err: unknown, name: string, re // Skip capturing if the request is aborted as Remix docs suggest // Ref: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror if (request.signal.aborted) { - DEBUG_BUILD && logger.warn('Skipping capture of aborted request'); + DEBUG_BUILD && debug.warn('Skipping capture of aborted request'); return; } @@ -55,7 +55,7 @@ export async function captureRemixServerException(err: unknown, name: string, re try { normalizedRequest = winterCGRequestToRequestData(request); } catch (e) { - DEBUG_BUILD && logger.warn('Failed to normalize Remix request'); + DEBUG_BUILD && debug.warn('Failed to normalize Remix request'); } const objectifiedErr = objectify(err); diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index 4f67f2ae8b3d..f93c195df355 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -16,6 +16,7 @@ import type { import type { RequestEventData, Span, TransactionSource, WrappedFunction } from '@sentry/core'; import { continueTrace, + debug, fill, getActiveSpan, getClient, @@ -24,7 +25,6 @@ import { hasSpansEnabled, isNodeEnv, loadModule, - logger, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -73,7 +73,7 @@ export function sentryHandleError(err: unknown, { request }: DataFunctionArgs): } captureRemixServerException(err, 'remix.server.handleError', request).then(null, e => { - DEBUG_BUILD && logger.warn('Failed to capture Remix Server exception.', e); + DEBUG_BUILD && debug.warn('Failed to capture Remix Server exception.', e); }); } @@ -220,7 +220,7 @@ function makeWrappedRootLoader() { // We skip injection of trace and baggage in those cases. // For `redirect`, a valid internal redirection target will have the trace and baggage injected. if (isRedirectResponse(res) || isCatchResponse(res)) { - DEBUG_BUILD && logger.warn('Skipping injection of trace and baggage as the response does not have a body'); + DEBUG_BUILD && debug.warn('Skipping injection of trace and baggage as the response does not have a body'); return res; } else { const data = await extractData(res); @@ -235,7 +235,7 @@ function makeWrappedRootLoader() { }, ); } else { - DEBUG_BUILD && logger.warn('Skipping injection of trace and baggage as the response body is not an object'); + DEBUG_BUILD && debug.warn('Skipping injection of trace and baggage as the response body is not an object'); return res; } } @@ -289,7 +289,7 @@ function wrapRequestHandler ServerBuild | Promise try { normalizedRequest = winterCGRequestToRequestData(request); } catch (e) { - DEBUG_BUILD && logger.warn('Failed to normalize Remix request'); + DEBUG_BUILD && debug.warn('Failed to normalize Remix request'); } if (options?.instrumentTracing && resolvedRoutes) { @@ -447,7 +447,7 @@ export function instrumentServer(options?: { instrumentTracing?: boolean }): voi }>('@remix-run/server-runtime', module); if (!pkg) { - DEBUG_BUILD && logger.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.'); + DEBUG_BUILD && debug.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.'); return; } diff --git a/packages/remix/src/server/sdk.ts b/packages/remix/src/server/sdk.ts index 816e5083aa26..145cb8a66b47 100644 --- a/packages/remix/src/server/sdk.ts +++ b/packages/remix/src/server/sdk.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { applySdkMetadata, logger } from '@sentry/core'; +import { applySdkMetadata, debug } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations as getDefaultNodeIntegrations, init as nodeInit, isInitialized } from '@sentry/node'; import { DEBUG_BUILD } from '../utils/debug-build'; @@ -26,7 +26,7 @@ export function init(options: RemixOptions): NodeClient | undefined { applySdkMetadata(options, 'remix', ['remix', 'node']); if (isInitialized()) { - DEBUG_BUILD && logger.log('SDK already initialized'); + DEBUG_BUILD && debug.log('SDK already initialized'); return; } diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts index 62fee4b20d61..5485cff5e0a3 100644 --- a/packages/remix/src/utils/utils.ts +++ b/packages/remix/src/utils/utils.ts @@ -1,7 +1,7 @@ import type { ActionFunctionArgs, LoaderFunctionArgs, ServerBuild } from '@remix-run/node'; import type { AgnosticRouteObject } from '@remix-run/router'; import type { Span, TransactionSource } from '@sentry/core'; -import { logger } from '@sentry/core'; +import { debug } from '@sentry/core'; import { DEBUG_BUILD } from './debug-build'; import { getRequestMatch, matchServerRoutes } from './vendor/response'; @@ -39,7 +39,7 @@ export async function storeFormDataKeys( } }); } catch (e) { - DEBUG_BUILD && logger.warn('Failed to read FormData from request', e); + DEBUG_BUILD && debug.warn('Failed to read FormData from request', e); } } diff --git a/packages/remix/test/integration/test/server/utils/helpers.ts b/packages/remix/test/integration/test/server/utils/helpers.ts index f273aaffc73a..af9a4934ee1e 100644 --- a/packages/remix/test/integration/test/server/utils/helpers.ts +++ b/packages/remix/test/integration/test/server/utils/helpers.ts @@ -2,7 +2,7 @@ import * as http from 'http'; import { AddressInfo } from 'net'; import * as path from 'path'; import { createRequestHandler } from '@remix-run/express'; -import { logger } from '@sentry/core'; +import { debug } from '@sentry/core'; import type { EnvelopeItemType, Event, TransactionEvent } from '@sentry/core'; /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import * as Sentry from '@sentry/node'; @@ -46,7 +46,7 @@ async function makeRequest( } catch (e) { // We sometimes expect the request to fail, but not the test. // So, we do nothing. - logger.warn(e); + debug.warn(e); } } @@ -195,7 +195,7 @@ class TestEnv { this._closeServer() .catch(e => { - logger.warn(e); + debug.warn(e); }) .finally(() => { resolve(envelopes); From c23fa706674aae4c8845e3944c9439d6c8a27951 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 15 Jul 2025 09:51:13 -0400 Subject: [PATCH 36/37] ref(nuxt): Use `debug` instead of `logger` (#16991) resolves https://github.com/getsentry/sentry-javascript/issues/16944 --- .../hooks/updateRouteBeforeResponse.ts | 4 ++-- .../runtime/plugins/route-detector.server.ts | 8 ++++---- .../plugins/sentry-cloudflare.server.ts | 10 +++++----- .../nuxt/src/runtime/plugins/sentry.server.ts | 4 ++-- packages/nuxt/src/runtime/utils.ts | 20 ++++++------------- .../src/runtime/utils/route-extraction.ts | 6 +++--- packages/nuxt/src/server/sdk.ts | 12 +++++------ packages/nuxt/src/vite/addServerConfig.ts | 8 ++++---- 8 files changed, 32 insertions(+), 40 deletions(-) diff --git a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts index d43f3bf34901..9b0f8f4d05fe 100644 --- a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts +++ b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts @@ -1,4 +1,4 @@ -import { getActiveSpan, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { debug, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import type { H3Event } from 'h3'; /** @@ -46,6 +46,6 @@ export function updateRouteBeforeResponse(event: H3Event): void { }); } - logger.log(`Updated transaction name for parametrized route: ${matchedRoutePath}`); + debug.log(`Updated transaction name for parametrized route: ${matchedRoutePath}`); } } diff --git a/packages/nuxt/src/runtime/plugins/route-detector.server.ts b/packages/nuxt/src/runtime/plugins/route-detector.server.ts index 9b6a172e1da2..37c6bc17a4b5 100644 --- a/packages/nuxt/src/runtime/plugins/route-detector.server.ts +++ b/packages/nuxt/src/runtime/plugins/route-detector.server.ts @@ -1,4 +1,4 @@ -import { getActiveSpan, getRootSpan, logger, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { debug, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import { defineNuxtPlugin } from 'nuxt/app'; import type { NuxtPageSubset } from '../utils/route-extraction'; import { extractParametrizedRouteFromContext } from '../utils/route-extraction'; @@ -11,10 +11,10 @@ export default defineNuxtPlugin(nuxtApp => { // @ts-expect-error This import is dynamically resolved at build time (`addTemplate` in module.ts) const { default: importedPagesData } = await import('#build/sentry--nuxt-pages-data.mjs'); buildTimePagesData = importedPagesData || []; - logger.log('Imported build-time pages data:', buildTimePagesData); + debug.log('Imported build-time pages data:', buildTimePagesData); } catch (error) { buildTimePagesData = []; - logger.warn('Failed to import build-time pages data:', error); + debug.warn('Failed to import build-time pages data:', error); } const ssrContext = renderContext.ssrContext; @@ -38,7 +38,7 @@ export default defineNuxtPlugin(nuxtApp => { return; } - logger.log('Matched parametrized server route:', routeInfo.parametrizedRoute); + debug.log('Matched parametrized server route:', routeInfo.parametrizedRoute); rootSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 96fa59a4c643..4ddbe8749586 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -1,7 +1,7 @@ import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types'; import type { CloudflareOptions } from '@sentry/cloudflare'; import { setAsyncLocalStorageAsyncContextStrategy, wrapRequestHandler } from '@sentry/cloudflare'; -import { getDefaultIsolationScope, getIsolationScope, getTraceData, logger } from '@sentry/core'; +import { debug, getDefaultIsolationScope, getIsolationScope, getTraceData } from '@sentry/core'; import type { H3Event } from 'h3'; import type { NitroApp, NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; @@ -99,7 +99,7 @@ export const sentryCloudflareNitroPlugin = const event = handlerArgs[1]; if (!isEventType(event)) { - logger.log("Nitro Cloudflare plugin did not detect a Cloudflare event type. Won't patch Cloudflare handler."); + debug.log("Nitro Cloudflare plugin did not detect a Cloudflare event type. Won't patch Cloudflare handler."); return handlerTarget.apply(handlerThisArg, handlerArgs); } else { // Usually, the protocol already includes ":" @@ -125,10 +125,10 @@ export const sentryCloudflareNitroPlugin = if (traceData && Object.keys(traceData).length > 0) { // Storing trace data in the WeakMap using event.context.cf as key for later use in HTML meta-tags traceDataMap.set(event.context.cf, traceData); - logger.log('Stored trace data for later use in HTML meta-tags: ', traceData); + debug.log('Stored trace data for later use in HTML meta-tags: ', traceData); } - logger.log( + debug.log( `Patched Cloudflare handler (\`nitroApp.localFetch\`). ${ isolationScope === newIsolationScope ? 'Using existing' : 'Created new' } isolation scope.`, @@ -147,7 +147,7 @@ export const sentryCloudflareNitroPlugin = const storedTraceData = event?.context?.cf ? traceDataMap.get(event.context.cf) : undefined; if (storedTraceData && Object.keys(storedTraceData).length > 0) { - logger.log('Using stored trace data for HTML meta-tags: ', storedTraceData); + debug.log('Using stored trace data for HTML meta-tags: ', storedTraceData); addSentryTracingMetaTags(html.head, storedTraceData); } else { addSentryTracingMetaTags(html.head); diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index 0f13fbec0fd3..543a8a78ebe1 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -1,4 +1,4 @@ -import { getDefaultIsolationScope, getIsolationScope, logger, withIsolationScope } from '@sentry/core'; +import { debug, getDefaultIsolationScope, getIsolationScope, withIsolationScope } from '@sentry/core'; // eslint-disable-next-line import/no-extraneous-dependencies import { type EventHandler } from 'h3'; // eslint-disable-next-line import/no-extraneous-dependencies @@ -27,7 +27,7 @@ function patchEventHandler(handler: EventHandler): EventHandler { const isolationScope = getIsolationScope(); const newIsolationScope = isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; - logger.log( + debug.log( `Patched h3 event handler. ${ isolationScope === newIsolationScope ? 'Using existing' : 'Created new' } isolation scope.`, diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 89b5839d737c..61a6726ec0d0 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,13 +1,5 @@ import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; -import { - captureException, - flush, - getClient, - getTraceMetaTags, - GLOBAL_OBJ, - logger, - vercelWaitUntil, -} from '@sentry/core'; +import { captureException, debug, flush, getClient, getTraceMetaTags, GLOBAL_OBJ, vercelWaitUntil } from '@sentry/core'; import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack/types'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; @@ -45,14 +37,14 @@ export function addSentryTracingMetaTags(head: NuxtRenderHTMLContext['head'], tr const metaTags = getTraceMetaTags(traceData); if (head.some(tag => tag.includes('meta') && tag.includes('sentry-trace'))) { - logger.warn( + debug.warn( 'Skipping addition of meta tags. Sentry tracing meta tags are already present in HTML page. Make sure to only set up Sentry once on the server-side. ', ); return; } if (metaTags) { - logger.log('Adding Sentry tracing meta tags to HTML page:', metaTags); + debug.log('Adding Sentry tracing meta tags to HTML page:', metaTags); head.push(metaTags); } } @@ -96,11 +88,11 @@ export function reportNuxtError(options: { async function flushWithTimeout(): Promise { try { - logger.log('Flushing events...'); + debug.log('Flushing events...'); await flush(2000); - logger.log('Done flushing events'); + debug.log('Done flushing events'); } catch (e) { - logger.log('Error while flushing events:\n', e); + debug.log('Error while flushing events:\n', e); } } diff --git a/packages/nuxt/src/runtime/utils/route-extraction.ts b/packages/nuxt/src/runtime/utils/route-extraction.ts index 2bec2c80110f..dd5968086072 100644 --- a/packages/nuxt/src/runtime/utils/route-extraction.ts +++ b/packages/nuxt/src/runtime/utils/route-extraction.ts @@ -1,4 +1,4 @@ -import { logger } from '@sentry/core'; +import { debug } from '@sentry/core'; import type { NuxtSSRContext } from 'nuxt/app'; import type { NuxtPage } from 'nuxt/schema'; @@ -39,11 +39,11 @@ export function extractParametrizedRouteFromContext( const cacheKey = Array.from(ssrContextModules).sort().join('|'); const cachedResult = extractionResultCache.get(cacheKey); if (cachedResult !== undefined) { - logger.log('Found cached result for parametrized route:', requestedUrl); + debug.log('Found cached result for parametrized route:', requestedUrl); return cachedResult; } - logger.log('No parametrized route found in cache lookup. Extracting parametrized route for:', requestedUrl); + debug.log('No parametrized route found in cache lookup. Extracting parametrized route for:', requestedUrl); const modulesArray = Array.from(ssrContextModules); diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index 9eaa2f274818..0a1ede6b83a1 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -1,6 +1,6 @@ import * as path from 'node:path'; import type { Client, EventProcessor, Integration } from '@sentry/core'; -import { applySdkMetadata, flush, getGlobalScope, logger, vercelWaitUntil } from '@sentry/core'; +import { applySdkMetadata, debug, flush, getGlobalScope, vercelWaitUntil } from '@sentry/core'; import { type NodeOptions, getDefaultIntegrations as getDefaultNodeIntegrations, @@ -48,7 +48,7 @@ export function lowQualityTransactionsFilter(options: SentryNuxtServerOptions): if (path.extname(event.transaction)) { options.debug && DEBUG_BUILD && - logger.log('NuxtLowQualityTransactionsFilter filtered transaction: ', event.transaction); + debug.log('NuxtLowQualityTransactionsFilter filtered transaction: ', event.transaction); return null; } return event; @@ -67,7 +67,7 @@ export function clientSourceMapErrorFilter(options: SentryNuxtServerOptions): Ev (event => { const errorMsg = event.exception?.values?.[0]?.value; if (errorMsg?.match(/^ENOENT: no such file or directory, open '.*\/_nuxt\/.*\.js\.map'/)) { - options.debug && DEBUG_BUILD && logger.log('NuxtClientSourceMapErrorFilter filtered error: ', errorMsg); + options.debug && DEBUG_BUILD && debug.log('NuxtClientSourceMapErrorFilter filtered error: ', errorMsg); return null; } return event; @@ -96,10 +96,10 @@ function getNuxtDefaultIntegrations(options: NodeOptions): Integration[] { */ export async function flushSafelyWithTimeout(): Promise { try { - DEBUG_BUILD && logger.log('Flushing events...'); + DEBUG_BUILD && debug.log('Flushing events...'); await flush(2000); - DEBUG_BUILD && logger.log('Done flushing events'); + DEBUG_BUILD && debug.log('Done flushing events'); } catch (e) { - DEBUG_BUILD && logger.log('Error while flushing events:\n', e); + DEBUG_BUILD && debug.log('Error while flushing events:\n', e); } } diff --git a/packages/nuxt/src/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts index 771c534705cb..fe91a6a75c56 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import { createResolver } from '@nuxt/kit'; -import { logger } from '@sentry/core'; +import { debug } from '@sentry/core'; import * as fs from 'fs'; import type { Nitro } from 'nitropack'; import type { InputPluginOption } from 'rollup'; @@ -124,7 +124,7 @@ export function addDynamicImportEntryFileWrapper( /** * Rollup plugin to include the Sentry server configuration file to the server build output. */ -function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, debug?: boolean): InputPluginOption { +function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebug?: boolean): InputPluginOption { const filePrefix = '\0virtual:sentry-server-config:'; return { @@ -134,8 +134,8 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, debug? const configPath = createResolver(nitro.options.srcDir).resolve(`/${serverConfigFile}`); if (!existsSync(configPath)) { - if (debug) { - logger.log(`[Sentry] Sentry server config file not found: ${configPath}`); + if (isDebug) { + debug.log(`[Sentry] Sentry server config file not found: ${configPath}`); } return; } From fb80b36be3e7f2042537e9f5a4cc6927b15cb595 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 15 Jul 2025 16:03:35 +0200 Subject: [PATCH 37/37] meta(changelog): Update changelog for 9.39.0 --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec94603f9608..ef2a5393537a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.39.0 + +### Important Changes + +- **feat(browser): Add `afterStartPageloadSpan` hook to improve spanId assignment on web vital spans ([#16893](https://github.com/getsentry/sentry-javascript/pull/16893))** + +This PR adds a new afterStartPageloadSpan lifecycle hook to more robustly assign the correct pageload span ID to web vital spans, replacing the previous unreliable "wait for a tick" approach with a direct callback that fires when the pageload span becomes available. + +- **feat(nextjs): Client-side parameterized routes ([#16934](https://github.com/getsentry/sentry-javascript/pull/16934))** + +This PR implements client-side parameterized routes for Next.js by leveraging an injected manifest within the existing app-router instrumentation to automatically parameterize all client-side transactions (e.g. `users/123` and `users/456` now become become `users/:id`). + +- **feat(node): Drop 401-404 and 3xx status code spans by default ([#16972](https://github.com/getsentry/sentry-javascript/pull/16972))** + +This PR changes the default behavior in the Node SDK to drop HTTP spans with 401-404 and 3xx status codes by default to reduce noise in tracing data. + +### Other Changes + +- feat(core): Prepend vercel ai attributes with `vercel.ai.X` ([#16908](https://github.com/getsentry/sentry-javascript/pull/16908)) +- feat(nextjs): Add `disableSentryWebpackConfig` flag ([#17013](https://github.com/getsentry/sentry-javascript/pull/17013)) +- feat(nextjs): Build app manifest ([#16851](https://github.com/getsentry/sentry-javascript/pull/16851)) +- feat(nextjs): Inject manifest into client for turbopack builds ([#16902](https://github.com/getsentry/sentry-javascript/pull/16902)) +- feat(nextjs): Inject manifest into client for webpack builds ([#16857](https://github.com/getsentry/sentry-javascript/pull/16857)) +- feat(node-native): Add option to disable event loop blocked detection ([#16919](https://github.com/getsentry/sentry-javascript/pull/16919)) +- feat(react-router): Ensure http.server route handling is consistent ([#16986](https://github.com/getsentry/sentry-javascript/pull/16986)) +- fix(core): Avoid prolonging idle span when starting standalone span ([#16928](https://github.com/getsentry/sentry-javascript/pull/16928)) +- fix(core): Remove side-effect from `tracing/errors.ts` ([#16888](https://github.com/getsentry/sentry-javascript/pull/16888)) +- fix(core): Wrap `beforeSendLog` in `consoleSandbox` ([#16968](https://github.com/getsentry/sentry-javascript/pull/16968)) +- fix(node-core): Apply correct SDK metadata ([#17014](https://github.com/getsentry/sentry-javascript/pull/17014)) +- fix(react-router): Ensure that all browser spans have `source=route` ([#16984](https://github.com/getsentry/sentry-javascript/pull/16984)) + Work in this release was contributed by @janpapenbrock. Thank you for your contribution! ## 9.38.0