Skip to content

Commit bdbb79b

Browse files
chargomeclaude
andcommitted
fix(sveltekit): Read Cloudflare execution context from platform.ctx
`@sveltejs/adapter-cloudflare` 8 renamed `platform.context` to `platform.ctx`. Read `ctx` and fall back to `context` so adapter <= 7 keeps working. This fails silently: the execution context is optional-chained everywhere, so a missed lookup drops events instead of throwing. Refs sveltejs/kit#16668 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1310ba7 commit bdbb79b

5 files changed

Lines changed: 84 additions & 14 deletions

File tree

packages/sveltekit/src/server-common/handleError.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { captureException, consoleSandbox, flushIfServerless } from '@sentry/core';
22
import type { HandleServerError } from '@sveltejs/kit';
3+
import { getCloudflareExecutionContext } from './utils';
34

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

44-
const platform = input.event.platform as {
45-
context?: {
46-
waitUntil?: (p: Promise<void>) => void;
47-
};
48-
};
45+
const cloudflareCtx = getCloudflareExecutionContext(input.event.platform);
4946

5047
// Cloudflare workers have a `waitUntil` method on `ctx` that we can use to flush the event queue
5148
// We already call this in `wrapRequestHandler` from `sentryHandleInitCloudflare`
5249
// However, `handleError` can be invoked when wrapRequestHandler already finished
5350
// (e.g. when responses are streamed / returning promises from load functions)
54-
if (typeof platform?.context?.waitUntil === 'function') {
55-
await flushIfServerless({ cloudflareCtx: platform.context as { waitUntil(promise: Promise<void>): void } });
51+
if (typeof cloudflareCtx?.waitUntil === 'function') {
52+
await flushIfServerless({ cloudflareCtx });
5653
} else {
5754
await flushIfServerless();
5855
}

packages/sveltekit/src/server-common/utils.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@ import { captureException, objectify } from '@sentry/core';
22
import type { RequestEvent } from '@sveltejs/kit';
33
import { isHttpError, isRedirect } from '../common/utils';
44

5+
/** The subset of Cloudflare's `ExecutionContext` the SDK relies on. */
6+
export type MinimalCloudflareExecutionContext = {
7+
// oxlint-disable-next-line typescript/no-explicit-any
8+
waitUntil(promise: Promise<any>): void;
9+
};
10+
11+
/**
12+
* Reads the Cloudflare execution context off a SvelteKit `platform` object.
13+
*
14+
* The property name differs by adapter version:
15+
* - `@sveltejs/adapter-cloudflare` <= 7 exposes it as `platform.context`
16+
* - `@sveltejs/adapter-cloudflare` 8 renamed it to `platform.ctx`
17+
*
18+
* We read both so that request isolation and `waitUntil`-based flushing keep working across the
19+
* adapter versions our peer range allows. Both accesses fail silently when the shape changes, so
20+
* dropping either one costs us events without surfacing an error.
21+
*
22+
* @see https://github.com/sveltejs/kit/pull/16668
23+
*/
24+
export function getCloudflareExecutionContext(platform: unknown): MinimalCloudflareExecutionContext | undefined {
25+
const { ctx, context } = (platform ?? {}) as {
26+
ctx?: MinimalCloudflareExecutionContext;
27+
context?: MinimalCloudflareExecutionContext;
28+
};
29+
30+
return ctx ?? context;
31+
}
32+
533
/**
634
* Takes a request event and extracts traceparent and DSC data
735
* from the `sentry-trace` and `baggage` DSC headers.

packages/sveltekit/src/worker/cloudflare.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { addNonEnumerableProperty } from '@sentry/core';
88
import type { Handle } from '@sveltejs/kit';
99
import { rewriteFramesIntegration } from '../server-common/integrations/rewriteFramesIntegration';
1010
import { svelteKitSpansIntegration } from '../server-common/integrations/svelteKitSpans';
11+
import { getCloudflareExecutionContext } from '../server-common/utils';
1112

1213
/**
1314
* Initializes Sentry SvelteKit Cloudflare SDK
@@ -45,7 +46,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle {
4546
options: opts,
4647
request: event.request,
4748
// @ts-expect-error This will exist in Cloudflare
48-
context: event.platform.context,
49+
context: getCloudflareExecutionContext(event.platform),
4950
// We don't want to capture errors here, as we want to capture them in the `sentryHandle` handler
5051
// where we can distinguish between redirects and actual errors.
5152
captureErrors: false,

packages/sveltekit/test/server-common/handleError.test.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ describe('handleError (server)', () => {
9595
expect(consoleErrorSpy).toHaveBeenCalledTimes(0);
9696
});
9797

98-
it('calls waitUntil if available', async () => {
98+
it.each([
99+
['context', 'adapter-cloudflare <= 7'],
100+
['ctx', 'adapter-cloudflare 8'],
101+
])('calls waitUntil if available on platform.%s (%s)', async platformKey => {
99102
const wrappedHandleError = handleErrorWithSentry();
100103
const mockError = new Error('test');
101104
const waitUntilSpy = vi.fn();
@@ -105,7 +108,7 @@ describe('handleError (server)', () => {
105108
event: {
106109
...requestEvent,
107110
platform: {
108-
context: {
111+
[platformKey]: {
109112
waitUntil: waitUntilSpy,
110113
},
111114
},
@@ -118,5 +121,42 @@ describe('handleError (server)', () => {
118121
// flush() returns a promise, this is what we expect here
119122
expect(waitUntilSpy).toHaveBeenCalledWith(expect.any(Promise));
120123
});
124+
125+
it('prefers platform.ctx over platform.context when both are present', async () => {
126+
const wrappedHandleError = handleErrorWithSentry();
127+
const mockError = new Error('test');
128+
const ctxWaitUntilSpy = vi.fn();
129+
const contextWaitUntilSpy = vi.fn();
130+
131+
await wrappedHandleError({
132+
error: mockError,
133+
event: {
134+
...requestEvent,
135+
platform: {
136+
ctx: { waitUntil: ctxWaitUntilSpy },
137+
context: { waitUntil: contextWaitUntilSpy },
138+
},
139+
},
140+
status: 500,
141+
message: 'Internal Error',
142+
});
143+
144+
expect(ctxWaitUntilSpy).toHaveBeenCalledTimes(1);
145+
expect(contextWaitUntilSpy).not.toHaveBeenCalled();
146+
});
147+
148+
it('does not throw if the platform exposes no execution context', async () => {
149+
const wrappedHandleError = handleErrorWithSentry();
150+
const mockError = new Error('test');
151+
152+
await wrappedHandleError({
153+
error: mockError,
154+
event: { ...requestEvent, platform: {} },
155+
status: 500,
156+
message: 'Internal Error',
157+
});
158+
159+
expect(mockCaptureException).toHaveBeenCalledTimes(1);
160+
});
121161
});
122162
});

packages/sveltekit/test/worker/cloudflare.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ vi.mock('@sentry/cloudflare/request', async importOriginal => {
1212

1313
const globalWithSentry = globalThis as typeof GLOBAL_OBJ & Carrier;
1414

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

20-
const event = { request, platform: { context } };
20+
const event = { request, platform: { [platformKey]: context } };
2121
const resolve = vi.fn(() => Promise.resolve({}));
2222
return { options, event, resolve, request, context };
2323
}
@@ -39,8 +39,12 @@ describe('initCloudflareSentryHandle', () => {
3939
).toBeDefined();
4040
});
4141

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

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

0 commit comments

Comments
 (0)