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
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { wrapRequestHandler } from '@sentry/cloudflare/request';

interface Env {
SENTRY_DSN: string;
}

// Mirrors how Hydrogen (Remix) uses the SDK: only `wrapRequestHandler` from the
// `/request` subpath, without `nodejs_compat` compatibility flags.
// The subpath must stay free of Node.js-only modules.
export default {
async fetch(request, env, ctx) {
return wrapRequestHandler(
{
options: {
dsn: env.SENTRY_DSN,
traceLifecycle: 'static',
tracesSampleRate: 1,
},
request,
context: ctx,
},
() => new Response('ok'),
);
},
} satisfies ExportedHandler<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';

it('captures a transaction via wrapRequestHandler from the /request subpath without node compatibility flags', async ({

Check failure on line 4 in dev-packages/cloudflare-integration-tests/suites/request-handler/subpath/test.ts

View workflow job for this annotation

GitHub Actions / Cloudflare Integration Tests

suites/request-handler/subpath/test.ts > captures a transaction via wrapRequestHandler from the /request subpath without node compatibility flags

Error: Test timed out in 60000ms. If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout". ❯ suites/request-handler/subpath/test.ts:4:1
signal,
}) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as any;
expect(transactionEvent.transaction).toBe('GET /');
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare');
})
.start(signal);

await runner.makeRequest('get', '/');
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "worker-name",
"compatibility_date": "2025-06-17",
"main": "index.ts",
}
138 changes: 138 additions & 0 deletions packages/cloudflare/src/baseSdk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { Integration } from '@sentry/core';
import {
consoleIntegration,
conversationIdIntegration,
dedupeIntegration,
functionToStringIntegration,
getIntegrationsToSetup,
GLOBAL_OBJ,
inboundFiltersIntegration,
initAndBind,
linkedErrorsIntegration,
requestDataIntegration,
stackParserFromStackParserOptions,
} from '@sentry/core';
import type { CloudflareClientOptions, CloudflareOptions } from './client';
import { CloudflareClient } from './client';
import { makeFlushLock } from './flush';
import { fetchIntegration } from './integrations/fetch';
import { httpServerIntegration } from './integrations/httpServer';
import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from './integrations/spotlight';
import { setupOpenTelemetryTracer } from './opentelemetry/tracer';
import { makeCloudflareTransport } from './transport';
import { defaultStackParser } from './vendor/stacktrace';

/**
* Instantiate the channel-subscriber factories the `@sentry/cloudflare/vite`
* plugin registered on the global marker. The plugin splices a small snippet
* into each instrumented module that `.set`s its factory here (keyed by export
* name), so the marker holds one factory per package actually bundled.
*
* The marker is read directly instead of importing the factories, so a worker
* built without the plugin — where the channels never fire — ships none of this
* code.
* TODO(v11): Use `@sentry/server-utils/orchestrion` once we move to `nodejs_compat` by default.
*/
function getRegisteredChannelIntegrations(): Integration[] {
const registered = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations;

return registered ? [...registered.values()].map(factory => factory()) : [];
}

/**
* Get the default integrations that run on any Workers-compatible runtime, i.e. without the
* `nodejs_compat` compatibility flag.
*
* `getDefaultIntegrations` in `sdk.ts` extends this set with the integrations that do depend on
* Node.js APIs. Keeping the two apart is what allows `wrapRequestHandler` to stay usable on runtimes
* that cannot enable `nodejs_compat`, such as Shopify Oxygen.
*/
export function getBaseDefaultIntegrations(options: CloudflareOptions): Integration[] {
// TODO(v11): Drop this transitional gating and let `requestDataIntegration` rely on the resolved
// `dataCollection` defaults directly. Until then, preserve the historical Cloudflare behavior of not
// attaching cookies unless the user explicitly opts in via `sendDefaultPii` or `dataCollection.cookies`.
// eslint-disable-next-line typescript/no-deprecated
const cookiesEnabled = options.sendDefaultPii || options.dataCollection?.cookies != null;
return [
// The Dedupe integration should not be used in workflows because we want to
// capture all step failures, even if they are the same error.
...(options.enableDedupe === false ? [] : [dedupeIntegration()]),
// TODO(v11): Replace with `eventFiltersIntegration` once we remove the deprecated `inboundFiltersIntegration`
// eslint-disable-next-line typescript/no-deprecated
inboundFiltersIntegration(),
functionToStringIntegration(),
conversationIdIntegration(),
linkedErrorsIntegration(),
fetchIntegration(),
httpServerIntegration(),
requestDataIntegration(cookiesEnabled ? undefined : { include: { cookies: false } }),
consoleIntegration(),
// The orchestrion diagnostics-channel subscribers (mysql, pg, …). The
// `@sentry/cloudflare/vite` plugin injects the channels at build time and,
// next to each, a snippet that registers the matching subscriber factory on
// the global marker. Read from there instead of importing them so bundles
// built without the plugin — where the channels would never fire — don't
// ship the code.
...getRegisteredChannelIntegrations(),
];
}

