Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { error } from '@sveltejs/kit';

export const load = async () => {
// SvelteKit 3 passes expected errors to `handleError` as `kind: 'app'`.
// 4xx are expected, so the SDK must not capture them.
error(404, 'Expected 404 Error');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Expected 4xx error</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { error } from '@sveltejs/kit';

export const load = async () => {
// SvelteKit 3 passes expected errors to `handleError` as `kind: 'app'`.
// 5xx are worth reporting, so the SDK captures them.
error(500, 'Expected 500 Error');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>Expected 5xx error</h1>
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,48 @@ test.describe('server-side errors', () => {
});
});
});

test.describe('expected errors thrown with `error()`', () => {
// SvelteKit 3 passes *every* error to `handleError`, discriminated by `kind` — including
// expected ones thrown with `error()`, which never reached the hook on SvelteKit 2.
// The SDK applies the same rule as everywhere else: 4xx are expected, 5xx are reported.
//
// These match on the request URL rather than the exception value: SvelteKit hands `handleError`
// the error *body* (a plain object), so the captured exception gets a synthesized message
// ("Object captured as exception with keys: ...") rather than the message passed to `error()`.
test("doesn't capture a 4xx error", async ({ page }) => {
let captured4xxError = false;
// Deliberately floating: this must never resolve, so it can't be awaited
void waitForError('sveltekit-3', errorEvent => {
return !!errorEvent?.request?.url?.endsWith('/expected-error-4xx');
}).then(() => {
captured4xxError = true;
});

// The 5xx route *is* captured, so its error event is a concrete signal that the preceding
// 4xx request was fully processed - no sleeping on a timeout to prove a negative.
const signalErrorPromise = waitForError('sveltekit-3', errorEvent => {
return !!errorEvent?.request?.url?.endsWith('/expected-error-5xx');
});

await page.goto('/expected-error-4xx');
await page.goto('/expected-error-5xx');
await signalErrorPromise;

expect(captured4xxError).toBe(false);
});
Comment thread
cursor[bot] marked this conversation as resolved.

test('captures a 5xx error', async ({ page }) => {
const errorEventPromise = waitForError('sveltekit-3', errorEvent => {
return !!errorEvent?.request?.url?.endsWith('/expected-error-5xx');
});

await page.goto('/expected-error-5xx');

const errorEvent = await errorEventPromise;

expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual(
expect.objectContaining({ type: 'auto.function.sveltekit.handle_error' }),
);
});
});
68 changes: 43 additions & 25 deletions packages/sveltekit/src/client/handleError.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,41 @@
import { isObjectLike, consoleSandbox } from '@sentry/core';
import { consoleSandbox } from '@sentry/core';
import { captureException } from '@sentry/svelte';
import type { HandleClientError } from '@sveltejs/kit';
import type { AnyErrorHandler, SentryHandleClientErrorInput } from '../common/handleErrorTypes';
import { getErrorStatus, shouldCaptureError } from '../common/handleErrorTypes';

type ClientErrorHandler = (input: SentryHandleClientErrorInput) => unknown;

/**
* The default shape of the wrapped hook: structurally compatible with SvelteKit's
* `HandleClientError` on every supported major.
*/
type SentryHandleClientError = (input: SentryHandleClientErrorInput) => void | App.Error;

