Skip to content

Commit 31da830

Browse files
chargomeclaude
andauthored
fix(sveltekit): Handle SvelteKit 3 error kinds in handleErrorWithSentry (#23651)
SvelteKit 3 changed the `handleError` input to `{ kind, error, event }` - `status` moved onto the error, and expected errors thrown with `error(...)` now reach the hook too. Reading the old `status` meant capturing framework 404s and every intentional `error(400, …)` on Kit 3, plus a SvelteKit deprecation warning per error in dev. The wrapper now branches on `kind` and applies the SDK's usual rule: capture unexpected errors always, app and framework errors only at 5xx, skip validation errors. Hook types are declared structurally because Kit 3 moved them to `@sveltejs/kit/hooks`, which doesn't resolve on Kit 2. SvelteKit 1.x/2.x behaviour is unchanged. Fixes #23650 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3016b1f commit 31da830

11 files changed

Lines changed: 548 additions & 56 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { error } from '@sveltejs/kit';
2+
3+
export const load = async () => {
4+
// SvelteKit 3 passes expected errors to `handleError` as `kind: 'app'`.
5+
// 4xx are expected, so the SDK must not capture them.
6+
error(404, 'Expected 404 Error');
7+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h1>Expected 4xx error</h1>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { error } from '@sveltejs/kit';
2+
3+
export const load = async () => {
4+
// SvelteKit 3 passes expected errors to `handleError` as `kind: 'app'`.
5+
// 5xx are worth reporting, so the SDK captures them.
6+
error(500, 'Expected 500 Error');
7+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h1>Expected 5xx error</h1>

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,48 @@ test.describe('server-side errors', () => {
8888
});
8989
});
9090
});
91+
92+
test.describe('expected errors thrown with `error()`', () => {
93+
// SvelteKit 3 passes *every* error to `handleError`, discriminated by `kind` — including
94+
// expected ones thrown with `error()`, which never reached the hook on SvelteKit 2.
95+
// The SDK applies the same rule as everywhere else: 4xx are expected, 5xx are reported.
96+
//
97+
// These match on the request URL rather than the exception value: SvelteKit hands `handleError`
98+
// the error *body* (a plain object), so the captured exception gets a synthesized message
99+
// ("Object captured as exception with keys: ...") rather than the message passed to `error()`.
100+
test("doesn't capture a 4xx error", async ({ page }) => {
101+
let captured4xxError = false;
102+
// Deliberately floating: this must never resolve, so it can't be awaited
103+
void waitForError('sveltekit-3', errorEvent => {
104+
return !!errorEvent?.request?.url?.endsWith('/expected-error-4xx');
105+
}).then(() => {
106+
captured4xxError = true;
107+
});
108+
109+
// The 5xx route *is* captured, so its error event is a concrete signal that the preceding
110+
// 4xx request was fully processed - no sleeping on a timeout to prove a negative.
111+
const signalErrorPromise = waitForError('sveltekit-3', errorEvent => {
112+
return !!errorEvent?.request?.url?.endsWith('/expected-error-5xx');
113+
});
114+
115+
await page.goto('/expected-error-4xx');
116+
await page.goto('/expected-error-5xx');
117+
await signalErrorPromise;
118+
119+
expect(captured4xxError).toBe(false);
120+
});
121+
122+
test('captures a 5xx error', async ({ page }) => {
123+
const errorEventPromise = waitForError('sveltekit-3', errorEvent => {
124+
return !!errorEvent?.request?.url?.endsWith('/expected-error-5xx');
125+
});
126+
127+
await page.goto('/expected-error-5xx');
128+
129+
const errorEvent = await errorEventPromise;
130+
131+
expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
132+
expect.objectContaining({ type: 'auto.function.sveltekit.handle_error' }),
133+
);
134+
});
135+
});
Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,41 @@
1-
import { isObjectLike, consoleSandbox } from '@sentry/core';
1+
import { consoleSandbox } from '@sentry/core';
22
import { captureException } from '@sentry/svelte';
3-
import type { HandleClientError } from '@sveltejs/kit';
3+
import type { AnyErrorHandler, SentryHandleClientErrorInput } from '../common/handleErrorTypes';
4+
import { getErrorStatus, shouldCaptureError } from '../common/handleErrorTypes';
5+
6+
type ClientErrorHandler = (input: SentryHandleClientErrorInput) => unknown;
7+
8+
/**
9+
* The default shape of the wrapped hook: structurally compatible with SvelteKit's
10+
* `HandleClientError` on every supported major.
11+
*/
12+
type SentryHandleClientError = (input: SentryHandleClientErrorInput) => void | App.Error;
13+
14+
// Mirrors SvelteKit's own default client error handler, which differs by major version:
15+
// - SvelteKit 1.x/2.x log every error
16+
// - SvelteKit 3 only logs unexpected errors
17+
// see: https://github.com/sveltejs/kit/blob/49f0808f3e983d0cb5a4d586cf0d1678467431ed/packages/kit/src/core/sync/write_client_manifest.js#L157-L160
18+
function defaultErrorHandler({ kind, error }: SentryHandleClientErrorInput): void {
19+
if (kind && kind !== 'unknown') {
20+
return;
21+
}
422

5-
// The SvelteKit default error handler just logs the error to the console
6-
// see: https://github.com/sveltejs/kit/blob/369e7d6851f543a40c947e033bfc4a9506fdc0a8/packages/kit/src/core/sync/write_client_manifest.js#LL127C2-L127C2
7-
function defaultErrorHandler({ error }: Parameters<HandleClientError>[0]): ReturnType<HandleClientError> {
823
consoleSandbox(() => {
924
// eslint-disable-next-line no-console
1025
console.error(error);
1126
});
1227
}
1328

14-
type HandleClientErrorInput = Parameters<HandleClientError>[0];
15-
16-
/**
17-
* Backwards-compatible HandleServerError Input type for SvelteKit 1.x and 2.x
18-
* `message` and `status` were added in 2.x.
19-
* For backwards-compatibility, we make them optional
20-
*
21-
* @see https://kit.svelte.dev/docs/migrating-to-sveltekit-2#improved-error-handling
22-
*/
23-
type SafeHandleServerErrorInput = Omit<HandleClientErrorInput, 'status' | 'message'> &
24-
Partial<Pick<HandleClientErrorInput, 'status' | 'message'>>;
25-
2629
/**
2730
* Wrapper for the SvelteKit error handler that sends the error to Sentry.
2831
*
2932
* @param handleError The original SvelteKit error handler.
3033
*/
31-
export function handleErrorWithSentry(handleError?: HandleClientError): HandleClientError {
32-
const errorHandler = handleError ?? defaultErrorHandler;
34+
export function handleErrorWithSentry<T extends AnyErrorHandler = SentryHandleClientError>(handleError?: T): T {
35+
const errorHandler = (handleError ?? defaultErrorHandler) as ClientErrorHandler;
3336

34-
return (input: HandleClientErrorInput): ReturnType<HandleClientError> => {
35-
if (is4xxError(input)) {
37+
const sentryErrorHandler = (input: SentryHandleClientErrorInput): unknown => {
38+
if (!shouldCaptureError(input, () => isExpectedLegacyError(input))) {
3639
return errorHandler(input);
3740
}
3841

@@ -45,10 +48,25 @@ export function handleErrorWithSentry(handleError?: HandleClientError): HandleCl
4548

4649
return errorHandler(input);
4750
};
51+
52+
// Returning `T` (the caller's own hook type) is what keeps the result assignable to
53+
// `HandleClientError` on both SvelteKit 2 and 3. The wrapper itself is written against our
54+
// structural input type, which TS can't prove is identical to `T`, so it can't be narrowed
55+
// without the double cast.
56+
return sentryErrorHandler as unknown as T;
4857
}
4958

50-
// 4xx are expected errors and thus we don't want to capture them
51-
function is4xxError(input: SafeHandleServerErrorInput): boolean {
59+
/**
60+
* Whether a SvelteKit 1.x/2.x error is an expected 4xx we don't want to capture.
61+
*
62+
* SvelteKit 3 errors are classified by `shouldCaptureError` instead.
63+
*/
64+
function isExpectedLegacyError(input: SentryHandleClientErrorInput): boolean {
65+
if (input.kind) {
66+
// Not a SvelteKit 1.x/2.x input - narrows the union so `status` below is readable
67+
return false;
68+
}
69+
5270
const { status } = input;
5371

5472
if (status && status >= 400 && status < 500) {
@@ -58,7 +76,7 @@ function is4xxError(input: SafeHandleServerErrorInput): boolean {
5876
// SvelteKit __data.json requests return HTTP 200 with errors embedded in JSON,
5977
// so get_status() may resolve to 500 for a deserialized plain error object.
6078
// Fall back to checking input.error.status directly.
61-
const errorStatus = isObjectLike(input.error) ? (input.error as Record<string, unknown>)['status'] : undefined;
79+
const errorStatus = getErrorStatus(input.error);
6280

63-
return typeof errorStatus === 'number' && errorStatus >= 400 && errorStatus < 500;
81+
return errorStatus !== undefined && errorStatus >= 400 && errorStatus < 500;
6482
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Where an error passed to `handleError` came from. Added in SvelteKit 3; `undefined` on
3+
* SvelteKit 1.x and 2.x.
4+
*
5+
* - `app`: thrown with the `error(...)` helper
6+
* - `framework`: generated by SvelteKit itself (404s, 405s, 413s, ...)
7+
* - `validation`: invalid remote function arguments (server only)
8+
* - `unknown`: thrown by user code, or code it calls
9+
*
10+
* @see https://svelte.dev/docs/kit/hooks#handleError
11+
*/
12+
export type CaughtErrorKind = 'app' | 'framework' | 'validation' | 'unknown';
13+
14+
/**
15+
* The `handleError` input as of SvelteKit 3, where errors are discriminated by `kind` and the
16+
* status lives on the error instead of the input.
17+
*/
18+
export type CaughtErrorInput = {
19+
kind: CaughtErrorKind;
20+
error: unknown;
21+
/** Only present for `kind: 'validation'` */
22+
issues?: unknown[];
23+
};
24+
25+
/**
26+
* The `handleError` input on SvelteKit 1.x and 2.x, which had no `kind` and carried the status and
27+
* message on the input itself.
28+
*
29+
* SvelteKit 3 keeps both alive in dev builds as deprecated getters that log a warning when read.
30+
* Modelling the two shapes as a discriminated union is what stops us reading them on a SvelteKit 3
31+
* input: as far as the type system is concerned, `status` doesn't exist there.
32+
*/
33+
export type LegacyCaughtErrorInput = {
34+
kind?: undefined;
35+
error: unknown;
36+
status?: number;
37+
message?: string;
38+
};
39+
40+
/**
41+
* The input of a SvelteKit `handleError` hook, covering SvelteKit 1.x, 2.x and 3.
42+
*
43+
* We declare this structurally instead of importing SvelteKit's `HandleServerError`/
44+
* `HandleClientError`, because those types moved from `@sveltejs/kit` to `@sveltejs/kit/hooks`
45+
* in SvelteKit 3 and neither import path type-checks against both majors.
46+
*/
47+
export type SentryHandleErrorInput = CaughtErrorInput | LegacyCaughtErrorInput;
48+
49+
/** The `handleError` input on the server, where we also read from the request event. */
50+
export type SentryHandleServerErrorInput = SentryHandleErrorInput & {
51+
event: {
52+
route?: { id?: string | null };
53+
platform?: unknown;
54+
};
55+
};
56+
57+
/** The `handleError` input on the client. */
58+
export type SentryHandleClientErrorInput = SentryHandleErrorInput & {
59+
event: unknown;
60+
};
61+
62+
/**
63+
* Constrains the user-provided `handleError` hook without depending on SvelteKit's own types.
64+
* `never` as the parameter type accepts any single-argument function (parameters are
65+
* contravariant), so a SvelteKit 1.x, 2.x or 3 hook all satisfy it.
66+
*/
67+
export type AnyErrorHandler = (input: never) => unknown;
68+
69+
/**
70+
* Reads the HTTP status off an error. In SvelteKit 3, `app`, `framework` and `validation` errors
71+
* all carry their status here.
72+
*/
73+
export function getErrorStatus(error: unknown): number | undefined {
74+
if (error == null || typeof error !== 'object') {
75+
return undefined;
76+
}
77+
78+
const { status } = error as { status?: unknown };
79+
80+
return typeof status === 'number' ? status : undefined;
81+
}
82+
83+
/**
84+
* Whether an error passed to `handleError` should be sent to Sentry.
85+
*
86+
* @param isExpectedLegacyError checks whether a SvelteKit 1.x/2.x error is an expected one. Those
87+
* versions have no `kind`, and what counts as expected differs between server and client.
88+
*/
89+
export function shouldCaptureError(input: SentryHandleErrorInput, isExpectedLegacyError: () => boolean): boolean {
90+
if (input.kind) {
91+
return shouldCaptureCaughtError(input);
92+
}
93+
94+
return !isExpectedLegacyError();
95+
}
96+
97+
/**
98+
* The SvelteKit 3+ rule. Every error reaches `handleError` there — including expected ones thrown
99+
* with `error(...)` and framework errors like 404s, neither of which showed up here on SvelteKit 2.
100+
* We apply the same rule the rest of the SDK uses for thrown `HttpError`s (see `sendErrorToSentry`):
101+
* 4xx are expected and noisy, 5xx are worth reporting.
102+
*/
103+
function shouldCaptureCaughtError(input: CaughtErrorInput): boolean {
104+
// Invalid remote function arguments are a caller mistake, not an app failure. SvelteKit always
105+
// gives these a 400, but don't let that be the only reason we skip them.
106+
if (input.kind === 'validation') {
107+
return false;
108+
}
109+
110+
// Unexpected errors have no status of their own; SvelteKit reports them as 500s.
111+
if (input.kind === 'unknown') {
112+
return true;
113+
}
114+
115+
const status = getErrorStatus(input.error);
116+
117+
// If we can't tell, err on the side of capturing.
118+
return status === undefined || status >= 500;
119+
}

packages/sveltekit/src/index.types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
// Some of the exports collide, which is not allowed, unless we redefine the colliding
77
// exports in this file - which we do below.
88
import type { Client, Integration, Options, StackParser } from '@sentry/core';
9-
import type { HandleClientError, HandleServerError } from '@sveltejs/kit';
9+
import type { AnyErrorHandler } from './common/handleErrorTypes';
1010
import type * as clientSdk from './client';
1111
import type * as serverSdk from './server';
1212

@@ -22,7 +22,7 @@ export { initCloudflareSentryHandle } from './worker';
2222
/** Initializes Sentry SvelteKit SDK */
2323
export declare function init(options: Options | clientSdk.BrowserOptions | serverSdk.NodeOptions): Client | undefined;
2424

25-
export declare function handleErrorWithSentry<T extends HandleClientError | HandleServerError>(handleError?: T): T;
25+
export declare function handleErrorWithSentry<T extends AnyErrorHandler>(handleError?: T): T;
2626

2727
/**
2828
* Wrap a universal load function (e.g. +page.js or +layout.js) with Sentry functionality

0 commit comments

Comments
 (0)