Skip to content

Commit a67a69e

Browse files
authored
feat(sveltekit): Add wrapServerRouteWithSentry wrapper (#13247)
Add a wrapper for SvelteKit server routes. The reason is that some errors (e.g. sveltekit `error()` calls) are not caught within server (API) routes, as reported in #13224 because in contrast to `load` function we don't directly try/catch the function invokation. For now, users will have to add this wrapper manually. At a later time we can think about auto instrumentation, similarly to `load` functions but for now this will remain manual.
1 parent 21830b1 commit a67a69e

File tree

11 files changed

+307
-79
lines changed

11 files changed

+307
-79
lines changed

dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,7 @@
3838
<li>
3939
<a href="components">Component Tracking</a>
4040
</li>
41+
<li>
42+
<a href="/wrap-server-route">server routes</a>
43+
</li>
4144
</ul>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script lang="ts">
2+
export let data;
3+
</script>
4+
5+
<p>
6+
Message from API: {data.myMessage}
7+
</p>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const load = async ({ fetch }) => {
2+
const res = await fetch('/wrap-server-route/api');
3+
const myMessage = await res.json();
4+
return {
5+
myMessage: myMessage.myMessage,
6+
};
7+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { wrapServerRouteWithSentry } from '@sentry/sveltekit';
2+
import { error } from '@sveltejs/kit';
3+
4+
export const GET = wrapServerRouteWithSentry(async () => {
5+
error(500, 'error() error');
6+
});

dev-packages/e2e-tests/test-applications/sveltekit-2/tests/errors.server.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,37 @@ test.describe('server-side errors', () => {
5858

5959
expect(errorEvent.transaction).toEqual('GET /server-route-error');
6060
});
61+
62+
test('captures error() thrown in server route with `wrapServerRouteWithSentry`', async ({ page }) => {
63+
const errorEventPromise = waitForError('sveltekit-2', errorEvent => {
64+
return errorEvent?.exception?.values?.[0]?.value === "'HttpError' captured as exception with keys: body, status";
65+
});
66+
67+
await page.goto('/wrap-server-route');
68+
69+
expect(await errorEventPromise).toMatchObject({
70+
exception: {
71+
values: [
72+
{
73+
value: "'HttpError' captured as exception with keys: body, status",
74+
mechanism: {
75+
handled: false,
76+
data: {
77+
function: 'serverRoute',
78+
},
79+
},
80+
stacktrace: { frames: expect.any(Array) },
81+
},
82+
],
83+
},
84+
extra: {
85+
__serialized__: {
86+
body: {
87+
message: 'error() error',
88+
},
89+
status: 500,
90+
},
91+
},
92+
});
93+
});
6194
});

packages/sveltekit/src/server/handle.ts

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,15 @@ import {
1111
withIsolationScope,
1212
} from '@sentry/core';
1313
import { startSpan } from '@sentry/core';
14-
import { captureException, continueTrace } from '@sentry/node';
14+
import { continueTrace } from '@sentry/node';
1515
import type { Span } from '@sentry/types';
16-
import {
17-
dynamicSamplingContextToSentryBaggageHeader,
18-
logger,
19-
objectify,
20-
winterCGRequestToRequestData,
21-
} from '@sentry/utils';
16+
import { dynamicSamplingContextToSentryBaggageHeader, logger, winterCGRequestToRequestData } from '@sentry/utils';
2217
import type { Handle, ResolveOptions } from '@sveltejs/kit';
2318

2419
import { getDynamicSamplingContextFromSpan } from '@sentry/opentelemetry';
2520

2621
import { DEBUG_BUILD } from '../common/debug-build';
27-
import { isHttpError, isRedirect } from '../common/utils';
28-
import { flushIfServerless, getTracePropagationData } from './utils';
22+
import { flushIfServerless, getTracePropagationData, sendErrorToSentry } from './utils';
2923

