Skip to content
Open
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
11 changes: 4 additions & 7 deletions packages/sveltekit/src/server-common/handleError.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { captureException, consoleSandbox, flushIfServerless } from '@sentry/core';
import type { HandleServerError } from '@sveltejs/kit';
import { getCloudflareExecutionContext } from './utils';

// The SvelteKit default error handler just logs the error's stack trace to the console
// see: https://github.com/sveltejs/kit/blob/369e7d6851f543a40c947e033bfc4a9506fdc0a8/packages/kit/src/runtime/server/index.js#L43
Expand Down Expand Up @@ -41,18 +42,14 @@ export function handleErrorWithSentry(handleError?: HandleServerError): HandleSe
},
});

const platform = input.event.platform as {
context?: {
waitUntil?: (p: Promise<void>) => void;
};
};
const cloudflareCtx = getCloudflareExecutionContext(input.event.platform);

// Cloudflare workers have a `waitUntil` method on `ctx` that we can use to flush the event queue
// We already call this in `wrapRequestHandler` from `sentryHandleInitCloudflare`
// However, `handleError` can be invoked when wrapRequestHandler already finished
// (e.g. when responses are streamed / returning promises from load functions)
if (typeof platform?.context?.waitUntil === 'function') {
await flushIfServerless({ cloudflareCtx: platform.context as { waitUntil(promise: Promise<void>): void } });
if (typeof cloudflareCtx?.waitUntil === 'function') {
await flushIfServerless({ cloudflareCtx });
} else {
await flushIfServerless();
}
Expand Down
27 changes: 27 additions & 0 deletions packages/sveltekit/src/server-common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@ import { captureException, objectify } from '@sentry/core';
import type { RequestEvent } from '@sveltejs/kit';
import { isHttpError, isRedirect } from '../common/utils';

/** The subset of Cloudflare's `ExecutionContext` the SDK relies on. */
export type MinimalCloudflareExecutionContext = {
waitUntil(promise: Promise<unknown>): void;
};

/**
* Reads the Cloudflare execution context off a SvelteKit `platform` object.
*
* The property name differs by adapter version:
* - `@sveltejs/adapter-cloudflare` <= 7 exposes it as `platform.context`
* - `@sveltejs/adapter-cloudflare` 8 renamed it to `platform.ctx`
*
* We read both so that request isolation and `waitUntil`-based flushing keep working across the
* adapter versions our peer range allows. Both accesses fail silently when the shape changes, so
* dropping either one costs us events without surfacing an error.
*
* @see https://github.com/sveltejs/kit/pull/16668
*/
export function getCloudflareExecutionContext(platform: unknown): MinimalCloudflareExecutionContext | undefined {
const { ctx, context } = (platform ?? {}) as {
ctx?: MinimalCloudflareExecutionContext;
context?: MinimalCloudflareExecutionContext;
};

return ctx ?? context;
}

/**
* Takes a request event and extracts traceparent and DSC data
* from the `sentry-trace` and `baggage` DSC headers.
Expand Down
3 changes: 2 additions & 1 deletion packages/sveltekit/src/worker/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { addNonEnumerableProperty } from '@sentry/core';
import type { Handle } from '@sveltejs/kit';
import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration';
import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans';
import { getCloudflareExecutionContext } from '../server-common/utils';

/**
* Initializes Sentry SvelteKit Cloudflare SDK
Expand Down Expand Up @@ -45,7 +46,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle {
options: opts,
request: event.request,
// @ts-expect-error This will exist in Cloudflare
context: event.platform.context,
context: getCloudflareExecutionContext(event.platform),
// We don't want to capture errors here, as we want to capture them in the `sentryHandle` handler
// where we can distinguish between redirects and actual errors.
captureErrors: false,
Expand Down
44 changes: 42 additions & 2 deletions packages/sveltekit/test/server-common/handleError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ describe('handleError (server)', () => {
expect(consoleErrorSpy).toHaveBeenCalledTimes(0);
});

it('calls waitUntil if available', async () => {
it.each([
['context', 'adapter-cloudflare <= 7'],
['ctx', 'adapter-cloudflare 8'],
])('calls waitUntil if available on platform.%s (%s)', async platformKey => {
const wrappedHandleError = handleErrorWithSentry();
const mockError = new Error('test');
const waitUntilSpy = vi.fn();
Expand All @@ -105,7 +108,7 @@ describe('handleError (server)', () => {
event: {
...requestEvent,
platform: {
context: {
[platformKey]: {
waitUntil: waitUntilSpy,
},
},
Expand All @@ -118,5 +121,42 @@ describe('handleError (server)', () => {
// flush() returns a promise, this is what we expect here
expect(waitUntilSpy).toHaveBeenCalledWith(expect.any(Promise));
});

it('prefers platform.ctx over platform.context when both are present', async () => {
const wrappedHandleError = handleErrorWithSentry();
const mockError = new Error('test');
const ctxWaitUntilSpy = vi.fn();
const contextWaitUntilSpy = vi.fn();

await wrappedHandleError({
error: mockError,
event: {
...requestEvent,
platform: {
ctx: { waitUntil: ctxWaitUntilSpy },
context: { waitUntil: contextWaitUntilSpy },
},
},
status: 500,
message: 'Internal Error',
});

expect(ctxWaitUntilSpy).toHaveBeenCalledTimes(1);
expect(contextWaitUntilSpy).not.toHaveBeenCalled();
});

it('does not throw if the platform exposes no execution context', async () => {
const wrappedHandleError = handleErrorWithSentry();
const mockError = new Error('test');

await wrappedHandleError({
error: mockError,
event: { ...requestEvent, platform: {} },
status: 500,
message: 'Internal Error',
});

expect(mockCaptureException).toHaveBeenCalledTimes(1);
});
});
});
12 changes: 8 additions & 4 deletions packages/sveltekit/test/worker/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ vi.mock('@sentry/cloudflare/request', async importOriginal => {

const globalWithSentry = globalThis as typeof GLOBAL_OBJ & Carrier;

function getHandlerInput() {
function getHandlerInput(platformKey: 'context' | 'ctx' = 'context') {
const options = { dsn: 'https://public@dsn.ingest.sentry.io/1337' };
const request = { foo: 'bar' };
const context = { bar: 'baz' };

const event = { request, platform: { context } };
const event = { request, platform: { [platformKey]: context } };
const resolve = vi.fn(() => Promise.resolve({}));
return { options, event, resolve, request, context };
}
Expand All @@ -39,8 +39,12 @@ describe('initCloudflareSentryHandle', () => {
).toBeDefined();
});

it('calls wrapRequestHandler with the correct arguments', async () => {
const { options, event, resolve, request, context } = getHandlerInput();
// `@sveltejs/adapter-cloudflare` 8 renamed `platform.context` to `platform.ctx`
it.each([
['context' as const, 'adapter-cloudflare <= 7'],
['ctx' as const, 'adapter-cloudflare 8'],
])('calls wrapRequestHandler with the correct arguments, reading platform.%s (%s)', async (platformKey, _adapter) => {
const { options, event, resolve, request, context } = getHandlerInput(platformKey);

// @ts-expect-error - resolving an empty object is enough for this test
vi.mocked(wrapRequestHandler).mockImplementationOnce((_, cb) => cb());
Expand Down
Loading