Skip to content

Commit afa06b9

Browse files
authored
ref(cloudflare): Ensure wrapRequestHandler stays free of node specifcs (#22894)
This prepares for the next PRs, where we extend the default integrations with integrations that include node specific imports, such as the diagnostics channels e.g. `vercelAIIntegration`. The main refactors were - There is now a `wrapRequestHandlerWithInit`, that holds only default integrations and imports, which are "pure" and don't have any node libraries, in order to support the [Oxygen runtime](https://shopify.dev/docs/storefronts/headless/hydrogen/deployments/oxygen-runtime) (a new test has a `wrangler.jsonc` without any compatibility flags, that mirrors what Oxygen does in their environment). So this new function only adds a third parameter with the `init`. - There is also now a `./baseSdk`, which is just a refactor from the previous `./sdk`, but without the extra imports - The `./sdk` can now be extended with diagnostics channels, such as `vercelAIIntegration` or other `server-utils` imports. - To ensure the `/request` entrypoint stays "pure", there are 2 tests to ensure that 1. Just a unit test that checks for `node:` imports 2. A integration test that runs with `wrangler dev`, which doesn't have tree-shaking baked in. Perfect for testing if there is a `node:` import
1 parent ad88bd8 commit afa06b9

12 files changed

Lines changed: 328 additions & 113 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { wrapRequestHandler } from '@sentry/cloudflare/request';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
}
6+
7+
// Mirrors how Hydrogen (Remix) uses the SDK: only `wrapRequestHandler` from the
8+
// `/request` subpath, without `nodejs_compat` compatibility flags.
9+
// The subpath must stay free of Node.js-only modules.
10+
export default {
11+
async fetch(request, env, ctx) {
12+
return wrapRequestHandler(
13+
{
14+
options: {
15+
dsn: env.SENTRY_DSN,
16+
traceLifecycle: 'static',
17+
tracesSampleRate: 1,
18+
},
19+
request,
20+
context: ctx,
21+
},
22+
() => new Response('ok'),
23+
);
24+
},
25+
} satisfies ExportedHandler<Env>;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { expect, it } from 'vitest';
2+
import { createRunner } from '../../../runner';
3+
4+
it('captures a transaction via wrapRequestHandler from the /request subpath without node compatibility flags', async ({
5+
signal,
6+
}) => {
7+
const runner = createRunner(__dirname)
8+
.expect(envelope => {
9+
const transactionEvent = envelope[1]?.[0]?.[1] as any;
10+
expect(transactionEvent.transaction).toBe('GET /');
11+
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
12+
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare');
13+
})
14+
.start(signal);
15+
16+
await runner.makeRequest('get', '/');
17+
await runner.completed();
18+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "worker-name",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
}

packages/cloudflare/rollup.npm.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu
22

33
export default makeNPMConfigVariants(
44
makeBaseNPMConfig({
5-
entrypoints: ['src/index.ts', 'src/nodejs_compat/index.ts', 'src/vite/index.ts'],
5+
entrypoints: ['src/index.ts', 'src/request.ts', 'src/nodejs_compat/index.ts', 'src/vite/index.ts'],
66
}),
77
{ splitDevProd: true },
88
);

packages/cloudflare/src/baseSdk.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import type { Integration } from '@sentry/core';
2+
import {
3+
consoleIntegration,
4+
conversationIdIntegration,
5+
dedupeIntegration,
6+
functionToStringIntegration,
7+
getIntegrationsToSetup,
8+
GLOBAL_OBJ,
9+
inboundFiltersIntegration,
10+
initAndBind,
11+
linkedErrorsIntegration,
12+
requestDataIntegration,
13+
stackParserFromStackParserOptions,
14+
} from '@sentry/core';
15+
import type { CloudflareClientOptions, CloudflareOptions } from './client';
16+
import { CloudflareClient } from './client';
17+
import { makeFlushLock } from './flush';
18+
import { fetchIntegration } from './integrations/fetch';
19+
import { httpServerIntegration } from './integrations/httpServer';
20+
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from './integrations/spotlight';
21+
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
22+
import { makeCloudflareTransport } from './transport';
23+
import { defaultStackParser } from './vendor/stacktrace';
24+
25+
/**
26+
* Instantiate the channel-subscriber factories the `@sentry/cloudflare/vite`
27+
* plugin registered on the global marker. The plugin splices a small snippet
28+
* into each instrumented module that `.set`s its factory here (keyed by export
29+
* name), so the marker holds one factory per package actually bundled.
30+
*
31+
* The marker is read directly instead of importing the factories, so a worker
32+
* built without the plugin — where the channels never fire — ships none of this
33+
* code.
34+
* TODO(v11): Use `@sentry/server-utils/orchestrion` once we move to `nodejs_compat` by default.
35+
*/
36+
function getRegisteredChannelIntegrations(): Integration[] {
37+
const registered = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations;
38+
39+
return registered ? [...registered.values()].map(factory => factory()) : [];
40+
}
41+
42+
/**
43+
* Get the default integrations that run on any Workers-compatible runtime, i.e. without the
44+
* `nodejs_compat` compatibility flag.
45+
*
46+
* `getDefaultIntegrations` in `sdk.ts` extends this set with the integrations that do depend on
47+
* Node.js APIs. Keeping the two apart is what allows `wrapRequestHandler` to stay usable on runtimes
48+
* that cannot enable `nodejs_compat`, such as Shopify Oxygen.
49+
*/
50+
export function getBaseDefaultIntegrations(options: CloudflareOptions): Integration[] {
51+
return [
52+
// The Dedupe integration should not be used in workflows because we want to
53+
// capture all step failures, even if they are the same error.
54+
...(options.enableDedupe === false ? [] : [dedupeIntegration()]),
55+
// TODO(v11): Replace with `eventFiltersIntegration` once we remove the deprecated `inboundFiltersIntegration`
56+
// eslint-disable-next-line typescript/no-deprecated
57+
inboundFiltersIntegration(),
58+
functionToStringIntegration(),
59+
conversationIdIntegration(),
60+
linkedErrorsIntegration(),
61+
fetchIntegration(),
62+
httpServerIntegration(),
63+
// oxlint-disable-next-line typescript/no-deprecated
64+
requestDataIntegration(),
65+
consoleIntegration(),
66+
// The orchestrion diagnostics-channel subscribers (mysql, pg, …). The
67+
// `@sentry/cloudflare/vite` plugin injects the channels at build time and,
68+
// next to each, a snippet that registers the matching subscriber factory on
69+
// the global marker. Read from there instead of importing them so bundles
70+
// built without the plugin — where the channels would never fire — don't
71+
// ship the code.
72+
...getRegisteredChannelIntegrations(),
73+
];
74+
}
75+
76+
/**
77+
* Initializes the Cloudflare SDK with the passed default integrations.
78+
*
79+
* The default integrations are injected rather than imported so that this module stays free of
80+
* Node.js-only code. `request.ts` — which backs both `wrapRequestHandler` and the
81+
* `@sentry/cloudflare/request` entry point, and therefore has to work on runtimes without the
82+
* `nodejs_compat` compatibility flag — creates its client from here instead of from `sdk.ts`.
83+
*/
84+
export function initWithDefaultIntegrations(
85+
options: CloudflareOptions,
86+
getDefaultIntegrationsImpl: (options: CloudflareOptions) => Integration[],
87+
): CloudflareClient | undefined {
88+
if (options.defaultIntegrations === undefined) {
89+
options.defaultIntegrations = getDefaultIntegrationsImpl(options);
90+
}
91+
92+
const flushLock = options.ctx ? makeFlushLock(options.ctx) : undefined;
93+
delete options.ctx;
94+
95+
const clientOptions: CloudflareClientOptions = {
96+
...options,
97+
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
98+
integrations: getIntegrationsToSetup(options),
99+
transport: options.transport || makeCloudflareTransport,
100+
flushLock,
101+
};
102+
103+
/*! rollup-include-development-only */
104+
if (options.spotlight && !clientOptions.integrations.some(({ name }) => name === SPOTLIGHT_INTEGRATION_NAME)) {
105+
clientOptions.integrations.push(
106+
spotlightIntegration({
107+
sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined,
108+
}),
109+
);
110+
}
111+
/*! rollup-include-development-only-end */
112+
113+
/**
114+
* The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility
115+
* via a custom trace provider.
116+
* This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry.
117+
* HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope.
118+
* This should be good enough for many, but not all integrations.
119+
*/
120+
if (!options.skipOpenTelemetrySetup) {
121+
setupOpenTelemetryTracer();
122+
}
123+
124+
return initAndBind(CloudflareClient, clientOptions) as CloudflareClient;
125+
}
126+
127+
/**
128+
* Initializes the Cloudflare SDK with only the default integrations from
129+
* {@link getBaseDefaultIntegrations}, i.e. those that work without the `nodejs_compat`
130+
* compatibility flag.
131+
*/
132+
export function initBaseSdk(options: CloudflareOptions): CloudflareClient | undefined {
133+
return initWithDefaultIntegrations(options, getBaseDefaultIntegrations);
134+
}

packages/cloudflare/src/durableobject.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import type { CloudflareOptions } from './client';
66
import { ensureInstrumented } from './instrument';
77
import { instrumentEnv } from './instrumentations/worker/instrumentEnv';
88
import { getFinalOptions } from './options';
9-
import { wrapRequestHandler } from './request';
9+
import { wrapRequestHandlerWithInit } from './request';
10+
import { init } from './sdk';
1011
import { instrumentContext } from './utils/instrumentContext';
1112
import { extractRpcMeta } from './utils/rpcMeta';
1213
import { getEffectiveRpcPropagation } from './utils/rpcOptions';
@@ -89,9 +90,13 @@ function instrumentDurableObjectHandlers<E, T extends DurableObject<E>>(
8990
original =>
9091
new Proxy(original, {
9192
apply(target, thisArg, args) {
92-
return wrapRequestHandler({ options, request: args[0], context }, () => {
93-
return Reflect.apply(target, thisArg, args);
94-
});
93+
return wrapRequestHandlerWithInit(
94+
{ options, request: args[0], context },
95+
() => {
96+
return Reflect.apply(target, thisArg, args);
97+
},
98+
init,
99+
);
95100
},
96101
}),
97102
);

packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'
33
import type { CloudflareOptions } from '../../client';
44
import { ensureInstrumented } from '../../instrument';
55
import { getFinalOptions } from '../../options';
6-
import { wrapRequestHandler } from '../../request';
6+
import { wrapRequestHandlerWithInit } from '../../request';
7+
import { init } from '../../sdk';
78
import { instrumentContext } from '../../utils/instrumentContext';
89
import { instrumentEnv } from './instrumentEnv';
910

@@ -35,7 +36,7 @@ export function instrumentExportedHandlerFetch<T extends ExportedHandler<any, an
3536
args[1] = instrumentEnv(env, options);
3637
args[2] = context;
3738

38-
return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args));
39+
return wrapRequestHandlerWithInit({ options, request, context }, () => target.apply(thisArg, args), init);
3940
},
4041
}),
4142
);
@@ -62,7 +63,11 @@ export function instrumentWorkerEntrypointFetch<T extends WorkerEntrypoint>(
6263
return Reflect.apply(target, thisArg, args);
6364
}
6465