3024
export type SentryHandleOptions = {
3125
/**
@@ -62,32 +56,6 @@ export type SentryHandleOptions = {
6256
fetchProxyScriptNonce?: string;
6357
};
6458

65-
function sendErrorToSentry(e: unknown): unknown {
66-
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
67-
// store a seen flag on it.
68-
const objectifiedErr = objectify(e);
69-
70-
// similarly to the `load` function, we don't want to capture 4xx errors or redirects
71-
if (
72-
isRedirect(objectifiedErr) ||
73-
(isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400)
74-
) {
75-
return objectifiedErr;
76-
}
77-
78-
captureException(objectifiedErr, {
79-
mechanism: {
80-
type: 'sveltekit',
81-
handled: false,
82-
data: {
83-
function: 'handle',
84-
},
85-
},
86-
});
87-
88-
return objectifiedErr;
89-
}
90-
9159
/**
9260
* Exported only for testing
9361
*/
@@ -225,7 +193,7 @@ async function instrumentHandle(
225193
);
226194
return resolveResult;
227195
} catch (e: unknown) {
228-
sendErrorToSentry(e);
196+
sendErrorToSentry(e, 'handle');
229197
throw e;
230198
} finally {
231199
await flushIfServerless();

packages/sveltekit/src/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export { init } from './sdk';
129129
export { handleErrorWithSentry } from './handleError';
130130
export { wrapLoadWithSentry, wrapServerLoadWithSentry } from './load';
131131
export { sentryHandle } from './handle';
132+
export { wrapServerRouteWithSentry } from './serverRoute';
132133

133134
/**
134135
* Tracks the Svelte component's initialization and mounting operation as well as

packages/sveltekit/src/server/load.ts

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,13 @@
1-
import {
2-
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
3-
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
4-
captureException,
5-
startSpan,
6-
} from '@sentry/node';
7-
import { addNonEnumerableProperty, objectify } from '@sentry/utils';
1+
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan } from '@sentry/node';
2+
import { addNonEnumerableProperty } from '@sentry/utils';
83
import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit';
94

105
import type { SentryWrappedFlag } from '../common/utils';
11-
import { isHttpError, isRedirect } from '../common/utils';
12-
import { flushIfServerless } from './utils';
6+
import { flushIfServerless, sendErrorToSentry } from './utils';
137

148
type PatchedLoadEvent = LoadEvent & SentryWrappedFlag;
159
type PatchedServerLoadEvent = ServerLoadEvent & SentryWrappedFlag;
1610

17-
function sendErrorToSentry(e: unknown): unknown {
18-
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
19-
// store a seen flag on it.
20-
const objectifiedErr = objectify(e);
21-
22-
// The error() helper is commonly used to throw errors in load functions: https://kit.svelte.dev/docs/modules#sveltejs-kit-error
23-
// If we detect a thrown error that is an instance of HttpError, we don't want to capture 4xx errors as they
24-
// could be noisy.
25-
// Also the `redirect(...)` helper is used to redirect users from one page to another. We don't want to capture thrown
26-
// `Redirect`s as they're not errors but expected behaviour
27-
if (
28-
isRedirect(objectifiedErr) ||
29-
(isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400)
30-
) {
31-
return objectifiedErr;
32-
}
33-
34-
captureException(objectifiedErr, {
35-
mechanism: {
36-
type: 'sveltekit',
37-
handled: false,
38-
data: {
39-
function: 'load',
40-
},
41-
},
42-
});
43-
44-
return objectifiedErr;
45-
}
46-
4711
/**
4812
* @inheritdoc
4913
*/
@@ -81,7 +45,7 @@ export function wrapLoadWithSentry<T extends (...args: any) => any>(origLoad: T)
8145
() => wrappingTarget.apply(thisArg, args),
8246
);
8347
} catch (e) {
84-
sendErrorToSentry(e);
48+
sendErrorToSentry(e, 'load');
8549
throw e;
8650
} finally {
8751
await flushIfServerless();
@@ -146,7 +110,7 @@ export function wrapServerLoadWithSentry<T extends (...args: any) => any>(origSe
146110
() => wrappingTarget.apply(thisArg, args),
147111
);
148112
} catch (e: unknown) {
149-
sendErrorToSentry(e);
113+
sendErrorToSentry(e, 'load');
150114
throw e;
151115
} finally {
152116
await flushIfServerless();
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan } from '@sentry/node';
2+
import { addNonEnumerableProperty } from '@sentry/utils';
3+
import type { RequestEvent } from '@sveltejs/kit';
4+
import { flushIfServerless, sendErrorToSentry } from './utils';
5+
6+
type PatchedServerRouteEvent = RequestEvent & { __sentry_wrapped__?: boolean };
7+
8+
/**
9+
* Wraps a server route handler for API or server routes registered in `+server.(js|js)` files.
10+
*
11+
* This function will automatically capture any errors that occur during the execution of the route handler
12+
* and it will start a span for the duration of your route handler.
13+
*
14+
* @example
15+
* ```js
16+
* import { wrapServerRouteWithSentry } from '@sentry/sveltekit';
17+
*
18+
* const get = async event => {
19+
* return new Response(JSON.stringify({ message: 'hello world' }));
20+
* }
21+
*
22+
* export const GET = wrapServerRouteWithSentry(get);
23+
* ```
24+
*
25+
* @param originalRouteHandler your server route handler
26+
* @param httpMethod the HTTP method of your route handler
27+
*
28+
* @returns a wrapped version of your server route handler
29+
*/
30+
export function wrapServerRouteWithSentry(
31+
originalRouteHandler: (request: RequestEvent) => Promise<Response>,
32+
): (requestEvent: RequestEvent) => Promise<Response> {
33+
return new Proxy(originalRouteHandler, {
34+
apply: async (wrappingTarget, thisArg, args) => {
35+
const event = args[0] as PatchedServerRouteEvent;
36+
37+
if (event.__sentry_wrapped__) {
38+
return wrappingTarget.apply(thisArg, args);
39+
}
40+
41+
const routeId = event.route && event.route.id;
42+
const httpMethod = event.request.method;
43+
44+
addNonEnumerableProperty(event as unknown as Record<string, unknown>, '__sentry_wrapped__', true);
45+
46+
try {
47+
return await startSpan(
48+
{
49+
name: `${httpMethod} ${routeId || 'Server Route'}`,
50+
op: `function.sveltekit.server.${httpMethod.toLowerCase()}`,
51+
attributes: {
52+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit',
53+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
54+
},
55+
onlyIfParent: true,
56+
},
57+
() => wrappingTarget.apply(thisArg, args),
58+
);
59+
} catch (e) {
60+
sendErrorToSentry(e, 'serverRoute');
61+
throw e;
62+
} finally {
63+
await flushIfServerless();
64+
}
65+
},
66+
});
67+
}

