Skip to content

Commit e97b097

Browse files
authored
feat(sveltekit): Add SvelteKit routing instrumentation (#7565)
Add routing instrumentation to the client SvelteKit SDK. Pageload navigations are created on function call as always and updated with a proper name once the `page` store emits the route id. Navigation transactions are created by subscribing to the `navigating` store. No need for user configuration, as the instrumentation will be added automatically on SDK intialization.
1 parent 89b5720 commit e97b097

File tree

9 files changed

+313
-26
lines changed

9 files changed

+313
-26
lines changed

packages/sveltekit/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
},
3131
"devDependencies": {
3232
"@sveltejs/kit": "^1.11.0",
33+
"svelte": "^3.44.0",
3334
"typescript": "^4.9.3",
3435
"vite": "4.0.0"
3536
},
Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import { makeBaseNPMConfig, makeNPMConfigVariants } from '../../rollup/index.js';
22

3-
export default
4-
makeNPMConfigVariants(
5-
makeBaseNPMConfig({
6-
entrypoints: [
7-
'src/index.server.ts',
8-
'src/index.client.ts',
9-
'src/client/index.ts',
10-
'src/server/index.ts',
11-
],
12-
}),
13-
)
14-
;
3+
export default makeNPMConfigVariants(
4+
makeBaseNPMConfig({
5+
entrypoints: ['src/index.server.ts', 'src/index.client.ts', 'src/client/index.ts', 'src/server/index.ts'],
6+
packageSpecificConfig: {
7+
external: ['$app/stores'],
8+
},
9+
}),
10+
);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { getActiveTransaction } from '@sentry/core';
2+
import { WINDOW } from '@sentry/svelte';
3+
import type { Span, Transaction, TransactionContext } from '@sentry/types';
4+
5+
import { navigating, page } from '$app/stores';
6+
7+
const DEFAULT_TAGS = {
8+
'routing.instrumentation': '@sentry/sveltekit',
9+
};
10+
11+
/**
12+
* Automatically creates pageload and navigation transactions for the client-side SvelteKit router.
13+
*
14+
* This instrumentation makes use of SvelteKit's `page` and `navigating` stores which can be accessed
15+
* anywhere on the client side.
16+
*
17+
* @param startTransactionFn the function used to start (idle) transactions
18+
* @param startTransactionOnPageLoad controls if pageload transactions should be created (defaults to `true`)
19+
* @param startTransactionOnLocationChange controls if navigation transactions should be created (defauls to `true`)
20+
*/
21+
export function svelteKitRoutingInstrumentation<T extends Transaction>(
22+
startTransactionFn: (context: TransactionContext) => T | undefined,
23+
startTransactionOnPageLoad: boolean = true,
24+
startTransactionOnLocationChange: boolean = true,
25+
): void {
26+
if (startTransactionOnPageLoad) {
27+
instrumentPageload(startTransactionFn);
28+
}
29+
30+
if (startTransactionOnLocationChange) {
31+
instrumentNavigations(startTransactionFn);
32+
}
33+
}
34+
35+
function instrumentPageload(startTransactionFn: (context: TransactionContext) => Transaction | undefined): void {
36+
const initialPath = WINDOW && WINDOW.location && WINDOW.location.pathname;
37+
38+
const pageloadTransaction = startTransactionFn({
39+
name: initialPath,
40+
op: 'pageload',
41+
description: initialPath,
42+
tags: {
43+
...DEFAULT_TAGS,
44+
},
45+
});
46+
47+
page.subscribe(page => {
48+
if (!page) {
49+
return;
50+
}
51+
52+
const routeId = page.route && page.route.id;
53+
54+
if (pageloadTransaction && routeId) {
55+
pageloadTransaction.setName(routeId, 'route');
56+
}
57+
});
58+
}
59+
60+
/**
61+
* Use the `navigating` store to start a transaction on navigations.
62+
*/
63+
function instrumentNavigations(startTransactionFn: (context: TransactionContext) => Transaction | undefined): void {
64+
let routingSpan: Span | undefined = undefined;
65+
let activeTransaction: Transaction | undefined;
66+
67+
navigating.subscribe(navigation => {
68+
if (!navigation) {
69+
// `navigating` emits a 'null' value when the navigation is completed.
70+
// So in this case, we can finish the routing span. If the transaction was an IdleTransaction,
71+
// it will finish automatically and if it was user-created users also need to finish it.
72+
if (routingSpan) {
73+
routingSpan.finish();
74+
routingSpan = undefined;
75+
}
76+
return;
77+
}
78+
79+
const routeDestination = navigation.to && navigation.to.route.id;
80+
const routeOrigin = navigation.from && navigation.from.route.id;
81+
82+
if (routeOrigin === routeDestination) {
83+
return;
84+
}
85+
86+
activeTransaction = getActiveTransaction();
87+
88+
if (!activeTransaction) {
89+
activeTransaction = startTransactionFn({
90+
name: routeDestination || (WINDOW && WINDOW.location && WINDOW.location.pathname),
91+
op: 'navigation',
92+
metadata: { source: 'route' },
93+
tags: {
94+
...DEFAULT_TAGS,
95+
},
96+
});
97+
}
98+
99+
if (activeTransaction) {
100+
if (routingSpan) {
101+
// If a routing span is still open from a previous navigation, we finish it.
102+
routingSpan.finish();
103+
}
104+
routingSpan = activeTransaction.startChild({
105+
op: 'ui.sveltekit.routing',
106+
description: 'SvelteKit Route Change',
107+
});
108+
activeTransaction.setTag('from', routeOrigin);
109+
}
110+
});
111+
}

