Skip to content

Commit 6fbd1e9

Browse files
committed
feat(sveltekit): Add SvelteKit routing instrumentation
1 parent 86b89b9 commit 6fbd1e9

File tree

9 files changed

+293
-26
lines changed

9 files changed

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

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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
};
34+
return returnedTransaction;
35+
});
36+
37+
const mockedRoutingSpan = {
38+
finish: () => {},
39+
};
40+
41+
const routingSpanFinishSpy = vi.spyOn(mockedRoutingSpan, 'finish');
42+
43+
beforeEach(() => {
44+
navigatingStore = writable();
45+
vi.clearAllMocks();
46+
});
47+
48+
it("starts a pageload transaction when it's called with default params", () => {
49+
svelteKitRoutingInstrumentation(mockedStartTransaction);
50+
51+
expect(mockedStartTransaction).toHaveBeenCalledTimes(1);
52+
expect(mockedStartTransaction).toHaveBeenCalledWith({
53+
name: 'pageload',
54+
op: 'pageload',
55+
description: '/',
56+
});
57+
58+
// We emit an update to the `page` store to simulate the SvelteKit router lifecycle
59+
// @ts-ignore This is fine because we testUtils/stores.ts defines `page` as a writable store
60+
page.set({ route: { id: 'testRoute' } });
61+
62+
// This should update the transaction name with the parameterized route:
63+
expect(returnedTransaction?.setName).toHaveBeenCalledTimes(1);
64+
expect(returnedTransaction?.setName).toHaveBeenCalledWith('testRoute', 'route');
65+
});
66+
67+
it("doesn't start a pageload transaction if `startTransactionOnPageLoad` is false", () => {
68+
svelteKitRoutingInstrumentation(mockedStartTransaction, false);
69+
expect(mockedStartTransaction).toHaveBeenCalledTimes(0);
70+
});
71+
72+
it("doesn't starts a navigation transaction when `startTransactionOnLocationChange` is false", () => {
73+
svelteKitRoutingInstrumentation(mockedStartTransaction, false, false);
74+
75+
// We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle
76+
// @ts-ignore This is fine because we testUtils/stores.ts defines `navigating` as a writable store
77+
navigating.set(
78+
{ from: { route: { id: 'testNavigationOrigin' } } },
79+
{ to: { route: { id: 'testNavigationDestination' } } },
80+
);
81+
82+
// This should update the transaction name with the parameterized route:
83+
expect(mockedStartTransaction).toHaveBeenCalledTimes(0);
84+
});
85+
86+
it('starts a navigation transaction when `startTransactionOnLocationChange` is true', () => {
87+
svelteKitRoutingInstrumentation(mockedStartTransaction, false, true);
88+
89+
// We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle
90+
// @ts-ignore This is fine because we testUtils/stores.ts defines `navigating` as a writable store
91+
navigating.set({
92+
from: { route: { id: 'testNavigationOrigin' } },
93+
to: { route: { id: 'testNavigationDestination' } },
94+
});
95+
96+
// This should update the transaction name with the parameterized route:
97+
expect(mockedStartTransaction).toHaveBeenCalledTimes(1);
98+
expect(mockedStartTransaction).toHaveBeenCalledWith({
99+
name: 'testNavigationDestination',
100+
op: 'navigation',
101+
metadata: {
102+
source: 'route',
103+
},
104+
});
105+
106+
expect(returnedTransaction?.startChild).toHaveBeenCalledWith({
107+
op: 'ui.sveltekit.routing',
108+
description: 'SvelteKit Route Change',
109+
tags: {
110+
'routing.instrumentation': '@sentry/sveltekit',
111+
from: 'testNavigationOrigin',
112+
to: 'testNavigationDestination',
113+
},
114+
});
115+
116+
// We emit `null` here to simulate the end of the navigation lifecycle
117+
// @ts-ignore this is fine
118+
navigating.set(null);
119+
120+
expect(routingSpanFinishSpy).toHaveBeenCalledTimes(1);
121+
});
122+
});

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+
}

packages/sveltekit/vite.config.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
import type { UserConfig } from 'vitest';
2+
13
import baseConfig from '../../vite/vite.config';
24

3-
export default baseConfig;
5+
export default {
6+
...baseConfig,
7+
test: {
8+
// test exists, no idea why TS doesn't recognize it
9+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
10+
...(baseConfig as UserConfig & { test: any }).test,
11+
environment: 'jsdom',
12+
setupFiles: ['./test/vitest.setup.ts'],
13+
},
14+
};

0 commit comments

Comments
 (0)