65-
return wrapRequestHandler({ options, request, context }, () => Reflect.apply(target, thisArg, args));
66+
return wrapRequestHandlerWithInit(
67+
{ options, request, context },
68+
() => Reflect.apply(target, thisArg, args),
69+
init,
70+
);
6671
},
6772
});
6873
}

packages/cloudflare/src/pages-plugin.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
22
import type { CloudflareOptions } from './client';
33
import type { ExecutionContextCompat } from './executionContext';
4-
import { wrapRequestHandler } from './request';
4+
import { wrapRequestHandlerWithInit } from './request';
5+
import { init } from './sdk';
56

67
/**
78
* Plugin middleware for Cloudflare Pages.
@@ -57,6 +58,10 @@ export function sentryPagesPlugin<
5758
// A Pages `EventPluginContext` is not a Workers `ExecutionContext`, but `wrapRequestHandler` only
5859
// uses `waitUntil` and a `'storage' in context` check, both of which this satisfies.
5960
const executionContext = { ...context, props: {} } as unknown as ExecutionContextCompat;
60-
return wrapRequestHandler({ options, request: context.request, context: executionContext }, () => context.next());
61+
return wrapRequestHandlerWithInit(
62+
{ options, request: context.request, context: executionContext },
63+
() => context.next(),
64+
init,
65+
);
6166
};
6267
}

packages/cloudflare/src/request.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ import {
1313
withIsolationScope,
1414
} from '@sentry/core';
1515
import { captureIncomingRequestBody } from './integrations/httpServer';
16-
import type { CloudflareOptions } from './client';
16+
import { initBaseSdk } from './baseSdk';
17+
import type { CloudflareClient, CloudflareOptions } from './client';
1718
import type { ExecutionContextCompat } from './executionContext';
1819
import { flushAndDispose, getOriginalWaitUntil } from './flush';
1920
import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils';
20-
import { init } from './sdk';
2121
import { classifyResponseStreaming } from './utils/streaming';
2222

2323
function getRequestErrorMechanismType(context: ExecutionContextCompat | undefined): string {
@@ -42,12 +42,35 @@ interface RequestHandlerWrapperOptions {
4242
captureErrors?: boolean;
4343
}
4444

45+
type InitSdk = (options: CloudflareOptions) => CloudflareClient | undefined;
46+
4547
/**
46-
* Wraps a cloudflare request handler in Sentry instrumentation
48+
* Wraps a cloudflare request handler in Sentry instrumentation.
49+
*
50+
* The client is set up with the default integrations that work without the `nodejs_compat`
51+
* compatibility flag, so that this also works on runtimes that cannot enable it (e.g. Shopify
52+
* Oxygen). On a runtime that has `nodejs_compat`, pass `defaultIntegrations:
53+
* getDefaultIntegrations(options)` in `options` to get the full set instead.
4754
*/
4855
export function wrapRequestHandler(
4956
wrapperOptions: RequestHandlerWrapperOptions,
5057
handler: (...args: unknown[]) => Response | Promise<Response>,
58+
): Promise<Response> {
59+
return wrapRequestHandlerWithInit(wrapperOptions, handler, initBaseSdk);
60+
}
61+
62+
/**
63+
* Same as {@link wrapRequestHandler}, but with the SDK initialization injected.
64+
*
65+
* Wrappers that are only reachable from the main entry point — where `nodejs_compat` is a
66+
* requirement anyway — pass `init` from `sdk.ts` to get the full default integrations.
67+
*
68+
* @internal
69+
*/
70+
export function wrapRequestHandlerWithInit(
71+
wrapperOptions: RequestHandlerWrapperOptions,
72+
handler: (...args: unknown[]) => Response | Promise<Response>,
73+
initSdk: InitSdk,
5174
): Promise<Response> {
5275
return withIsolationScope(async isolationScope => {
5376
const { options, request, captureErrors = true } = wrapperOptions;
@@ -61,7 +84,7 @@ export function wrapRequestHandler(
6184
const waitUntil = context ? getOriginalWaitUntil(context)?.bind(context) : undefined;
6285
const errorMechanismType = getRequestErrorMechanismType(context);
6386

64-
const client = init({ ...options, ctx: context });
87+
const client = initSdk({ ...options, ctx: context });
6588
isolationScope.setClient(client);
6689

6790
const urlObject = parseStringToURLObject(request.url);

0 commit comments

Comments
 (0)