From c7ac38c45ba2922fa3420e226a8b5f5bc6030981 Mon Sep 17 00:00:00 2001
From: Abhijeet Prasad
Date: Tue, 30 Jul 2024 17:05:43 -0400
Subject: [PATCH] feat(cloudflare): Add plugin for cloudflare pages
---
CHANGELOG.md | 22 ++
packages/cloudflare/README.md | 53 +++-
packages/cloudflare/src/handler.ts | 104 +------
packages/cloudflare/src/index.ts | 1 +
packages/cloudflare/src/pages-plugin.ts | 32 ++
packages/cloudflare/src/request.ts | 123 ++++++++
packages/cloudflare/src/sdk.ts | 5 +-
packages/cloudflare/test/handler.test.ts | 248 +---------------
packages/cloudflare/test/pages-plugin.test.ts | 36 +++
packages/cloudflare/test/request.test.ts | 274 ++++++++++++++++++
10 files changed, 543 insertions(+), 355 deletions(-)
create mode 100644 packages/cloudflare/src/pages-plugin.ts
create mode 100644 packages/cloudflare/src/request.ts
create mode 100644 packages/cloudflare/test/pages-plugin.test.ts
create mode 100644 packages/cloudflare/test/request.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 208ec7eb2a68..7c85a5da036b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,28 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## Unreleased
+
+### Important Changes
+
+- **feat(cloudflare): Add plugin for cloudflare pages (#13123)**
+
+This release adds support for Cloudflare Pages to `@sentry/cloudflare`, our SDK for the
+[Cloudflare Workers JavaScript Runtime](https://developers.cloudflare.com/workers/)! For details on how to use it,
+please see the [README](./packages/cloudflare/README.md). Any feedback/bug reports are greatly appreciated, please
+[reach out on GitHub](https://github.com/getsentry/sentry-javascript/issues/12620).
+
+```javascript
+// functions/_middleware.js
+import * as Sentry from '@sentry/cloudflare';
+
+export const onRequest = Sentry.sentryPagesPlugin({
+ dsn: __PUBLIC_DSN__,
+ // Set tracesSampleRate to 1.0 to capture 100% of spans for tracing.
+ tracesSampleRate: 1.0,
+});
+```
+
## 8.21.0
### Important Changes
diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md
index 37f0cd94f412..dc0d6de01274 100644
--- a/packages/cloudflare/README.md
+++ b/packages/cloudflare/README.md
@@ -4,7 +4,7 @@
-# Official Sentry SDK for Cloudflare [UNRELEASED]
+# Official Sentry SDK for Cloudflare
[](https://www.npmjs.com/package/@sentry/cloudflare)
[](https://www.npmjs.com/package/@sentry/cloudflare)
@@ -18,9 +18,7 @@
**Note: This SDK is unreleased. Please follow the
[tracking GH issue](https://github.com/getsentry/sentry-javascript/issues/12620) for updates.**
-Below details the setup for the Cloudflare Workers. Cloudflare Pages support is in active development.
-
-## Setup (Cloudflare Workers)
+## Install
To get started, first install the `@sentry/cloudflare` package:
@@ -36,6 +34,46 @@ compatibility_flags = ["nodejs_compat"]
# compatibility_flags = ["nodejs_als"]
```
+Then you can either setup up the SDK for [Cloudflare Pages](#setup-cloudflare-pages) or
+[Cloudflare Workers](#setup-cloudflare-workers).
+
+## Setup (Cloudflare Pages)
+
+To use this SDK, add the `sentryPagesPlugin` as
+[middleware to your Cloudflare Pages application](https://developers.cloudflare.com/pages/functions/middleware/).
+
+We recommend adding a `functions/_middleware.js` for the middleware setup so that Sentry is initialized for your entire
+app.
+
+```javascript
+// functions/_middleware.js
+import * as Sentry from '@sentry/cloudflare';
+
+export const onRequest = Sentry.sentryPagesPlugin({
+ dsn: process.env.SENTRY_DSN,
+ // Set tracesSampleRate to 1.0 to capture 100% of spans for tracing.
+ tracesSampleRate: 1.0,
+});
+```
+
+If you need to to chain multiple middlewares, you can do so by exporting an array of middlewares. Make sure the Sentry
+middleware is the first one in the array.
+
+```javascript
+import * as Sentry from '@sentry/cloudflare';
+
+export const onRequest = [
+ // Make sure Sentry is the first middleware
+ Sentry.sentryPagesPlugin({
+ dsn: process.env.SENTRY_DSN,
+ tracesSampleRate: 1.0,
+ }),
+ // Add more middlewares here
+];
+```
+
+## Setup (Cloudflare Workers)
+
To use this SDK, wrap your handler with the `withSentry` function. This will initialize the SDK and hook into the
environment. Note that you can turn off almost all side effects using the respective options.
@@ -58,7 +96,7 @@ export default withSentry(
);
```
-### Sourcemaps (Cloudflare Workers)
+### Sourcemaps
Configure uploading sourcemaps via the Sentry Wizard:
@@ -68,10 +106,11 @@ npx @sentry/wizard@latest -i sourcemaps
See more details in our [docs](https://docs.sentry.io/platforms/javascript/sourcemaps/).
-## Usage (Cloudflare Workers)
+## Usage
To set context information or send manual events, use the exported functions of `@sentry/cloudflare`. Note that these
-functions will require your exported handler to be wrapped in `withSentry`.
+functions will require the usage of the Sentry helpers, either `withSentry` function for Cloudflare Workers or the
+`sentryPagesPlugin` middleware for Cloudflare Pages.
```javascript
import * as Sentry from '@sentry/cloudflare';
diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts
index 45eca78f9946..65f3cf8bcbf1 100644
--- a/packages/cloudflare/src/handler.ts
+++ b/packages/cloudflare/src/handler.ts
@@ -1,23 +1,7 @@
-import type {
- ExportedHandler,
- ExportedHandlerFetchHandler,
- IncomingRequestCfProperties,
-} from '@cloudflare/workers-types';
-import {
- SEMANTIC_ATTRIBUTE_SENTRY_OP,
- SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
- SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
- captureException,
- continueTrace,
- flush,
- setHttpStatus,
- startSpan,
- withIsolationScope,
-} from '@sentry/core';
-import type { Options, Scope, SpanAttributes } from '@sentry/types';
-import { stripUrlQueryAndFragment, winterCGRequestToRequestData } from '@sentry/utils';
+import type { ExportedHandler, ExportedHandlerFetchHandler } from '@cloudflare/workers-types';
+import type { Options } from '@sentry/types';
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
-import { init } from './sdk';
+import { wrapRequestHandler } from './request';
/**
* Extract environment generic from exported handler.
@@ -47,70 +31,8 @@ export function withSentry>(
handler.fetch = new Proxy(handler.fetch, {
apply(target, thisArg, args: Parameters>>) {
const [request, env, context] = args;
- return withIsolationScope(isolationScope => {
- const options = optionsCallback(env);
- const client = init(options);
- isolationScope.setClient(client);
-
- const attributes: SpanAttributes = {
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.cloudflare-worker',
- [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
- [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
- ['http.request.method']: request.method,
- ['url.full']: request.url,
- };
-
- const contentLength = request.headers.get('content-length');
- if (contentLength) {
- attributes['http.request.body.size'] = parseInt(contentLength, 10);
- }
-
- let pathname = '';
- try {
- const url = new URL(request.url);
- pathname = url.pathname;
- attributes['server.address'] = url.hostname;
- attributes['url.scheme'] = url.protocol.replace(':', '');
- } catch {
- // skip
- }
-
- addRequest(isolationScope, request);
- addCloudResourceContext(isolationScope);
- if (request.cf) {
- addCultureContext(isolationScope, request.cf);
- attributes['network.protocol.name'] = request.cf.httpProtocol;
- }
-
- const routeName = `${request.method} ${pathname ? stripUrlQueryAndFragment(pathname) : '/'}`;
-
- return continueTrace(
- { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') },
- () => {
- // Note: This span will not have a duration unless I/O happens in the handler. This is
- // because of how the cloudflare workers runtime works.
- // See: https://developers.cloudflare.com/workers/runtime-apis/performance/
- return startSpan(
- {
- name: routeName,
- attributes,
- },
- async span => {
- try {
- const res = await (target.apply(thisArg, args) as ReturnType);
- setHttpStatus(span, res.status);
- return res;
- } catch (e) {
- captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
- throw e;
- } finally {
- context.waitUntil(flush(2000));
- }
- },
- );
- },
- );
- });
+ const options = optionsCallback(env);
+ return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args));
},
});
@@ -120,19 +42,3 @@ export function withSentry>(
return handler;
}
-
-function addCloudResourceContext(isolationScope: Scope): void {
- isolationScope.setContext('cloud_resource', {
- 'cloud.provider': 'cloudflare',
- });
-}
-
-function addCultureContext(isolationScope: Scope, cf: IncomingRequestCfProperties): void {
- isolationScope.setContext('culture', {
- timezone: cf.timezone,
- });
-}
-
-function addRequest(isolationScope: Scope, request: Request): void {
- isolationScope.setSDKProcessingMetadata({ request: winterCGRequestToRequestData(request) });
-}
diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts
index 6ef2b536aef4..3708d3ae9382 100644
--- a/packages/cloudflare/src/index.ts
+++ b/packages/cloudflare/src/index.ts
@@ -85,6 +85,7 @@ export {
} from '@sentry/core';
export { withSentry } from './handler';
+export { sentryPagesPlugin } from './pages-plugin';
export { CloudflareClient } from './client';
export { getDefaultIntegrations } from './sdk';
diff --git a/packages/cloudflare/src/pages-plugin.ts b/packages/cloudflare/src/pages-plugin.ts
new file mode 100644
index 000000000000..7f7070ddfbf7
--- /dev/null
+++ b/packages/cloudflare/src/pages-plugin.ts
@@ -0,0 +1,32 @@
+import { setAsyncLocalStorageAsyncContextStrategy } from './async';
+import type { CloudflareOptions } from './client';
+import { wrapRequestHandler } from './request';
+
+/**
+ * Plugin middleware for Cloudflare Pages.
+ *
+ * Initializes the SDK and wraps cloudflare pages requests with SDK instrumentation.
+ *
+ * @example
+ * ```javascript
+ * // functions/_middleware.js
+ * import * as Sentry from '@sentry/cloudflare';
+ *
+ * export const onRequest = Sentry.sentryPagesPlugin({
+ * dsn: process.env.SENTRY_DSN,
+ * tracesSampleRate: 1.0,
+ * });
+ * ```
+ *
+ * @param _options
+ * @returns
+ */
+export function sentryPagesPlugin<
+ Env = unknown,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ Params extends string = any,
+ Data extends Record = Record,
+>(options: CloudflareOptions): PagesPluginFunction {
+ setAsyncLocalStorageAsyncContextStrategy();
+ return context => wrapRequestHandler({ options, request: context.request, context }, () => context.next());
+}
diff --git a/packages/cloudflare/src/request.ts b/packages/cloudflare/src/request.ts
new file mode 100644
index 000000000000..b10037ec8bc0
--- /dev/null
+++ b/packages/cloudflare/src/request.ts
@@ -0,0 +1,123 @@
+import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types';
+
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
+ captureException,
+ continueTrace,
+ flush,
+ setHttpStatus,
+ startSpan,
+ withIsolationScope,
+} from '@sentry/core';
+import type { Scope, SpanAttributes } from '@sentry/types';
+import { stripUrlQueryAndFragment, winterCGRequestToRequestData } from '@sentry/utils';
+import type { CloudflareOptions } from './client';
+import { init } from './sdk';
+
+interface RequestHandlerWrapperOptions {
+ options: CloudflareOptions;
+ request: Request>;
+ context: ExecutionContext;
+}
+
+/**
+ * Wraps a cloudflare request handler in Sentry instrumentation
+ */
+export function wrapRequestHandler(
+ wrapperOptions: RequestHandlerWrapperOptions,
+ handler: (...args: unknown[]) => Response | Promise,
+): Promise {
+ return withIsolationScope(async isolationScope => {
+ const { options, request, context } = wrapperOptions;
+ const client = init(options);
+ isolationScope.setClient(client);
+
+ const attributes: SpanAttributes = {
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.cloudflare',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
+ ['http.request.method']: request.method,
+ ['url.full']: request.url,
+ };
+
+ const contentLength = request.headers.get('content-length');
+ if (contentLength) {
+ attributes['http.request.body.size'] = parseInt(contentLength, 10);
+ }
+
+ let pathname = '';
+ try {
+ const url = new URL(request.url);
+ pathname = url.pathname;
+ attributes['server.address'] = url.hostname;
+ attributes['url.scheme'] = url.protocol.replace(':', '');
+ } catch {
+ // skip
+ }
+
+ addCloudResourceContext(isolationScope);
+ if (request) {
+ addRequest(isolationScope, request);
+ if (request.cf) {
+ addCultureContext(isolationScope, request.cf);
+ attributes['network.protocol.name'] = request.cf.httpProtocol;
+ }
+ }
+
+ const routeName = `${request.method} ${pathname ? stripUrlQueryAndFragment(pathname) : '/'}`;
+
+ return continueTrace(
+ { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') },
+ () => {
+ // Note: This span will not have a duration unless I/O happens in the handler. This is
+ // because of how the cloudflare workers runtime works.
+ // See: https://developers.cloudflare.com/workers/runtime-apis/performance/
+ return startSpan(
+ {
+ name: routeName,
+ attributes,
+ },
+ async span => {
+ try {
+ const res = await handler();
+ setHttpStatus(span, res.status);
+ return res;
+ } catch (e) {
+ captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
+ throw e;
+ } finally {
+ context.waitUntil(flush(2000));
+ }
+ },
+ );
+ },
+ );
+ });
+}
+
+/**
+ * Set cloud resource context on scope.
+ */
+function addCloudResourceContext(scope: Scope): void {
+ scope.setContext('cloud_resource', {
+ 'cloud.provider': 'cloudflare',
+ });
+}
+
+/**
+ * Set culture context on scope
+ */
+function addCultureContext(scope: Scope, cf: IncomingRequestCfProperties): void {
+ scope.setContext('culture', {
+ timezone: cf.timezone,
+ });
+}
+
+/**
+ * Set request data on scope
+ */
+function addRequest(scope: Scope, request: Request): void {
+ scope.setSDKProcessingMetadata({ request: winterCGRequestToRequestData(request) });
+}
diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts
index edc242656195..ca2035388c12 100644
--- a/packages/cloudflare/src/sdk.ts
+++ b/packages/cloudflare/src/sdk.ts
@@ -17,14 +17,15 @@ import { makeCloudflareTransport } from './transport';
import { defaultStackParser } from './vendor/stacktrace';
/** Get the default integrations for the Cloudflare SDK. */
-export function getDefaultIntegrations(_options: Options): Integration[] {
+export function getDefaultIntegrations(options: Options): Integration[] {
+ const sendDefaultPii = options.sendDefaultPii ?? false;
return [
dedupeIntegration(),
inboundFiltersIntegration(),
functionToStringIntegration(),
linkedErrorsIntegration(),
fetchIntegration(),
- requestDataIntegration(),
+ requestDataIntegration(sendDefaultPii ? undefined : { include: { cookies: false } }),
];
}
diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts
index e8358dd63f50..238fbd987c90 100644
--- a/packages/cloudflare/test/handler.test.ts
+++ b/packages/cloudflare/test/handler.test.ts
@@ -3,16 +3,13 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
-import * as SentryCore from '@sentry/core';
-import type { Event } from '@sentry/types';
-import { CloudflareClient } from '../src/client';
import { withSentry } from '../src/handler';
const MOCK_ENV = {
SENTRY_DSN: 'https://public@dsn.ingest.sentry.io/1337',
};
-describe('withSentry', () => {
+describe('sentryPagesPlugin', () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -50,249 +47,6 @@ describe('withSentry', () => {
expect(result).toBe(response);
});
-
- test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => {
- const handler = {
- async fetch(_request, _env, _context) {
- return new Response('test');
- },
- } satisfies ExportedHandler;
-
- const context = createMockExecutionContext();
- const wrappedHandler = withSentry(() => ({}), handler);
- await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, context);
-
- // eslint-disable-next-line @typescript-eslint/unbound-method
- expect(context.waitUntil).toHaveBeenCalledTimes(1);
- // eslint-disable-next-line @typescript-eslint/unbound-method
- expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise));
- });
-
- test('creates a cloudflare client and sets it on the handler', async () => {
- const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind');
- const handler = {
- async fetch(_request, _env, _context) {
- return new Response('test');
- },
- } satisfies ExportedHandler;
-
- const context = createMockExecutionContext();
- const wrappedHandler = withSentry(() => ({}), handler);
- await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, context);
-
- expect(initAndBindSpy).toHaveBeenCalledTimes(1);
- expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object));
- });
-
- describe('scope instrumentation', () => {
- test('adds cloud resource context', async () => {
- const handler = {
- async fetch(_request, _env, _context) {
- SentryCore.captureMessage('test');
- return new Response('test');
- },
- } satisfies ExportedHandler;
-
- let sentryEvent: Event = {};
- const wrappedHandler = withSentry(
- (env: any) => ({
- dsn: env.MOCK_DSN,
- beforeSend(event) {
- sentryEvent = event;
- return null;
- },
- }),
- handler,
- );
- await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext());
- expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' });
- });
-
- test('adds request information', async () => {
- const handler = {
- async fetch(_request, _env, _context) {
- SentryCore.captureMessage('test');
- return new Response('test');
- },
- } satisfies ExportedHandler;
-
- let sentryEvent: Event = {};
- const wrappedHandler = withSentry(
- (env: any) => ({
- dsn: env.MOCK_DSN,
- beforeSend(event) {
- sentryEvent = event;
- return null;
- },
- }),
- handler,
- );
- await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext());
- expect(sentryEvent.sdkProcessingMetadata?.request).toEqual({
- headers: {},
- url: 'https://example.com/',
- method: 'GET',
- });
- });
-
- test('adds culture context', async () => {
- const handler = {
- async fetch(_request, _env, _context) {
- SentryCore.captureMessage('test');
- return new Response('test');
- },
- } satisfies ExportedHandler;
-
- let sentryEvent: Event = {};
- const wrappedHandler = withSentry(
- (env: any) => ({
- dsn: env.MOCK_DSN,
- beforeSend(event) {
- sentryEvent = event;
- return null;
- },
- }),
- handler,
- );
- const mockRequest = new Request('https://example.com') as any;
- mockRequest.cf = {
- timezone: 'UTC',
- };
- await wrappedHandler.fetch(mockRequest, { ...MOCK_ENV }, createMockExecutionContext());
- expect(sentryEvent.contexts?.culture).toEqual({ timezone: 'UTC' });
- });
- });
-
- describe('error instrumentation', () => {
- test('captures errors thrown by the handler', async () => {
- const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException');
- const error = new Error('test');
- const handler = {
- async fetch(_request, _env, _context) {
- throw error;
- },
- } satisfies ExportedHandler;
-
- const wrappedHandler = withSentry(() => ({}), handler);
- expect(captureExceptionSpy).not.toHaveBeenCalled();
- try {
- await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext());
- } catch {
- // ignore
- }
- expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
- expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, {
- mechanism: { handled: false, type: 'cloudflare' },
- });
- });
-
- test('re-throws the error after capturing', async () => {
- const error = new Error('test');
- const handler = {
- async fetch(_request, _env, _context) {
- throw error;
- },
- } satisfies ExportedHandler;
-
- const wrappedHandler = withSentry(() => ({}), handler);
- let thrownError: Error | undefined;
- try {
- await wrappedHandler.fetch(new Request('https://example.com'), MOCK_ENV, createMockExecutionContext());
- } catch (e: any) {
- thrownError = e;
- }
-
- expect(thrownError).toBe(error);
- });
- });
-
- describe('tracing instrumentation', () => {
- test('continues trace with sentry trace and baggage', async () => {
- const handler = {
- async fetch(_request, _env, _context) {
- SentryCore.captureMessage('test');
- return new Response('test');
- },
- } satisfies ExportedHandler;
-
- let sentryEvent: Event = {};
- const wrappedHandler = withSentry(
- (env: any) => ({
- dsn: env.MOCK_DSN,
- tracesSampleRate: 0,
- beforeSend(event) {
- sentryEvent = event;
- return null;
- },
- }),
- handler,
- );
-
- const request = new Request('https://example.com') as any;
- request.headers.set('sentry-trace', '12312012123120121231201212312012-1121201211212012-1');
- request.headers.set(
- 'baggage',
- 'sentry-release=2.1.12,sentry-public_key=public,sentry-trace_id=12312012123120121231201212312012,sentry-sample_rate=0.3232',
- );
- await wrappedHandler.fetch(request, MOCK_ENV, createMockExecutionContext());
- expect(sentryEvent.contexts?.trace).toEqual({
- parent_span_id: '1121201211212012',
- span_id: expect.any(String),
- trace_id: '12312012123120121231201212312012',
- });
- });
-
- test('creates a span that wraps fetch handler', async () => {
- const handler = {
- async fetch(_request, _env, _context) {
- return new Response('test');
- },
- } satisfies ExportedHandler;
-
- let sentryEvent: Event = {};
- const wrappedHandler = withSentry(
- (env: any) => ({
- dsn: env.MOCK_DSN,
- tracesSampleRate: 1,
- beforeSendTransaction(event) {
- sentryEvent = event;
- return null;
- },
- }),
- handler,
- );
-
- const request = new Request('https://example.com') as any;
- request.cf = {
- httpProtocol: 'HTTP/1.1',
- };
- request.headers.set('content-length', '10');
-
- await wrappedHandler.fetch(request, MOCK_ENV, createMockExecutionContext());
- expect(sentryEvent.transaction).toEqual('GET /');
- expect(sentryEvent.spans).toHaveLength(0);
- expect(sentryEvent.contexts?.trace).toEqual({
- data: {
- 'sentry.origin': 'auto.http.cloudflare-worker',
- 'sentry.op': 'http.server',
- 'sentry.source': 'url',
- 'http.request.method': 'GET',
- 'url.full': 'https://example.com/',
- 'server.address': 'example.com',
- 'network.protocol.name': 'HTTP/1.1',
- 'url.scheme': 'https',
- 'sentry.sample_rate': 1,
- 'http.response.status_code': 200,
- 'http.request.body.size': 10,
- },
- op: 'http.server',
- origin: 'auto.http.cloudflare-worker',
- span_id: expect.any(String),
- status: 'ok',
- trace_id: expect.any(String),
- });
- });
- });
});
function createMockExecutionContext(): ExecutionContext {
diff --git a/packages/cloudflare/test/pages-plugin.test.ts b/packages/cloudflare/test/pages-plugin.test.ts
new file mode 100644
index 000000000000..6e8b87351f8e
--- /dev/null
+++ b/packages/cloudflare/test/pages-plugin.test.ts
@@ -0,0 +1,36 @@
+// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime.
+// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers.
+
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import type { CloudflareOptions } from '../src/client';
+
+import { sentryPagesPlugin } from '../src/pages-plugin';
+
+const MOCK_OPTIONS: CloudflareOptions = {
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+};
+
+describe('sentryPagesPlugin', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('passes through the response from the handler', async () => {
+ const response = new Response('test');
+ const mockOnRequest = sentryPagesPlugin(MOCK_OPTIONS);
+
+ const result = await mockOnRequest({
+ request: new Request('https://example.com'),
+ functionPath: 'test',
+ waitUntil: vi.fn(),
+ passThroughOnException: vi.fn(),
+ next: () => Promise.resolve(response),
+ env: { ASSETS: { fetch: vi.fn() } },
+ params: {},
+ data: {},
+ pluginArgs: MOCK_OPTIONS,
+ });
+
+ expect(result).toBe(response);
+ });
+});
diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts
new file mode 100644
index 000000000000..93764a292ab4
--- /dev/null
+++ b/packages/cloudflare/test/request.test.ts
@@ -0,0 +1,274 @@
+// Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime.
+// Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers.
+
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import * as SentryCore from '@sentry/core';
+import type { Event } from '@sentry/types';
+import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async';
+import type { CloudflareOptions } from '../src/client';
+import { CloudflareClient } from '../src/client';
+import { wrapRequestHandler } from '../src/request';
+
+const MOCK_OPTIONS: CloudflareOptions = {
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+};
+
+describe('withSentry', () => {
+ beforeAll(() => {
+ setAsyncLocalStorageAsyncContextStrategy();
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('passes through the response from the handler', async () => {
+ const response = new Response('test');
+ const result = await wrapRequestHandler(
+ { options: MOCK_OPTIONS, request: new Request('https://example.com'), context: createMockExecutionContext() },
+ () => response,
+ );
+ expect(result).toBe(response);
+ });
+
+ test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => {
+ const context = createMockExecutionContext();
+ await wrapRequestHandler(
+ { options: MOCK_OPTIONS, request: new Request('https://example.com'), context },
+ () => new Response('test'),
+ );
+
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ expect(context.waitUntil).toHaveBeenCalledTimes(1);
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise));
+ });
+
+ test('creates a cloudflare client and sets it on the handler', async () => {
+ const initAndBindSpy = vi.spyOn(SentryCore, 'initAndBind');
+ await wrapRequestHandler(
+ { options: MOCK_OPTIONS, request: new Request('https://example.com'), context: createMockExecutionContext() },
+ () => new Response('test'),
+ );
+
+ expect(initAndBindSpy).toHaveBeenCalledTimes(1);
+ expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object));
+ });
+
+ describe('scope instrumentation', () => {
+ test('adds cloud resource context', async () => {
+ let sentryEvent: Event = {};
+ await wrapRequestHandler(
+ {
+ options: {
+ ...MOCK_OPTIONS,
+ beforeSend(event) {
+ sentryEvent = event;
+ return null;
+ },
+ },
+ request: new Request('https://example.com'),
+ context: createMockExecutionContext(),
+ },
+ () => {
+ SentryCore.captureMessage('cloud resource');
+ return new Response('test');
+ },
+ );
+
+ expect(sentryEvent.contexts?.cloud_resource).toEqual({ 'cloud.provider': 'cloudflare' });
+ });
+
+ test('adds request information', async () => {
+ let sentryEvent: Event = {};
+ await wrapRequestHandler(
+ {
+ options: {
+ ...MOCK_OPTIONS,
+ beforeSend(event) {
+ sentryEvent = event;
+ return null;
+ },
+ },
+ request: new Request('https://example.com'),
+ context: createMockExecutionContext(),
+ },
+ () => {
+ SentryCore.captureMessage('request');
+ return new Response('test');
+ },
+ );
+
+ expect(sentryEvent.sdkProcessingMetadata?.request).toEqual({
+ headers: {},
+ url: 'https://example.com/',
+ method: 'GET',
+ });
+ });
+
+ test('adds culture context', async () => {
+ const mockRequest = new Request('https://example.com') as any;
+ mockRequest.cf = {
+ timezone: 'UTC',
+ };
+
+ let sentryEvent: Event = {};
+ await wrapRequestHandler(
+ {
+ options: {
+ ...MOCK_OPTIONS,
+ beforeSend(event) {
+ sentryEvent = event;
+ return null;
+ },
+ },
+ request: mockRequest,
+ context: createMockExecutionContext(),
+ },
+ () => {
+ SentryCore.captureMessage('culture');
+ return new Response('test');
+ },
+ );
+
+ expect(sentryEvent.contexts?.culture).toEqual({ timezone: 'UTC' });
+ });
+ });
+
+ describe('error instrumentation', () => {
+ test('captures errors thrown by the handler', async () => {
+ const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException');
+ const error = new Error('test');
+
+ expect(captureExceptionSpy).not.toHaveBeenCalled();
+
+ try {
+ await wrapRequestHandler(
+ { options: MOCK_OPTIONS, request: new Request('https://example.com'), context: createMockExecutionContext() },
+ () => {
+ throw error;
+ },
+ );
+ } catch {
+ // ignore
+ }
+
+ expect(captureExceptionSpy).toHaveBeenCalledTimes(1);
+ expect(captureExceptionSpy).toHaveBeenLastCalledWith(error, {
+ mechanism: { handled: false, type: 'cloudflare' },
+ });
+ });
+
+ test('re-throws the error after capturing', async () => {
+ const error = new Error('test');
+ let thrownError: Error | undefined;
+ try {
+ await wrapRequestHandler(
+ { options: MOCK_OPTIONS, request: new Request('https://example.com'), context: createMockExecutionContext() },
+ () => {
+ throw error;
+ },
+ );
+ } catch (e: any) {
+ thrownError = e;
+ }
+
+ expect(thrownError).toBe(error);
+ });
+ });
+
+ describe('tracing instrumentation', () => {
+ test('continues trace with sentry trace and baggage', async () => {
+ const mockRequest = new Request('https://example.com') as any;
+ mockRequest.headers.set('sentry-trace', '12312012123120121231201212312012-1121201211212012-1');
+ mockRequest.headers.set(
+ 'baggage',
+ 'sentry-release=2.1.12,sentry-public_key=public,sentry-trace_id=12312012123120121231201212312012,sentry-sample_rate=0.3232',
+ );
+
+ let sentryEvent: Event = {};
+ await wrapRequestHandler(
+ {
+ options: {
+ ...MOCK_OPTIONS,
+ tracesSampleRate: 0,
+ beforeSend(event) {
+ sentryEvent = event;
+ return null;
+ },
+ },
+ request: mockRequest,
+ context: createMockExecutionContext(),
+ },
+ () => {
+ SentryCore.captureMessage('sentry-trace');
+ return new Response('test');
+ },
+ );
+ expect(sentryEvent.contexts?.trace).toEqual({
+ parent_span_id: '1121201211212012',
+ span_id: expect.any(String),
+ trace_id: '12312012123120121231201212312012',
+ });
+ });
+
+ test('creates a span that wraps request handler', async () => {
+ const mockRequest = new Request('https://example.com') as any;
+ mockRequest.cf = {
+ httpProtocol: 'HTTP/1.1',
+ };
+ mockRequest.headers.set('content-length', '10');
+
+ let sentryEvent: Event = {};
+ await wrapRequestHandler(
+ {
+ options: {
+ ...MOCK_OPTIONS,
+ tracesSampleRate: 1,
+ beforeSendTransaction(event) {
+ sentryEvent = event;
+ return null;
+ },
+ },
+ request: mockRequest,
+ context: createMockExecutionContext(),
+ },
+ () => {
+ SentryCore.captureMessage('sentry-trace');
+ return new Response('test');
+ },
+ );
+
+ expect(sentryEvent.transaction).toEqual('GET /');
+ expect(sentryEvent.spans).toHaveLength(0);
+ expect(sentryEvent.contexts?.trace).toEqual({
+ data: {
+ 'sentry.origin': 'auto.http.cloudflare',
+ 'sentry.op': 'http.server',
+ 'sentry.source': 'url',
+ 'http.request.method': 'GET',
+ 'url.full': 'https://example.com/',
+ 'server.address': 'example.com',
+ 'network.protocol.name': 'HTTP/1.1',
+ 'url.scheme': 'https',
+ 'sentry.sample_rate': 1,
+ 'http.response.status_code': 200,
+ 'http.request.body.size': 10,
+ },
+ op: 'http.server',
+ origin: 'auto.http.cloudflare',
+ span_id: expect.any(String),
+ status: 'ok',
+ trace_id: expect.any(String),
+ });
+ });
+ });
+});
+
+function createMockExecutionContext(): ExecutionContext {
+ return {
+ waitUntil: vi.fn(),
+ passThroughOnException: vi.fn(),
+ };
+}