diff --git a/packages/solidjs/README.md b/packages/solidjs/README.md index 3e37b30e7032..00d451763624 100644 --- a/packages/solidjs/README.md +++ b/packages/solidjs/README.md @@ -7,3 +7,44 @@ # Official Sentry SDK for SolidJS This SDK is work in progress, and should not be used before officially released. + +# Solid Router + +The Solid Router instrumentation uses the Solid Router library to create navigation spans to ensure you collect +meaningful performance data about the health of your page loads and associated requests. + +Add `Sentry.solidRouterBrowserTracingIntegration` instead of the regular `Sentry.browserTracingIntegration` and provide +the hooks it needs to enable performance tracing: + +`useBeforeLeave` from `@solidjs/router` +`useLocation` from `@solidjs/router` + +Make sure `Sentry.solidRouterBrowserTracingIntegration` is initialized by your `Sentry.init` call, before you wrap +`Router`. Otherwise, the routing instrumentation may not work properly. + +Wrap `Router`, `MemoryRouter` or `HashRouter` from `@solidjs/router` using `Sentry.withSentryRouterRouting`. This +creates a higher order component, which will enable Sentry to reach your router context. + +```js +import * as Sentry from '@sentry/solidjs'; +import { Route, Router, useBeforeLeave, useLocation } from '@solidjs/router'; + +Sentry.init({ + dsn: '__PUBLIC_DSN__', + integrations: [Sentry.solidRouterBrowserTracingIntegration({ useBeforeLeave, useLocation })], + tracesSampleRate: 1.0, // Capture 100% of the transactions + debug: true, +}); + +const SentryRouter = Sentry.withSentryRouterRouting(Router); + +render( + () => ( + + + ... + + ), + document.getElementById('root'), +); +``` diff --git a/packages/solidjs/package.json b/packages/solidjs/package.json index f3b91d7b706a..7b4317c79d60 100644 --- a/packages/solidjs/package.json +++ b/packages/solidjs/package.json @@ -44,14 +44,15 @@ "dependencies": { "@sentry/browser": "8.7.0", "@sentry/core": "8.7.0", - "@sentry/types": "8.7.0" + "@sentry/types": "8.7.0", + "@sentry/utils": "8.7.0" }, "peerDependencies": { - "solid-js": "1.8.x" + "solid-js": "^1.8.4" }, "devDependencies": { "@solidjs/testing-library": "0.8.5", - "solid-js": "1.8.11", + "solid-js": "^1.8.11", "vite-plugin-solid": "^2.8.2" }, "scripts": { diff --git a/packages/solidjs/src/debug-build.ts b/packages/solidjs/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/solidjs/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/solidjs/src/index.ts b/packages/solidjs/src/index.ts index 8e25b84c4a0c..77f17110f5e1 100644 --- a/packages/solidjs/src/index.ts +++ b/packages/solidjs/src/index.ts @@ -1,3 +1,5 @@ export * from '@sentry/browser'; export { init } from './sdk'; + +export * from './solidrouter'; diff --git a/packages/solidjs/src/solidrouter.ts b/packages/solidjs/src/solidrouter.ts new file mode 100644 index 000000000000..d38b21313570 --- /dev/null +++ b/packages/solidjs/src/solidrouter.ts @@ -0,0 +1,179 @@ +import { + browserTracingIntegration, + getActiveSpan, + getRootSpan, + spanToJSON, + startBrowserTracingNavigationSpan, +} from '@sentry/browser'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + getClient, +} from '@sentry/core'; +import type { Client, Integration, Span } from '@sentry/types'; +import { logger } from '@sentry/utils'; +import { createEffect, mergeProps, splitProps } from 'solid-js'; +import type { Component, JSX, ParentProps } from 'solid-js'; +import { createComponent } from 'solid-js/web'; +import { DEBUG_BUILD } from './debug-build'; + +// Vendored solid router types so that we don't need to depend on solid router. +// These are not exhaustive and loose on purpose. +interface Location { + pathname: string; +} + +interface BeforeLeaveEventArgs { + from: Location; + to: string | number; +} + +interface RouteSectionProps { + location: Location; + data?: T; + children?: JSX.Element; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type RouteDefinition = { + path?: S; + children?: RouteDefinition | RouteDefinition[]; + component?: Component>; +}; + +interface RouterProps { + base?: string; + root?: Component; + children?: JSX.Element | RouteDefinition | RouteDefinition[]; +} + +interface SolidRouterOptions { + useBeforeLeave: UserBeforeLeave; + useLocation: UseLocation; +} + +type UserBeforeLeave = (listener: (e: BeforeLeaveEventArgs) => void) => void; +type UseLocation = () => Location; + +const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); + +let _useBeforeLeave: UserBeforeLeave; +let _useLocation: UseLocation; + +function handleNavigation(location: string): void { + const client = getClient(); + if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) { + return; + } + + startBrowserTracingNavigationSpan(client, { + name: location, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solidjs.solidrouter', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); +} + +function getActiveRootSpan(): Span | undefined { + const span = getActiveSpan(); + return span ? getRootSpan(span) : undefined; +} + +/** Pass-through component in case user didn't specify a root **/ +function SentryDefaultRoot(props: ParentProps): JSX.Element { + return props.children; +} + +/** + * Unfortunately, we cannot use router hooks directly in the Router, so we + * need to wrap the `root` prop to instrument navigation. + */ +function withSentryRouterRoot(Root: Component): Component { + const SentryRouterRoot = (props: RouteSectionProps): JSX.Element => { + // TODO: This is a rudimentary first version of handling navigation spans + // It does not + // - use query params + // - parameterize the route + + _useBeforeLeave(({ to }: BeforeLeaveEventArgs) => { + // `to` could be `-1` if the browser back-button was used + handleNavigation(to.toString()); + }); + + const location = _useLocation(); + createEffect(() => { + const name = location.pathname; + const rootSpan = getActiveRootSpan(); + + if (rootSpan) { + const { op, description } = spanToJSON(rootSpan); + + // We only need to update navigation spans that have been created by + // a browser back-button navigation (stored as `-1` by solid router) + // everything else was already instrumented correctly in `useBeforeLeave` + if (op === 'navigation' && description === '-1') { + rootSpan.updateName(name); + } + } + }); + + return createComponent(Root, props); + }; + + return SentryRouterRoot; +} + +/** + * A browser tracing integration that uses Solid Router to instrument navigations. + */ +export function solidRouterBrowserTracingIntegration( + options: Parameters[0] & SolidRouterOptions, +): Integration { + const integration = browserTracingIntegration({ + ...options, + instrumentNavigation: false, + }); + + const { instrumentNavigation = true, useBeforeLeave, useLocation } = options; + + return { + ...integration, + setup() { + _useBeforeLeave = useBeforeLeave; + _useLocation = useLocation; + }, + afterAllSetup(client) { + integration.afterAllSetup(client); + + if (instrumentNavigation) { + CLIENTS_WITH_INSTRUMENT_NAVIGATION.add(client); + } + }, + }; +} + +/** + * A higher-order component to instrument Solid Router to create navigation spans. + */ +export function withSentryRouterRouting(Router: Component): Component { + if (!_useBeforeLeave || !_useLocation) { + DEBUG_BUILD && + logger.warn(`solidRouterBrowserTracingIntegration was unable to wrap Solid Router because of one or more missing hooks. + useBeforeLeave: ${_useBeforeLeave}. useLocation: ${_useLocation}.`); + + return Router; + } + + const SentryRouter = (props: RouterProps): JSX.Element => { + const [local, others] = splitProps(props, ['root']); + // We need to wrap root here in case the user passed in their own root + const Root = withSentryRouterRoot(local.root ? local.root : SentryDefaultRoot); + + return createComponent(Router, mergeProps({ root: Root }, others)); + }; + + return SentryRouter; +} diff --git a/yarn.lock b/yarn.lock index e0e07a2aefae..e054994642fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8452,6 +8452,7 @@ "@types/unist" "*" "@types/history-4@npm:@types/history@4.7.8", "@types/history-5@npm:@types/history@4.7.8", "@types/history@*": + name "@types/history-4" version "4.7.8" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== @@ -26109,6 +26110,7 @@ react-is@^18.0.0: "@remix-run/router" "1.0.2" "react-router-6@npm:react-router@6.3.0", react-router@6.3.0: + name react-router-6 version "6.3.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== @@ -27492,7 +27494,7 @@ seroval-plugins@^1.0.3: resolved "https://registry.yarnpkg.com/seroval-plugins/-/seroval-plugins-1.0.7.tgz#c02511a1807e9bc8f68a91fbec13474fa9cea670" integrity sha512-GO7TkWvodGp6buMEX9p7tNyIkbwlyuAWbI6G9Ec5bhcm7mQdu3JOK1IXbEUwb3FVzSc363GraG/wLW23NSavIw== -seroval@^1.0.3: +seroval@^1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/seroval/-/seroval-1.0.7.tgz#ee48ad8ba69f1595bdd5c55d1a0d1da29dee7455" integrity sha512-n6ZMQX5q0Vn19Zq7CIKNIo7E75gPkGCFUEqDpa8jgwpYr/vScjqnQ6H09t1uIiZ0ZSK0ypEGvrYK2bhBGWsGdw== @@ -27934,13 +27936,13 @@ socks@^2.6.2: ip "^2.0.0" smart-buffer "^4.2.0" -solid-js@1.8.11: - version "1.8.11" - resolved "https://registry.yarnpkg.com/solid-js/-/solid-js-1.8.11.tgz#0e7496a9834720b10fe739eaac250221d3f72cd5" - integrity sha512-WdwmER+TwBJiN4rVQTVBxocg+9pKlOs41KzPYntrC86xO5sek8TzBYozPEZPL1IRWDouf2lMrvSbIs3CanlPvQ== +solid-js@^1.8.11: + version "1.8.17" + resolved "https://registry.yarnpkg.com/solid-js/-/solid-js-1.8.17.tgz#780ed6f0fd8633009d1b3c29d56bf6b6bb33bd50" + integrity sha512-E0FkUgv9sG/gEBWkHr/2XkBluHb1fkrHywUgA6o6XolPDCJ4g1HaLmQufcBBhiF36ee40q+HpG/vCZu7fLpI3Q== dependencies: csstype "^3.1.0" - seroval "^1.0.3" + seroval "^1.0.4" seroval-plugins "^1.0.3" solid-refresh@^0.6.3: @@ -28442,6 +28444,7 @@ string-template@~0.2.1: integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -31190,6 +31193,7 @@ workerpool@^6.4.0: integrity sha512-i3KR1mQMNwY2wx20ozq2EjISGtQWDIfV56We+yGJ5yDs8jTwQiLLaqHlkBHITlCuJnYlVRmXegxFxZg7gqI++A== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==