Skip to content

Commit b1ef00d

Browse files
authored
feat(sveltekit): Add wrapper for server load function (#7416)
1 parent 42e542e commit b1ef00d

File tree

6 files changed

+183
-9
lines changed

6 files changed

+183
-9
lines changed

packages/sveltekit/.eslintrc.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,14 @@ module.exports = {
33
browser: true,
44
node: true,
55
},
6+
overrides: [
7+
{
8+
files: ['*.ts'],
9+
rules: {
10+
// Turning this off because it's not working with @sveltejs/kit
11+
'import/no-unresolved': 'off',
12+
},
13+
},
14+
],
615
extends: ['../../.eslintrc.js'],
716
};

packages/sveltekit/src/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from '@sentry/node';
22

33
export { init } from './sdk';
44
export { handleErrorWithSentry } from './handleError';
5+
export { wrapLoadWithSentry } from './load';

packages/sveltekit/src/server/load.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { captureException } from '@sentry/node';
2+
import { addExceptionMechanism, isThenable, objectify } from '@sentry/utils';
3+
import type { HttpError, ServerLoad } from '@sveltejs/kit';
4+
5+
function isHttpError(err: unknown): err is HttpError {
6+
return typeof err === 'object' && err !== null && 'status' in err && 'body' in err;
7+
}
8+
9+
function sendErrorToSentry(e: unknown): unknown {
10+
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
11+
// store a seen flag on it.
12+
const objectifiedErr = objectify(e);
13+
14+
// The error() helper is commonly used to throw errors in load functions: https://kit.svelte.dev/docs/modules#sveltejs-kit-error
15+
// If we detect a thrown error that is an instance of HttpError, we don't want to capture 4xx errors as they
16+
// could be noisy.
17+
if (isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400) {
18+
return objectifiedErr;
19+
}
20+
21+
captureException(objectifiedErr, scope => {
22+
scope.addEventProcessor(event => {
23+
addExceptionMechanism(event, {
24+
type: 'sveltekit',
25+
handled: false,
26+
data: {
27+
function: 'load',
28+
},
29+
});
30+
return event;
31+
});
32+
33+
return scope;
34+
});
35+
36+
return objectifiedErr;
37+
}
38+
39+
/**
40+
* Wrap load function with Sentry
41+
*
42+
* @param origLoad SvelteKit user defined load function
43+
*/
44+
export function wrapLoadWithSentry(origLoad: ServerLoad): ServerLoad {
45+
return new Proxy(origLoad, {
46+
apply: (wrappingTarget, thisArg, args: Parameters<ServerLoad>) => {
47+
let maybePromiseResult;
48+
49+
try {
50+
maybePromiseResult = wrappingTarget.apply(thisArg, args);
51+
} catch (e) {
52+
throw sendErrorToSentry(e);
53+
}
54+
55+
if (isThenable(maybePromiseResult)) {
56+
Promise.resolve(maybePromiseResult).then(null, e => {
57+
sendErrorToSentry(e);
58+
});
59+
}
60+
61+
return maybePromiseResult;
62+
},
63+
});
64+
}

packages/sveltekit/test/client/handleError.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
import { Scope } from '@sentry/svelte';
2-
// For now disable the import/no-unresolved rule, because we don't have a way to
3-
// tell eslint that we are only importing types from the @sveltejs/kit package without
4-
// adding a custom resolver, which will take too much time.
5-
// eslint-disable-next-line import/no-unresolved
62
import type { HandleClientError, NavigationEvent } from '@sveltejs/kit';
73
import { vi } from 'vitest';
84

packages/sveltekit/test/server/handleError.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
import { Scope } from '@sentry/node';
2-
// For now disable the import/no-unresolved rule, because we don't have a way to
3-
// tell eslint that we are only importing types from the @sveltejs/kit package without
4-
// adding a custom resolver, which will take too much time.
5-
// eslint-disable-next-line import/no-unresolved
62
import type { HandleServerError, RequestEvent } from '@sveltejs/kit';
73
import { vi } from 'vitest';
84

@@ -12,7 +8,7 @@ const mockCaptureException = vi.fn();
128
let mockScope = new Scope();
139

