Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(sveltekit): Add SvelteKit routing instrumentation#7565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,10 @@ | ||
| import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js'; | ||
| export default | ||
| makeNPMConfigVariants( | ||
| makeBaseNPMConfig({ | ||
| entrypoints: [ | ||
| 'src/index.server.ts', | ||
| 'src/index.client.ts', | ||
| 'src/client/index.ts', | ||
| 'src/server/index.ts', | ||
| ], | ||
| }), | ||
| ) | ||
| ; | ||
| export default makeNPMConfigVariants( | ||
| makeBaseNPMConfig({ | ||
| entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/client/index.ts', 'src/server/index.ts'], | ||
| packageSpecificConfig: { | ||
| external: ['$app/stores'], | ||
| }, | ||
| }), | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import { getActiveTransaction } from '@sentry/core'; | ||
| import { WINDOW } from '@sentry/svelte'; | ||
| import type { Span, Transaction, TransactionContext } from '@sentry/types'; | ||
| import { navigating, page } from '$app/stores'; | ||
| const DEFAULT_TAGS = { | ||
| 'routing.instrumentation': '@sentry/sveltekit', | ||
| }; | ||
| /** | ||
| * Automatically creates pageload and navigation transactions for the client-side SvelteKit router. | ||
| * | ||
| * This instrumentation makes use of SvelteKit's `page` and `navigating` stores which can be accessed | ||
| * anywhere on the client side. | ||
| * | ||
| * @param startTransactionFn the function used to start (idle) transactions | ||
| * @param startTransactionOnPageLoad controls if pageload transactions should be created (defaults to `true`) | ||
| * @param startTransactionOnLocationChange controls if navigation transactions should be created (defauls to `true`) | ||
| */ | ||
| export function svelteKitRoutingInstrumentation<T extends Transaction>( | ||
| startTransactionFn: (context: TransactionContext) => T | undefined, | ||
| startTransactionOnPageLoad: boolean = true, | ||
| startTransactionOnLocationChange: boolean = true, | ||
| ): void { | ||
| if (startTransactionOnPageLoad) { | ||
| instrumentPageload(startTransactionFn); | ||
| } | ||
| if (startTransactionOnLocationChange) { | ||
| instrumentNavigations(startTransactionFn); | ||
| } | ||
| } | ||
| function instrumentPageload(startTransactionFn: (context: TransactionContext) => Transaction | undefined): void { | ||
| const initialPath = WINDOW && WINDOW.location && WINDOW.location.pathname; | ||
| const pageloadTransaction = startTransactionFn({ | ||
| name: initialPath, | ||
| op: 'pageload', | ||
| description: initialPath, | ||
| tags: { | ||
| ...DEFAULT_TAGS, | ||
| }, | ||
| }); | ||
| page.subscribe(page => { | ||
| if (!page) { | ||
| return; | ||
| } | ||
| const routeId = page.route && page.route.id; | ||
| if (pageloadTransaction && routeId) { | ||
| pageloadTransaction.setName(routeId, 'route'); | ||
| } | ||
| }); | ||
| } | ||
| /** | ||
| * Use the `navigating` store to start a transaction on navigations. | ||
| */ | ||
| function instrumentNavigations(startTransactionFn: (context: TransactionContext) => Transaction | undefined): void { | ||
| let routingSpan: Span | undefined = undefined; | ||
| let activeTransaction: Transaction | undefined; | ||
| navigating.subscribe(navigation => { | ||
| if (!navigation) { | ||
| // `navigating` emits a 'null' value when the navigation is completed. | ||
| // So in this case, we can finish the routing span. If the transaction was an IdleTransaction, | ||
| // it will finish automatically and if it was user-created users also need to finish it. | ||
| if (routingSpan) { | ||
| routingSpan.finish(); | ||
| routingSpan = undefined; | ||
| } | ||
| return; | ||
| } | ||
| const routeDestination = navigation.to && navigation.to.route.id; | ||
| const routeOrigin = navigation.from && navigation.from.route.id; | ||
| if (routeOrigin === routeDestination) { | ||
| return; | ||
Lms24 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| activeTransaction = getActiveTransaction(); | ||
| if (!activeTransaction) { | ||
| activeTransaction = startTransactionFn({ | ||
| name: routeDestination || (WINDOW && WINDOW.location && WINDOW.location.pathname), | ||
| op: 'navigation', | ||
| metadata: { source: 'route' }, | ||
| tags: { | ||
| ...DEFAULT_TAGS, | ||
| }, | ||
| }); | ||
| } | ||
| if (activeTransaction) { | ||
| if (routingSpan) { | ||
| // If a routing span is still open from a previous navigation, we finish it. | ||
| routingSpan.finish(); | ||
AbhiPrasad marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
| routingSpan = activeTransaction.startChild({ | ||
| op: 'ui.sveltekit.routing', | ||
| description: 'SvelteKit Route Change', | ||
| }); | ||
| activeTransaction.setTag('from', routeOrigin); | ||
| } | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| /* eslint-disable @typescript-eslint/unbound-method */ | ||
| import type { Transaction } from '@sentry/types'; | ||
| import { writable } from 'svelte/store'; | ||
| import type { SpyInstance } from 'vitest'; | ||
| import { vi } from 'vitest'; | ||
| import { navigating, page } from '$app/stores'; | ||
| import { svelteKitRoutingInstrumentation } from '../../src/client/router'; | ||
| // we have to overwrite the global mock from `vitest.setup.ts` here to reset the | ||
| // `navigating` store for each test. | ||
| vi.mock('$app/stores', async () => { | ||
| return { | ||
| get navigating() { | ||
| return navigatingStore; | ||
| }, | ||
| page: writable(), | ||
| }; | ||
| }); | ||
| let navigatingStore = writable(); | ||
| describe('sveltekitRoutingInstrumentation', () => { | ||
| let returnedTransaction: (Transaction & { returnedTransaction: SpyInstance }) | undefined; | ||
| const mockedStartTransaction = vi.fn().mockImplementation(txnCtx => { | ||
| returnedTransaction = { | ||
| ...txnCtx, | ||
| setName: vi.fn(), | ||
| startChild: vi.fn().mockImplementation(ctx => { | ||
| return { ...mockedRoutingSpan, ...ctx }; | ||
| }), | ||
| setTag: vi.fn(), | ||
| }; | ||
| return returnedTransaction; | ||
| }); | ||
| const mockedRoutingSpan = { | ||
| finish: () => {}, | ||
| }; | ||
| const routingSpanFinishSpy = vi.spyOn(mockedRoutingSpan, 'finish'); | ||
| beforeEach(() => { | ||
| navigatingStore = writable(); | ||
| vi.clearAllMocks(); | ||
| }); | ||
| it("starts a pageload transaction when it's called with default params", () => { | ||
| svelteKitRoutingInstrumentation(mockedStartTransaction); | ||
| expect(mockedStartTransaction).toHaveBeenCalledTimes(1); | ||
| expect(mockedStartTransaction).toHaveBeenCalledWith({ | ||
| name: '/', | ||
| op: 'pageload', | ||
| description: '/', | ||
| tags: { | ||
| 'routing.instrumentation': '@sentry/sveltekit', | ||
| }, | ||
| }); | ||
| // We emit an update to the `page` store to simulate the SvelteKit router lifecycle | ||
| // @ts-ignore This is fine because we testUtils/stores.ts defines `page` as a writable store | ||
| page.set({ route: { id: 'testRoute' } }); | ||
| // This should update the transaction name with the parameterized route: | ||
| expect(returnedTransaction?.setName).toHaveBeenCalledTimes(1); | ||
| expect(returnedTransaction?.setName).toHaveBeenCalledWith('testRoute', 'route'); | ||
| }); | ||
| it("doesn't start a pageload transaction if `startTransactionOnPageLoad` is false", () => { | ||
| svelteKitRoutingInstrumentation(mockedStartTransaction, false); | ||
| expect(mockedStartTransaction).toHaveBeenCalledTimes(0); | ||
| }); | ||
| it("doesn't starts a navigation transaction when `startTransactionOnLocationChange` is false", () => { | ||
| svelteKitRoutingInstrumentation(mockedStartTransaction, false, false); | ||
| // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle | ||
| // @ts-ignore This is fine because we testUtils/stores.ts defines `navigating` as a writable store | ||
| navigating.set( | ||
| { from: { route: { id: 'testNavigationOrigin' } } }, | ||
| { to: { route: { id: 'testNavigationDestination' } } }, | ||
| ); | ||
| // This should update the transaction name with the parameterized route: | ||
| expect(mockedStartTransaction).toHaveBeenCalledTimes(0); | ||
| }); | ||
| it('starts a navigation transaction when `startTransactionOnLocationChange` is true', () => { | ||
| svelteKitRoutingInstrumentation(mockedStartTransaction, false, true); | ||
| // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle | ||
| // @ts-ignore This is fine because we testUtils/stores.ts defines `navigating` as a writable store | ||
| navigating.set({ | ||
| from: { route: { id: 'testNavigationOrigin' } }, | ||
| to: { route: { id: 'testNavigationDestination' } }, | ||
| }); | ||
| // This should update the transaction name with the parameterized route: | ||
| expect(mockedStartTransaction).toHaveBeenCalledTimes(1); | ||
| expect(mockedStartTransaction).toHaveBeenCalledWith({ | ||
| name: 'testNavigationDestination', | ||
| op: 'navigation', | ||
| metadata: { | ||
| source: 'route', | ||
| }, | ||
| tags: { | ||
| 'routing.instrumentation': '@sentry/sveltekit', | ||
| }, | ||
| }); | ||
| expect(returnedTransaction?.startChild).toHaveBeenCalledWith({ | ||
| op: 'ui.sveltekit.routing', | ||
| description: 'SvelteKit Route Change', | ||
| }); | ||
| expect(returnedTransaction?.setTag).toHaveBeenCalledWith('from', 'testNavigationOrigin'); | ||
| // We emit `null` here to simulate the end of the navigation lifecycle | ||
| // @ts-ignore this is fine | ||
| navigating.set(null); | ||
| expect(routingSpanFinishSpy).toHaveBeenCalledTimes(1); | ||
| }); | ||
| it("doesn't start a navigation transaction if navigation origin and destination are equal", () => { | ||
| svelteKitRoutingInstrumentation(mockedStartTransaction, false, true); | ||
| // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle | ||
| // @ts-ignore This is fine because we testUtils/stores.ts defines `navigating` as a writable store | ||
| navigating.set({ | ||
| from: { route: { id: 'testRoute' } }, | ||
| to: { route: { id: 'testRoute' } }, | ||
| }); | ||
| expect(mockedStartTransaction).toHaveBeenCalledTimes(0); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { writable } from 'svelte/store'; | ||
| import { vi } from 'vitest'; | ||
| export function setup() { | ||
| // mock $app/stores because vitest can't resolve this import from SvelteKit. | ||
| // Seems like $app/stores is only created at build time of a SvelteKit app. | ||
| vi.mock('$app/stores', async () => { | ||
| return { | ||
| navigating: writable(), | ||
| page: writable(), | ||
| }; | ||
| }); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.