packages/sveltekit/src/client/sdk.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1-
import { defaultRequestInstrumentationOptions } from '@sentry-internal/tracing';
21
import { hasTracingEnabled } from '@sentry/core';
32
import type { BrowserOptions } from '@sentry/svelte';
43
import { BrowserTracing, configureScope, init as initSvelteSdk } from '@sentry/svelte';
54
import { addOrUpdateIntegration } from '@sentry/utils';
65

76
import { applySdkMetadata } from '../common/metadata';
7+
import { svelteKitRoutingInstrumentation } from './router';
88

99
// Treeshakable guard to remove all code related to tracing
1010
declare const __SENTRY_TRACING__: boolean;
1111

1212
/**
13+
* Initialize the client side of the Sentry SvelteKit SDK.
1314
*
14-
* @param options
15+
* @param options Configuration options for the SDK.
1516
*/
1617
export function init(options: BrowserOptions): void {
1718
applySdkMetadata(options, ['sveltekit', 'svelte']);
@@ -33,14 +34,11 @@ function addClientIntegrations(options: BrowserOptions): void {
3334
if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) {
3435
if (hasTracingEnabled(options)) {
3536
const defaultBrowserTracingIntegration = new BrowserTracing({
36-
tracePropagationTargets: [...defaultRequestInstrumentationOptions.tracePropagationTargets],
37-
// TODO: Add SvelteKit router instrumentations
38-
// routingInstrumentation: sveltekitRoutingInstrumentation,
37+
routingInstrumentation: svelteKitRoutingInstrumentation,
3938
});
4039

4140
integrations = addOrUpdateIntegration(defaultBrowserTracingIntegration, integrations, {
42-
// TODO: Add SvelteKit router instrumentations
43-
// options.routingInstrumentation: sveltekitRoutingInstrumentation,
41+
'options.routingInstrumentation': svelteKitRoutingInstrumentation,
4442
});
4543
}
4644
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/* eslint-disable @typescript-eslint/unbound-method */
2+
import type { Transaction } from '@sentry/types';
3+
import { writable } from 'svelte/store';
4+
import type { SpyInstance } from 'vitest';
5+
import { vi } from 'vitest';
6+
7+
import { navigating, page } from '$app/stores';
8+
9+
import { svelteKitRoutingInstrumentation } from '../../src/client/router';
10+
11+
// we have to overwrite the global mock from `vitest.setup.ts` here to reset the
12+
// `navigating` store for each test.
13+
vi.mock('$app/stores', async () => {
14+
return {
15+
get navigating() {
16+
return navigatingStore;
17+
},
18+
page: writable(),
19+
};
20+
});
21+
22+
let navigatingStore = writable();
23+
24+
describe('sveltekitRoutingInstrumentation', () => {
25+
let returnedTransaction: (Transaction & { returnedTransaction: SpyInstance }) | undefined;
26+
const mockedStartTransaction = vi.fn().mockImplementation(txnCtx => {
27+
returnedTransaction = {
28+
...txnCtx,
29+
setName: vi.fn(),
30+
startChild: vi.fn().mockImplementation(ctx => {
31+
return { ...mockedRoutingSpan, ...ctx };
32+
}),
33+
setTag: vi.fn(),
34+
};
35+
return returnedTransaction;
36+
});
37+
38+
const mockedRoutingSpan = {
39+
finish: () => {},
40+
};
41+
42+
const routingSpanFinishSpy = vi.spyOn(mockedRoutingSpan, 'finish');
43+
44+
beforeEach(() => {
45+
navigatingStore = writable();
46+
vi.clearAllMocks();
47+
});
48+
49+
it("starts a pageload transaction when it's called with default params", () => {
50+
svelteKitRoutingInstrumentation(mockedStartTransaction);
51+
52+
expect(mockedStartTransaction).toHaveBeenCalledTimes(1);
53+
expect(mockedStartTransaction).toHaveBeenCalledWith({
54+
name: '/',
55+
op: 'pageload',
56+
description: '/',
57+
tags: {
58+
'routing.instrumentation': '@sentry/sveltekit',
59+
},
60+
});
61+
62+
// We emit an update to the `page` store to simulate the SvelteKit router lifecycle
63+
// @ts-ignore This is fine because we testUtils/stores.ts defines `page` as a writable store
64+
page.set({ route: { id: 'testRoute' } });
65+
66+
// This should update the transaction name with the parameterized route:
67+
expect(returnedTransaction?.setName).toHaveBeenCalledTimes(1);
68+
expect(returnedTransaction?.setName).toHaveBeenCalledWith('testRoute', 'route');
69+
});
70+
71+
it("doesn't start a pageload transaction if `startTransactionOnPageLoad` is false", () => {
72+
svelteKitRoutingInstrumentation(mockedStartTransaction, false);
73+
expect(mockedStartTransaction).toHaveBeenCalledTimes(0);
74+
});
75+
76+
it("doesn't starts a navigation transaction when `startTransactionOnLocationChange` is false", () => {
77+
svelteKitRoutingInstrumentation(mockedStartTransaction, false, false);
78+
79+
// We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle
80+
// @ts-ignore This is fine because we testUtils/stores.ts defines `navigating` as a writable store
81+
navigating.set(
82+
{ from: { route: { id: 'testNavigationOrigin' } } },
83+
{ to: { route: { id: 'testNavigationDestination' } } },
84+
);
85+
86+
// This should update the transaction name with the parameterized route:
87+
expect(mockedStartTransaction).toHaveBeenCalledTimes(0);
88+
});
89+
90+
it('starts a navigation transaction when `startTransactionOnLocationChange` is true', () => {
91+
svelteKitRoutingInstrumentation(mockedStartTransaction, false, true);
92+
93+
// We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle
94+
// @ts-ignore This is fine because we testUtils/stores.ts defines `navigating` as a writable store
95+
navigating.set({
96+
from: { route: { id: 'testNavigationOrigin' } },
97+
to: { route: { id: 'testNavigationDestination' } },
98+
});
99+
100+
// This should update the transaction name with the parameterized route:
101+
expect(mockedStartTransaction).toHaveBeenCalledTimes(1);
102+
expect(mockedStartTransaction).toHaveBeenCalledWith({
103+
name: 'testNavigationDestination',
104+
op: 'navigation',
105+
metadata: {
106+
source: 'route',
107+
},
108+
tags: {
109+
'routing.instrumentation': '@sentry/sveltekit',
110+
},
111+
});
112+
113+
expect(returnedTransaction?.startChild).toHaveBeenCalledWith({
114+
op: 'ui.sveltekit.routing',
115+
description: 'SvelteKit Route Change',
116+
});
117+
118+
expect(returnedTransaction?.setTag).toHaveBeenCalledWith('from', 'testNavigationOrigin');
119+
120+
// We emit `null` here to simulate the end of the navigation lifecycle
121+
// @ts-ignore this is fine
122+
navigating.set(null);
123+
124+
expect(routingSpanFinishSpy).toHaveBeenCalledTimes(1);
125+
});
126+
127+
it("doesn't start a navigation transaction if navigation origin and destination are equal", () => {
128+
svelteKitRoutingInstrumentation(mockedStartTransaction, false, true);
129+
130+
// We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle
131+
// @ts-ignore This is fine because we testUtils/stores.ts defines `navigating` as a writable store
132+
navigating.set({
133+
from: { route: { id: 'testRoute' } },
134+
to: { route: { id: 'testRoute' } },
135+
});
136+
137+
expect(mockedStartTransaction).toHaveBeenCalledTimes(0);
138+
});
139+
});

packages/sveltekit/test/client/sdk.test.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SDK_VERSION, WINDOW } from '@sentry/svelte';
55
import { vi } from 'vitest';
66

77
import { BrowserTracing, init } from '../../src/client';
8+
import { svelteKitRoutingInstrumentation } from '../../src/client/router';
89

910
const svelteInit = vi.spyOn(SentrySvelte, 'init');
1011

@@ -87,6 +88,7 @@ describe('Sentry client SDK', () => {
8788
// This is the closest we can get to unit-testing the `__SENTRY_TRACING__` tree-shaking guard
8889
// IRL, the code to add the integration would most likely be removed by the bundler.
8990

91+
// @ts-ignore this is fine in the test
9092
globalThis.__SENTRY_TRACING__ = false;
9193

9294
init({
@@ -100,24 +102,35 @@ describe('Sentry client SDK', () => {
100102
expect(integrationsToInit).not.toContainEqual(expect.objectContaining({ name: 'BrowserTracing' }));
101103
expect(browserTracing).toBeUndefined();
102104

105+
// @ts-ignore this is fine in the test
103106
delete globalThis.__SENTRY_TRACING__;
104107
});
105108

106-
// TODO: this test is only meaningful once we have a routing instrumentation which we always want to add
107-
// to a user-provided BrowserTracing integration (see NextJS SDK)
108-
it.skip('Merges the user-provided BrowserTracing integration with the automatically added one', () => {
109+
it('Merges a user-provided BrowserTracing integration with the automatically added one', () => {
109110
init({
110111
dsn: 'https://[email protected]/1337',
111-
integrations: [new BrowserTracing({ tracePropagationTargets: ['myDomain.com'] })],
112+
integrations: [
113+
new BrowserTracing({ tracePropagationTargets: ['myDomain.com'], startTransactionOnLocationChange: false }),
114+
],
112115
enableTracing: true,
113116
});
114117

115118
const integrationsToInit = svelteInit.mock.calls[0][0].integrations;
116-
const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById('BrowserTracing');
119+
120+
const browserTracing = (getCurrentHub().getClient() as BrowserClient)?.getIntegrationById(
121+
'BrowserTracing',
122+
) as BrowserTracing;
123+
const options = browserTracing.options;
117124

118125
expect(integrationsToInit).toContainEqual(expect.objectContaining({ name: 'BrowserTracing' }));
119126
expect(browserTracing).toBeDefined();
120-
expect((browserTracing as BrowserTracing).options.tracePropagationTargets).toEqual(['myDomain.com']);
127+
128+
// This shows that the user-configured options are still here
129+
expect(options.tracePropagationTargets).toEqual(['myDomain.com']);
130+
expect(options.startTransactionOnLocationChange).toBe(false);
131+
132+
// But we force the routing instrumentation to be ours
133+
expect(options.routingInstrumentation).toEqual(svelteKitRoutingInstrumentation);
121134
});
122135
});
123136
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { writable } from 'svelte/store';
2+
import { vi } from 'vitest';
3+
4+
export function setup() {
5+
// mock $app/stores because vitest can't resolve this import from SvelteKit.
6+
// Seems like $app/stores is only created at build time of a SvelteKit app.
7+
vi.mock('$app/stores', async () => {
8+
return {
9+
navigating: writable(),
10+
page: writable(),
11+
};
12+
});
13+
}

0 commit comments

Comments
 (0)