/**
* Initializes the Cloudflare SDK with the passed default integrations.
*
* The default integrations are injected rather than imported so that this module stays free of
* Node.js-only code. `request.ts` — which backs both `wrapRequestHandler` and the
* `@sentry/cloudflare/request` entry point, and therefore has to work on runtimes without the
* `nodejs_compat` compatibility flag — creates its client from here instead of from `sdk.ts`.
*/
export function initWithDefaultIntegrations(
options: CloudflareOptions,
getDefaultIntegrationsImpl: (options: CloudflareOptions) => Integration[],
): CloudflareClient | undefined {
if (options.defaultIntegrations === undefined) {
options.defaultIntegrations = getDefaultIntegrationsImpl(options);
}

const flushLock = options.ctx ? makeFlushLock(options.ctx) : undefined;
delete options.ctx;

const clientOptions: CloudflareClientOptions = {
...options,
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
integrations: getIntegrationsToSetup(options),
transport: options.transport || makeCloudflareTransport,
flushLock,
};

/*! rollup-include-development-only */
if (options.spotlight && !clientOptions.integrations.some(({ name }) => name === SPOTLIGHT_INTEGRATION_NAME)) {
clientOptions.integrations.push(
spotlightIntegration({
sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined,
}),
);
}
/*! rollup-include-development-only-end */

/**
* The Cloudflare SDK is not OpenTelemetry native, however, we set up some OpenTelemetry compatibility
* via a custom trace provider.
* This ensures that any spans emitted via `@opentelemetry/api` will be captured by Sentry.
* HOWEVER, big caveat: This does not handle custom context handling, it will always work off the current scope.
* This should be good enough for many, but not all integrations.
*/
if (!options.skipOpenTelemetrySetup) {
setupOpenTelemetryTracer();
}

return initAndBind(CloudflareClient, clientOptions) as CloudflareClient;
}

/**
* Initializes the Cloudflare SDK with only the default integrations from
* {@link getBaseDefaultIntegrations}, i.e. those that work without the `nodejs_compat`
* compatibility flag.
*/
export function initBaseSdk(options: CloudflareOptions): CloudflareClient | undefined {
return initWithDefaultIntegrations(options, getBaseDefaultIntegrations);
}
13 changes: 9 additions & 4 deletions packages/cloudflare/src/durableobject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import type { CloudflareOptions } from './client';
import { ensureInstrumented } from './instrument';
import { instrumentEnv } from './instrumentations/worker/instrumentEnv';
import { getFinalOptions } from './options';
import { wrapRequestHandler } from './request';
import { wrapRequestHandlerWithInit } from './request';
import { init } from './sdk';
import { instrumentContext } from './utils/instrumentContext';
import { extractRpcMeta } from './utils/rpcMeta';
import { getEffectiveRpcPropagation } from './utils/rpcOptions';
Expand Down Expand Up @@ -89,9 +90,13 @@ function instrumentDurableObjectHandlers<E, T extends DurableObject<E>>(
original =>
new Proxy(original, {
apply(target, thisArg, args) {
return wrapRequestHandler({ options, request: args[0], context }, () => {
return Reflect.apply(target, thisArg, args);
});
return wrapRequestHandlerWithInit(
{ options, request: args[0], context },
() => {
return Reflect.apply(target, thisArg, args);
},
init,
);
},
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'
import type { CloudflareOptions } from '../../client';
import { ensureInstrumented } from '../../instrument';
import { getFinalOptions } from '../../options';
import { wrapRequestHandler } from '../../request';
import { wrapRequestHandlerWithInit } from '../../request';
import { init } from '../../sdk';
import { instrumentContext } from '../../utils/instrumentContext';
import { instrumentEnv } from './instrumentEnv';

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

return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args));
return wrapRequestHandlerWithInit({ options, request, context }, () => target.apply(thisArg, args), init);
},
}),
);
Expand All @@ -62,7 +63,11 @@ export function instrumentWorkerEntrypointFetch<T extends WorkerEntrypoint>(
return Reflect.apply(target, thisArg, args);
}

