Product} />
- // We should check against the branch.pathname for the number of / separators
- getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) &&
- // We should not count wildcard operators in the url segments calculation
- !pathEndsWithWildcard(pathBuilder)
- ) {
- return [(_stripBasename ? '' : basename) + newPath, 'route'];
- }
-
- // if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard
- if (pathIsWildcardAndHasChildren(pathBuilder, branch)) {
- pathBuilder = pathBuilder.slice(0, -1);
- }
-
- return [(_stripBasename ? '' : basename) + pathBuilder, 'route'];
- }
- }
- }
- }
- }
-
- const fallbackTransactionName = _stripBasename
- ? stripBasenameFromPathname(location.pathname, basename)
- : location.pathname || '/';
-
- return [fallbackTransactionName, 'url'];
-}
-
-function updatePageloadTransaction(
- activeRootSpan: Span | undefined,
- location: Location,
- routes: RouteObject[],
- matches?: AgnosticDataRouteMatch,
- basename?: string,
- allRoutes?: RouteObject[],
-): void {
+function updatePageloadTransaction({
+ activeRootSpan,
+ location,
+ routes,
+ matches,
+ basename,
+ allRoutes,
+}: {
+ activeRootSpan: Span | undefined;
+ location: Location;
+ routes: RouteObject[];
+ matches?: AgnosticDataRouteMatch;
+ basename?: string;
+ allRoutes?: RouteObject[];
+}): void {
const branches = Array.isArray(matches)
? matches
: (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]);
@@ -808,7 +718,12 @@ export function createV6CompatibleWithSentryReactRouterRouting s.length > 0 && s !== ',').length;
+export function handleExistingNavigationSpan(
+ activeSpan: Span,
+ spanJson: ReturnType,
+ name: string,
+ source: TransactionSource,
+ isLikelyLazyRoute: boolean,
+): void {
+ // Check if we've already set the name for this span using a custom property
+ const hasBeenNamed = (
+ activeSpan as {
+ __sentry_navigation_name_set__?: boolean;
+ }
+ )?.__sentry_navigation_name_set__;
+
+ if (!hasBeenNamed) {
+ // This is the first time we're setting the name for this span
+ if (!spanJson.timestamp) {
+ activeSpan?.updateName(name);
+ }
+
+ // For lazy routes, don't mark as named yet so it can be updated later
+ if (!isLikelyLazyRoute) {
+ addNonEnumerableProperty(
+ activeSpan as { __sentry_navigation_name_set__?: boolean },
+ '__sentry_navigation_name_set__',
+ true,
+ );
+ }
+ }
+
+ // Always set the source attribute to keep it consistent with the current route
+ activeSpan?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
+}
+
+/**
+ * Creates a new navigation span
+ */
+export function createNewNavigationSpan(
+ client: Client,
+ name: string,
+ source: TransactionSource,
+ version: string,
+ isLikelyLazyRoute: boolean,
+): void {
+ const newSpan = startBrowserTracingNavigationSpan(client, {
+ name,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`,
+ },
+ });
+
+ // For lazy routes, don't mark as named yet so it can be updated later when the route loads
+ if (!isLikelyLazyRoute && newSpan) {
+ addNonEnumerableProperty(
+ newSpan as { __sentry_navigation_name_set__?: boolean },
+ '__sentry_navigation_name_set__',
+ true,
+ );
+ }
}
diff --git a/packages/react/src/reactrouter-compat-utils/lazy-routes.tsx b/packages/react/src/reactrouter-compat-utils/lazy-routes.tsx
new file mode 100644
index 000000000000..49923854554c
--- /dev/null
+++ b/packages/react/src/reactrouter-compat-utils/lazy-routes.tsx
@@ -0,0 +1,74 @@
+import { addNonEnumerableProperty, debug, isThenable } from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
+import type { Location, RouteObject } from '../types';
+
+/**
+ * Creates a proxy wrapper for an async handler function.
+ */
+export function createAsyncHandlerProxy(
+ originalFunction: (...args: unknown[]) => unknown,
+ route: RouteObject,
+ handlerKey: string,
+ processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+): (...args: unknown[]) => unknown {
+ const proxy = new Proxy(originalFunction, {
+ apply(target: (...args: unknown[]) => unknown, thisArg, argArray) {
+ const result = target.apply(thisArg, argArray);
+ handleAsyncHandlerResult(result, route, handlerKey, processResolvedRoutes);
+ return result;
+ },
+ });
+
+ addNonEnumerableProperty(proxy, '__sentry_proxied__', true);
+
+ return proxy;
+}
+
+/**
+ * Handles the result of an async handler function call.
+ */
+export function handleAsyncHandlerResult(
+ result: unknown,
+ route: RouteObject,
+ handlerKey: string,
+ processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+): void {
+ if (isThenable(result)) {
+ (result as Promise)
+ .then((resolvedRoutes: unknown) => {
+ if (Array.isArray(resolvedRoutes)) {
+ processResolvedRoutes(resolvedRoutes, route);
+ }
+ })
+ .catch((e: unknown) => {
+ DEBUG_BUILD && debug.warn(`Error resolving async handler '${handlerKey}' for route`, route, e);
+ });
+ } else if (Array.isArray(result)) {
+ processResolvedRoutes(result, route);
+ }
+}
+
+/**
+ * Recursively checks a route for async handlers and sets up Proxies to add discovered child routes to allRoutes when called.
+ */
+export function checkRouteForAsyncHandler(
+ route: RouteObject,
+ processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+): void {
+ // Set up proxies for any functions in the route's handle
+ if (route.handle && typeof route.handle === 'object') {
+ for (const key of Object.keys(route.handle)) {
+ const maybeFn = route.handle[key];
+ if (typeof maybeFn === 'function' && !(maybeFn as { __sentry_proxied__?: boolean }).__sentry_proxied__) {
+ route.handle[key] = createAsyncHandlerProxy(maybeFn, route, key, processResolvedRoutes);
+ }
+ }
+ }
+
+ // Recursively check child routes
+ if (Array.isArray(route.children)) {
+ for (const child of route.children) {
+ checkRouteForAsyncHandler(child, processResolvedRoutes);
+ }
+ }
+}
diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts
new file mode 100644
index 000000000000..8f7abb7d548e
--- /dev/null
+++ b/packages/react/src/reactrouter-compat-utils/utils.ts
@@ -0,0 +1,286 @@
+import type { TransactionSource } from '@sentry/core';
+import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../types';
+
+// Global variables that these utilities depend on
+let _matchRoutes: MatchRoutes;
+let _stripBasename: boolean = false;
+
+/**
+ * Initialize function to set dependencies that the router utilities need.
+ * Must be called before using any of the exported utility functions.
+ */
+export function initializeRouterUtils(matchRoutes: MatchRoutes, stripBasename: boolean = false): void {
+ _matchRoutes = matchRoutes;
+ _stripBasename = stripBasename;
+}
+
+/**
+ * Checks if the given routes or location context suggests this might be a lazy route scenario.
+ * This helps determine if we should delay marking navigation spans as "named" to allow for updates
+ * when lazy routes are loaded.
+ */
+export function isLikelyLazyRouteContext(routes: RouteObject[], location: Location): boolean {
+ // Check if any route in the current match has lazy properties
+ const hasLazyRoute = routes.some(route => {
+ return (
+ // React Router lazy() route
+ route.lazy ||
+ // Route with async handlers that might load child routes
+ (route.handle &&
+ typeof route.handle === 'object' &&
+ Object.values(route.handle).some(handler => typeof handler === 'function'))
+ );
+ });
+
+ if (hasLazyRoute) {
+ return true;
+ }
+
+ // Check if current route is unmatched, which might indicate a lazy route that hasn't loaded yet
+ const currentMatches = _matchRoutes(routes, location);
+ if (!currentMatches || currentMatches.length === 0) {
+ return true;
+ }
+
+ return false;
+}
+
+// Helper functions
+function pickPath(match: RouteMatch): string {
+ return trimWildcard(match.route.path || '');
+}
+
+function pickSplat(match: RouteMatch): string {
+ return match.params['*'] || '';
+}
+
+function trimWildcard(path: string): string {
+ return path[path.length - 1] === '*' ? path.slice(0, -1) : path;
+}
+
+function trimSlash(path: string): string {
+ return path[path.length - 1] === '/' ? path.slice(0, -1) : path;
+}
+
+/**
+ * Checks if a path ends with a wildcard character (*).
+ */
+export function pathEndsWithWildcard(path: string): boolean {
+ return path.endsWith('*');
+}
+
+/**
+ * Checks if a path is a wildcard and has child routes.
+ */
+export function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch): boolean {
+ return (pathEndsWithWildcard(path) && !!branch.route.children?.length) || false;
+}
+
+function routeIsDescendant(route: RouteObject): boolean {
+ return !!(!route.children && route.element && route.path?.endsWith('/*'));
+}
+
+function sendIndexPath(pathBuilder: string, pathname: string, basename: string): [string, TransactionSource] {
+ const reconstructedPath = pathBuilder || _stripBasename ? stripBasenameFromPathname(pathname, basename) : pathname;
+
+ const formattedPath =
+ // If the path ends with a slash, remove it
+ reconstructedPath[reconstructedPath.length - 1] === '/'
+ ? reconstructedPath.slice(0, -1)
+ : // If the path ends with a wildcard, remove it
+ reconstructedPath.slice(-2) === '/*'
+ ? reconstructedPath.slice(0, -1)
+ : reconstructedPath;
+
+ return [formattedPath, 'route'];
+}
+
+/**
+ * Returns the number of URL segments in the given URL string.
+ * Splits at '/' or '\/' to handle regex URLs correctly.
+ *
+ * @param url - The URL string to segment.
+ * @returns The number of segments in the URL.
+ */
+export function getNumberOfUrlSegments(url: string): number {
+ // split at '/' or at '\/' to split regex urls correctly
+ return url.split(/\\?\//).filter(s => s.length > 0 && s !== ',').length;
+}
+
+/**
+ * Strip the basename from a pathname if exists.
+ *
+ * Vendored and modified from `react-router`
+ * https://github.com/remix-run/react-router/blob/462bb712156a3f739d6139a0f14810b76b002df6/packages/router/utils.ts#L1038
+ */
+function stripBasenameFromPathname(pathname: string, basename: string): string {
+ if (!basename || basename === '/') {
+ return pathname;
+ }
+
+ if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) {
+ return pathname;
+ }
+
+ // We want to leave trailing slash behavior in the user's control, so if they
+ // specify a basename with a trailing slash, we should support it
+ const startIndex = basename.endsWith('/') ? basename.length - 1 : basename.length;
+ const nextChar = pathname.charAt(startIndex);
+ if (nextChar && nextChar !== '/') {
+ // pathname does not start with basename/
+ return pathname;
+ }
+
+ return pathname.slice(startIndex) || '/';
+}
+
+// Exported utility functions
+
+/**
+ * Ensures a path string starts with a forward slash.
+ */
+export function prefixWithSlash(path: string): string {
+ return path[0] === '/' ? path : `/${path}`;
+}
+
+/**
+ * Rebuilds the route path from all available routes by matching against the current location.
+ */
+export function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location: Location): string {
+ const matchedRoutes = _matchRoutes(allRoutes, location) as RouteMatch[];
+
+ if (!matchedRoutes || matchedRoutes.length === 0) {
+ return '';
+ }
+
+ for (const match of matchedRoutes) {
+ if (match.route.path && match.route.path !== '*') {
+ const path = pickPath(match);
+ const strippedPath = stripBasenameFromPathname(location.pathname, prefixWithSlash(match.pathnameBase));
+
+ if (location.pathname === strippedPath) {
+ return trimSlash(strippedPath);
+ }
+
+ return trimSlash(
+ trimSlash(path || '') +
+ prefixWithSlash(
+ rebuildRoutePathFromAllRoutes(
+ allRoutes.filter(route => route !== match.route),
+ {
+ pathname: strippedPath,
+ },
+ ),
+ ),
+ );
+ }
+ }
+
+ return '';
+}
+
+/**
+ * Checks if the current location is inside a descendant route (route with splat parameter).
+ */
+export function locationIsInsideDescendantRoute(location: Location, routes: RouteObject[]): boolean {
+ const matchedRoutes = _matchRoutes(routes, location) as RouteMatch[];
+
+ if (matchedRoutes) {
+ for (const match of matchedRoutes) {
+ if (routeIsDescendant(match.route) && pickSplat(match)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Gets a normalized route name and transaction source from the current routes and location.
+ */
+export function getNormalizedName(
+ routes: RouteObject[],
+ location: Location,
+ branches: RouteMatch[],
+ basename: string = '',
+): [string, TransactionSource] {
+ if (!routes || routes.length === 0) {
+ return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url'];
+ }
+
+ let pathBuilder = '';
+
+ if (branches) {
+ for (const branch of branches) {
+ const route = branch.route;
+ if (route) {
+ // Early return if index route
+ if (route.index) {
+ return sendIndexPath(pathBuilder, branch.pathname, basename);
+ }
+ const path = route.path;
+
+ // If path is not a wildcard and has no child routes, append the path
+ if (path && !pathIsWildcardAndHasChildren(path, branch)) {
+ const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`;
+ pathBuilder = trimSlash(pathBuilder) + prefixWithSlash(newPath);
+
+ // If the path matches the current location, return the path
+ if (trimSlash(location.pathname) === trimSlash(basename + branch.pathname)) {
+ if (
+ // If the route defined on the element is something like
+ // Product} />
+ // We should check against the branch.pathname for the number of / separators
+ getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) &&
+ // We should not count wildcard operators in the url segments calculation
+ !pathEndsWithWildcard(pathBuilder)
+ ) {
+ return [(_stripBasename ? '' : basename) + newPath, 'route'];
+ }
+
+ // if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard
+ if (pathIsWildcardAndHasChildren(pathBuilder, branch)) {
+ pathBuilder = pathBuilder.slice(0, -1);
+ }
+
+ return [(_stripBasename ? '' : basename) + pathBuilder, 'route'];
+ }
+ }
+ }
+ }
+ }
+
+ const fallbackTransactionName = _stripBasename
+ ? stripBasenameFromPathname(location.pathname, basename)
+ : location.pathname || '';
+
+ return [fallbackTransactionName, 'url'];
+}
+
+/**
+ * Shared helper function to resolve route name and source
+ */
+export function resolveRouteNameAndSource(
+ location: Location,
+ routes: RouteObject[],
+ allRoutes: RouteObject[],
+ branches: RouteMatch[],
+ basename: string = '',
+): [string, TransactionSource] {
+ let name: string | undefined;
+ let source: TransactionSource = 'url';
+
+ const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes);
+
+ if (isInDescendantRoute) {
+ name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes, location));
+ source = 'route';
+ }
+
+ if (!isInDescendantRoute || !name) {
+ [name, source] = getNormalizedName(routes, location, branches, basename);
+ }
+
+ return [name || location.pathname, source];
+}
diff --git a/packages/react/src/reactrouterv6.tsx b/packages/react/src/reactrouterv6.tsx
index a32e2bb02bf1..0c58bdb68f35 100644
--- a/packages/react/src/reactrouterv6.tsx
+++ b/packages/react/src/reactrouterv6.tsx
@@ -1,13 +1,13 @@
import type { browserTracingIntegration } from '@sentry/browser';
import type { Integration } from '@sentry/core';
-import type { ReactRouterOptions } from './reactrouterv6-compat-utils';
+import type { ReactRouterOptions } from './reactrouter-compat-utils';
import {
createReactRouterV6CompatibleTracingIntegration,
createV6CompatibleWithSentryReactRouterRouting,
createV6CompatibleWrapCreateBrowserRouter,
createV6CompatibleWrapCreateMemoryRouter,
createV6CompatibleWrapUseRoutes,
-} from './reactrouterv6-compat-utils';
+} from './reactrouter-compat-utils';
import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types';
/**
diff --git a/packages/react/src/reactrouterv7.tsx b/packages/react/src/reactrouterv7.tsx
index 5a80482cd2c3..a25e12d40e68 100644
--- a/packages/react/src/reactrouterv7.tsx
+++ b/packages/react/src/reactrouterv7.tsx
@@ -1,14 +1,14 @@
// React Router v7 uses the same integration as v6
import type { browserTracingIntegration } from '@sentry/browser';
import type { Integration } from '@sentry/core';
-import type { ReactRouterOptions } from './reactrouterv6-compat-utils';
+import type { ReactRouterOptions } from './reactrouter-compat-utils';
import {
createReactRouterV6CompatibleTracingIntegration,
createV6CompatibleWithSentryReactRouterRouting,
createV6CompatibleWrapCreateBrowserRouter,
createV6CompatibleWrapCreateMemoryRouter,
createV6CompatibleWrapUseRoutes,
-} from './reactrouterv6-compat-utils';
+} from './reactrouter-compat-utils';
import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types';
/**
diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
new file mode 100644
index 000000000000..840adbf0a816
--- /dev/null
+++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
@@ -0,0 +1,183 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { startBrowserTracingNavigationSpan } from '@sentry/browser';
+import type { Client, Span } from '@sentry/core';
+import { addNonEnumerableProperty } from '@sentry/core';
+import * as React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ addResolvedRoutesToParent,
+ createNewNavigationSpan,
+ createReactRouterV6CompatibleTracingIntegration,
+ handleExistingNavigationSpan,
+ updateNavigationSpan,
+} from '../../src/reactrouter-compat-utils';
+import type { Location, RouteObject } from '../../src/types';
+
+const mockUpdateName = vi.fn();
+const mockSetAttribute = vi.fn();
+const mockSpan = { updateName: mockUpdateName, setAttribute: mockSetAttribute } as unknown as Span;
+const mockClient = { addIntegration: vi.fn() } as unknown as Client;
+
+vi.mock('@sentry/core', async requireActual => {
+ const actual = await requireActual();
+ return {
+ ...(actual as any),
+ addNonEnumerableProperty: vi.fn(),
+ getActiveSpan: vi.fn(() => mockSpan),
+ getClient: vi.fn(() => mockClient),
+ getRootSpan: vi.fn(() => mockSpan),
+ spanToJSON: vi.fn(() => ({ op: 'navigation' })),
+ };
+});
+
+vi.mock('@sentry/browser', async requireActual => {
+ const actual = await requireActual();
+ return {
+ ...(actual as any),
+ startBrowserTracingNavigationSpan: vi.fn(),
+ browserTracingIntegration: vi.fn(() => ({
+ setup: vi.fn(),
+ afterAllSetup: vi.fn(),
+ name: 'BrowserTracing',
+ })),
+ };
+});
+
+vi.mock('../../src/reactrouter-compat-utils/utils', () => ({
+ resolveRouteNameAndSource: vi.fn(() => ['Test Route', 'route']),
+ initializeRouterUtils: vi.fn(),
+ getGlobalLocation: vi.fn(() => ({ pathname: '/test', search: '', hash: '' })),
+ getGlobalPathname: vi.fn(() => '/test'),
+}));
+
+vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({
+ checkRouteForAsyncHandler: vi.fn(),
+}));
+
+describe('reactrouter-compat-utils/instrumentation', () => {
+ const sampleLocation: Location = {
+ pathname: '/test',
+ search: '',
+ hash: '',
+ state: null,
+ key: 'default',
+ };
+
+ const sampleRoutes: RouteObject[] = [
+ { path: '/', element: Home
},
+ { path: '/about', element: About
},
+ ];
+
+ const mockMatchRoutes = vi.fn(() => []);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('updateNavigationSpan', () => {
+ it('should update navigation span name and source when not already named', () => {
+ updateNavigationSpan(mockSpan, sampleLocation, sampleRoutes, false, mockMatchRoutes);
+
+ expect(mockUpdateName).toHaveBeenCalledWith('Test Route');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ expect(addNonEnumerableProperty).toHaveBeenCalledWith(mockSpan, '__sentry_navigation_name_set__', true);
+ });
+
+ it('should not update when span already has name set', () => {
+ const spanWithNameSet = { ...mockSpan, __sentry_navigation_name_set__: true };
+
+ updateNavigationSpan(spanWithNameSet as any, sampleLocation, sampleRoutes, false, mockMatchRoutes);
+
+ expect(mockUpdateName).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('handleExistingNavigationSpan', () => {
+ it('should update span name when not already named', () => {
+ const spanJson = { op: 'navigation', data: {}, span_id: 'test', start_timestamp: 0, trace_id: 'test' };
+
+ handleExistingNavigationSpan(mockSpan, spanJson, 'Test Route', 'route', false);
+
+ expect(mockUpdateName).toHaveBeenCalledWith('Test Route');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ expect(addNonEnumerableProperty).toHaveBeenCalledWith(mockSpan, '__sentry_navigation_name_set__', true);
+ });
+
+ it('should not mark as named for lazy routes', () => {
+ const spanJson = { op: 'navigation', data: {}, span_id: 'test', start_timestamp: 0, trace_id: 'test' };
+
+ handleExistingNavigationSpan(mockSpan, spanJson, 'Test Route', 'route', true);
+
+ expect(mockUpdateName).toHaveBeenCalledWith('Test Route');
+ expect(mockSetAttribute).toHaveBeenCalledWith('sentry.source', 'route');
+ expect(addNonEnumerableProperty).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('createNewNavigationSpan', () => {
+ it('should create new navigation span with correct attributes', () => {
+ createNewNavigationSpan(mockClient, 'Test Route', 'route', '6', false);
+
+ expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith(mockClient, {
+ name: 'Test Route',
+ attributes: {
+ 'sentry.source': 'route',
+ 'sentry.op': 'navigation',
+ 'sentry.origin': 'auto.navigation.react.reactrouter_v6',
+ },
+ });
+ });
+ });
+
+ describe('addResolvedRoutesToParent', () => {
+ it('should add new routes to parent with no existing children', () => {
+ const parentRoute: RouteObject = { path: '/parent', element: Parent
};
+ const resolvedRoutes = [{ path: '/child1', element: Child 1
}];
+
+ addResolvedRoutesToParent(resolvedRoutes, parentRoute);
+
+ expect(parentRoute.children).toEqual(resolvedRoutes);
+ });
+
+ it('should not add duplicate routes by path', () => {
+ const existingRoute = { path: '/duplicate', element: Existing
};
+ const parentRoute: RouteObject = {
+ path: '/parent',
+ element: Parent
,
+ children: [existingRoute],
+ };
+ const duplicateRoute = { path: '/duplicate', element: Duplicate
};
+
+ addResolvedRoutesToParent([duplicateRoute], parentRoute);
+
+ expect(parentRoute.children).toEqual([existingRoute]);
+ });
+ });
+
+ describe('createReactRouterV6CompatibleTracingIntegration', () => {
+ it('should create integration with correct setup', () => {
+ const mockUseEffect = vi.fn();
+ const mockUseLocation = vi.fn();
+ const mockUseNavigationType = vi.fn();
+ const mockCreateRoutesFromChildren = vi.fn();
+
+ const integration = createReactRouterV6CompatibleTracingIntegration(
+ {
+ useEffect: mockUseEffect,
+ useLocation: mockUseLocation,
+ useNavigationType: mockUseNavigationType,
+ createRoutesFromChildren: mockCreateRoutesFromChildren,
+ matchRoutes: mockMatchRoutes,
+ },
+ '6',
+ );
+
+ expect(integration).toHaveProperty('setup');
+ expect(integration).toHaveProperty('afterAllSetup');
+ expect(typeof integration.setup).toBe('function');
+ expect(typeof integration.afterAllSetup).toBe('function');
+ });
+ });
+});
diff --git a/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
new file mode 100644
index 000000000000..732b893ea8f8
--- /dev/null
+++ b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
@@ -0,0 +1,706 @@
+import { addNonEnumerableProperty, debug } from '@sentry/core';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ checkRouteForAsyncHandler,
+ createAsyncHandlerProxy,
+ handleAsyncHandlerResult,
+} from '../../src/reactrouter-compat-utils';
+import type { RouteObject } from '../../src/types';
+
+vi.mock('@sentry/core', async requireActual => {
+ const actual = await requireActual();
+ return {
+ ...(actual as any),
+ addNonEnumerableProperty: vi.fn(),
+ debug: {
+ warn: vi.fn(),
+ },
+ };
+});
+
+vi.mock('../../src/debug-build', () => ({
+ DEBUG_BUILD: true,
+}));
+
+describe('reactrouter-compat-utils/lazy-routes', () => {
+ let mockProcessResolvedRoutes: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockProcessResolvedRoutes = vi.fn();
+ });
+
+ describe('createAsyncHandlerProxy', () => {
+ it('should create a proxy that calls the original function', () => {
+ const originalFunction = vi.fn(() => 'result');
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ const result = proxy('arg1', 'arg2');
+
+ expect(originalFunction).toHaveBeenCalledWith('arg1', 'arg2');
+ expect(result).toBe('result');
+ });
+
+ it('should preserve the original function context (this binding)', () => {
+ const context = { value: 'test' };
+ const originalFunction = vi.fn(function (this: typeof context) {
+ return this.value;
+ });
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ const result = proxy.call(context);
+
+ expect(originalFunction).toHaveBeenCalledWith();
+ expect(result).toBe('test');
+ });
+
+ it('should handle functions with no arguments', () => {
+ const originalFunction = vi.fn(() => 'no-args-result');
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ const result = proxy();
+
+ expect(originalFunction).toHaveBeenCalledWith();
+ expect(result).toBe('no-args-result');
+ });
+
+ it('should handle functions with many arguments', () => {
+ const originalFunction = vi.fn((...args) => args.length);
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ const result = proxy(1, 2, 3, 4, 5);
+
+ expect(originalFunction).toHaveBeenCalledWith(1, 2, 3, 4, 5);
+ expect(result).toBe(5);
+ });
+
+ it('should mark the proxy with __sentry_proxied__ property', () => {
+ const originalFunction = vi.fn();
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(addNonEnumerableProperty).toHaveBeenCalledWith(expect.any(Function), '__sentry_proxied__', true);
+ });
+
+ it('should call handleAsyncHandlerResult with the function result', () => {
+ const originalFunction = vi.fn(() => ['route1', 'route2']);
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ proxy();
+
+ // Since handleAsyncHandlerResult is called internally, we verify through its side effects
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(['route1', 'route2'], route);
+ });
+
+ it('should handle functions that throw exceptions', () => {
+ const originalFunction = vi.fn(() => {
+ throw new Error('Test error');
+ });
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(() => proxy()).toThrow('Test error');
+ expect(originalFunction).toHaveBeenCalled();
+ });
+
+ it('should handle complex route objects', () => {
+ const originalFunction = vi.fn(() => []);
+ const route: RouteObject = {
+ path: '/complex',
+ id: 'complex-route',
+ index: false,
+ caseSensitive: true,
+ children: [{ path: 'child' }],
+ element: 'Test
',
+ };
+ const handlerKey = 'complexHandler';
+
+ const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
+ proxy();
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith([], route);
+ });
+ });
+
+ describe('handleAsyncHandlerResult', () => {
+ const route: RouteObject = { path: '/test' };
+ const handlerKey = 'testHandler';
+
+ it('should handle array results directly', () => {
+ const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }];
+
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+
+ it('should handle empty array results', () => {
+ const routes: RouteObject[] = [];
+
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+
+ it('should handle Promise results that resolve to arrays', async () => {
+ const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }];
+ const promiseResult = Promise.resolve(routes);
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ // Wait for the promise to resolve
+ await promiseResult;
+
+ // Use setTimeout to wait for the async handling
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+
+ it('should handle Promise results that resolve to empty arrays', async () => {
+ const routes: RouteObject[] = [];
+ const promiseResult = Promise.resolve(routes);
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+
+ it('should handle Promise results that resolve to non-arrays', async () => {
+ const promiseResult = Promise.resolve('not an array');
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle Promise results that resolve to null', async () => {
+ const promiseResult = Promise.resolve(null);
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle Promise results that resolve to undefined', async () => {
+ const promiseResult = Promise.resolve(undefined);
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle Promise rejections gracefully', async () => {
+ const promiseResult = Promise.reject(new Error('Test error'));
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ // Wait for the promise to be handled
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(debug.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Error resolving async handler'),
+ route,
+ expect.any(Error),
+ );
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle Promise rejections with non-Error values', async () => {
+ const promiseResult = Promise.reject('string error');
+
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(debug.warn).toHaveBeenCalledWith(
+ expect.stringContaining('Error resolving async handler'),
+ route,
+ 'string error',
+ );
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should ignore non-promise, non-array results', () => {
+ handleAsyncHandlerResult('string result', route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(123, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult({ not: 'array' }, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(null, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(undefined, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should ignore boolean values', () => {
+ handleAsyncHandlerResult(true, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(false, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should ignore functions as results', () => {
+ const functionResult = () => 'test';
+ handleAsyncHandlerResult(functionResult, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it("should handle objects that look like promises but aren't", () => {
+ const fakeThenableButNotPromise = {
+ then: 'not a function',
+ };
+
+ handleAsyncHandlerResult(fakeThenableButNotPromise, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle objects that have then property but not a function', () => {
+ const almostPromise = {
+ then: null,
+ };
+
+ handleAsyncHandlerResult(almostPromise, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
+ });
+
+ it('should handle complex route objects in async handling', async () => {
+ const complexRoute: RouteObject = {
+ path: '/complex',
+ id: 'complex-route',
+ loader: vi.fn(),
+ element: 'Complex
',
+ };
+ const routes: RouteObject[] = [{ path: '/dynamic1' }, { path: '/dynamic2' }];
+ const promiseResult = Promise.resolve(routes);
+
+ handleAsyncHandlerResult(promiseResult, complexRoute, 'complexHandler', mockProcessResolvedRoutes);
+
+ await promiseResult;
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, complexRoute);
+ });
+
+ it('should handle nested route objects in arrays', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/parent',
+ children: [{ path: 'child1' }, { path: 'child2', children: [{ path: 'grandchild' }] }],
+ },
+ ];
+
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ });
+ });
+
+ describe('checkRouteForAsyncHandler', () => {
+ it('should proxy functions in route.handle', () => {
+ const testFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ notAFunction: 'string value',
+ },
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // The function should be replaced with a proxy
+ expect(route.handle!.testHandler).not.toBe(testFunction);
+ expect(typeof route.handle!.testHandler).toBe('function');
+ expect(route.handle!.notAFunction).toBe('string value');
+ });
+
+ it('should handle multiple functions in route.handle', () => {
+ const handler1 = vi.fn();
+ const handler2 = vi.fn();
+ const handler3 = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ handler1,
+ handler2,
+ handler3,
+ nonFunction: 'not a function',
+ anotherNonFunction: 42,
+ },
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // All functions should be proxied
+ expect(route.handle!.handler1).not.toBe(handler1);
+ expect(route.handle!.handler2).not.toBe(handler2);
+ expect(route.handle!.handler3).not.toBe(handler3);
+ expect(typeof route.handle!.handler1).toBe('function');
+ expect(typeof route.handle!.handler2).toBe('function');
+ expect(typeof route.handle!.handler3).toBe('function');
+
+ // Non-functions should remain unchanged
+ expect(route.handle!.nonFunction).toBe('not a function');
+ expect(route.handle!.anotherNonFunction).toBe(42);
+ });
+
+ it('should not proxy already proxied functions', () => {
+ const testFunction = vi.fn();
+ // Mark function as already proxied
+ (testFunction as any).__sentry_proxied__ = true;
+
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ },
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // The function should remain unchanged
+ expect(route.handle!.testHandler).toBe(testFunction);
+ });
+
+ it('should handle mix of proxied and non-proxied functions', () => {
+ const proxiedFunction = vi.fn();
+ const normalFunction = vi.fn();
+ (proxiedFunction as any).__sentry_proxied__ = true;
+
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ proxiedHandler: proxiedFunction,
+ normalHandler: normalFunction,
+ },
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // Proxied function should remain unchanged
+ expect(route.handle!.proxiedHandler).toBe(proxiedFunction);
+ // Normal function should be proxied
+ expect(route.handle!.normalHandler).not.toBe(normalFunction);
+ });
+
+ it('should recursively check child routes', () => {
+ const parentFunction = vi.fn();
+ const childFunction = vi.fn();
+
+ const route: RouteObject = {
+ path: '/parent',
+ handle: {
+ parentHandler: parentFunction,
+ },
+ children: [
+ {
+ path: 'child',
+ handle: {
+ childHandler: childFunction,
+ },
+ },
+ ],
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // Both parent and child functions should be proxied
+ expect(route.handle!.parentHandler).not.toBe(parentFunction);
+ expect(route.children![0].handle!.childHandler).not.toBe(childFunction);
+ });
+
+ it('should handle children without handle properties', () => {
+ const parentFunction = vi.fn();
+
+ const route: RouteObject = {
+ path: '/parent',
+ handle: {
+ parentHandler: parentFunction,
+ },
+ children: [
+ {
+ path: 'child1',
+ // No handle property
+ },
+ {
+ path: 'child2',
+ handle: undefined,
+ },
+ ],
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+
+ expect(route.handle!.parentHandler).not.toBe(parentFunction);
+ });
+
+ it('should handle routes without handle property', () => {
+ const route: RouteObject = {
+ path: '/test',
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with null handle property', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: null,
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with handle that is not an object', () => {
+ const route: RouteObject = {
+ path: '/test',
+ // @ts-expect-error - Testing edge case
+ handle: 'not an object',
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with handle that is a function', () => {
+ const route: RouteObject = {
+ path: '/test',
+ // @ts-expect-error - Testing edge case
+ handle: vi.fn(),
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes without children', () => {
+ const testFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ },
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with null children', () => {
+ const testFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ },
+ children: null,
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle routes with empty children array', () => {
+ const testFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ testHandler: testFunction,
+ },
+ children: [],
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+ });
+
+ it('should handle deeply nested child routes', () => {
+ const grandParentFunction = vi.fn();
+ const parentFunction = vi.fn();
+ const childFunction = vi.fn();
+
+ const route: RouteObject = {
+ path: '/grandparent',
+ handle: {
+ grandParentHandler: grandParentFunction,
+ },
+ children: [
+ {
+ path: 'parent',
+ handle: {
+ parentHandler: parentFunction,
+ },
+ children: [
+ {
+ path: 'child',
+ handle: {
+ childHandler: childFunction,
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // All functions should be proxied
+ expect(route.handle!.grandParentHandler).not.toBe(grandParentFunction);
+ expect(route.children![0].handle!.parentHandler).not.toBe(parentFunction);
+ expect(route.children![0].children![0].handle!.childHandler).not.toBe(childFunction);
+ });
+
+ it('should handle routes with complex nested structures', () => {
+ const route: RouteObject = {
+ path: '/complex',
+ handle: {
+ handler1: vi.fn(),
+ handler2: vi.fn(),
+ },
+ children: [
+ {
+ path: 'level1a',
+ handle: {
+ level1aHandler: vi.fn(),
+ },
+ children: [
+ {
+ path: 'level2a',
+ handle: {
+ level2aHandler: vi.fn(),
+ },
+ },
+ {
+ path: 'level2b',
+ // No handle
+ },
+ ],
+ },
+ {
+ path: 'level1b',
+ // No handle
+ children: [
+ {
+ path: 'level2c',
+ handle: {
+ level2cHandler: vi.fn(),
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+
+ // Check that functions were proxied at all levels
+ expect(typeof route.handle!.handler1).toBe('function');
+ expect(typeof route.handle!.handler2).toBe('function');
+ expect(typeof route.children![0].handle!.level1aHandler).toBe('function');
+ expect(typeof route.children![0].children![0].handle!.level2aHandler).toBe('function');
+ expect(typeof route.children![1].children![0].handle!.level2cHandler).toBe('function');
+ });
+
+ it('should preserve route properties during processing', () => {
+ const originalFunction = vi.fn();
+ const route: RouteObject = {
+ path: '/preserve',
+ id: 'preserve-route',
+ caseSensitive: true,
+ index: false,
+ handle: {
+ testHandler: originalFunction,
+ },
+ element: 'Test
',
+ loader: vi.fn(),
+ };
+
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+
+ // Route properties should be preserved
+ expect(route.path).toBe('/preserve');
+ expect(route.id).toBe('preserve-route');
+ expect(route.caseSensitive).toBe(true);
+ expect(route.index).toBe(false);
+ expect(route.element).toBe('Test
');
+ expect(route.loader).toBeDefined();
+
+ // Only the handler function should be changed
+ expect(route.handle!.testHandler).not.toBe(originalFunction);
+ });
+
+ it('should handle functions with special names', () => {
+ const route: RouteObject = {
+ path: '/test',
+ handle: {
+ constructor: vi.fn(),
+ toString: vi.fn(),
+ valueOf: vi.fn(),
+ hasOwnProperty: vi.fn(),
+ '0': vi.fn(),
+ 'special-name': vi.fn(),
+ 'with spaces': vi.fn(),
+ },
+ };
+
+ expect(() => {
+ checkRouteForAsyncHandler(route, mockProcessResolvedRoutes);
+ }).not.toThrow();
+
+ // All functions should be proxied regardless of name
+ expect(typeof route.handle!.constructor).toBe('function');
+ expect(typeof route.handle!.toString).toBe('function');
+ expect(typeof route.handle!.valueOf).toBe('function');
+ expect(typeof route.handle!.hasOwnProperty).toBe('function');
+ expect(typeof route.handle!['0']).toBe('function');
+ expect(typeof route.handle!['special-name']).toBe('function');
+ expect(typeof route.handle!['with spaces']).toBe('function');
+ });
+ });
+});
diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts
new file mode 100644
index 000000000000..91885940db31
--- /dev/null
+++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts
@@ -0,0 +1,632 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ getNormalizedName,
+ getNumberOfUrlSegments,
+ initializeRouterUtils,
+ locationIsInsideDescendantRoute,
+ pathEndsWithWildcard,
+ pathIsWildcardAndHasChildren,
+ prefixWithSlash,
+ rebuildRoutePathFromAllRoutes,
+ resolveRouteNameAndSource,
+} from '../../src/reactrouter-compat-utils';
+import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types';
+
+vi.mock('@sentry/browser', async requireActual => {
+ const actual = await requireActual();
+ return {
+ ...(actual as any),
+ WINDOW: {
+ location: {
+ pathname: '/test/path',
+ search: '?query=1',
+ hash: '#section',
+ href: 'https://example.com/test/path?query=1#section',
+ },
+ },
+ };
+});
+
+const mockMatchRoutes = vi.fn();
+
+describe('reactrouter-compat-utils/utils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ initializeRouterUtils(mockMatchRoutes as MatchRoutes, false);
+ });
+
+ describe('initializeRouterUtils', () => {
+ it('should initialize with matchRoutes function', () => {
+ expect(() => {
+ initializeRouterUtils(mockMatchRoutes as MatchRoutes, false);
+ }).not.toThrow();
+ });
+
+ it('should handle custom matchRoutes function with dev mode true', () => {
+ const customMatchRoutes = vi.fn();
+ expect(() => {
+ initializeRouterUtils(customMatchRoutes as MatchRoutes, true);
+ }).not.toThrow();
+ });
+
+ it('should handle custom matchRoutes function without dev mode flag', () => {
+ const customMatchRoutes = vi.fn();
+ expect(() => {
+ initializeRouterUtils(customMatchRoutes as MatchRoutes);
+ }).not.toThrow();
+ });
+ });
+
+ describe('prefixWithSlash', () => {
+ it('should add slash to string without leading slash', () => {
+ expect(prefixWithSlash('path')).toBe('/path');
+ });
+
+ it('should not add slash to string with leading slash', () => {
+ expect(prefixWithSlash('/path')).toBe('/path');
+ });
+
+ it('should handle empty string', () => {
+ expect(prefixWithSlash('')).toBe('/');
+ });
+ });
+
+ describe('pathEndsWithWildcard', () => {
+ it('should return true for path ending with /*', () => {
+ expect(pathEndsWithWildcard('/users/*')).toBe(true);
+ });
+
+ it('should return false for path not ending with /*', () => {
+ expect(pathEndsWithWildcard('/users')).toBe(false);
+ });
+
+ it('should return true for path ending with *', () => {
+ expect(pathEndsWithWildcard('/users*')).toBe(true);
+ });
+
+ it('should return false for empty string', () => {
+ expect(pathEndsWithWildcard('')).toBe(false);
+ });
+ });
+
+ describe('pathIsWildcardAndHasChildren', () => {
+ it('should return true for wildcard path with children', () => {
+ const branch = {
+ route: {
+ path: '/users/*',
+ children: [{ path: 'profile' }],
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users/*', branch)).toBe(true);
+ });
+
+ it('should return false for wildcard path without children', () => {
+ const branch = {
+ route: {
+ path: '/users/*',
+ children: [],
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users/*', branch)).toBe(false);
+ });
+
+ it('should return false for non-wildcard path with children', () => {
+ const branch = {
+ route: {
+ path: '/users',
+ children: [{ path: 'profile' }],
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users', branch)).toBe(false);
+ });
+
+ it('should return false for non-wildcard path without children', () => {
+ const branch = {
+ route: {
+ path: '/users',
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users', branch)).toBe(false);
+ });
+
+ it('should return false when route has undefined children', () => {
+ const branch = {
+ route: {
+ path: '/users/*',
+ children: undefined,
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ } as RouteMatch;
+
+ expect(pathIsWildcardAndHasChildren('/users/*', branch)).toBe(false);
+ });
+ });
+
+ describe('getNumberOfUrlSegments', () => {
+ it('should count URL segments correctly', () => {
+ expect(getNumberOfUrlSegments('/users/123/profile')).toBe(3);
+ });
+
+ it('should handle single segment', () => {
+ expect(getNumberOfUrlSegments('/users')).toBe(1);
+ });
+
+ it('should handle root path', () => {
+ expect(getNumberOfUrlSegments('/')).toBe(0);
+ });
+
+ it('should handle empty string', () => {
+ expect(getNumberOfUrlSegments('')).toBe(0);
+ });
+
+ it('should handle regex URLs with escaped slashes', () => {
+ expect(getNumberOfUrlSegments('/users\\/profile')).toBe(2);
+ });
+
+ it('should filter out empty segments and commas', () => {
+ expect(getNumberOfUrlSegments('/users//profile,test')).toBe(2);
+ });
+ });
+
+ describe('rebuildRoutePathFromAllRoutes', () => {
+ it('should return pathname when it matches stripped path', () => {
+ const allRoutes: RouteObject[] = [{ path: '/users', element: null }];
+
+ const location: Location = { pathname: '/users' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = rebuildRoutePathFromAllRoutes(allRoutes, location);
+ expect(result).toBe('/users');
+ });
+
+ it('should return empty string when no routes match', () => {
+ const allRoutes: RouteObject[] = [{ path: '/users', element: null }];
+
+ const location: Location = { pathname: '/nonexistent' };
+
+ mockMatchRoutes.mockReturnValue([]);
+
+ const result = rebuildRoutePathFromAllRoutes(allRoutes, location);
+ expect(result).toBe('');
+ });
+
+ it('should return empty string when no matches found', () => {
+ const allRoutes: RouteObject[] = [{ path: '/users', element: null }];
+
+ const location: Location = { pathname: '/users' };
+
+ mockMatchRoutes.mockReturnValue(null);
+
+ const result = rebuildRoutePathFromAllRoutes(allRoutes, location);
+ expect(result).toBe('');
+ });
+
+ it('should handle wildcard routes', () => {
+ const allRoutes: RouteObject[] = [{ path: '/users/*', element: null }];
+
+ const location: Location = { pathname: '/users/anything' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: { path: '*', element: null },
+ params: { '*': 'anything' },
+ pathname: '/users/anything',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = rebuildRoutePathFromAllRoutes(allRoutes, location);
+ expect(result).toBe('');
+ });
+ });
+
+ describe('locationIsInsideDescendantRoute', () => {
+ it('should return true when location is inside descendant route', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: 'div',
+ children: undefined,
+ },
+ ];
+
+ const location: Location = { pathname: '/users/123/profile' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: 'div',
+ children: undefined,
+ },
+ params: { '*': '123/profile' },
+ pathname: '/users/123/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(true);
+ });
+
+ it('should return false when route has children (not descendant)', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: 'div',
+ children: [{ path: 'profile' }],
+ },
+ ];
+
+ const location: Location = { pathname: '/users/123/profile' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: 'div',
+ children: [{ path: 'profile' }],
+ },
+ params: { '*': '123/profile' },
+ pathname: '/users/123/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+
+ it('should return false when route has no element', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: null,
+ children: undefined,
+ },
+ ];
+
+ const location: Location = { pathname: '/users/123/profile' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: null,
+ children: undefined,
+ },
+ params: { '*': '123/profile' },
+ pathname: '/users/123/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+
+ it('should return false when path does not end with /*', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users',
+ element: 'div',
+ children: undefined,
+ },
+ ];
+
+ const location: Location = { pathname: '/users' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users',
+ element: 'div',
+ children: undefined,
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+
+ it('should return false when no splat parameter', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: 'div',
+ children: undefined,
+ },
+ ];
+
+ const location: Location = { pathname: '/users' };
+
+ const mockMatches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: 'div',
+ children: undefined,
+ },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '/users',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(mockMatches);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+
+ it('should return false when no matches found', () => {
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+
+ const location: Location = { pathname: '/posts' };
+
+ mockMatchRoutes.mockReturnValue(null);
+
+ const result = locationIsInsideDescendantRoute(location, routes);
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('getNormalizedName', () => {
+ it('should return pathname with url source when no routes provided', () => {
+ const routes: RouteObject[] = [];
+ const location: Location = { pathname: '/test' };
+ const branches: RouteMatch[] = [];
+
+ const result = getNormalizedName(routes, location, branches);
+ expect(result).toEqual(['/test', 'url']);
+ });
+
+ it('should handle index route', () => {
+ const routes: RouteObject[] = [{ path: '/', index: true, element: null }];
+ const location: Location = { pathname: '/' };
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/', index: true, element: null },
+ params: {},
+ pathname: '/',
+ pathnameBase: '',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ expect(result).toEqual(['', 'route']);
+ });
+
+ it('should handle simple route path', () => {
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const location: Location = { pathname: '/users' };
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ expect(result).toEqual(['/users', 'route']);
+ });
+
+ it('should handle nested routes', () => {
+ const routes: RouteObject[] = [
+ { path: '/users', element: null },
+ { path: ':userId', element: null },
+ ];
+ const location: Location = { pathname: '/users/123' };
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ {
+ route: { path: ':userId', element: null },
+ params: { userId: '123' },
+ pathname: '/users/123',
+ pathnameBase: '/users',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ expect(result).toEqual(['/users/:userId', 'route']);
+ });
+
+ it('should handle wildcard routes with children', () => {
+ const routes: RouteObject[] = [
+ {
+ path: '/users/*',
+ element: null,
+ children: [{ path: 'profile', element: null }],
+ },
+ ];
+ const location: Location = { pathname: '/users/profile' };
+ const branches: RouteMatch[] = [
+ {
+ route: {
+ path: '/users/*',
+ element: null,
+ children: [{ path: 'profile', element: null }],
+ },
+ params: { '*': 'profile' },
+ pathname: '/users/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ // Function falls back to url when wildcard path with children doesn't match exact logic
+ expect(result).toEqual(['/users/profile', 'url']);
+ });
+
+ it('should handle basename stripping', () => {
+ // Initialize with stripBasename = true
+ initializeRouterUtils(mockMatchRoutes as MatchRoutes, true);
+
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const location: Location = { pathname: '/app/users' };
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/app/users',
+ pathnameBase: '/app',
+ },
+ ];
+
+ const result = getNormalizedName(routes, location, branches, '/app');
+ // Function falls back to url when basename stripping doesn't match exact logic
+ expect(result).toEqual(['/users', 'url']);
+ });
+
+ it('should fallback to pathname when no matches', () => {
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const location: Location = { pathname: '/posts' };
+ const branches: RouteMatch[] = [];
+
+ const result = getNormalizedName(routes, location, branches, '');
+ expect(result).toEqual(['/posts', 'url']);
+ });
+ });
+
+ describe('resolveRouteNameAndSource', () => {
+ beforeEach(() => {
+ // Reset to default stripBasename = false
+ initializeRouterUtils(mockMatchRoutes as MatchRoutes, false);
+ });
+
+ it('should use descendant route when location is inside one', () => {
+ const location: Location = { pathname: '/users/123/profile' };
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const allRoutes: RouteObject[] = [
+ { path: '/users/*', element: 'div' }, // element must be truthy for descendant route
+ { path: '/users', element: null },
+ ];
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ // Mock for descendant route check
+ const descendantMatches: RouteMatch[] = [
+ {
+ route: { path: '/users/*', element: 'div' },
+ params: { '*': '123/profile' },
+ pathname: '/users/123/profile',
+ pathnameBase: '/users',
+ },
+ ];
+
+ // Mock for rebuild route path - should return '/users'
+ const rebuildMatches: RouteMatch[] = [
+ {
+ route: { path: '/users/*', element: 'div' },
+ params: { '*': '123/profile' },
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ mockMatchRoutes
+ .mockReturnValueOnce(descendantMatches) // First call for descendant check
+ .mockReturnValueOnce(rebuildMatches); // Second call for path rebuild
+
+ const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, '');
+ // Since locationIsInsideDescendantRoute returns true, it uses route source
+ expect(result).toEqual(['/users/123/profile', 'route']);
+ });
+
+ it('should use normalized name when not in descendant route', () => {
+ const location: Location = { pathname: '/users' };
+ const routes: RouteObject[] = [{ path: '/users', element: null }];
+ const allRoutes: RouteObject[] = [{ path: '/users', element: null }];
+ const branches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ // Mock for descendant route check (no descendant)
+ const normalMatches: RouteMatch[] = [
+ {
+ route: { path: '/users', element: null },
+ params: {},
+ pathname: '/users',
+ pathnameBase: '',
+ },
+ ];
+
+ mockMatchRoutes.mockReturnValue(normalMatches);
+
+ const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, '');
+ expect(result).toEqual(['/users', 'route']);
+ });
+
+ it('should fallback to pathname when no name resolved', () => {
+ const location: Location = { pathname: '/unknown' };
+ const routes: RouteObject[] = [];
+ const allRoutes: RouteObject[] = [];
+ const branches: RouteMatch[] = [];
+
+ mockMatchRoutes.mockReturnValue(null);
+
+ const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, '');
+ expect(result).toEqual(['/unknown', 'url']);
+ });
+ });
+});
diff --git a/packages/react/test/reactrouter-cross-usage.test.tsx b/packages/react/test/reactrouter-cross-usage.test.tsx
index 57f9b4ce00cf..76cfe59f3df5 100644
--- a/packages/react/test/reactrouter-cross-usage.test.tsx
+++ b/packages/react/test/reactrouter-cross-usage.test.tsx
@@ -288,11 +288,6 @@ describe('React Router cross usage of wrappers', () => {
// It's called 1 time from the wrapped `MemoryRouter`
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
-
- // It's called 3 times from the 3 `useRoutes` components
- expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3);
- expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id');
-
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
name: '/second-level/:id/third-level/:id',
attributes: {
@@ -459,11 +454,6 @@ describe('React Router cross usage of wrappers', () => {
// It's called 1 time from the wrapped `MemoryRouter`
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
-
- // It's called 3 times from the 3 `useRoutes` components
- expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3);
- expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id');
- expect(mockNavigationSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
});
});
@@ -611,10 +601,6 @@ describe('React Router cross usage of wrappers', () => {
// It's called 1 time from the wrapped `createMemoryRouter`
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
- // It's called 3 times from the 3 `SentryRoutes` components
- expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3);
- expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id');
-
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
name: '/second-level/:id/third-level/:id',
attributes: {
@@ -790,11 +776,6 @@ describe('React Router cross usage of wrappers', () => {
// It's called 1 time from the wrapped `MemoryRouter`
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
-
- // It's called 3 times from the 2 `useRoutes` components and 1 component
- expect(mockNavigationSpan.updateName).toHaveBeenCalledTimes(3);
-
- expect(mockNavigationSpan.updateName).toHaveBeenLastCalledWith('/second-level/:id/third-level/:id');
expect(mockNavigationSpan.setAttribute).toHaveBeenLastCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
});
});
diff --git a/packages/react/test/reactrouterv6-compat-utils.test.tsx b/packages/react/test/reactrouterv6-compat-utils.test.tsx
deleted file mode 100644
index 1f10d89c8558..000000000000
--- a/packages/react/test/reactrouterv6-compat-utils.test.tsx
+++ /dev/null
@@ -1,315 +0,0 @@
-import { describe, expect, it, test } from 'vitest';
-import { checkRouteForAsyncHandler, getNumberOfUrlSegments } from '../src/reactrouterv6-compat-utils';
-import type { RouteObject } from '../src/types';
-
-describe('getNumberOfUrlSegments', () => {
- test.each([
- ['regular path', '/projects/123/views/234', 4],
- ['single param parameterized path', '/users/:id/details', 3],
- ['multi param parameterized path', '/stores/:storeId/products/:productId', 4],
- ['regex path', String(/\/api\/post[0-9]/), 2],
- ])('%s', (_: string, input, output) => {
- expect(getNumberOfUrlSegments(input)).toEqual(output);
- });
-});
-
-describe('checkRouteForAsyncHandler', () => {
- it('should not create nested proxies when called multiple times on the same route', () => {
- const mockHandler = () => Promise.resolve([]);
- const route: RouteObject = {
- path: '/test',
- handle: {
- lazyChildren: mockHandler,
- },
- };
-
- checkRouteForAsyncHandler(route);
- checkRouteForAsyncHandler(route);
-
- const proxiedHandler = route.handle?.lazyChildren;
- expect(typeof proxiedHandler).toBe('function');
- expect(proxiedHandler).not.toBe(mockHandler);
-
- expect((proxiedHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
-
- const proxyHandler = (proxiedHandler as any)?.__sentry_proxied__;
- expect(proxyHandler).toBe(true);
- });
-
- it('should handle routes without handle property', () => {
- const route: RouteObject = {
- path: '/test',
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with non-function handle properties', () => {
- const route: RouteObject = {
- path: '/test',
- handle: {
- someData: 'not a function',
- },
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with null/undefined handle properties', () => {
- const route: RouteObject = {
- path: '/test',
- handle: null as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with mixed function and non-function handle properties', () => {
- const mockHandler = () => Promise.resolve([]);
- const route: RouteObject = {
- path: '/test',
- handle: {
- lazyChildren: mockHandler,
- someData: 'not a function',
- anotherData: 123,
- },
- };
-
- checkRouteForAsyncHandler(route);
-
- const proxiedHandler = route.handle?.lazyChildren;
- expect(typeof proxiedHandler).toBe('function');
- expect(proxiedHandler).not.toBe(mockHandler);
- expect((proxiedHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
-
- // Non-function properties should remain unchanged
- expect(route.handle?.someData).toBe('not a function');
- expect(route.handle?.anotherData).toBe(123);
- });
-
- it('should handle nested routes with async handlers', () => {
- const parentHandler = () => Promise.resolve([]);
- const childHandler = () => Promise.resolve([]);
-
- const route: RouteObject = {
- path: '/parent',
- handle: {
- lazyChildren: parentHandler,
- },
- children: [
- {
- path: '/child',
- handle: {
- lazyChildren: childHandler,
- },
- },
- ],
- };
-
- checkRouteForAsyncHandler(route);
-
- // Check parent handler is proxied
- const proxiedParentHandler = route.handle?.lazyChildren;
- expect(typeof proxiedParentHandler).toBe('function');
- expect(proxiedParentHandler).not.toBe(parentHandler);
- expect((proxiedParentHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
-
- // Check child handler is proxied
- const proxiedChildHandler = route.children?.[0]?.handle?.lazyChildren;
- expect(typeof proxiedChildHandler).toBe('function');
- expect(proxiedChildHandler).not.toBe(childHandler);
- expect((proxiedChildHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
- });
-
- it('should handle deeply nested routes', () => {
- const level1Handler = () => Promise.resolve([]);
- const level2Handler = () => Promise.resolve([]);
- const level3Handler = () => Promise.resolve([]);
-
- const route: RouteObject = {
- path: '/level1',
- handle: {
- lazyChildren: level1Handler,
- },
- children: [
- {
- path: '/level2',
- handle: {
- lazyChildren: level2Handler,
- },
- children: [
- {
- path: '/level3',
- handle: {
- lazyChildren: level3Handler,
- },
- },
- ],
- },
- ],
- };
-
- checkRouteForAsyncHandler(route);
-
- // Check all handlers are proxied
- expect((route.handle?.lazyChildren as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
- expect((route.children?.[0]?.handle?.lazyChildren as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(
- true,
- );
- expect(
- (route.children?.[0]?.children?.[0]?.handle?.lazyChildren as { __sentry_proxied__?: boolean }).__sentry_proxied__,
- ).toBe(true);
- });
-
- it('should handle routes with multiple async handlers', () => {
- const handler1 = () => Promise.resolve([]);
- const handler2 = () => Promise.resolve([]);
- const handler3 = () => Promise.resolve([]);
-
- const route: RouteObject = {
- path: '/test',
- handle: {
- lazyChildren: handler1,
- asyncLoader: handler2,
- dataLoader: handler3,
- },
- };
-
- checkRouteForAsyncHandler(route);
-
- // Check all handlers are proxied
- expect((route.handle?.lazyChildren as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
- expect((route.handle?.asyncLoader as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
- expect((route.handle?.dataLoader as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
- });
-
- it('should not re-proxy already proxied functions', () => {
- const mockHandler = () => Promise.resolve([]);
- const route: RouteObject = {
- path: '/test',
- handle: {
- lazyChildren: mockHandler,
- },
- };
-
- // First call should proxy the function
- checkRouteForAsyncHandler(route);
- const firstProxiedHandler = route.handle?.lazyChildren;
- expect(firstProxiedHandler).not.toBe(mockHandler);
- expect((firstProxiedHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
-
- // Second call should not create a new proxy
- checkRouteForAsyncHandler(route);
- const secondProxiedHandler = route.handle?.lazyChildren;
- expect(secondProxiedHandler).toBe(firstProxiedHandler); // Should be the same proxy
- expect((secondProxiedHandler as { __sentry_proxied__?: boolean }).__sentry_proxied__).toBe(true);
- });
-
- it('should handle routes with empty children array', () => {
- const route: RouteObject = {
- path: '/test',
- children: [],
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with undefined children', () => {
- const route: RouteObject = {
- path: '/test',
- children: undefined,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with null children', () => {
- const route: RouteObject = {
- path: '/test',
- children: null as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with non-array children', () => {
- const route: RouteObject = {
- path: '/test',
- children: 'not an array' as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with handle that is not an object', () => {
- const route: RouteObject = {
- path: '/test',
- handle: 'not an object' as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with handle that is null', () => {
- const route: RouteObject = {
- path: '/test',
- handle: null as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with handle that is undefined', () => {
- const route: RouteObject = {
- path: '/test',
- handle: undefined as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with handle that is a function', () => {
- const route: RouteObject = {
- path: '/test',
- handle: (() => {}) as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with handle that is a string', () => {
- const route: RouteObject = {
- path: '/test',
- handle: 'string handle' as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with handle that is a number', () => {
- const route: RouteObject = {
- path: '/test',
- handle: 42 as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with handle that is a boolean', () => {
- const route: RouteObject = {
- path: '/test',
- handle: true as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-
- it('should handle routes with handle that is an array', () => {
- const route: RouteObject = {
- path: '/test',
- handle: [] as any,
- };
-
- expect(() => checkRouteForAsyncHandler(route)).not.toThrow();
- });
-});
diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts
index 696c3d765c5b..3f5797efd211 100644
--- a/packages/sveltekit/src/server-common/handle.ts
+++ b/packages/sveltekit/src/server-common/handle.ts
@@ -7,10 +7,13 @@ import {
getDefaultIsolationScope,
getIsolationScope,
getTraceMetaTags,
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
setHttpStatus,
+ spanToJSON,
startSpan,
+ updateSpanName,
winterCGRequestToRequestData,
withIsolationScope,
} from '@sentry/core';
@@ -88,11 +91,33 @@ export function isFetchProxyRequired(version: string): boolean {
return true;
}
+interface BackwardsForwardsCompatibleEvent {
+ /**
+ * For now taken from: https://github.com/sveltejs/kit/pull/13899
+ * Access to spans for tracing. If tracing is not enabled or the function is being run in the browser, these spans will do nothing.
+ * @since 2.31.0
+ */
+ tracing?: {
+ /** Whether tracing is enabled. */
+ enabled: boolean;
+ current: Span;
+ root: Span;
+ };
+}
+
async function instrumentHandle(
- { event, resolve }: Parameters[0],
+ {
+ event,
+ resolve,
+ }: {
+ event: Parameters[0]['event'] & BackwardsForwardsCompatibleEvent;
+ resolve: Parameters[0]['resolve'];
+ },
options: SentryHandleOptions,
): Promise {
- if (!event.route?.id && !options.handleUnknownRoutes) {
+ const routeId = event.route?.id;
+
+ if (!routeId && !options.handleUnknownRoutes) {
return resolve(event);
}
@@ -108,7 +133,7 @@ async function instrumentHandle(
}
}
- const routeName = `${event.request.method} ${event.route?.id || event.url.pathname}`;
+ const routeName = `${event.request.method} ${routeId || event.url.pathname}`;
if (getIsolationScope() !== getDefaultIsolationScope()) {
getIsolationScope().setTransactionName(routeName);
@@ -116,34 +141,72 @@ async function instrumentHandle(
DEBUG_BUILD && debug.warn('Isolation scope is default isolation scope - skipping setting transactionName');
}
+ // We only start a span if SvelteKit's native tracing is not enabled. Two reasons:
+ // - Used Kit version doesn't yet support tracing
+ // - Users didn't enable tracing
+ const kitTracingEnabled = event.tracing?.enabled;
+
try {
- const resolveResult = await startSpan(
- {
- op: 'http.server',
- attributes: {
+ const resolveWithSentry: (sentrySpan?: Span) => Promise = async (sentrySpan?: Span) => {
+ getCurrentScope().setSDKProcessingMetadata({
+ // We specifically avoid cloning the request here to avoid double read errors.
+ // We only read request headers so we're not consuming the body anyway.
+ // Note to future readers: This sounds counter-intuitive but please read
+ // https://github.com/getsentry/sentry-javascript/issues/14583
+ normalizedRequest: winterCGRequestToRequestData(event.request),
+ });
+ const kitRootSpan = event.tracing?.enabled ? event.tracing?.root : undefined;
+
+ if (kitRootSpan) {
+ // Update the root span emitted from SvelteKit to resemble a `http.server` span
+ // We're doing this here instead of an event processor to ensure we update the
+ // span name as early as possible (for dynamic sampling, et al.)
+ // Other spans are enhanced in the `processKitSpans` integration.
+ const spanJson = spanToJSON(kitRootSpan);
+ const kitRootSpanAttributes = spanJson.data;
+ const originalName = spanJson.description;
+
+ const routeName = kitRootSpanAttributes['http.route'];
+ if (routeName && typeof routeName === 'string') {
+ updateSpanName(kitRootSpan, `${event.request.method ?? 'GET'} ${routeName}`);
+ }
+
+ kitRootSpan.setAttributes({
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit',
- [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: event.route?.id ? 'route' : 'url',
- 'http.method': event.request.method,
- },
- name: routeName,
- },
- async (span?: Span) => {
- getCurrentScope().setSDKProcessingMetadata({
- // We specifically avoid cloning the request here to avoid double read errors.
- // We only read request headers so we're not consuming the body anyway.
- // Note to future readers: This sounds counter-intuitive but please read
- // https://github.com/getsentry/sentry-javascript/issues/14583
- normalizedRequest: winterCGRequestToRequestData(event.request),
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeName ? 'route' : 'url',
+ 'sveltekit.tracing.original_name': originalName,
});
- const res = await resolve(event, {
- transformPageChunk: addSentryCodeToPage({ injectFetchProxyScript: options.injectFetchProxyScript ?? true }),
- });
- if (span) {
- setHttpStatus(span, res.status);
- }
- return res;
- },
- );
+ }
+
+ const res = await resolve(event, {
+ transformPageChunk: addSentryCodeToPage({
+ injectFetchProxyScript: options.injectFetchProxyScript ?? true,
+ }),
+ });
+
+ if (sentrySpan) {
+ setHttpStatus(sentrySpan, res.status);
+ }
+
+ return res;
+ };
+
+ const resolveResult = kitTracingEnabled
+ ? await resolveWithSentry()
+ : await startSpan(
+ {
+ op: 'http.server',
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: routeId ? 'route' : 'url',
+ 'http.method': event.request.method,
+ },
+ name: routeName,
+ },
+ resolveWithSentry,
+ );
+
return resolveResult;
} catch (e: unknown) {
sendErrorToSentry(e, 'handle');
@@ -176,9 +239,12 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle {
};
const sentryRequestHandler: Handle = input => {
+ const backwardsForwardsCompatibleEvent = input.event as typeof input.event & BackwardsForwardsCompatibleEvent;
+
// Escape hatch to suppress request isolation and trace continuation (see initCloudflareSentryHandle)
const skipIsolation =
- '_sentrySkipRequestIsolation' in input.event.locals && input.event.locals._sentrySkipRequestIsolation;
+ '_sentrySkipRequestIsolation' in backwardsForwardsCompatibleEvent.locals &&
+ backwardsForwardsCompatibleEvent.locals._sentrySkipRequestIsolation;
// In case of a same-origin `fetch` call within a server`load` function,
// SvelteKit will actually just re-enter the `handle` function and set `isSubRequest`
@@ -187,7 +253,9 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle {
// currently active span instead of a new root span to correctly reflect this
// behavior.
if (skipIsolation || input.event.isSubRequest) {
- return instrumentHandle(input, options);
+ return instrumentHandle(input, {
+ ...options,
+ });
}
return withIsolationScope(isolationScope => {
@@ -200,6 +268,13 @@ export function sentryHandle(handlerOptions?: SentryHandleOptions): Handle {
// https://github.com/getsentry/sentry-javascript/issues/14583
normalizedRequest: winterCGRequestToRequestData(input.event.request),
});
+
+ if (backwardsForwardsCompatibleEvent.tracing?.enabled) {
+ // if sveltekit tracing is enabled (since 2.31.0), trace continuation is handled by
+ // kit before our hook is executed. No noeed to call `continueTrace` from our end
+ return instrumentHandle(input, options);
+ }
+
return continueTrace(getTracePropagationData(input.event), () => instrumentHandle(input, options));
});
};
diff --git a/packages/sveltekit/src/server-common/rewriteFramesIntegration.ts b/packages/sveltekit/src/server-common/integrations/rewriteFramesIntegration.ts
similarity index 95%
rename from packages/sveltekit/src/server-common/rewriteFramesIntegration.ts
rename to packages/sveltekit/src/server-common/integrations/rewriteFramesIntegration.ts
index 412dd6f98815..9f6f0add6944 100644
--- a/packages/sveltekit/src/server-common/rewriteFramesIntegration.ts
+++ b/packages/sveltekit/src/server-common/integrations/rewriteFramesIntegration.ts
@@ -7,8 +7,8 @@ import {
join,
rewriteFramesIntegration as originalRewriteFramesIntegration,
} from '@sentry/core';
-import { WRAPPED_MODULE_SUFFIX } from '../common/utils';
-import type { GlobalWithSentryValues } from '../vite/injectGlobalValues';
+import { WRAPPED_MODULE_SUFFIX } from '../../common/utils';
+import type { GlobalWithSentryValues } from '../../vite/injectGlobalValues';
type StackFrameIteratee = (frame: StackFrame) => StackFrame;
interface RewriteFramesOptions {
diff --git a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts
new file mode 100644
index 000000000000..5ab24a731279
--- /dev/null
+++ b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts
@@ -0,0 +1,78 @@
+import type { Integration, SpanOrigin } from '@sentry/core';
+import { type SpanJSON, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
+
+/**
+ * A small integration that preprocesses spans so that SvelteKit-generated spans
+ * (via Kit's tracing feature since 2.31.0) get the correct Sentry attributes
+ * and data.
+ */
+export function svelteKitSpansIntegration(): Integration {
+ return {
+ name: 'SvelteKitSpansEnhancement',
+ // Using preprocessEvent to ensure the processing happens before user-configured
+ // event processors are executed
+ preprocessEvent(event) {
+ // only iterate over the spans if the root span was emitted by SvelteKit
+ // TODO: Right now, we can't optimize this to only check traces with a kit-emitted root span
+ // this is because in Cloudflare, the kit-emitted root span is missing but our cloudflare
+ // SDK emits the http.server span.
+ if (event.type === 'transaction') {
+ event.spans?.forEach(_enhanceKitSpan);
+ }
+ },
+ };
+}
+
+/**
+ * Adds sentry-specific attributes and data to a span emitted by SvelteKit's native tracing (since 2.31.0)
+ * @exported for testing
+ */
+export function _enhanceKitSpan(span: SpanJSON): void {
+ let op: string | undefined = undefined;
+ let origin: SpanOrigin | undefined = undefined;
+
+ const spanName = span.description;
+
+ const previousOp = span.op || span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP];
+ const previousOrigin = span.origin || span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN];
+
+ switch (spanName) {
+ case 'sveltekit.resolve':
+ op = 'function.sveltekit.resolve';
+ origin = 'auto.http.sveltekit';
+ break;
+ case 'sveltekit.load':
+ op = 'function.sveltekit.load';
+ origin = 'auto.function.sveltekit.load';
+ break;
+ case 'sveltekit.form_action':
+ op = 'function.sveltekit.form_action';
+ origin = 'auto.function.sveltekit.action';
+ break;
+ case 'sveltekit.remote.call':
+ op = 'function.sveltekit.remote';
+ origin = 'auto.rpc.sveltekit.remote';
+ break;
+ case 'sveltekit.handle.root':
+ // We don't want to overwrite the root handle span at this point since
+ // we already enhance the root span in our `sentryHandle` hook.
+ break;
+ default: {
+ if (spanName?.startsWith('sveltekit.handle.sequenced.')) {
+ op = 'function.sveltekit.handle';
+ origin = 'auto.function.sveltekit.handle';
+ }
+ break;
+ }
+ }
+
+ if (!previousOp && op) {
+ span.op = op;
+ span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP] = op;
+ }
+
+ if ((!previousOrigin || previousOrigin === 'manual') && origin) {
+ span.origin = origin;
+ span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = origin;
+ }
+}
diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts
index 56400dcc5423..d287331df14d 100644
--- a/packages/sveltekit/src/server/index.ts
+++ b/packages/sveltekit/src/server/index.ts
@@ -53,7 +53,6 @@ export {
getTraceMetaTags,
graphqlIntegration,
hapiIntegration,
- httpIntegration,
// eslint-disable-next-line deprecation/deprecation
inboundFiltersIntegration,
eventFiltersIntegration,
@@ -141,6 +140,7 @@ export { wrapLoadWithSentry, wrapServerLoadWithSentry } from '../server-common/l
export { sentryHandle } from '../server-common/handle';
export { initCloudflareSentryHandle } from './handle';
export { wrapServerRouteWithSentry } from '../server-common/serverRoute';
+export { httpIntegration } from './integrations/http';
/**
* Tracks the Svelte component's initialization and mounting operation as well as
diff --git a/packages/sveltekit/src/server/integrations/http.ts b/packages/sveltekit/src/server/integrations/http.ts
new file mode 100644
index 000000000000..4d6844017d1d
--- /dev/null
+++ b/packages/sveltekit/src/server/integrations/http.ts
@@ -0,0 +1,39 @@
+import type { IntegrationFn } from '@sentry/core';
+import { httpIntegration as originalHttpIntegration } from '@sentry/node';
+
+type HttpOptions = Parameters[0];
+
+/**
+ * The http integration instruments Node's internal http and https modules.
+ * It creates breadcrumbs and spans for outgoing HTTP requests which will be attached to the currently active span.
+ *
+ * For SvelteKit, does not create spans for incoming requests but instead we use SvelteKit's own spans.
+ * If you need to create incoming spans, set the `disableIncomingRequestSpans` option to `false`.
+ * (You likely don't need this!)
+ *
+ */
+export const httpIntegration = ((options: HttpOptions = {}) => {
+ /*
+ * This is a slightly modified version of the original httpIntegration: We avoid creating
+ * incoming request spans because:
+ *
+ * - If Kit-tracing is available and enabled, we take the `sveltekit.handle.root` span
+ * as the root span and make it the `http.server` span. This gives us a single root
+ * span across all deployment plaftorms (while httpIntegration doesn't apply on e.g.
+ * AWS Lambda or edge)
+ * - If Kit-tracing is N/A or disabled and users follow the current/old docs, httpIntegration
+ * does nothing anyway, so this isn't a concern.
+ * - Which leaves the undocumented case that users --import an instrument.mjs file
+ * in which they initialize the SDK. IMHO it's fine to ignore this for now since it was
+ * well ... undocumented. Given in the future there won't be be an easy way for us
+ * to detect where the SDK is initialized, we should simply redirect users to use
+ * instrumentation.server.ts instead. If users want to, they can simply import and
+ * register `httpIntegration` and explicitly enable incoming request spans.
+ */
+
+ return originalHttpIntegration({
+ // We disable incoming request spans here, because otherwise we'd end up with duplicate spans.
+ disableIncomingRequestSpans: true,
+ ...options,
+ });
+}) satisfies IntegrationFn;
diff --git a/packages/sveltekit/src/server/sdk.ts b/packages/sveltekit/src/server/sdk.ts
index 19a0a8f9f5ad..fb7a5dbbb471 100644
--- a/packages/sveltekit/src/server/sdk.ts
+++ b/packages/sveltekit/src/server/sdk.ts
@@ -1,15 +1,24 @@
import { applySdkMetadata } from '@sentry/core';
import type { NodeClient, NodeOptions } from '@sentry/node';
import { getDefaultIntegrations as getDefaultNodeIntegrations, init as initNodeSdk } from '@sentry/node';
-import { rewriteFramesIntegration } from '../server-common/rewriteFramesIntegration';
+import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration';
+import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans';
+import { httpIntegration } from './integrations/http';
/**
* Initialize the Server-side Sentry SDK
* @param options
*/
export function init(options: NodeOptions): NodeClient | undefined {
+ const defaultIntegrations = [
+ ...getDefaultNodeIntegrations(options).filter(integration => integration.name !== 'Http'),
+ rewriteFramesIntegration(),
+ httpIntegration(),
+ svelteKitSpansIntegration(),
+ ];
+
const opts = {
- defaultIntegrations: [...getDefaultNodeIntegrations(options), rewriteFramesIntegration()],
+ defaultIntegrations,
...options,
};
diff --git a/packages/sveltekit/src/vite/autoInstrument.ts b/packages/sveltekit/src/vite/autoInstrument.ts
index 63f4888257de..58862e452ddc 100644
--- a/packages/sveltekit/src/vite/autoInstrument.ts
+++ b/packages/sveltekit/src/vite/autoInstrument.ts
@@ -27,6 +27,7 @@ export type AutoInstrumentSelection = {
type AutoInstrumentPluginOptions = AutoInstrumentSelection & {
debug: boolean;
+ onlyInstrumentClient: boolean;
};
/**
@@ -41,12 +42,26 @@ type AutoInstrumentPluginOptions = AutoInstrumentSelection & {
export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptions): Plugin {
const { load: wrapLoadEnabled, serverLoad: wrapServerLoadEnabled, debug } = options;
+ let isServerBuild: boolean | undefined = undefined;
+
return {
name: 'sentry-auto-instrumentation',
// This plugin needs to run as early as possible, before the SvelteKit plugin virtualizes all paths and ids
enforce: 'pre',
+ configResolved: config => {
+ // The SvelteKit plugins trigger additional builds within the main (SSR) build.
+ // We just need a mechanism to upload source maps only once.
+ // `config.build.ssr` is `true` for that first build and `false` in the other ones.
+ // Hence we can use it as a switch to upload source maps only once in main build.
+ isServerBuild = !!config.build.ssr;
+ },
+
async load(id) {
+ if (options.onlyInstrumentClient && isServerBuild) {
+ return null;
+ }
+
const applyUniversalLoadWrapper =
wrapLoadEnabled &&
/^\+(page|layout)\.(js|ts|mjs|mts)$/.test(path.basename(id)) &&
@@ -58,6 +73,12 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio
return getWrapperCode('wrapLoadWithSentry', `${id}${WRAPPED_MODULE_SUFFIX}`);
}
+ if (options.onlyInstrumentClient) {
+ // Now that we've checked universal files, we can early return and avoid further
+ // regexp checks below for server-only files, in case `onlyInstrumentClient` is `true`.
+ return null;
+ }
+
const applyServerLoadWrapper =
wrapServerLoadEnabled &&
/^\+(page|layout)\.server\.(js|ts|mjs|mts)$/.test(path.basename(id)) &&
diff --git a/packages/sveltekit/src/vite/injectGlobalValues.ts b/packages/sveltekit/src/vite/injectGlobalValues.ts
index 96ad05123ce6..20f446b6b46f 100644
--- a/packages/sveltekit/src/vite/injectGlobalValues.ts
+++ b/packages/sveltekit/src/vite/injectGlobalValues.ts
@@ -1,4 +1,8 @@
-import type { InternalGlobal } from '@sentry/core';
+import { type InternalGlobal, escapeStringForRegex } from '@sentry/core';
+import MagicString from 'magic-string';
+import type { Plugin } from 'vite';
+import { type BackwardsForwardsCompatibleSvelteConfig, getAdapterOutputDir, getHooksFileName } from './svelteConfig';
+import type { SentrySvelteKitPluginOptions } from './types';
export type GlobalSentryValues = {
__sentry_sveltekit_output_dir?: string;
@@ -27,3 +31,71 @@ export function getGlobalValueInjectionCode(globalSentryValues: GlobalSentryValu
return `${injectedValuesCode}\n`;
}
+
+/**
+ * Injects SvelteKit app configuration values the svelte.config.js into the
+ * server's global object so that the SDK can pick up the information at runtime
+ */
+export async function makeGlobalValuesInjectionPlugin(
+ svelteConfig: BackwardsForwardsCompatibleSvelteConfig,
+ options: Pick,
+): Promise {
+ const { adapter = 'other', debug = false } = options;
+
+ const serverHooksFile = getHooksFileName(svelteConfig, 'server');
+ const adapterOutputDir = await getAdapterOutputDir(svelteConfig, adapter);
+
+ const globalSentryValues: GlobalSentryValues = {
+ __sentry_sveltekit_output_dir: adapterOutputDir,
+ };
+
+ if (debug) {
+ // eslint-disable-next-line no-console
+ console.log('[Sentry SvelteKit] Global values:', globalSentryValues);
+ }
+
+ // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- not end user input + escaped anyway
+ const hooksFileRegexp = new RegExp(`/${escapeStringForRegex(serverHooksFile)}(.(js|ts|mjs|mts))?`);
+
+ return {
+ name: 'sentry-sveltekit-global-values-injection-plugin',
+ resolveId: (id, _importer, _ref) => {
+ if (id === VIRTUAL_GLOBAL_VALUES_FILE) {
+ return {
+ id: VIRTUAL_GLOBAL_VALUES_FILE,
+ external: false,
+ moduleSideEffects: true,
+ };
+ }
+ return null;
+ },
+
+ load: id => {
+ if (id === VIRTUAL_GLOBAL_VALUES_FILE) {
+ return {
+ code: getGlobalValueInjectionCode(globalSentryValues),
+ };
+ }
+ return null;
+ },
+
+ transform: async (code, id) => {
+ const isServerEntryFile = /instrumentation\.server\./.test(id) || hooksFileRegexp.test(id);
+
+ if (isServerEntryFile) {
+ if (debug) {
+ // eslint-disable-next-line no-console
+ console.log('[Global Values Plugin] Injecting global values into', id);
+ }
+ const ms = new MagicString(code);
+ ms.append(`\n; import "${VIRTUAL_GLOBAL_VALUES_FILE}";\n`);
+ return {
+ code: ms.toString(),
+ map: ms.generateMap({ hires: true }),
+ };
+ }
+
+ return null;
+ },
+ };
+}
diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts
index 4444ba9a6ab7..61b388a94cf2 100644
--- a/packages/sveltekit/src/vite/sentryVitePlugins.ts
+++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts
@@ -2,7 +2,9 @@ import type { Plugin } from 'vite';
import type { AutoInstrumentSelection } from './autoInstrument';
import { makeAutoInstrumentationPlugin } from './autoInstrument';
import { detectAdapter } from './detectAdapter';
+import { makeGlobalValuesInjectionPlugin } from './injectGlobalValues';
import { makeCustomSentryVitePlugins } from './sourceMaps';
+import { loadSvelteConfig } from './svelteConfig';
import type { CustomSentryVitePluginOptions, SentrySvelteKitPluginOptions } from './types';
const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = {
@@ -25,9 +27,14 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}
adapter: options.adapter || (await detectAdapter(options.debug)),
};
+ const svelteConfig = await loadSvelteConfig();
+
const sentryPlugins: Plugin[] = [];
if (mergedOptions.autoInstrument) {
+ // TODO: Once tracing is promoted stable, we need to adjust this check!
+ const kitTracingEnabled = !!svelteConfig.kit?.experimental?.tracing?.server;
+
const pluginOptions: AutoInstrumentSelection = {
load: true,
serverLoad: true,
@@ -38,15 +45,26 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}
makeAutoInstrumentationPlugin({
...pluginOptions,
debug: options.debug || false,
+ // if kit-internal tracing is enabled, we only want to wrap and instrument client-side code.
+ onlyInstrumentClient: kitTracingEnabled,
}),
);
}
const sentryVitePluginsOptions = generateVitePluginOptions(mergedOptions);
- if (sentryVitePluginsOptions) {
- const sentryVitePlugins = await makeCustomSentryVitePlugins(sentryVitePluginsOptions);
+ if (mergedOptions.autoUploadSourceMaps) {
+ // When source maps are enabled, we need to inject the output directory to get a correct
+ // stack trace, by using this SDK's `rewriteFrames` integration.
+ // This integration picks up the value.
+ // TODO: I don't think this is technically correct. Either we always or never inject the output directory.
+ // Stack traces shouldn't be different, depending on source maps config. With debugIds, we might not even
+ // need to rewrite frames anymore.
+ sentryPlugins.push(await makeGlobalValuesInjectionPlugin(svelteConfig, mergedOptions));
+ }
+ if (sentryVitePluginsOptions) {
+ const sentryVitePlugins = await makeCustomSentryVitePlugins(sentryVitePluginsOptions, svelteConfig);
sentryPlugins.push(...sentryVitePlugins);
}
diff --git a/packages/sveltekit/src/vite/sourceMaps.ts b/packages/sveltekit/src/vite/sourceMaps.ts
index eb3b449144f8..57bf21055bf1 100644
--- a/packages/sveltekit/src/vite/sourceMaps.ts
+++ b/packages/sveltekit/src/vite/sourceMaps.ts
@@ -1,17 +1,14 @@
-/* eslint-disable max-lines */
import { escapeStringForRegex, uuid4 } from '@sentry/core';
import { getSentryRelease } from '@sentry/node';
import type { SentryVitePluginOptions } from '@sentry/vite-plugin';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import * as child_process from 'child_process';
import * as fs from 'fs';
-import MagicString from 'magic-string';
import * as path from 'path';
import type { Plugin, UserConfig } from 'vite';
import { WRAPPED_MODULE_SUFFIX } from '../common/utils';
-import type { GlobalSentryValues } from './injectGlobalValues';
-import { getGlobalValueInjectionCode, VIRTUAL_GLOBAL_VALUES_FILE } from './injectGlobalValues';
-import { getAdapterOutputDir, getHooksFileName, loadSvelteConfig } from './svelteConfig';
+import type { BackwardsForwardsCompatibleSvelteConfig } from './svelteConfig';
+import { getAdapterOutputDir } from './svelteConfig';
import type { CustomSentryVitePluginOptions } from './types';
// sorcery has no types, so these are some basic type definitions:
@@ -45,9 +42,10 @@ type FilesToDeleteAfterUpload = string | string[] | undefined;
*
* @returns the custom Sentry Vite plugin
*/
-export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePluginOptions): Promise {
- const svelteConfig = await loadSvelteConfig();
-
+export async function makeCustomSentryVitePlugins(
+ options: CustomSentryVitePluginOptions,
+ svelteConfig: BackwardsForwardsCompatibleSvelteConfig,
+): Promise {
const usedAdapter = options?.adapter || 'other';
const adapterOutputDir = await getAdapterOutputDir(svelteConfig, usedAdapter);
@@ -149,12 +147,6 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug
let isSSRBuild = true;
- const serverHooksFile = getHooksFileName(svelteConfig, 'server');
-
- const globalSentryValues: GlobalSentryValues = {
- __sentry_sveltekit_output_dir: adapterOutputDir,
- };
-
const sourceMapSettingsPlugin: Plugin = {
name: 'sentry-sveltekit-update-source-map-setting-plugin',
apply: 'build', // only apply this plugin at build time
@@ -202,26 +194,6 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug
name: 'sentry-sveltekit-debug-id-upload-plugin',
apply: 'build', // only apply this plugin at build time
enforce: 'post', // this needs to be set to post, otherwise we don't pick up the output from the SvelteKit adapter
- resolveId: (id, _importer, _ref) => {
- if (id === VIRTUAL_GLOBAL_VALUES_FILE) {
- return {
- id: VIRTUAL_GLOBAL_VALUES_FILE,
- external: false,
- moduleSideEffects: true,
- };
- }
- return null;
- },
-
- load: id => {
- if (id === VIRTUAL_GLOBAL_VALUES_FILE) {
- return {
- code: getGlobalValueInjectionCode(globalSentryValues),
- };
- }
- return null;
- },
-
configResolved: config => {
// The SvelteKit plugins trigger additional builds within the main (SSR) build.
// We just need a mechanism to upload source maps only once.
@@ -232,22 +204,6 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug
}
},
- transform: async (code, id) => {
- // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- not end user input + escaped anyway
- const isServerHooksFile = new RegExp(`/${escapeStringForRegex(serverHooksFile)}(.(js|ts|mjs|mts))?`).test(id);
-
- if (isServerHooksFile) {
- const ms = new MagicString(code);
- ms.append(`\n; import "${VIRTUAL_GLOBAL_VALUES_FILE}";\n`);
- return {
- code: ms.toString(),
- map: ms.generateMap({ hires: true }),
- };
- }
-
- return null;
- },
-
// We need to start uploading source maps later than in the original plugin
// because SvelteKit is invoking the adapter at closeBundle.
// This means that we need to wait until the adapter is done before we start uploading.
diff --git a/packages/sveltekit/src/vite/svelteConfig.ts b/packages/sveltekit/src/vite/svelteConfig.ts
index 34874bfd2f97..f1e908af9c93 100644
--- a/packages/sveltekit/src/vite/svelteConfig.ts
+++ b/packages/sveltekit/src/vite/svelteConfig.ts
@@ -4,12 +4,32 @@ import * as path from 'path';
import * as url from 'url';
import type { SupportedSvelteKitAdapters } from './detectAdapter';
+export type SvelteKitTracingConfig = {
+ tracing?: {
+ server: boolean;
+ };
+ // TODO: Once instrumentation is promoted stable, this will be removed!
+ instrumentation?: {
+ server: boolean;
+ };
+};
+
+/**
+ * Experimental tracing and instrumentation config is available @since 2.31.0
+ * // TODO: Once instrumentation and tracing is promoted stable, adjust this type!s
+ */
+type BackwardsForwardsCompatibleKitConfig = Config['kit'] & { experimental?: SvelteKitTracingConfig };
+
+export interface BackwardsForwardsCompatibleSvelteConfig extends Config {
+ kit?: BackwardsForwardsCompatibleKitConfig;
+}
+
/**
* Imports the svelte.config.js file and returns the config object.
* The sveltekit plugins import the config in the same way.
* See: https://github.com/sveltejs/kit/blob/master/packages/kit/src/core/config/index.js#L63
*/
-export async function loadSvelteConfig(): Promise {
+export async function loadSvelteConfig(): Promise {
// This can only be .js (see https://github.com/sveltejs/kit/pull/4031#issuecomment-1049475388)
const SVELTE_CONFIG_FILE = 'svelte.config.js';
@@ -23,7 +43,7 @@ export async function loadSvelteConfig(): Promise {
const svelteConfigModule = await import(`${url.pathToFileURL(configFile).href}`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
- return (svelteConfigModule?.default as Config) || {};
+ return (svelteConfigModule?.default as BackwardsForwardsCompatibleSvelteConfig) || {};
} catch (e) {
// eslint-disable-next-line no-console
console.warn("[Source Maps Plugin] Couldn't load svelte.config.js:");
diff --git a/packages/sveltekit/src/vite/types.ts b/packages/sveltekit/src/vite/types.ts
index 0f6717a2c7e9..4267ce378bb1 100644
--- a/packages/sveltekit/src/vite/types.ts
+++ b/packages/sveltekit/src/vite/types.ts
@@ -219,6 +219,7 @@ export type SentrySvelteKitPluginOptions = {
* @default true`.
*/
autoUploadSourceMaps?: boolean;
+
/**
* Options related to source maps upload to Sentry
*/
diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts
index b27ceba87780..612b174f6c69 100644
--- a/packages/sveltekit/src/worker/cloudflare.ts
+++ b/packages/sveltekit/src/worker/cloudflare.ts
@@ -6,7 +6,8 @@ import {
} from '@sentry/cloudflare';
import { addNonEnumerableProperty } from '@sentry/core';
import type { Handle } from '@sveltejs/kit';
-import { rewriteFramesIntegration } from '../server-common/rewriteFramesIntegration';
+import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration';
+import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans';
/**
* Initializes Sentry SvelteKit Cloudflare SDK
@@ -16,7 +17,11 @@ import { rewriteFramesIntegration } from '../server-common/rewriteFramesIntegrat
*/
export function initCloudflareSentryHandle(options: CloudflareOptions): Handle {
const opts: CloudflareOptions = {
- defaultIntegrations: [...getDefaultCloudflareIntegrations(options), rewriteFramesIntegration()],
+ defaultIntegrations: [
+ ...getDefaultCloudflareIntegrations(options),
+ rewriteFramesIntegration(),
+ svelteKitSpansIntegration(),
+ ],
...options,
};
diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts
index 79c0f88e0b5d..db1e1fe4811f 100644
--- a/packages/sveltekit/test/server-common/handle.test.ts
+++ b/packages/sveltekit/test/server-common/handle.test.ts
@@ -111,7 +111,7 @@ describe('sentryHandle', () => {
[Type.Async, true, undefined],
[Type.Async, false, mockResponse],
])('%s resolve with error %s', (type, isError, mockResponse) => {
- it('should return a response', async () => {
+ it('returns a response', async () => {
let response: any = undefined;
try {
response = await sentryHandle()({ event: mockEvent(), resolve: resolve(type, isError) });
@@ -123,7 +123,7 @@ describe('sentryHandle', () => {
expect(response).toEqual(mockResponse);
});
- it("creates a transaction if there's no active span", async () => {
+ it("starts a span if there's no active span", async () => {
let _span: Span | undefined = undefined;
client.on('spanEnd', span => {
if (span === getRootSpan(span)) {
@@ -150,7 +150,27 @@ describe('sentryHandle', () => {
expect(spans).toHaveLength(1);
});
- it('creates a child span for nested server calls (i.e. if there is an active span)', async () => {
+ it("doesn't start a span if sveltekit tracing is enabled", async () => {
+ let _span: Span | undefined = undefined;
+ client.on('spanEnd', span => {
+ if (span === getRootSpan(span)) {
+ _span = span;
+ }
+ });
+
+ try {
+ await sentryHandle()({
+ event: mockEvent({ tracing: { enabled: true } }),
+ resolve: resolve(type, isError),
+ });
+ } catch {
+ //
+ }
+
+ expect(_span).toBeUndefined();
+ });
+
+ it('starts a child span for nested server calls (i.e. if there is an active span)', async () => {
let _span: Span | undefined = undefined;
let txnCount = 0;
client.on('spanEnd', span => {
@@ -197,7 +217,7 @@ describe('sentryHandle', () => {
);
});
- it("creates a transaction from sentry-trace header but doesn't populate a new DSC", async () => {
+ it("starts a span from sentry-trace header but doesn't populate a new DSC", async () => {
const event = mockEvent({
request: {
headers: {
diff --git a/packages/sveltekit/test/server-common/rewriteFramesIntegration.test.ts b/packages/sveltekit/test/server-common/integrations/rewriteFramesIntegration.test.ts
similarity index 93%
rename from packages/sveltekit/test/server-common/rewriteFramesIntegration.test.ts
rename to packages/sveltekit/test/server-common/integrations/rewriteFramesIntegration.test.ts
index 20f9c52a8e27..836152a81eb0 100644
--- a/packages/sveltekit/test/server-common/rewriteFramesIntegration.test.ts
+++ b/packages/sveltekit/test/server-common/integrations/rewriteFramesIntegration.test.ts
@@ -2,8 +2,8 @@ import { rewriteFramesIntegration } from '@sentry/browser';
import type { Event, StackFrame } from '@sentry/core';
import { basename } from '@sentry/core';
import { describe, expect, it } from 'vitest';
-import { rewriteFramesIteratee } from '../../src/server-common/rewriteFramesIntegration';
-import type { GlobalWithSentryValues } from '../../src/vite/injectGlobalValues';
+import { rewriteFramesIteratee } from '../../../src/server-common/integrations/rewriteFramesIntegration';
+import type { GlobalWithSentryValues } from '../../../src/vite/injectGlobalValues';
describe('rewriteFramesIteratee', () => {
it('removes the module property from the frame', () => {
diff --git a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts
new file mode 100644
index 000000000000..0d95cb3d6fb6
--- /dev/null
+++ b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts
@@ -0,0 +1,172 @@
+import type { SpanJSON, TransactionEvent } from '@sentry/core';
+import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
+import { describe, expect, it } from 'vitest';
+import { _enhanceKitSpan, svelteKitSpansIntegration } from '../../../src/server-common/integrations/svelteKitSpans';
+
+describe('svelteKitSpansIntegration', () => {
+ it('has a name and a preprocessEventHook', () => {
+ const integration = svelteKitSpansIntegration();
+
+ expect(integration.name).toBe('SvelteKitSpansEnhancement');
+ expect(typeof integration.preprocessEvent).toBe('function');
+ });
+
+ it('enhances spans from SvelteKit', () => {
+ const event: TransactionEvent = {
+ type: 'transaction',
+ contexts: {
+ trace: {
+ span_id: '123',
+ trace_id: 'abc',
+ data: {
+ 'sveltekit.tracing.original_name': 'sveltekit.handle.root',
+ },
+ },
+ },
+ spans: [
+ {
+ description: 'sveltekit.resolve',
+ data: {
+ someAttribute: 'someValue',
+ },
+ span_id: '123',
+ trace_id: 'abc',
+ start_timestamp: 0,
+ },
+ ],
+ };
+
+ // @ts-expect-error -- passing in an empty option for client but it is unused in the integration
+ svelteKitSpansIntegration().preprocessEvent?.(event, {}, {});
+
+ expect(event.spans).toHaveLength(1);
+ expect(event.spans?.[0]?.op).toBe('function.sveltekit.resolve');
+ expect(event.spans?.[0]?.origin).toBe('auto.http.sveltekit');
+ expect(event.spans?.[0]?.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('function.sveltekit.resolve');
+ expect(event.spans?.[0]?.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.sveltekit');
+ });
+
+ describe('_enhanceKitSpan', () => {
+ it.each([
+ ['sveltekit.resolve', 'function.sveltekit.resolve', 'auto.http.sveltekit'],
+ ['sveltekit.load', 'function.sveltekit.load', 'auto.function.sveltekit.load'],
+ ['sveltekit.form_action', 'function.sveltekit.form_action', 'auto.function.sveltekit.action'],
+ ['sveltekit.remote.call', 'function.sveltekit.remote', 'auto.rpc.sveltekit.remote'],
+ ['sveltekit.handle.sequenced.0', 'function.sveltekit.handle', 'auto.function.sveltekit.handle'],
+ ['sveltekit.handle.sequenced.myHandler', 'function.sveltekit.handle', 'auto.function.sveltekit.handle'],
+ ])('enhances %s span with the correct op and origin', (spanName, op, origin) => {
+ const span = {
+ description: spanName,
+ data: {
+ someAttribute: 'someValue',
+ },
+ span_id: '123',
+ trace_id: 'abc',
+ start_timestamp: 0,
+ } as SpanJSON;
+
+ _enhanceKitSpan(span);
+
+ expect(span.op).toBe(op);
+ expect(span.origin).toBe(origin);
+ expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe(op);
+ expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(origin);
+ });
+
+ it("doesn't change spans from other origins", () => {
+ const span = {
+ description: 'someOtherSpan',
+ data: {},
+ } as SpanJSON;
+
+ _enhanceKitSpan(span);
+
+ expect(span.op).toBeUndefined();
+ expect(span.origin).toBeUndefined();
+ expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBeUndefined();
+ expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBeUndefined();
+ });
+
+ it("doesn't overwrite the sveltekit.handle.root span", () => {
+ const rootHandleSpan = {
+ description: 'sveltekit.handle.root',
+ op: 'http.server',
+ origin: 'auto.http.sveltekit',
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.sveltekit',
+ },
+ span_id: '123',
+ trace_id: 'abc',
+ start_timestamp: 0,
+ } as SpanJSON;
+
+ _enhanceKitSpan(rootHandleSpan);
+
+ expect(rootHandleSpan.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('http.server');
+ expect(rootHandleSpan.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.http.sveltekit');
+ expect(rootHandleSpan.op).toBe('http.server');
+ expect(rootHandleSpan.origin).toBe('auto.http.sveltekit');
+ });
+
+ it("doesn't enhance unrelated spans", () => {
+ const span = {
+ description: 'someOtherSpan',
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.pg',
+ },
+ op: 'db',
+ origin: 'auto.db.pg',
+ span_id: '123',
+ trace_id: 'abc',
+ start_timestamp: 0,
+ } as SpanJSON;
+
+ _enhanceKitSpan(span);
+
+ expect(span.op).toBe('db');
+ expect(span.origin).toBe('auto.db.pg');
+ expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('db');
+ expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.db.pg');
+ });
+
+ it("doesn't overwrite already set ops or origins on sveltekit spans", () => {
+ // for example, if users manually set this (for whatever reason)
+ const span = {
+ description: 'sveltekit.resolve',
+ origin: 'auto.custom.origin',
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'custom.op',
+ },
+ span_id: '123',
+ trace_id: 'abc',
+ start_timestamp: 0,
+ } as SpanJSON;
+
+ _enhanceKitSpan(span);
+
+ expect(span.origin).toBe('auto.custom.origin');
+ expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('custom.op');
+ });
+
+ it('overwrites previously set "manual" origins on sveltekit spans', () => {
+ // for example, if users manually set this (for whatever reason)
+ const span = {
+ description: 'sveltekit.resolve',
+ origin: 'manual',
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'custom.op',
+ },
+ span_id: '123',
+ trace_id: 'abc',
+ start_timestamp: 0,
+ } as SpanJSON;
+
+ _enhanceKitSpan(span);
+
+ expect(span.origin).toBe('auto.http.sveltekit');
+ expect(span.data[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe('custom.op');
+ });
+ });
+});
diff --git a/packages/sveltekit/test/server/integrations/http.test.ts b/packages/sveltekit/test/server/integrations/http.test.ts
new file mode 100644
index 000000000000..08a6fffcd06f
--- /dev/null
+++ b/packages/sveltekit/test/server/integrations/http.test.ts
@@ -0,0 +1,27 @@
+import * as SentryNode from '@sentry/node';
+import { describe, expect, it, vi } from 'vitest';
+import { httpIntegration as svelteKitHttpIntegration } from '../../../src/server/integrations/http';
+
+describe('httpIntegration', () => {
+ it('calls the original httpIntegration with incoming request span recording disabled', () => {
+ const sentryNodeHttpIntegration = vi.spyOn(SentryNode, 'httpIntegration');
+ svelteKitHttpIntegration({ breadcrumbs: false });
+
+ expect(sentryNodeHttpIntegration).toHaveBeenCalledTimes(1);
+ expect(sentryNodeHttpIntegration).toHaveBeenCalledWith({
+ breadcrumbs: false, // leaves other options untouched
+ disableIncomingRequestSpans: true,
+ });
+ });
+
+ it('allows users to override incoming request span recording', () => {
+ const sentryNodeHttpIntegration = vi.spyOn(SentryNode, 'httpIntegration');
+ svelteKitHttpIntegration({ breadcrumbs: false, disableIncomingRequestSpans: false });
+
+ expect(sentryNodeHttpIntegration).toHaveBeenCalledTimes(1);
+ expect(sentryNodeHttpIntegration).toHaveBeenCalledWith({
+ breadcrumbs: false,
+ disableIncomingRequestSpans: false,
+ });
+ });
+});
diff --git a/packages/sveltekit/test/vite/autoInstrument.test.ts b/packages/sveltekit/test/vite/autoInstrument.test.ts
index 364680e31bf3..b58f0280cdea 100644
--- a/packages/sveltekit/test/vite/autoInstrument.test.ts
+++ b/packages/sveltekit/test/vite/autoInstrument.test.ts
@@ -41,7 +41,12 @@ describe('makeAutoInstrumentationPlugin()', () => {
});
it('returns the auto instrumentation plugin', async () => {
- const plugin = makeAutoInstrumentationPlugin({ debug: true, load: true, serverLoad: true });
+ const plugin = makeAutoInstrumentationPlugin({
+ debug: true,
+ load: true,
+ serverLoad: true,
+ onlyInstrumentClient: false,
+ });
expect(plugin.name).toEqual('sentry-auto-instrumentation');
expect(plugin.enforce).toEqual('pre');
expect(plugin.load).toEqual(expect.any(Function));
@@ -58,7 +63,12 @@ describe('makeAutoInstrumentationPlugin()', () => {
'path/to/+layout.mjs',
])('transform %s files', (path: string) => {
it('wraps universal load if `load` option is `true`', async () => {
- const plugin = makeAutoInstrumentationPlugin({ debug: false, load: true, serverLoad: true });
+ const plugin = makeAutoInstrumentationPlugin({
+ debug: false,
+ load: true,
+ serverLoad: true,
+ onlyInstrumentClient: false,
+ });
// @ts-expect-error this exists
const loadResult = await plugin.load(path);
expect(loadResult).toEqual(
@@ -74,6 +84,7 @@ describe('makeAutoInstrumentationPlugin()', () => {
debug: false,
load: false,
serverLoad: false,
+ onlyInstrumentClient: false,
});
// @ts-expect-error this exists
const loadResult = await plugin.load(path);
@@ -92,7 +103,12 @@ describe('makeAutoInstrumentationPlugin()', () => {
'path/to/+layout.server.mjs',
])('transform %s files', (path: string) => {
it('wraps universal load if `load` option is `true`', async () => {
- const plugin = makeAutoInstrumentationPlugin({ debug: false, load: false, serverLoad: true });
+ const plugin = makeAutoInstrumentationPlugin({
+ debug: false,
+ load: false,
+ serverLoad: true,
+ onlyInstrumentClient: false,
+ });
// @ts-expect-error this exists
const loadResult = await plugin.load(path);
expect(loadResult).toEqual(
@@ -108,12 +124,101 @@ describe('makeAutoInstrumentationPlugin()', () => {
debug: false,
load: false,
serverLoad: false,
+ onlyInstrumentClient: false,
});
// @ts-expect-error this exists
const loadResult = await plugin.load(path);
expect(loadResult).toEqual(null);
});
});
+
+ describe('when `onlyInstrumentClient` is `true`', () => {
+ it.each([
+ // server-only files
+ 'path/to/+page.server.ts',
+ 'path/to/+layout.server.js',
+ // universal files
+ 'path/to/+page.mts',
+ 'path/to/+layout.mjs',
+ ])("doesn't wrap code in SSR build in %s", async (path: string) => {
+ const plugin = makeAutoInstrumentationPlugin({
+ debug: false,
+ load: true,
+ serverLoad: true,
+ onlyInstrumentClient: true,
+ });
+
+ // @ts-expect-error this exists and is callable
+ plugin.configResolved({
+ build: {
+ ssr: true,
+ },
+ });
+
+ // @ts-expect-error this exists
+ const loadResult = await plugin.load(path);
+
+ expect(loadResult).toEqual(null);
+ });
+
+ it.each(['path/to/+page.ts', 'path/to/+layout.js'])(
+ 'wraps client-side code in universal files in %s',
+ async (path: string) => {
+ const plugin = makeAutoInstrumentationPlugin({
+ debug: false,
+ load: true,
+ serverLoad: true,
+ onlyInstrumentClient: true,
+ });
+
+ // @ts-expect-error this exists and is callable
+ plugin.configResolved({
+ build: {
+ ssr: false,
+ },
+ });
+
+ // @ts-expect-error this exists and is callable
+ const loadResult = await plugin.load(path);
+
+ expect(loadResult).toBe(
+ 'import { wrapLoadWithSentry } from "@sentry/sveltekit";' +
+ `import * as userModule from "${path}?sentry-auto-wrap";` +
+ 'export const load = userModule.load ? wrapLoadWithSentry(userModule.load) : undefined;' +
+ `export * from "${path}?sentry-auto-wrap";`,
+ );
+ },
+ );
+
+ /**
+ * This is a bit of a constructed case because in a client build, server-only files
+ * shouldn't even be passed into the load hook. But just to be extra careful, let's
+ * make sure we don't wrap server-only files in a client build.
+ */
+ it.each(['path/to/+page.server.ts', 'path/to/+layout.server.js'])(
+ "doesn't wrap client-side code in server-only files in %s",
+ async (path: string) => {
+ const plugin = makeAutoInstrumentationPlugin({
+ debug: false,
+ load: true,
+ serverLoad: true,
+ onlyInstrumentClient: true,
+ });
+
+ // @ts-expect-error this exists and is callable
+ plugin.configResolved({
+ build: {
+ ssr: false,
+ },
+ });
+
+ // @ts-expect-error this exists and is callable
+ const loadResult = await plugin.load(path);
+
+ expect(loadResult).toBe(null);
+ },
+ );
+ });
});
describe('canWrapLoad', () => {
diff --git a/packages/sveltekit/test/vite/injectGlobalValues.test.ts b/packages/sveltekit/test/vite/injectGlobalValues.test.ts
index 3e07bf7e26a7..50f41c84880f 100644
--- a/packages/sveltekit/test/vite/injectGlobalValues.test.ts
+++ b/packages/sveltekit/test/vite/injectGlobalValues.test.ts
@@ -4,17 +4,18 @@ import { getGlobalValueInjectionCode } from '../../src/vite/injectGlobalValues';
describe('getGlobalValueInjectionCode', () => {
it('returns code that injects values into the global object', () => {
const injectionCode = getGlobalValueInjectionCode({
- // @ts-expect-error - just want to test this with multiple values
- something: 'else',
__sentry_sveltekit_output_dir: '.svelte-kit/output',
});
- expect(injectionCode).toEqual(`globalThis["something"] = "else";
-globalThis["__sentry_sveltekit_output_dir"] = ".svelte-kit/output";
-`);
+
+ expect(injectionCode).toMatchInlineSnapshot(`
+ "globalThis["__sentry_sveltekit_output_dir"] = ".svelte-kit/output";
+ "
+ `);
// Check that the code above is in fact valid and works as expected
// The return value of eval here is the value of the last expression in the code
- expect(eval(`${injectionCode}`)).toEqual('.svelte-kit/output');
+ eval(injectionCode);
+ expect(globalThis.__sentry_sveltekit_output_dir).toEqual('.svelte-kit/output');
delete globalThis.__sentry_sveltekit_output_dir;
});
diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts
index d21bcf0d8d04..fa8df96f03a6 100644
--- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts
+++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts
@@ -41,8 +41,8 @@ describe('sentrySvelteKit()', () => {
const plugins = await getSentrySvelteKitPlugins();
expect(plugins).toBeInstanceOf(Array);
- // 1 auto instrument plugin + 5 source maps plugins
- expect(plugins).toHaveLength(9);
+ // 1 auto instrument plugin + 1 global values injection plugin + 5 source maps plugins
+ expect(plugins).toHaveLength(10);
});
it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => {
@@ -51,6 +51,8 @@ describe('sentrySvelteKit()', () => {
expect(pluginNames).toEqual([
// auto instrument plugin:
'sentry-auto-instrumentation',
+ // global values injection plugin:
+ 'sentry-sveltekit-global-values-injection-plugin',
// default source maps plugins:
'sentry-telemetry-plugin',
'sentry-vite-release-injection-plugin',
@@ -68,7 +70,7 @@ describe('sentrySvelteKit()', () => {
it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => {
const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false });
- expect(plugins).toHaveLength(1);
+ expect(plugins).toHaveLength(1); // auto instrument
});
it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => {
@@ -78,7 +80,7 @@ describe('sentrySvelteKit()', () => {
const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true });
const instrumentPlugin = plugins[0];
- expect(plugins).toHaveLength(1);
+ expect(plugins).toHaveLength(2); // auto instrument + global values injection
expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation');
process.env.NODE_ENV = previousEnv;
@@ -87,8 +89,8 @@ describe('sentrySvelteKit()', () => {
it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => {
const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false });
const pluginNames = plugins.map(plugin => plugin.name);
- expect(plugins).toHaveLength(8);
- expect(pluginNames).not.toContain('sentry-upload-source-maps');
+ expect(plugins).toHaveLength(9); // global values injection + 5 source maps plugins + 3 default plugins
+ expect(pluginNames).not.toContain('sentry-auto-instrumentation');
});
it('passes user-specified vite plugin options to the custom sentry source maps plugin', async () => {
@@ -106,15 +108,18 @@ describe('sentrySvelteKit()', () => {
adapter: 'vercel',
});
- expect(makePluginSpy).toHaveBeenCalledWith({
- debug: true,
- sourcemaps: {
- assets: ['foo/*.js'],
- ignore: ['bar/*.js'],
- filesToDeleteAfterUpload: ['baz/*.js'],
+ expect(makePluginSpy).toHaveBeenCalledWith(
+ {
+ debug: true,
+ sourcemaps: {
+ assets: ['foo/*.js'],
+ ignore: ['bar/*.js'],
+ filesToDeleteAfterUpload: ['baz/*.js'],
+ },
+ adapter: 'vercel',
},
- adapter: 'vercel',
- });
+ {},
+ );
});
it('passes user-specified vite plugin options to the custom sentry source maps plugin', async () => {
@@ -152,26 +157,29 @@ describe('sentrySvelteKit()', () => {
adapter: 'vercel',
});
- expect(makePluginSpy).toHaveBeenCalledWith({
- debug: true,
- org: 'other-org',
- sourcemaps: {
- assets: ['foo/*.js'],
- ignore: ['bar/*.js'],
- filesToDeleteAfterUpload: ['baz/*.js'],
- },
- release: {
- inject: false,
- name: '3.0.0',
- setCommits: {
- auto: true,
+ expect(makePluginSpy).toHaveBeenCalledWith(
+ {
+ debug: true,
+ org: 'other-org',
+ sourcemaps: {
+ assets: ['foo/*.js'],
+ ignore: ['bar/*.js'],
+ filesToDeleteAfterUpload: ['baz/*.js'],
},
+ release: {
+ inject: false,
+ name: '3.0.0',
+ setCommits: {
+ auto: true,
+ },
+ },
+ headers: {
+ 'X-My-Header': 'foo',
+ },
+ adapter: 'vercel',
},
- headers: {
- 'X-My-Header': 'foo',
- },
- adapter: 'vercel',
- });
+ {},
+ );
});
it('passes user-specified options to the auto instrument plugin', async () => {
@@ -192,27 +200,36 @@ describe('sentrySvelteKit()', () => {
debug: true,
load: true,
serverLoad: false,
+ onlyInstrumentClient: false,
});
});
});
describe('generateVitePluginOptions', () => {
- it('should return null if no relevant options are provided', () => {
+ it('returns null if no relevant options are provided', () => {
const options: SentrySvelteKitPluginOptions = {};
const result = generateVitePluginOptions(options);
expect(result).toBeNull();
});
- it('should use default `debug` value if only default options are provided', () => {
+ it('uses default `debug` value if only default options are provided', () => {
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production'; // Ensure we're not in development mode
+
const options: SentrySvelteKitPluginOptions = { autoUploadSourceMaps: true, autoInstrument: true, debug: false };
const expected: CustomSentryVitePluginOptions = {
debug: false,
};
const result = generateVitePluginOptions(options);
expect(result).toEqual(expected);
+
+ process.env.NODE_ENV = originalEnv;
});
- it('should apply user-defined sourceMapsUploadOptions', () => {
+ it('applies user-defined sourceMapsUploadOptions', () => {
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production'; // Ensure we're not in development mode
+
const options: SentrySvelteKitPluginOptions = {
autoUploadSourceMaps: true,
sourceMapsUploadOptions: {
@@ -234,9 +251,14 @@ describe('generateVitePluginOptions', () => {
};
const result = generateVitePluginOptions(options);
expect(result).toEqual(expected);
+
+ process.env.NODE_ENV = originalEnv;
});
- it('should override options with unstable_sentryVitePluginOptions', () => {
+ it('overrides options with unstable_sentryVitePluginOptions', () => {
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production'; // Ensure we're not in development mode
+
const options: SentrySvelteKitPluginOptions = {
autoUploadSourceMaps: true,
sourceMapsUploadOptions: {
@@ -264,9 +286,14 @@ describe('generateVitePluginOptions', () => {
};
const result = generateVitePluginOptions(options);
expect(result).toEqual(expected);
+
+ process.env.NODE_ENV = originalEnv;
});
- it('should merge release options correctly', () => {
+ it('merges release options correctly', () => {
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production'; // Ensure we're not in development mode
+
const options: SentrySvelteKitPluginOptions = {
autoUploadSourceMaps: true,
sourceMapsUploadOptions: {
@@ -293,9 +320,14 @@ describe('generateVitePluginOptions', () => {
};
const result = generateVitePluginOptions(options);
expect(result).toEqual(expected);
+
+ process.env.NODE_ENV = originalEnv;
});
- it('should handle adapter and debug options correctly', () => {
+ it('handles adapter and debug options correctly', () => {
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production'; // Ensure we're not in development mode
+
const options: SentrySvelteKitPluginOptions = {
autoUploadSourceMaps: true,
adapter: 'vercel',
@@ -315,9 +347,14 @@ describe('generateVitePluginOptions', () => {
};
const result = generateVitePluginOptions(options);
expect(result).toEqual(expected);
+
+ process.env.NODE_ENV = originalEnv;
});
- it('should apply bundleSizeOptimizations AND sourceMapsUploadOptions when both are set', () => {
+ it('applies bundleSizeOptimizations AND sourceMapsUploadOptions when both are set', () => {
+ const originalEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production'; // Ensure we're not in development mode
+
const options: SentrySvelteKitPluginOptions = {
bundleSizeOptimizations: {
excludeTracing: true,
@@ -349,5 +386,7 @@ describe('generateVitePluginOptions', () => {
};
const result = generateVitePluginOptions(options);
expect(result).toEqual(expected);
+
+ process.env.NODE_ENV = originalEnv;
});
});
diff --git a/packages/sveltekit/test/vite/sourceMaps.test.ts b/packages/sveltekit/test/vite/sourceMaps.test.ts
index 97d223dc309b..45d0b74ad6ff 100644
--- a/packages/sveltekit/test/vite/sourceMaps.test.ts
+++ b/packages/sveltekit/test/vite/sourceMaps.test.ts
@@ -52,12 +52,15 @@ beforeEach(() => {
});
async function getSentryViteSubPlugin(name: string): Promise {
- const plugins = await makeCustomSentryVitePlugins({
- authToken: 'token',
- org: 'org',
- project: 'project',
- adapter: 'other',
- });
+ const plugins = await makeCustomSentryVitePlugins(
+ {
+ authToken: 'token',
+ org: 'org',
+ project: 'project',
+ adapter: 'other',
+ },
+ { kit: {} },
+ );
return plugins.find(plugin => plugin.name === name);
}
@@ -79,8 +82,8 @@ describe('makeCustomSentryVitePlugins()', () => {
expect(plugin?.apply).toEqual('build');
expect(plugin?.enforce).toEqual('post');
- expect(plugin?.resolveId).toBeInstanceOf(Function);
- expect(plugin?.transform).toBeInstanceOf(Function);
+ expect(plugin?.resolveId).toBeUndefined();
+ expect(plugin?.transform).toBeUndefined();
expect(plugin?.configResolved).toBeInstanceOf(Function);
@@ -178,18 +181,10 @@ describe('makeCustomSentryVitePlugins()', () => {
});
});
- describe('Custom debug id source maps plugin plugin', () => {
- it('injects the output dir into the server hooks file', async () => {
- const plugin = await getSentryViteSubPlugin('sentry-sveltekit-debug-id-upload-plugin');
- // @ts-expect-error this function exists!
- const transformOutput = await plugin.transform('foo', '/src/hooks.server.ts');
- const transformedCode = transformOutput.code;
- const transformedSourcemap = transformOutput.map;
- const expectedTransformedCode = 'foo\n; import "\0sentry-inject-global-values-file";\n';
- expect(transformedCode).toEqual(expectedTransformedCode);
- expect(transformedSourcemap).toBeDefined();
- });
+ // Note: The global values injection plugin tests are now in a separate test file
+ // since the plugin was moved to injectGlobalValues.ts
+ describe('Custom debug id source maps plugin plugin', () => {
it('uploads source maps during the SSR build', async () => {
const plugin = await getSentryViteSubPlugin('sentry-sveltekit-debug-id-upload-plugin');
// @ts-expect-error this function exists!
@@ -423,12 +418,15 @@ describe('deleteFilesAfterUpload', () => {
};
});
- const plugins = await makeCustomSentryVitePlugins({
- authToken: 'token',
- org: 'org',
- project: 'project',
- adapter: 'other',
- });
+ const plugins = await makeCustomSentryVitePlugins(
+ {
+ authToken: 'token',
+ org: 'org',
+ project: 'project',
+ adapter: 'other',
+ },
+ { kit: {} },
+ );
// @ts-expect-error this function exists!
const mergedOptions = sentryVitePlugin.mock.calls[0][0];
@@ -498,15 +496,18 @@ describe('deleteFilesAfterUpload', () => {
};
});
- const plugins = await makeCustomSentryVitePlugins({
- authToken: 'token',
- org: 'org',
- project: 'project',
- adapter: 'other',
- sourcemaps: {
- filesToDeleteAfterUpload,
+ const plugins = await makeCustomSentryVitePlugins(
+ {
+ authToken: 'token',
+ org: 'org',
+ project: 'project',
+ adapter: 'other',
+ sourcemaps: {
+ filesToDeleteAfterUpload,
+ },
},
- });
+ { kit: {} },
+ );
// @ts-expect-error this function exists!
const mergedOptions = sentryVitePlugin.mock.calls[0][0];