// Mirrors SvelteKit's own default client error handler, which differs by major version:
// - SvelteKit 1.x/2.x log every error
// - SvelteKit 3 only logs unexpected errors
// see: https://github.com/sveltejs/kit/blob/49f0808f3e983d0cb5a4d586cf0d1678467431ed/packages/kit/src/core/sync/write_client_manifest.js#L157-L160
function defaultErrorHandler({ kind, error }: SentryHandleClientErrorInput): void {
if (kind && kind !== 'unknown') {
return;
}

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

type HandleClientErrorInput = Parameters<HandleClientError>[0];

/**
* Backwards-compatible HandleServerError Input type for SvelteKit 1.x and 2.x
* `message` and `status` were added in 2.x.
* For backwards-compatibility, we make them optional
*
* @see https://kit.svelte.dev/docs/migrating-to-sveltekit-2#improved-error-handling
*/
type SafeHandleServerErrorInput = Omit<HandleClientErrorInput, 'status' | 'message'> &
Partial<Pick<HandleClientErrorInput, 'status' | 'message'>>;

/**
* Wrapper for the SvelteKit error handler that sends the error to Sentry.
*
* @param handleError The original SvelteKit error handler.
*/
export function handleErrorWithSentry(handleError?: HandleClientError): HandleClientError {
const errorHandler = handleError ?? defaultErrorHandler;
export function handleErrorWithSentry<T extends AnyErrorHandler = SentryHandleClientError>(handleError?: T): T {
Comment thread
chargome marked this conversation as resolved.
const errorHandler = (handleError ?? defaultErrorHandler) as ClientErrorHandler;

return (input: HandleClientErrorInput): ReturnType<HandleClientError> => {
if (is4xxError(input)) {
const sentryErrorHandler = (input: SentryHandleClientErrorInput): unknown => {
if (!shouldCaptureError(input, () => isExpectedLegacyError(input))) {
return errorHandler(input);
}

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

return errorHandler(input);
};

// Returning `T` (the caller's own hook type) is what keeps the result assignable to
// `HandleClientError` on both SvelteKit 2 and 3. The wrapper itself is written against our
// structural input type, which TS can't prove is identical to `T`, so it can't be narrowed
// without the double cast.
return sentryErrorHandler as unknown as T;
Comment thread
cursor[bot] marked this conversation as resolved.
}

// 4xx are expected errors and thus we don't want to capture them
function is4xxError(input: SafeHandleServerErrorInput): boolean {
/**
* Whether a SvelteKit 1.x/2.x error is an expected 4xx we don't want to capture.
*
* SvelteKit 3 errors are classified by `shouldCaptureError` instead.
*/
function isExpectedLegacyError(input: SentryHandleClientErrorInput): boolean {
if (input.kind) {
// Not a SvelteKit 1.x/2.x input - narrows the union so `status` below is readable
return false;
}

const { status } = input;

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

return typeof errorStatus === 'number' && errorStatus >= 400 && errorStatus < 500;
return errorStatus !== undefined && errorStatus >= 400 && errorStatus < 500;
}
119 changes: 119 additions & 0 deletions packages/sveltekit/src/common/handleErrorTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Where an error passed to `handleError` came from. Added in SvelteKit 3; `undefined` on
* SvelteKit 1.x and 2.x.
*
* - `app`: thrown with the `error(...)` helper
* - `framework`: generated by SvelteKit itself (404s, 405s, 413s, ...)
* - `validation`: invalid remote function arguments (server only)
* - `unknown`: thrown by user code, or code it calls
*
* @see https://svelte.dev/docs/kit/hooks#handleError
*/
export type CaughtErrorKind = 'app' | 'framework' | 'validation' | 'unknown';

/**
* The `handleError` input as of SvelteKit 3, where errors are discriminated by `kind` and the
* status lives on the error instead of the input.
*/
export type CaughtErrorInput = {
kind: CaughtErrorKind;
error: unknown;
/** Only present for `kind: 'validation'` */
issues?: unknown[];
};

/**
* The `handleError` input on SvelteKit 1.x and 2.x, which had no `kind` and carried the status and
* message on the input itself.
*
* SvelteKit 3 keeps both alive in dev builds as deprecated getters that log a warning when read.
* Modelling the two shapes as a discriminated union is what stops us reading them on a SvelteKit 3
* input: as far as the type system is concerned, `status` doesn't exist there.
*/
export type LegacyCaughtErrorInput = {
kind?: undefined;
error: unknown;
status?: number;
message?: string;
};

/**
* The input of a SvelteKit `handleError` hook, covering SvelteKit 1.x, 2.x and 3.
*
* We declare this structurally instead of importing SvelteKit's `HandleServerError`/
* `HandleClientError`, because those types moved from `@sveltejs/kit` to `@sveltejs/kit/hooks`
* in SvelteKit 3 and neither import path type-checks against both majors.
*/
export type SentryHandleErrorInput = CaughtErrorInput | LegacyCaughtErrorInput;

/** The `handleError` input on the server, where we also read from the request event. */
export type SentryHandleServerErrorInput = SentryHandleErrorInput & {
event: {
route?: { id?: string | null };
platform?: unknown;
};
};

/** The `handleError` input on the client. */
export type SentryHandleClientErrorInput = SentryHandleErrorInput & {
event: unknown;
};

/**
* Constrains the user-provided `handleError` hook without depending on SvelteKit's own types.
* `never` as the parameter type accepts any single-argument function (parameters are
* contravariant), so a SvelteKit 1.x, 2.x or 3 hook all satisfy it.
*/
export type AnyErrorHandler = (input: never) => unknown;

/**
* Reads the HTTP status off an error. In SvelteKit 3, `app`, `framework` and `validation` errors
* all carry their status here.
*/
export function getErrorStatus(error: unknown): number | undefined {
if (error == null || typeof error !== 'object') {
return undefined;
}

const { status } = error as { status?: unknown };

return typeof status === 'number' ? status : undefined;
}

/**
* Whether an error passed to `handleError` should be sent to Sentry.
*
* @param isExpectedLegacyError checks whether a SvelteKit 1.x/2.x error is an expected one. Those
* versions have no `kind`, and what counts as expected differs between server and client.
*/
export function shouldCaptureError(input: SentryHandleErrorInput, isExpectedLegacyError: () => boolean): boolean {
if (input.kind) {
return shouldCaptureCaughtError(input);
}

return !isExpectedLegacyError();
}

/**
* The SvelteKit 3+ rule. Every error reaches `handleError` there — including expected ones thrown
* with `error(...)` and framework errors like 404s, neither of which showed up here on SvelteKit 2.
* We apply the same rule the rest of the SDK uses for thrown `HttpError`s (see `sendErrorToSentry`):
* 4xx are expected and noisy, 5xx are worth reporting.
*/
function shouldCaptureCaughtError(input: CaughtErrorInput): boolean {
// Invalid remote function arguments are a caller mistake, not an app failure. SvelteKit always
// gives these a 400, but don't let that be the only reason we skip them.
if (input.kind === 'validation') {
return false;
}

// Unexpected errors have no status of their own; SvelteKit reports them as 500s.
if (input.kind === 'unknown') {
return true;
}

const status = getErrorStatus(input.error);

// If we can't tell, err on the side of capturing.
return status === undefined || status >= 500;
}
Comment thread
sentry[bot] marked this conversation as resolved.
4 changes: 2 additions & 2 deletions packages/sveltekit/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// Some of the exports collide, which is not allowed, unless we redefine the colliding
// exports in this file - which we do below.
import type { Client, Integration, Options, StackParser } from '@sentry/core';
import type { HandleClientError, HandleServerError } from '@sveltejs/kit';
import type { AnyErrorHandler } from './common/handleErrorTypes';
import type * as clientSdk from './client';
import type * as serverSdk from './server';

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

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

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