Skip to content

Commit 85b56dc

Browse files
committed
fix(sveltekit): Handle server-only and shared load functions
1 parent 1121507 commit 85b56dc

File tree

2 files changed

+197
-44
lines changed

2 files changed

+197
-44
lines changed

packages/sveltekit/src/server/load.ts

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
/* eslint-disable @sentry-internal/sdk/no-optional-chaining */
2+
import { trace } from '@sentry/core';
23
import { captureException } from '@sentry/node';
3-
import { addExceptionMechanism, isThenable, objectify } from '@sentry/utils';
4-
import type { HttpError, Load, ServerLoad } from '@sveltejs/kit';
4+
import type { TransactionContext } from '@sentry/types';
5+
import {
6+
addExceptionMechanism,
7+
baggageHeaderToDynamicSamplingContext,
8+
extractTraceparentData,
9+
objectify,
10+
} from '@sentry/utils';
11+
import type { HttpError, Load, LoadEvent, ServerLoad, ServerLoadEvent } from '@sveltejs/kit';
512
import * as domain from 'domain';
613

714
function isHttpError(err: unknown): err is HttpError {
@@ -43,27 +50,55 @@ function sendErrorToSentry(e: unknown): unknown {
4350
*
4451
* @param origLoad SvelteKit user defined load function
4552
*/
46-
export function wrapLoadWithSentry<T extends ServerLoad | Load>(origLoad: T): T {
53+
export function wrapLoadWithSentry<T extends Load | ServerLoad>(origLoad: T): T {
4754
return new Proxy(origLoad, {
48-
apply: (wrappingTarget, thisArg, args: Parameters<ServerLoad>) => {
55+
apply: (wrappingTarget, thisArg, args: Parameters<ServerLoad | Load>) => {
4956
return domain.create().bind(() => {
50-
let maybePromiseResult: ReturnType<T>;
57+
const [event] = args;
58+
const routeId = event.route && event.route.id;
5159

52-
try {
53-
maybePromiseResult = wrappingTarget.apply(thisArg, args);
54-
} catch (e) {
55-
sendErrorToSentry(e);
56-
throw e;
57-
}
60+
const traceSharedLoadContext: TransactionContext = {
61+
op: 'function.sveltekit.load',
62+
name: routeId ? routeId : event.url.pathname,
63+
status: 'ok',
64+
metadata: {
65+
source: routeId ? 'route' : 'url',
66+
},
67+
};
68+
69+
let finalTraceLoadContext = { ...traceSharedLoadContext };
70+
71+
if (isServerOnlyLoad(event)) {
72+
const sentryTraceHeader = event.request.headers.get('sentry-trace');
73+
const baggageHeader = event.request.headers.get('baggage');
74+
const traceparentData = sentryTraceHeader ? extractTraceparentData(sentryTraceHeader) : undefined;
75+
const dynamicSamplingContext = baggageHeaderToDynamicSamplingContext(baggageHeader);
5876

59-
if (isThenable(maybePromiseResult)) {
60-
Promise.resolve(maybePromiseResult).then(null, e => {
61-
sendErrorToSentry(e);
62-
});
77+
const traceSeverOnlyLoadContext = {
78+
...traceparentData,
79+
metadata: {
80+
...traceSharedLoadContext.metadata,
81+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
82+
},
83+
};
84+
85+
finalTraceLoadContext = { ...traceSharedLoadContext, ...traceSeverOnlyLoadContext };
6386
}
6487

65-
return maybePromiseResult;
88+
return trace(finalTraceLoadContext, () => wrappingTarget.apply(thisArg, args), sendErrorToSentry);
6689
})();
6790
},
6891
});
6992
}
93+
94+
/**
95+
* Our server-side wrapLoadWithSentry can be used to wrap two different kinds of `load` functions:
96+
* - load functions from `+(page|layout).ts`: These can be called both on client and on server
97+
* - load functions from `+(page|layout).server.ts`: These are only called on the server
98+
*
99+
* In both cases, load events look differently. We can distinguish them by checking if the
100+
* event has a `request` field (which only the server-exclusive load event has).
101+
*/
102+
function isServerOnlyLoad(event: ServerLoadEvent | LoadEvent): event is ServerLoadEvent {
103+
return 'request' in event;
104+
}

packages/sveltekit/test/server/load.test.ts

Lines changed: 146 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ const MOCK_LOAD_ARGS: any = {
5454
id: '/users/[id]',
5555
},
5656
url: new URL('http://localhost:3000/users/123'),
57+
};
58+
59+
const MOCK_LOAD_NO_ROUTE_ARGS: any = {
60+
params: { id: '123' },
61+
url: new URL('http://localhost:3000/users/123'),
62+
};
63+
64+
const MOCK_SERVER_ONLY_LOAD_ARGS: any = {
65+
...MOCK_LOAD_ARGS,
5766
request: {
5867
headers: {
5968
get: (key: string) => {
@@ -75,6 +84,32 @@ const MOCK_LOAD_ARGS: any = {
7584
},
7685
};
7786

87+
const MOCK_SERVER_ONLY_NO_TRACE_LOAD_ARGS: any = {
88+
...MOCK_LOAD_ARGS,
89+
request: {
90+
headers: {
91+
get: (_: string) => {
92+
return null;
93+
},
94+
},
95+
},
96+
};
97+
98+
const MOCK_SERVER_ONLY_NO_BAGGAGE_LOAD_ARGS: any = {
99+
...MOCK_LOAD_ARGS,
100+
request: {
101+
headers: {
102+
get: (key: string) => {
103+
if (key === 'sentry-trace') {
104+
return '1234567890abcdef1234567890abcdef-1234567890abcdef-1';
105+
}
106+
107+
return null;
108+
},
109+
},
110+
},
111+
};
112+
78113
beforeAll(() => {
79114
addTracingExtensions();
80115
});
@@ -101,42 +136,125 @@ describe('wrapLoadWithSentry', () => {
101136
expect(mockCaptureException).toHaveBeenCalledTimes(1);
102137
});
103138

104-
// TODO: enable this once we figured out how tracing the load function doesn't result in creating a new transaction
105-
it.skip('calls trace function', async () => {
139+
describe('calls trace', () => {
106140
async function load({ params }: Parameters<ServerLoad>[0]): Promise<ReturnType<ServerLoad>> {
107141
return {
108142
post: params.id,
109143
};
110144
}
111145

112-
const wrappedLoad = wrapLoadWithSentry(load);
113-
await wrappedLoad(MOCK_LOAD_ARGS);
114-
115-
expect(mockTrace).toHaveBeenCalledTimes(1);
116-
expect(mockTrace).toHaveBeenCalledWith(
117-
{
118-
op: 'function.sveltekit.load',
119-
name: '/users/[id]',
120-
parentSampled: true,
121-
parentSpanId: '1234567890abcdef',
122-
status: 'ok',
123-
traceId: '1234567890abcdef1234567890abcdef',
124-
metadata: {
125-
dynamicSamplingContext: {
126-
environment: 'production',
127-
public_key: 'dogsarebadatkeepingsecrets',
128-
release: '1.0.0',
129-
sample_rate: '1',
130-
trace_id: '1234567890abcdef1234567890abcdef',
131-
transaction: 'dogpark',
132-
user_segment: 'segmentA',
146+
describe('for server-only load', () => {
147+
it('attaches trace data if available', async () => {
148+
const wrappedLoad = wrapLoadWithSentry(load);
149+
await wrappedLoad(MOCK_SERVER_ONLY_LOAD_ARGS);
150+
151+
expect(mockTrace).toHaveBeenCalledTimes(1);
152+
expect(mockTrace).toHaveBeenCalledWith(
153+
{
154+
op: 'function.sveltekit.load',
155+
name: '/users/[id]',
156+
parentSampled: true,
157+
parentSpanId: '1234567890abcdef',
158+
status: 'ok',
159+
traceId: '1234567890abcdef1234567890abcdef',
160+
metadata: {
161+
dynamicSamplingContext: {
162+
environment: 'production',
163+
public_key: 'dogsarebadatkeepingsecrets',
164+
release: '1.0.0',
165+
sample_rate: '1',
166+
trace_id: '1234567890abcdef1234567890abcdef',
167+
transaction: 'dogpark',
168+
user_segment: 'segmentA',
169+
},
170+
source: 'route',
171+
},
172+
},
173+
expect.any(Function),
174+
expect.any(Function),
175+
);
176+
});
177+
178+
it("doesn't attach trace data if it's not available", async () => {
179+
const wrappedLoad = wrapLoadWithSentry(load);
180+
await wrappedLoad(MOCK_SERVER_ONLY_NO_TRACE_LOAD_ARGS);
181+
182+
expect(mockTrace).toHaveBeenCalledTimes(1);
183+
expect(mockTrace).toHaveBeenCalledWith(
184+
{
185+
op: 'function.sveltekit.load',
186+
name: '/users/[id]',
187+
status: 'ok',
188+
metadata: {
189+
source: 'route',
190+
},
191+
},
192+
expect.any(Function),
193+
expect.any(Function),
194+
);
195+
});
196+
197+
it("doesn't attach the DSC data if the baggage header not available", async () => {
198+
const wrappedLoad = wrapLoadWithSentry(load);
199+
await wrappedLoad(MOCK_SERVER_ONLY_NO_BAGGAGE_LOAD_ARGS);
200+
201+
expect(mockTrace).toHaveBeenCalledTimes(1);
202+
expect(mockTrace).toHaveBeenCalledWith(
203+
{
204+
op: 'function.sveltekit.load',
205+
name: '/users/[id]',
206+
parentSampled: true,
207+
parentSpanId: '1234567890abcdef',
208+
status: 'ok',
209+
traceId: '1234567890abcdef1234567890abcdef',
210+
metadata: {
211+
dynamicSamplingContext: {},
212+
source: 'route',
213+
},
214+
},
215+
expect.any(Function),
216+
expect.any(Function),
217+
);
218+
});
219+
});
220+
221+
it('for shared load', async () => {
222+
const wrappedLoad = wrapLoadWithSentry(load);
223+
await wrappedLoad(MOCK_LOAD_ARGS);
224+
225+
expect(mockTrace).toHaveBeenCalledTimes(1);
226+
expect(mockTrace).toHaveBeenCalledWith(
227+
{
228+
op: 'function.sveltekit.load',
229+
name: '/users/[id]',
230+
status: 'ok',
231+
metadata: {
232+
source: 'route',
133233
},
134-
source: 'route',
135234
},
136-
},
137-
expect.any(Function),
138-
expect.any(Function),
139-
);
235+
expect.any(Function),
236+
expect.any(Function),
237+
);
238+
});
239+
240+
it('falls back to the raw url if `event.route.id` is not available', async () => {
241+
const wrappedLoad = wrapLoadWithSentry(load);
242+
await wrappedLoad(MOCK_LOAD_NO_ROUTE_ARGS);
243+
244+
expect(mockTrace).toHaveBeenCalledTimes(1);
245+
expect(mockTrace).toHaveBeenCalledWith(
246+
{
247+
op: 'function.sveltekit.load',
248+
name: '/users/123',
249+
status: 'ok',
250+
metadata: {
251+
source: 'url',
252+
},
253+
},
254+
expect.any(Function),
255+
expect.any(Function),
256+
);
257+
});
140258
});
141259

142260
describe('with error() helper', () => {

0 commit comments

Comments
 (0)