diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte
index e7788b6433cd..0cfae1c54741 100644
--- a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/+page.svelte
@@ -38,4 +38,7 @@
Component Tracking
+
+ server routes
+
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.svelte
new file mode 100644
index 000000000000..adc04d52c0ea
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.svelte
@@ -0,0 +1,7 @@
+
+
+
+ Message from API: {data.myMessage}
+
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.ts
new file mode 100644
index 000000000000..3f3f5942366e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/+page.ts
@@ -0,0 +1,7 @@
+export const load = async ({ fetch }) => {
+ const res = await fetch('/wrap-server-route/api');
+ const myMessage = await res.json();
+ return {
+ myMessage: myMessage.myMessage,
+ };
+};
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/api/+server.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/api/+server.ts
new file mode 100644
index 000000000000..6ba210690ad5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/src/routes/wrap-server-route/api/+server.ts
@@ -0,0 +1,6 @@
+import { wrapServerRouteWithSentry } from '@sentry/sveltekit';
+import { error } from '@sveltejs/kit';
+
+export const GET = wrapServerRouteWithSentry(async () => {
+ error(500, 'error() error');
+});
diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/errors.server.test.ts
index c9dc56b9c96b..fd2e58e9c2a3 100644
--- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/errors.server.test.ts
+++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/errors.server.test.ts
@@ -58,4 +58,37 @@ test.describe('server-side errors', () => {
expect(errorEvent.transaction).toEqual('GET /server-route-error');
});
+
+ test('captures error() thrown in server route with `wrapServerRouteWithSentry`', async ({ page }) => {
+ const errorEventPromise = waitForError('sveltekit-2', errorEvent => {
+ return errorEvent?.exception?.values?.[0]?.value === "'HttpError' captured as exception with keys: body, status";
+ });
+
+ await page.goto('/wrap-server-route');
+
+ expect(await errorEventPromise).toMatchObject({
+ exception: {
+ values: [
+ {
+ value: "'HttpError' captured as exception with keys: body, status",
+ mechanism: {
+ handled: false,
+ data: {
+ function: 'serverRoute',
+ },
+ },
+ stacktrace: { frames: expect.any(Array) },
+ },
+ ],
+ },
+ extra: {
+ __serialized__: {
+ body: {
+ message: 'error() error',
+ },
+ status: 500,
+ },
+ },
+ });
+ });
});
diff --git a/packages/sveltekit/src/server/handle.ts b/packages/sveltekit/src/server/handle.ts
index 56ddc23e1885..7f4b8d980ad8 100644
--- a/packages/sveltekit/src/server/handle.ts
+++ b/packages/sveltekit/src/server/handle.ts
@@ -11,21 +11,15 @@ import {
withIsolationScope,
} from '@sentry/core';
import { startSpan } from '@sentry/core';
-import { captureException, continueTrace } from '@sentry/node';
+import { continueTrace } from '@sentry/node';
import type { Span } from '@sentry/types';
-import {
- dynamicSamplingContextToSentryBaggageHeader,
- logger,
- objectify,
- winterCGRequestToRequestData,
-} from '@sentry/utils';
+import { dynamicSamplingContextToSentryBaggageHeader, logger, winterCGRequestToRequestData } from '@sentry/utils';
import type { Handle, ResolveOptions } from '@sveltejs/kit';
import { getDynamicSamplingContextFromSpan } from '@sentry/opentelemetry';
import { DEBUG_BUILD } from '../common/debug-build';
-import { isHttpError, isRedirect } from '../common/utils';
-import { flushIfServerless, getTracePropagationData } from './utils';
+import { flushIfServerless, getTracePropagationData, sendErrorToSentry } from './utils';
export type SentryHandleOptions = {
/**
@@ -62,32 +56,6 @@ export type SentryHandleOptions = {
fetchProxyScriptNonce?: string;
};
-function sendErrorToSentry(e: unknown): unknown {
- // In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
- // store a seen flag on it.
- const objectifiedErr = objectify(e);
-
- // similarly to the `load` function, we don't want to capture 4xx errors or redirects
- if (
- isRedirect(objectifiedErr) ||
- (isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400)
- ) {
- return objectifiedErr;
- }
-
- captureException(objectifiedErr, {
- mechanism: {
- type: 'sveltekit',
- handled: false,
- data: {
- function: 'handle',
- },
- },
- });
-
- return objectifiedErr;
-}
-
/**
* Exported only for testing
*/
@@ -225,7 +193,7 @@ async function instrumentHandle(
);
return resolveResult;
} catch (e: unknown) {
- sendErrorToSentry(e);
+ sendErrorToSentry(e, 'handle');
throw e;
} finally {
await flushIfServerless();
diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts
index a74e5bb89dc0..478a16fd1deb 100644
--- a/packages/sveltekit/src/server/index.ts
+++ b/packages/sveltekit/src/server/index.ts
@@ -128,6 +128,7 @@ export { init } from './sdk';
export { handleErrorWithSentry } from './handleError';
export { wrapLoadWithSentry, wrapServerLoadWithSentry } from './load';
export { sentryHandle } from './handle';
+export { wrapServerRouteWithSentry } from './serverRoute';
/**
* Tracks the Svelte component's initialization and mounting operation as well as
diff --git a/packages/sveltekit/src/server/load.ts b/packages/sveltekit/src/server/load.ts
index fe61ed0913bd..82a8c548c6ec 100644
--- a/packages/sveltekit/src/server/load.ts
+++ b/packages/sveltekit/src/server/load.ts
@@ -1,49 +1,13 @@
-import {
- SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
- SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
- captureException,
- startSpan,
-} from '@sentry/node';
-import { addNonEnumerableProperty, objectify } from '@sentry/utils';
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan } from '@sentry/node';
+import { addNonEnumerableProperty } from '@sentry/utils';
import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit';
import type { SentryWrappedFlag } from '../common/utils';
-import { isHttpError, isRedirect } from '../common/utils';
-import { flushIfServerless } from './utils';
+import { flushIfServerless, sendErrorToSentry } from './utils';
type PatchedLoadEvent = LoadEvent & SentryWrappedFlag;
type PatchedServerLoadEvent = ServerLoadEvent & SentryWrappedFlag;
-function sendErrorToSentry(e: unknown): unknown {
- // In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
- // store a seen flag on it.
- const objectifiedErr = objectify(e);
-
- // The error() helper is commonly used to throw errors in load functions: https://kit.svelte.dev/docs/modules#sveltejs-kit-error
- // If we detect a thrown error that is an instance of HttpError, we don't want to capture 4xx errors as they
- // could be noisy.
- // Also the `redirect(...)` helper is used to redirect users from one page to another. We don't want to capture thrown
- // `Redirect`s as they're not errors but expected behaviour
- if (
- isRedirect(objectifiedErr) ||
- (isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400)
- ) {
- return objectifiedErr;
- }
-
- captureException(objectifiedErr, {
- mechanism: {
- type: 'sveltekit',
- handled: false,
- data: {
- function: 'load',
- },
- },
- });
-
- return objectifiedErr;
-}
-
/**
* @inheritdoc
*/
@@ -81,7 +45,7 @@ export function wrapLoadWithSentry any>(origLoad: T)
() => wrappingTarget.apply(thisArg, args),
);
} catch (e) {
- sendErrorToSentry(e);
+ sendErrorToSentry(e, 'load');
throw e;
} finally {
await flushIfServerless();
@@ -146,7 +110,7 @@ export function wrapServerLoadWithSentry any>(origSe
() => wrappingTarget.apply(thisArg, args),
);
} catch (e: unknown) {
- sendErrorToSentry(e);
+ sendErrorToSentry(e, 'load');
throw e;
} finally {
await flushIfServerless();
diff --git a/packages/sveltekit/src/server/serverRoute.ts b/packages/sveltekit/src/server/serverRoute.ts
new file mode 100644
index 000000000000..a5f13f9a73ca
--- /dev/null
+++ b/packages/sveltekit/src/server/serverRoute.ts
@@ -0,0 +1,67 @@
+import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startSpan } from '@sentry/node';
+import { addNonEnumerableProperty } from '@sentry/utils';
+import type { RequestEvent } from '@sveltejs/kit';
+import { flushIfServerless, sendErrorToSentry } from './utils';
+
+type PatchedServerRouteEvent = RequestEvent & { __sentry_wrapped__?: boolean };
+
+/**
+ * Wraps a server route handler for API or server routes registered in `+server.(js|js)` files.
+ *
+ * This function will automatically capture any errors that occur during the execution of the route handler
+ * and it will start a span for the duration of your route handler.
+ *
+ * @example
+ * ```js
+ * import { wrapServerRouteWithSentry } from '@sentry/sveltekit';
+ *
+ * const get = async event => {
+ * return new Response(JSON.stringify({ message: 'hello world' }));
+ * }
+ *
+ * export const GET = wrapServerRouteWithSentry(get);
+ * ```
+ *
+ * @param originalRouteHandler your server route handler
+ * @param httpMethod the HTTP method of your route handler
+ *
+ * @returns a wrapped version of your server route handler
+ */
+export function wrapServerRouteWithSentry(
+ originalRouteHandler: (request: RequestEvent) => Promise,
+): (requestEvent: RequestEvent) => Promise {
+ return new Proxy(originalRouteHandler, {
+ apply: async (wrappingTarget, thisArg, args) => {
+ const event = args[0] as PatchedServerRouteEvent;
+
+ if (event.__sentry_wrapped__) {
+ return wrappingTarget.apply(thisArg, args);
+ }
+
+ const routeId = event.route && event.route.id;
+ const httpMethod = event.request.method;
+
+ addNonEnumerableProperty(event as unknown as Record, '__sentry_wrapped__', true);
+
+ try {
+ return await startSpan(
+ {
+ name: `${httpMethod} ${routeId || 'Server Route'}`,
+ op: `function.sveltekit.server.${httpMethod.toLowerCase()}`,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ },
+ onlyIfParent: true,
+ },
+ () => wrappingTarget.apply(thisArg, args),
+ );
+ } catch (e) {
+ sendErrorToSentry(e, 'serverRoute');
+ throw e;
+ } finally {
+ await flushIfServerless();
+ }
+ },
+ });
+}
diff --git a/packages/sveltekit/src/server/utils.ts b/packages/sveltekit/src/server/utils.ts
index 0db8ee893783..8d7f2c649331 100644
--- a/packages/sveltekit/src/server/utils.ts
+++ b/packages/sveltekit/src/server/utils.ts
@@ -1,8 +1,9 @@
-import { flush } from '@sentry/node';
-import { logger } from '@sentry/utils';
+import { captureException, flush } from '@sentry/node';
+import { logger, objectify } from '@sentry/utils';
import type { RequestEvent } from '@sveltejs/kit';
import { DEBUG_BUILD } from '../common/debug-build';
+import { isHttpError, isRedirect } from '../common/utils';
/**
* Takes a request event and extracts traceparent and DSC data
@@ -31,3 +32,41 @@ export async function flushIfServerless(): Promise {
}
}
}
+
+/**
+ * Extracts a server-side sveltekit error, filters a couple of known errors we don't want to capture
+ * and captures the error via `captureException`.
+ *
+ * @param e error
+ *
+ * @returns an objectified version of @param e
+ */
+export function sendErrorToSentry(e: unknown, handlerFn: 'handle' | 'load' | 'serverRoute'): object {
+ // In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
+ // store a seen flag on it.
+ const objectifiedErr = objectify(e);
+
+ // The error() helper is commonly used to throw errors in load functions: https://kit.svelte.dev/docs/modules#sveltejs-kit-error
+ // If we detect a thrown error that is an instance of HttpError, we don't want to capture 4xx errors as they
+ // could be noisy.
+ // Also the `redirect(...)` helper is used to redirect users from one page to another. We don't want to capture thrown
+ // `Redirect`s as they're not errors but expected behaviour
+ if (
+ isRedirect(objectifiedErr) ||
+ (isHttpError(objectifiedErr) && objectifiedErr.status < 500 && objectifiedErr.status >= 400)
+ ) {
+ return objectifiedErr;
+ }
+
+ captureException(objectifiedErr, {
+ mechanism: {
+ type: 'sveltekit',
+ handled: false,
+ data: {
+ function: handlerFn,
+ },
+ },
+ });
+
+ return objectifiedErr;
+}
diff --git a/packages/sveltekit/test/server/serverRoute.test.ts b/packages/sveltekit/test/server/serverRoute.test.ts
new file mode 100644
index 000000000000..de99db5a548e
--- /dev/null
+++ b/packages/sveltekit/test/server/serverRoute.test.ts
@@ -0,0 +1,133 @@
+import * as SentryNode from '@sentry/node';
+import type { NumericRange } from '@sveltejs/kit';
+import { type RequestEvent, error, redirect } from '@sveltejs/kit';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ wrapServerRouteWithSentry,
+} from '../../src/server';
+
+describe('wrapServerRouteWithSentry', () => {
+ const originalRouteHandler = vi.fn();
+
+ const getRequestEventMock = () =>
+ ({
+ request: {
+ method: 'GET',
+ },
+ route: {
+ id: '/api/users/:id',
+ },
+ }) as RequestEvent;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('wraps a server route span around the original server route handler', () => {
+ const startSpanSpy = vi.spyOn(SentryNode, 'startSpan');
+
+ it('assigns the route id as name if available', () => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(originalRouteHandler);
+
+ wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ },
+ name: 'GET /api/users/:id',
+ onlyIfParent: true,
+ op: 'function.sveltekit.server.get',
+ },
+ expect.any(Function),
+ );
+
+ expect(originalRouteHandler).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to a generic name if route id is not available', () => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(originalRouteHandler);
+
+ wrappedRouteHandler({ ...getRequestEventMock(), route: undefined } as unknown as RequestEvent);
+
+ expect(startSpanSpy).toHaveBeenCalledWith(
+ {
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
+ },
+ name: 'GET Server Route',
+ onlyIfParent: true,
+ op: 'function.sveltekit.server.get',
+ },
+ expect.any(Function),
+ );
+
+ expect(originalRouteHandler).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ const captureExceptionSpy = vi.spyOn(SentryNode, 'captureException');
+ describe('captures server route errors', () => {
+ it('captures and rethrows normal server route error', async () => {
+ const error = new Error('Server Route Error');
+
+ const wrappedRouteHandler = wrapServerRouteWithSentry(() => {
+ throw error;
+ });
+
+ await expect(async () => {
+ await wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+ }).rejects.toThrowError('Server Route Error');
+
+ expect(captureExceptionSpy).toHaveBeenCalledWith(error, {
+ mechanism: { type: 'sveltekit', handled: false, data: { function: 'serverRoute' } },
+ });
+ });
+
+ it.each([500, 501, 599])('captures and rethrows %s error() calls', async status => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(() => {
+ error(status as NumericRange<400, 599>, `error(${status}) error`);
+ });
+
+ await expect(async () => {
+ await wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+ }).rejects.toThrow();
+
+ expect(captureExceptionSpy).toHaveBeenCalledWith(
+ { body: { message: `error(${status}) error` }, status },
+ {
+ mechanism: { type: 'sveltekit', handled: false, data: { function: 'serverRoute' } },
+ },
+ );
+ });
+
+ it.each([400, 401, 499])("doesn't capture but rethrows %s error() calls", async status => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(() => {
+ error(status as NumericRange<400, 599>, `error(${status}) error`);
+ });
+
+ await expect(async () => {
+ await wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+ }).rejects.toThrow();
+
+ expect(captureExceptionSpy).not.toHaveBeenCalled();
+ });
+
+ it.each([300, 301, 308])("doesn't capture redirect(%s) calls", async status => {
+ const wrappedRouteHandler = wrapServerRouteWithSentry(() => {
+ redirect(status as NumericRange<300, 308>, '/redirect');
+ });
+
+ await expect(async () => {
+ await wrappedRouteHandler(getRequestEventMock() as RequestEvent);
+ }).rejects.toThrow();
+
+ expect(captureExceptionSpy).not.toHaveBeenCalled();
+ });
+ });
+});