diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ac1c8b9f16..bfcb48fb1a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(vue): Add TanStack Router integration ([#18359](https://github.com/getsentry/sentry-javascript/pull/18359))** + +The `@sentry/vue` package now includes support for TanStack Router. Use `tanstackRouterBrowserTracingIntegration` to automatically instrument pageload and navigation transactions with parameterized routes: + +```javascript +import { createApp } from 'vue'; +import { createRouter } from '@tanstack/vue-router'; +import * as Sentry from '@sentry/vue'; +import { tanstackRouterBrowserTracingIntegration } from '@sentry/vue/tanstackrouter'; + +const router = createRouter({ + // your router config +}); + +Sentry.init({ + app, + dsn: '__PUBLIC_DSN__', + integrations: [tanstackRouterBrowserTracingIntegration(router)], + tracesSampleRate: 1.0, +}); +``` + - **feat(nextjs): Add tree-shaking configuration to `webpack` build config ([#18359](https://github.com/getsentry/sentry-javascript/pull/18359))** - **feat(replay): Add Request body with `attachRawBodyFromRequest` option ([#18501](https://github.com/getsentry/sentry-javascript/pull/18501))** diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/.gitignore b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/.gitignore new file mode 100644 index 000000000000..04471798a413 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/.npmrc b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/README.md b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/README.md new file mode 100644 index 000000000000..f9e5908ae9b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/README.md @@ -0,0 +1,61 @@ +# Vue TanStack Router E2E Test App + +E2E test application for `@sentry/vue` with TanStack Router integration. + +## Getting Started + +To run this application: + +```bash +pnpm install +pnpm dev +``` + +## Building For Production + +To build this application for production: + +```bash +pnpm build +``` + +## Running Tests + +To run E2E tests: + +```bash +pnpm test:build # Install deps and build +pnpm test:assert # Run Playwright tests +``` + +## Routing + +This project uses [TanStack Router](https://tanstack.com/router) for Vue.js. The router is set up with code-based routing in the `./src/main.ts` file. + +### Routes + +- `/` - Home page with navigation links +- `/posts/$postId` - Post detail page with parameterized route + +### Sentry Integration + +The app demonstrates: + +- TanStack Router browser tracing integration +- Pageload transaction tracking with parameterized routes +- Navigation transaction tracking +- Route parameter extraction and span attribution + +## Testing + +The E2E tests verify: + +1. Pageload transactions are created with correct route parameters +2. Navigation transactions are properly instrumented +3. Route parameters are captured in transaction data +4. Sentry origins are correctly set for Vue TanStack Router + +## Learn More + +- [TanStack Router Documentation](https://tanstack.com/router) +- [Sentry Vue SDK](https://docs.sentry.io/platforms/javascript/guides/vue/) diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/env.d.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/env.d.ts new file mode 100644 index 000000000000..11f02fe2a006 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/index.html b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/index.html new file mode 100644 index 000000000000..22db65a78f3c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Vue TanStack Router - Sentry E2E Tests + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json new file mode 100644 index 000000000000..fc99c9ea3c6c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/package.json @@ -0,0 +1,33 @@ +{ + "name": "vue-tanstack-router", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "dev": "vite", + "start": "vite preview", + "preview": "vite preview", + "test:prod": "TEST_ENV=production playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod" + }, + "dependencies": { + "@sentry/vue": "latest || *", + "@tanstack/vue-router": "^1.64.0", + "vue": "^3.4.15" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@tsconfig/node20": "^20.1.2", + "@types/node": "^18.19.1", + "@vitejs/plugin-vue": "^5.0.3", + "@vue/tsconfig": "^0.5.1", + "typescript": "~5.3.0", + "vite": "^5.4.11" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/playwright.config.mjs new file mode 100644 index 000000000000..94f38d88ad70 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'pnpm preview --port 4173', + port: 4173, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/public/favicon.ico b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/public/favicon.ico new file mode 100644 index 000000000000..df36fcfb7258 Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/App.vue b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/App.vue new file mode 100644 index 000000000000..fa9ef37a3794 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/App.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/HomeView.vue b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/HomeView.vue new file mode 100644 index 000000000000..b87ae701593b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/HomeView.vue @@ -0,0 +1,16 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/PostView.vue b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/PostView.vue new file mode 100644 index 000000000000..3c595e1a7cfb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/PostView.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts new file mode 100644 index 000000000000..cdeec524fb50 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/src/main.ts @@ -0,0 +1,62 @@ +import { createApp } from 'vue'; +import { RouterProvider, createRoute, createRootRoute, createRouter } from '@tanstack/vue-router'; +import * as Sentry from '@sentry/vue'; +import { tanstackRouterBrowserTracingIntegration } from '@sentry/vue/tanstackrouter'; + +import App from './App.vue'; +import HomeView from './HomeView.vue'; +import PostView from './PostView.vue'; + +const rootRoute = createRootRoute({ + component: App, +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: HomeView, +}); + +const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts/', +}); + +const postIdRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + loader: async ({ params }) => { + return Sentry.startSpan({ name: `loading-post-${params.postId}` }, async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + }); + }, + component: PostView, +}); + +const routeTree = rootRoute.addChildren([indexRoute, postsRoute.addChildren([postIdRoute])]); + +const router = createRouter({ + routeTree, + defaultPreload: 'intent', +}); + +declare module '@tanstack/vue-router' { + interface Register { + router: typeof router; + } +} + +const app = createApp(RouterProvider, { router }); + +Sentry.init({ + app, + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + debug: true, + environment: 'qa', // dynamic sampling bias to keep transactions + integrations: [tanstackRouterBrowserTracingIntegration(router)], + release: 'e2e-test', + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, +}); + +app.mount('#app'); diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/start-event-proxy.mjs new file mode 100644 index 000000000000..00c6a511b251 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'vue-tanstack-router', +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts new file mode 100644 index 000000000000..d6d9d4d9b0ba --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tests/routing-instrumentation.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends pageload transaction with web vitals measurements', async ({ page }) => { + const transactionPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.vue.tanstack_router', + }, + }, + transaction: '/', + transaction_info: { + source: 'route', + }, + measurements: expect.objectContaining({ + ttfb: expect.objectContaining({ + value: expect.any(Number), + unit: 'millisecond', + }), + lcp: expect.objectContaining({ + value: expect.any(Number), + unit: 'millisecond', + }), + fp: expect.objectContaining({ + value: expect.any(Number), + unit: 'millisecond', + }), + fcp: expect.objectContaining({ + value: expect.any(Number), + unit: 'millisecond', + }), + }), + }); +}); + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/posts/456`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.pageload.vue.tanstack_router', + 'sentry.op': 'pageload', + 'url.path.parameter.postId': '456', + }, + op: 'pageload', + origin: 'auto.pageload.vue.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + const pageloadTxnPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('vue-tanstack-router', async transactionEvent => { + return ( + !!transactionEvent?.transaction && + transactionEvent.transaction === '/posts/$postId' && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + await page.waitForTimeout(5000); + + await page.locator('#nav-link').click(); + + const navigationTxn = await navigationTxnPromise; + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.navigation.vue.tanstack_router', + 'sentry.op': 'navigation', + 'url.path.parameter.postId': '2', + }, + op: 'navigation', + origin: 'auto.navigation.vue.tanstack_router', + }, + }, + transaction: '/posts/$postId', + transaction_info: { + source: 'route', + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tsconfig.app.json new file mode 100644 index 000000000000..e14c754d3ae5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tsconfig.json b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tsconfig.json new file mode 100644 index 000000000000..66b5e5703e83 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tsconfig.node.json new file mode 100644 index 000000000000..7463c5c1b1f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "include": ["vite.config.*", "vitest.config.*", "playwright.config.*"], + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/dev-packages/e2e-tests/test-applications/vue-tanstack-router/vite.config.ts b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/vite.config.ts new file mode 100644 index 000000000000..9a935c24bba8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-tanstack-router/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath, URL } from 'node:url'; + +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + envPrefix: 'PUBLIC_', +}); diff --git a/packages/vue/.eslintignore b/packages/vue/.eslintignore new file mode 100644 index 000000000000..3e89751310a5 --- /dev/null +++ b/packages/vue/.eslintignore @@ -0,0 +1,2 @@ +/*.d.ts +/*.d.ts.map diff --git a/packages/vue/.gitignore b/packages/vue/.gitignore new file mode 100644 index 000000000000..3e89751310a5 --- /dev/null +++ b/packages/vue/.gitignore @@ -0,0 +1,2 @@ +/*.d.ts +/*.d.ts.map diff --git a/packages/vue/README.md b/packages/vue/README.md index b2b0b349b15f..7573e61e115c 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -58,3 +58,35 @@ new Vue({ template: '', }); ``` + +### TanStack Router + +If you use TanStack Router for Vue instead of Vue Router, you can use the TanStack Router instrumentation to create +navigation spans and collect meaningful performance data about the health of your page loads and associated requests. + +Add `tanstackRouterBrowserTracingIntegration` from `@sentry/vue/tanstackrouter` instead of the regular +`Sentry.browserTracingIntegration`. + +Make sure `tanstackRouterBrowserTracingIntegration` is initialized by your `Sentry.init` call. Otherwise, the routing +instrumentation will not work properly. + +Pass your router instance from `createRouter` to the integration. + +```javascript +import { createApp } from 'vue'; +import { createRouter } from '@tanstack/vue-router'; +import * as Sentry from '@sentry/vue'; +import { tanstackRouterBrowserTracingIntegration } from '@sentry/vue/tanstackrouter'; + +const router = createRouter({ + // your router config + // ... +}); + +Sentry.init({ + app, + dsn: '__PUBLIC_DSN__', + integrations: [tanstackRouterBrowserTracingIntegration(router)], + tracesSampleRate: 1.0, // Capture 100% of the transactions +}); +``` diff --git a/packages/vue/package.json b/packages/vue/package.json index 13b3c4cd29d1..43959fc71901 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -10,7 +10,9 @@ "node": ">=18" }, "files": [ - "/build" + "/build", + "/*.d.ts", + "/*.d.ts.map" ], "main": "build/cjs/index.js", "module": "build/esm/index.js", @@ -26,6 +28,16 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./tanstackrouter": { + "import": { + "types": "./tanstackrouter.d.ts", + "default": "./build/esm/tanstackrouter.js" + }, + "require": { + "types": "./tanstackrouter.d.ts", + "default": "./build/cjs/tanstackrouter.js" + } } }, "typesVersions": { @@ -43,30 +55,36 @@ "@sentry/core": "10.31.0" }, "peerDependencies": { + "@tanstack/vue-router": "^1.64.0", "pinia": "2.x || 3.x", "vue": "2.x || 3.x" }, "peerDependenciesMeta": { + "@tanstack/vue-router": { + "optional": true + }, "pinia": { "optional": true } }, "devDependencies": { + "@tanstack/vue-router": "^1.64.0", "vue": "~3.2.41" }, "scripts": { "build": "run-p build:transpile build:types", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.mjs", - "build:types": "run-s build:types:core build:types:downlevel", + "build:types": "run-s build:types:core build:types:router build:types:downlevel", "build:types:core": "tsc -p tsconfig.types.json", + "build:types:router": "tsc -p tsconfig.router-types.json", "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "run-p build:transpile:watch build:types:watch", "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "npm pack", - "circularDepCheck": "madge --circular src/index.ts", + "circularDepCheck": "madge --circular src/index.ts && madge --circular src/tanstackrouter.ts", "clean": "rimraf build coverage sentry-vue-*.tgz", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", diff --git a/packages/vue/rollup.npm.config.mjs b/packages/vue/rollup.npm.config.mjs index 84a06f2fb64a..f7f7d8786fcc 100644 --- a/packages/vue/rollup.npm.config.mjs +++ b/packages/vue/rollup.npm.config.mjs @@ -1,3 +1,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants(makeBaseNPMConfig()); +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/tanstackrouter.ts'], + }), +); diff --git a/packages/vue/src/pinia.ts b/packages/vue/src/pinia.ts index f55be4d6602c..596efb6ef182 100644 --- a/packages/vue/src/pinia.ts +++ b/packages/vue/src/pinia.ts @@ -2,7 +2,7 @@ import { addBreadcrumb, addNonEnumerableProperty, getClient, getCurrentScope, ge import type { Ref } from 'vue'; // Inline Pinia types -type StateTree = Record; +type StateTree = Record; type PiniaPlugin = (context: { store: { $id: string; @@ -15,7 +15,9 @@ type PiniaPlugin = (context: { type SentryPiniaPluginOptions = { attachPiniaState: boolean; addBreadcrumbs: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any actionTransformer: (action: string) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any stateTransformer: (state: Record) => any; }; @@ -29,7 +31,8 @@ const DEFAULT_PINIA_PLUGIN_OPTIONS: SentryPiniaPluginOptions = { const getAllStoreStates = ( pinia: { state: Ref> }, stateTransformer?: SentryPiniaPluginOptions['stateTransformer'], -): Record => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any => { const states: Record = {}; try { diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts new file mode 100644 index 000000000000..a0ae76814c0c --- /dev/null +++ b/packages/vue/src/tanstackrouter.ts @@ -0,0 +1,145 @@ +import { + browserTracingIntegration as originalBrowserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, + WINDOW, +} from '@sentry/browser'; +import type { Integration } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, +} from '@sentry/core'; +import type { AnyRouter } from '@tanstack/vue-router'; + +type RouteMatch = ReturnType[number]; + +/** + * A custom browser tracing integration for TanStack Router. + * + * The minimum compatible version of `@tanstack/vue-router` is `1.64.0`. + * + * @param router A TanStack Router `Router` instance that should be used for routing instrumentation. + * @param options Sentry browser tracing configuration. + */ +export function tanstackRouterBrowserTracingIntegration( + router: R, + options: Parameters[0] = {}, +): Integration { + const browserTracingIntegrationInstance = originalBrowserTracingIntegration({ + ...options, + instrumentNavigation: false, + instrumentPageLoad: false, + }); + + const { instrumentPageLoad = true, instrumentNavigation = true } = options; + + return { + ...browserTracingIntegrationInstance, + afterAllSetup(client) { + browserTracingIntegrationInstance.afterAllSetup(client); + + const initialWindowLocation = WINDOW.location; + if (instrumentPageLoad && initialWindowLocation) { + const matchedRoutes = router.matchRoutes( + initialWindowLocation.pathname, + + router.options.parseSearch(initialWindowLocation.search), + { preload: false, throwOnError: false }, + ); + + const lastMatch = matchedRoutes[matchedRoutes.length - 1]; + + startBrowserTracingPageLoadSpan(client, { + name: lastMatch ? lastMatch.routeId : initialWindowLocation.pathname, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue.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 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + router.subscribe('onBeforeNavigate', (onBeforeNavigateArgs: any) => { + // onBeforeNavigate is called during pageloads. We can avoid creating navigation spans by: + // 1. Checking if there's no fromLocation (initial pageload) + // 2. Comparing the states of the to and from arguments + + if ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + !onBeforeNavigateArgs.fromLocation || + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + onBeforeNavigateArgs.toLocation.state === onBeforeNavigateArgs.fromLocation.state + ) { + return; + } + + const onResolvedMatchedRoutes = router.matchRoutes( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + onBeforeNavigateArgs.toLocation.pathname, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + 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 + : // In SSR/non-browser contexts, WINDOW.location may be undefined, so fall back to the router's location + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + navigationLocation?.pathname || onBeforeNavigateArgs.toLocation.pathname, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue.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. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unsubscribeOnResolved = router.subscribe('onResolved', (onResolvedArgs: any) => { + unsubscribeOnResolved(); + if (navigationSpan) { + const onResolvedMatchedRoutes = router.matchRoutes( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + onResolvedArgs.toLocation.pathname, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + onResolvedArgs.toLocation.search, + { preload: false, throwOnError: false }, + ); + + const onResolvedLastMatch = onResolvedMatchedRoutes[onResolvedMatchedRoutes.length - 1]; + + if (onResolvedLastMatch) { + navigationSpan.updateName(onResolvedLastMatch.routeId); + navigationSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + navigationSpan.setAttributes(routeMatchToParamSpanAttributes(onResolvedLastMatch)); + } + } + }); + }); + } + }, + }; +} + +function routeMatchToParamSpanAttributes(match: RouteMatch | undefined): Record { + if (!match) { + return {}; + } + + const paramAttributes: Record = {}; + Object.entries(match.params as Record).forEach(([key, value]) => { + paramAttributes[`url.path.parameter.${key}`] = value; + paramAttributes[`params.${key}`] = value; // params.[key] is an alias + }); + + return paramAttributes; +} diff --git a/packages/vue/test/tanstackrouter.test.ts b/packages/vue/test/tanstackrouter.test.ts new file mode 100644 index 000000000000..7b23a4241799 --- /dev/null +++ b/packages/vue/test/tanstackrouter.test.ts @@ -0,0 +1,269 @@ +import * as SentryBrowser from '@sentry/browser'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import type { AnyRouter } from '@tanstack/vue-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { tanstackRouterBrowserTracingIntegration } from '../src/tanstackrouter'; + +vi.mock('@sentry/browser', async () => { + const actual = await vi.importActual('@sentry/browser'); + return { + ...actual, + WINDOW: { + location: { + pathname: '/test/123', + search: '?foo=bar', + }, + }, + }; +}); + +const startBrowserTracingPageLoadSpanSpy = vi.spyOn(SentryBrowser, 'startBrowserTracingPageLoadSpan'); +const startBrowserTracingNavigationSpanSpy = vi.spyOn(SentryBrowser, 'startBrowserTracingNavigationSpan'); + +const mockNavigationSpan = { + updateName: vi.fn(), + setAttribute: vi.fn(), + setAttributes: vi.fn(), +}; + +describe('tanstackRouterBrowserTracingIntegration', () => { + const mockMatchedRoutes = [ + { + routeId: '/test/:id', + pathname: '/test/123', + params: { id: '123' }, + }, + ]; + + const mockRouter: Partial = { + options: { + parseSearch: vi.fn((search: string) => { + const params = new URLSearchParams(search); + const result: Record = {}; + params.forEach((value, key) => { + result[key] = value; + }); + return result; + }), + } as AnyRouter['options'], + matchRoutes: vi.fn(() => mockMatchedRoutes), + subscribe: vi.fn(() => vi.fn()), // Return an unsubscribe function + }; + + const mockClient = { + on: vi.fn(), + emit: vi.fn(), + getOptions: vi.fn(() => ({})), + addEventProcessor: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + startBrowserTracingNavigationSpanSpy.mockReturnValue(mockNavigationSpan as any); + + // Mock window.location + vi.stubGlobal('window', { + location: { + pathname: '/test/123', + search: '?foo=bar', + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('creates an integration with the correct name', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter); + expect(integration.name).toBe('BrowserTracing'); + }); + + it('instruments pageload on setup', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentPageLoad: true, + }); + + integration.afterAllSetup(mockClient as any); + + expect(mockRouter.matchRoutes).toHaveBeenCalledWith( + '/test/123', + { foo: 'bar' }, + { + preload: false, + throwOnError: false, + }, + ); + + expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledTimes(1); + expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledWith(mockClient, { + name: '/test/:id', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.vue.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'url.path.parameter.id': '123', + 'params.id': '123', + }), + }); + }); + + it('does not instrument pageload when instrumentPageLoad is false', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentPageLoad: false, + }); + + integration.afterAllSetup(mockClient as any); + + expect(startBrowserTracingPageLoadSpanSpy).not.toHaveBeenCalled(); + }); + + it('subscribes to router navigation events when instrumentNavigation is true', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentNavigation: true, + }); + + integration.afterAllSetup(mockClient as any); + + expect(mockRouter.subscribe).toHaveBeenCalledWith('onBeforeNavigate', expect.any(Function)); + }); + + it('does not subscribe to navigation events when instrumentNavigation is false', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentNavigation: false, + }); + + integration.afterAllSetup(mockClient as any); + + // Only pageload should have been called + expect(mockRouter.subscribe).not.toHaveBeenCalled(); + }); + + it('creates navigation spans with correct attributes', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentNavigation: true, + instrumentPageLoad: false, // Disable pageload to isolate navigation test + }); + + integration.afterAllSetup(mockClient as any); + + // Get the onBeforeNavigate callback + const onBeforeNavigateCallback = (mockRouter.subscribe as any).mock.calls.find( + (call: [string, (...args: any[]) => void]) => call[0] === 'onBeforeNavigate', + )?.[1]; + + expect(onBeforeNavigateCallback).toBeDefined(); + + // Simulate navigation + onBeforeNavigateCallback({ + toLocation: { + pathname: '/test/456', + search: {}, + state: 'state-1', + }, + fromLocation: { + pathname: '/test/123', + search: {}, + state: 'state-0', + }, + }); + + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(1); + expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledWith(mockClient, { + name: '/test/:id', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.vue.tanstack_router', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + }), + }); + }); + + it('skips navigation span creation when state is the same', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentNavigation: true, + instrumentPageLoad: false, + }); + + integration.afterAllSetup(mockClient as any); + + const onBeforeNavigateCallback = (mockRouter.subscribe as any).mock.calls.find( + (call: [string, (...args: any[]) => void]) => call[0] === 'onBeforeNavigate', + )?.[1]; + + // Simulate navigation with same state (e.g., during pageload) + onBeforeNavigateCallback({ + toLocation: { + pathname: '/test/456', + search: {}, + state: 'same-state', + }, + fromLocation: { + pathname: '/test/123', + search: {}, + state: 'same-state', + }, + }); + + expect(startBrowserTracingNavigationSpanSpy).not.toHaveBeenCalled(); + }); + + it('updates navigation span on redirect using onResolved', () => { + const integration = tanstackRouterBrowserTracingIntegration(mockRouter, { + instrumentNavigation: true, + instrumentPageLoad: false, + }); + + integration.afterAllSetup(mockClient as any); + + const onBeforeNavigateCallback = (mockRouter.subscribe as any).mock.calls.find( + (call: [string, (...args: any[]) => void]) => call[0] === 'onBeforeNavigate', + )?.[1]; + + // Simulate navigation + onBeforeNavigateCallback({ + toLocation: { + pathname: '/test/456', + search: {}, + state: 'state-1', + }, + fromLocation: { + pathname: '/test/123', + search: {}, + state: 'state-0', + }, + }); + + // Get the onResolved callback that was registered + const onResolvedCallback = (mockRouter.subscribe as any).mock.calls.find( + (call: [string, (...args: any[]) => void]) => call[0] === 'onResolved', + )?.[1]; + + expect(onResolvedCallback).toBeDefined(); + + // Mock different matched routes for the redirect + const redirectedMatchedRoutes = [ + { + routeId: '/redirected/:id', + pathname: '/redirected/789', + params: { id: '789' }, + }, + ]; + + (mockRouter.matchRoutes as any).mockReturnValueOnce(redirectedMatchedRoutes); + + // Simulate redirect resolution + onResolvedCallback({ + toLocation: { + pathname: '/redirected/789', + search: {}, + }, + }); + + expect(mockNavigationSpan.updateName).toHaveBeenCalledWith('/redirected/:id'); + expect(mockNavigationSpan.setAttribute).toHaveBeenCalledWith(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + expect(mockNavigationSpan.setAttributes).toHaveBeenCalledWith({ + 'url.path.parameter.id': '789', + 'params.id': '789', + }); + }); +}); diff --git a/packages/vue/tsconfig.router-types.json b/packages/vue/tsconfig.router-types.json new file mode 100644 index 000000000000..ef17c20dd7c1 --- /dev/null +++ b/packages/vue/tsconfig.router-types.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./" + }, + + "//": "This type is built separately because it is for a subpath export, which has problems if it is not in the root", + "include": ["src/tanstackrouter.ts"], + "//": "Without this, we cannot output into the root dir", + "exclude": [] +} diff --git a/yarn.lock b/yarn.lock index b26b3f069801..db80dbff16ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8008,6 +8008,11 @@ resolved "https://registry.yarnpkg.com/@tanstack/history/-/history-1.132.21.tgz#09ae649b0c0c2d1093f0b1e34b9ab0cd3b2b1d2f" integrity sha512-5ziPz3YarKU5cBJoEJ4muV8cy+5W4oWdJMqW7qosMrK5fb9Qfm+QWX+kO3emKJMu4YOUofVu3toEuuD3x1zXKw== +"@tanstack/history@1.141.0": + version "1.141.0" + resolved "https://registry.yarnpkg.com/@tanstack/history/-/history-1.141.0.tgz#6fa5119dd870f1617943903d30e799222a21f6c2" + integrity sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ== + "@tanstack/router-core@1.132.27": version "1.132.27" resolved "https://registry.yarnpkg.com/@tanstack/router-core/-/router-core-1.132.27.tgz#8869e98d10ea42338cb115af45bdcbc10eaf2b7f" @@ -8021,6 +8026,19 @@ tiny-invariant "^1.3.3" tiny-warning "^1.0.3" +"@tanstack/router-core@1.141.4": + version "1.141.4" + resolved "https://registry.yarnpkg.com/@tanstack/router-core/-/router-core-1.141.4.tgz#035caa9c8df8ed3e5cfb0c5249d080461782ba93" + integrity sha512-/7x0Ilo/thg1Hiev6wLL9SA4kk9PICga96qfLuLHVYMEYMfm1+F8BBxgNYeSZfehxTI0Fdo03LjMLYka9JCk4g== + dependencies: + "@tanstack/history" "1.141.0" + "@tanstack/store" "^0.8.0" + cookie-es "^2.0.0" + seroval "^1.4.0" + seroval-plugins "^1.4.0" + tiny-invariant "^1.3.3" + tiny-warning "^1.0.3" + "@tanstack/solid-router@^1.132.27": version "1.132.27" resolved "https://registry.yarnpkg.com/@tanstack/solid-router/-/solid-router-1.132.27.tgz#cafa331a8190fb6775f3cd3b88f31adce82e8cc8" @@ -8048,11 +8066,37 @@ resolved "https://registry.yarnpkg.com/@tanstack/store/-/store-0.7.0.tgz#afef29b06c6b592e93181cee9baa62fe77454459" integrity sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg== +"@tanstack/store@0.8.0", "@tanstack/store@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@tanstack/store/-/store-0.8.0.tgz#f1c533b9cff000fc792ed77edda178000abc9442" + integrity sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ== + "@tanstack/store@^0.7.0": version "0.7.7" resolved "https://registry.yarnpkg.com/@tanstack/store/-/store-0.7.7.tgz#2c8b1d8c094f3614ae4e0483253239abd0e14488" integrity sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ== +"@tanstack/vue-router@^1.64.0": + version "1.141.4" + resolved "https://registry.yarnpkg.com/@tanstack/vue-router/-/vue-router-1.141.4.tgz#1a66bc2b4db182b732c6ca2b11cb58b49ecaffe3" + integrity sha512-GGTNBaWcrDmT80sptyWDxz+f25tmgTsBiZCSw94bzIoMk3M4qWgi5uFmnyVkDQ3rYIu5BVO6OgiO/1o1+UK5hQ== + dependencies: + "@tanstack/history" "1.141.0" + "@tanstack/router-core" "1.141.4" + "@tanstack/vue-store" "^0.8.0" + isbot "^5.1.22" + jsesc "^3.0.2" + tiny-invariant "^1.3.3" + tiny-warning "^1.0.3" + +"@tanstack/vue-store@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@tanstack/vue-store/-/vue-store-0.8.0.tgz#beba4021b52513a00a9924416bde0032de6ff5c4" + integrity sha512-YLsinYboBLIjNkxDpAn1ydaMS35dKq3M3a788JRCJi4/stWcN7Swp0pxxJ+p0IwKSY4tBXx7vMz22OYWQ1QsUQ== + dependencies: + "@tanstack/store" "0.8.0" + vue-demi "^0.14.10" + "@testing-library/dom@^7.21.4": version "7.31.2" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.2.tgz#df361db38f5212b88555068ab8119f5d841a8c4a" @@ -27687,12 +27731,22 @@ serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^ dependencies: randombytes "^2.1.0" -seroval-plugins@^1.0.2, seroval-plugins@^1.3.2, seroval-plugins@~1.3.0: +seroval-plugins@^1.0.2, seroval-plugins@^1.3.2, seroval-plugins@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/seroval-plugins/-/seroval-plugins-1.4.0.tgz#0bd453983a0a26039ca161a341c00eca7d42b19a" + integrity sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ== + +seroval-plugins@~1.3.0: version "1.3.3" resolved "https://registry.yarnpkg.com/seroval-plugins/-/seroval-plugins-1.3.3.tgz#51bcacf09e5384080d7ea4002b08fd9f6166daf5" integrity sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w== -seroval@^1.0.2, seroval@^1.3.2, seroval@~1.3.0: +seroval@^1.0.2, seroval@^1.3.2, seroval@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/seroval/-/seroval-1.4.0.tgz#b62f1d0c332d2a085f1059866b9c7dac57486682" + integrity sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg== + +seroval@~1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/seroval/-/seroval-1.3.2.tgz#7e5be0dc1a3de020800ef013ceae3a313f20eca7" integrity sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ== @@ -31248,6 +31302,11 @@ vue-bundle-renderer@^2.1.0: dependencies: ufo "^1.5.3" +vue-demi@^0.14.10: + version "0.14.10" + resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04" + integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg== + vue-devtools-stub@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/vue-devtools-stub/-/vue-devtools-stub-0.1.0.tgz#a65b9485edecd4273cedcb8102c739b83add2c81"