packages/sveltekit/src/server/utils.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { flush } from '@sentry/node';
2-
import { logger } from '@sentry/utils';
1+
import { captureException, flush } from '@sentry/node';
2+
import { logger, objectify } from '@sentry/utils';
33
import type { RequestEvent } from '@sveltejs/kit';
44

55
import { DEBUG_BUILD } from '../common/debug-build';
6+
import { isHttpError, isRedirect } from '../common/utils';
67

78
/**
89
* Takes a request event and extracts traceparent and DSC data
@@ -31,3 +32,41 @@ export async function flushIfServerless(): Promise<void> {
3132
}
3233
}
3334
}
35+
36+
/**
37+
* Extracts a server-side sveltekit error, filters a couple of known errors we don't want to capture
38+
* and captures the error via `captureException`.
39+
*
40+
* @param e error
41+
*
42+
* @returns an objectified version of @param e
43+
*/
44+
export function sendErrorToSentry(e: unknown, handlerFn: 'handle' | 'load' | 'serverRoute'): object {
45+
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
46+
// store a seen flag on it.
47+
const objectifiedErr = objectify(e);
48+
49+
// The error() helper is commonly used to throw errors in load functions: https://kit.svelte.dev/docs/modules#sveltejs-kit-error
50+
// If we detect a thrown error that is an instance of HttpError, we don't want to capture 4xx errors as they
51+
// could be noisy.
52+
// Also the `redirect(...)` helper is used to redirect users from one page to another. We don't want to capture thrown
53+
// `Redirect`s as they're not errors but expected behaviour
54+
if (
55+
isRedirect(objectifiedErr) ||
56+
(isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400)
57+
) {
58+
return objectifiedErr;
59+
}
60+
61+
captureException(objectifiedErr, {
62+
mechanism: {
63+
type: 'sveltekit',
64+
handled: false,
65+
data: {
66+
function: handlerFn,
67+
},
68+
},
69+
});
70+
71+
return objectifiedErr;
72+
}

0 commit comments

Comments
 (0)