return wrapRequestHandler({ options, request, context }, () => Reflect.apply(target, thisArg, args));
return wrapRequestHandlerWithInit(
{ options, request, context },
() => Reflect.apply(target, thisArg, args),
init,
);
},
});
}
9 changes: 7 additions & 2 deletions packages/cloudflare/src/pages-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import type { CloudflareOptions } from './client';
import type { ExecutionContextCompat } from './executionContext';
import { wrapRequestHandler } from './request';
import { wrapRequestHandlerWithInit } from './request';
import { init } from './sdk';

/**
* Plugin middleware for Cloudflare Pages.
Expand Down Expand Up @@ -57,6 +58,10 @@ export function sentryPagesPlugin<
// A Pages `EventPluginContext` is not a Workers `ExecutionContext`, but `wrapRequestHandler` only
// uses `waitUntil` and a `'storage' in context` check, both of which this satisfies.
const executionContext = { ...context, props: {} } as unknown as ExecutionContextCompat;
return wrapRequestHandler({ options, request: context.request, context: executionContext }, () => context.next());
return wrapRequestHandlerWithInit(
{ options, request: context.request, context: executionContext },
() => context.next(),
init,
);
};
}
31 changes: 27 additions & 4 deletions packages/cloudflare/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import {
withIsolationScope,
} from '@sentry/core';
import { captureIncomingRequestBody } from './integrations/httpServer';
import type { CloudflareOptions } from './client';
import { initBaseSdk } from './baseSdk';
import type { CloudflareClient, CloudflareOptions } from './client';
import type { ExecutionContextCompat } from './executionContext';
import { flushAndDispose, getOriginalWaitUntil } from './flush';
import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils';
import { init } from './sdk';
import { classifyResponseStreaming } from './utils/streaming';

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

type InitSdk = (options: CloudflareOptions) => CloudflareClient | undefined;

/**
* Wraps a cloudflare request handler in Sentry instrumentation
* Wraps a cloudflare request handler in Sentry instrumentation.
*
* The client is set up with the default integrations that work without the `nodejs_compat`
* compatibility flag, so that this also works on runtimes that cannot enable it (e.g. Shopify
* Oxygen). On a runtime that has `nodejs_compat`, pass `defaultIntegrations:
* getDefaultIntegrations(options)` in `options` to get the full set instead.
*/
export function wrapRequestHandler(
wrapperOptions: RequestHandlerWrapperOptions,
handler: (...args: unknown[]) => Response | Promise<Response>,
): Promise<Response> {
return wrapRequestHandlerWithInit(wrapperOptions, handler, initBaseSdk);
}

/**
* Same as {@link wrapRequestHandler}, but with the SDK initialization injected.
*
* Wrappers that are only reachable from the main entry point — where `nodejs_compat` is a
* requirement anyway — pass `init` from `sdk.ts` to get the full default integrations.
*
* @internal
*/
export function wrapRequestHandlerWithInit(
wrapperOptions: RequestHandlerWrapperOptions,
handler: (...args: unknown[]) => Response | Promise<Response>,
initSdk: InitSdk,
): Promise<Response> {
return withIsolationScope(async isolationScope => {
const { options, request, captureErrors = true } = wrapperOptions;
Expand All @@ -61,7 +84,7 @@ export function wrapRequestHandler(
const waitUntil = context ? getOriginalWaitUntil(context)?.bind(context) : undefined;
const errorMechanismType = getRequestErrorMechanismType(context);

const client = init({ ...options, ctx: context });
const client = initSdk({ ...options, ctx: context });
isolationScope.setClient(client);

const urlObject = parseStringToURLObject(request.url);
Expand Down
Loading
Loading