diff --git a/packages/solid/package.json b/packages/solid/package.json index 6986e039feef..16c08cd829e0 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -38,6 +38,16 @@ "types": "./solidrouter.d.ts", "default": "./build/cjs/solidrouter.js" } + }, + "./tanstack-router": { + "import": { + "types": "./tanstackrouter.d.ts", + "default": "./build/esm/tanstackrouter.js" + }, + "require": { + "types": "./tanstackrouter.d.ts", + "default": "./build/cjs/tanstackrouter.js" + } } }, "publishConfig": { diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts new file mode 100644 index 000000000000..be6454e1c8ab --- /dev/null +++ b/packages/solid/src/tanstackrouter.ts @@ -0,0 +1,128 @@ +import { + browserTracingIntegration as originalBrowserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, + WINDOW, +} from '@sentry/browser'; +import type { Integration } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import type { VendoredTanstackRouter, VendoredTanstackRouterRouteMatch } from './vendor/tanstackrouter-types'; + +/** + * A custom browser tracing integration for TanStack Router. + * + * The minimum compatible version of `@tanstack/solid-router` is `1.64.0`. + * + * @param router A TanStack Router `Router` instance that should be used for routing instrumentation. + * @param options Sentry browser tracing configuration. + */ +export function tanstackRouterBrowserTracingIntegration( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router: any, // This is `any` because we don't want any type mismatches if TanStack Router changes their types + options: Parameters[0] = {}, +): Integration { + const castRouterInstance: VendoredTanstackRouter = router; + + const browserTracingIntegrationInstance = originalBrowserTracingIntegration({ + ...options, + instrumentNavigation: false, + instrumentPageLoad: false, + }); + + const { instrumentPageLoad = true, instrumentNavigation = true } = options; + + return { + ...browserTracingIntegrationInstance, + afterAllSetup(client) { + browserTracingIntegrationInstance.afterAllSetup(client); + + const initialWindowLocation = WINDOW.location; + if (instrumentPageLoad && initialWindowLocation) { + const matchedRoutes = castRouterInstance.matchRoutes( + initialWindowLocation.pathname, + castRouterInstance.options.parseSearch(initialWindowLocation.search), + { preload: false, throwOnError: false }, + ); + + const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + + startBrowserTracingPageLoadSpan(client, { + name: lastMatch ? lastMatch.routeId : initialWindowLocation.pathname, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.solid.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: lastMatch ? 'route' : 'url', + ...routeMatchToParamSpanAttributes(lastMatch), + }, + }); + } + + if (instrumentNavigation) { + // The onBeforeNavigate hook is called at the very beginning of a navigation and is only called once per navigation, even when the user is redirected + castRouterInstance.subscribe('onBeforeNavigate', onBeforeNavigateArgs => { + // onBeforeNavigate is called during pageloads. We can avoid creating navigation spans by comparing the states of the to and from arguments. + if (onBeforeNavigateArgs.toLocation.state === onBeforeNavigateArgs.fromLocation?.state) { + return; + } + + const onResolvedMatchedRoutes = castRouterInstance.matchRoutes( + onBeforeNavigateArgs.toLocation.pathname, + onBeforeNavigateArgs.toLocation.search, + { preload: false, throwOnError: false }, + ); + + const onBeforeNavigateLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + + const navigationLocation = WINDOW.location; + const navigationSpan = startBrowserTracingNavigationSpan(client, { + name: onBeforeNavigateLastMatch ? onBeforeNavigateLastMatch.routeId : navigationLocation.pathname, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: onBeforeNavigateLastMatch ? 'route' : 'url', + }, + }); + + // In case the user is redirected during navigation we want to update the span with the right value. + const unsubscribeOnResolved = castRouterInstance.subscribe('onResolved', onResolvedArgs => { + unsubscribeOnResolved(); + if (navigationSpan) { + const onResolvedMatchedRoutes = castRouterInstance.matchRoutes( + onResolvedArgs.toLocation.pathname, + onResolvedArgs.toLocation.search, + { preload: false, throwOnError: false }, + ); + + const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + + if (onResolvedLastMatch) { + navigationSpan.updateName(onResolvedLastMatch.routeId); + navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedLastMatch)); + } + } + }); + }); + } + }, + }; +} + +function routeMatchToParamSpanAttributes(match: VendoredTanstackRouterRouteMatch | undefined): Record { + if (!match) { + return {}; + } + + const paramAttributes: Record = {}; + Object.entries(match.params).forEach(([key, value]) => { + paramAttributes[`url.path.params.${key}`] = value; // TODO(v11): remove attribute which does not adhere to Sentry's semantic convention + paramAttributes[`url.path.parameter.${key}`] = value; + paramAttributes[`params.${key}`] = value; // params.[key] is an alias + }); + + return paramAttributes; +} diff --git a/packages/solid/src/vendor/tanstackrouter-types.ts b/packages/solid/src/vendor/tanstackrouter-types.ts new file mode 100644 index 000000000000..417d2b1447b1 --- /dev/null +++ b/packages/solid/src/vendor/tanstackrouter-types.ts @@ -0,0 +1,74 @@ +/* + +MIT License + +Copyright (c) 2021-present Tanner Linsley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +// The following types are vendored types from TanStack Router, so we don't have to depend on the actual package + +export interface VendoredTanstackRouter { + history: VendoredTanstackRouterHistory; + state: VendoredTanstackRouterState; + options: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parseSearch: (search: string) => Record; + }; + matchRoutes: ( + pathname: string, + // eslint-disable-next-line @typescript-eslint/ban-types + locationSearch: {}, + opts?: { + preload?: boolean; + throwOnError?: boolean; + }, + ) => Array; + subscribe( + eventType: 'onResolved' | 'onBeforeNavigate', + callback: (stateUpdate: { + toLocation: VendoredTanstackRouterLocation; + fromLocation?: VendoredTanstackRouterLocation; + }) => void, + ): () => void; +} + +interface VendoredTanstackRouterLocation { + pathname: string; + // eslint-disable-next-line @typescript-eslint/ban-types + search: {}; + state: string; +} + +interface VendoredTanstackRouterHistory { + subscribe: (cb: () => void) => () => void; +} + +interface VendoredTanstackRouterState { + matches: Array; + pendingMatches?: Array; +} + +export interface VendoredTanstackRouterRouteMatch { + routeId: string; + pathname: string; + params: { [key: string]: string }; +}