1410
vi.mock('@sentry/node', async () => {
15-
const original = (await vi.importActual('@sentry/core')) as any;
11+
const original = (await vi.importActual('@sentry/node')) as any;
1612
return {
1713
...original,
1814
captureException: (err: unknown, cb: (arg0: unknown) => unknown) => {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Scope } from '@sentry/node';
2+
import type { ServerLoad } from '@sveltejs/kit';
3+
import { error } from '@sveltejs/kit';
4+
import { vi } from 'vitest';
5+
6+
import { wrapLoadWithSentry } from '../../src/server/load';
7+
8+
const mockCaptureException = vi.fn();
9+
let mockScope = new Scope();
10+
11+
vi.mock('@sentry/node', async () => {
12+
const original = (await vi.importActual('@sentry/node')) as any;
13+
return {
14+
...original,
15+
captureException: (err: unknown, cb: (arg0: unknown) => unknown) => {
16+
cb(mockScope);
17+
mockCaptureException(err, cb);
18+
return original.captureException(err, cb);
19+
},
20+
};
21+
});
22+
23+
const mockAddExceptionMechanism = vi.fn();
24+
25+
vi.mock('@sentry/utils', async () => {
26+
const original = (await vi.importActual('@sentry/utils')) as any;
27+
return {
28+
...original,
29+
addExceptionMechanism: (...args: unknown[]) => mockAddExceptionMechanism(...args),
30+
};
31+
});
32+
33+
function getById(_id?: string) {
34+
throw new Error('error');
35+
}
36+
37+
describe('wrapLoadWithSentry', () => {
38+
beforeEach(() => {
39+
mockCaptureException.mockClear();
40+
mockAddExceptionMechanism.mockClear();
41+
mockScope = new Scope();
42+
});
43+
44+
it('calls captureException', async () => {
45+
async function load({ params }: Parameters<ServerLoad>[0]): Promise<ReturnType<ServerLoad>> {
46+
return {
47+
post: getById(params.id),
48+
};
49+
}
50+
51+
const wrappedLoad = wrapLoadWithSentry(load);
52+
const res = wrappedLoad({ params: { id: '1' } } as any);
53+
await expect(res).rejects.toThrow();
54+
55+
expect(mockCaptureException).toHaveBeenCalledTimes(1);
56+
});
57+
58+
describe('with error() helper', () => {
59+
it.each([
60+
// [statusCode, timesCalled]
61+
[400, 0],
62+
[401, 0],
63+
[403, 0],
64+
[404, 0],
65+
[409, 0],
66+
[429, 0],
67+
[499, 0],
68+
[500, 1],
69+
[501, 1],
70+
[503, 1],
71+
[504, 1],
72+
])('error with status code %s calls captureException %s times', async (code, times) => {
73+
async function load({ params }: Parameters<ServerLoad>[0]): Promise<ReturnType<ServerLoad>> {
74+
throw error(code, params.id);
75+
}
76+
77+
const wrappedLoad = wrapLoadWithSentry(load);
78+
const res = wrappedLoad({ params: { id: '1' } } as any);
79+
await expect(res).rejects.toThrow();
80+
81+
expect(mockCaptureException).toHaveBeenCalledTimes(times);
82+
});
83+
});
84+
85+
it('adds an exception mechanism', async () => {
86+
const addEventProcessorSpy = vi.spyOn(mockScope, 'addEventProcessor').mockImplementationOnce(callback => {
87+
void callback({}, { event_id: 'fake-event-id' });
88+
return mockScope;
89+
});
90+
91+
async function load({ params }: Parameters<ServerLoad>[0]): Promise<ReturnType<ServerLoad>> {
92+
return {
93+
post: getById(params.id),
94+
};
95+
}
96+
97+
const wrappedLoad = wrapLoadWithSentry(load);
98+
const res = wrappedLoad({ params: { id: '1' } } as any);
99+
await expect(res).rejects.toThrow();
100+
101+
expect(addEventProcessorSpy).toBeCalledTimes(1);
102+
expect(mockAddExceptionMechanism).toBeCalledTimes(1);
103+
expect(mockAddExceptionMechanism).toBeCalledWith(
104+
{},
105+
{ handled: false, type: 'sveltekit', data: { function: 'load' } },
106+
);
107+
});
108+
});

0 commit comments

Comments
 (0)