From 945cdbca5afc6058102cb35d53e3f205e5945cbf Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 31 Jul 2024 09:46:59 -0400 Subject: [PATCH] feat(cloudflare): Add plugin for cloudflare pages (#13123) Before reviewing this change, I recommend reading through a GH discussion I wrote up that explains the reasoning behind the API surface of the cloudflare SDK: https://github.com/getsentry/sentry-javascript/discussions/13007 This PR adds support for [Cloudflare Pages](https://developers.cloudflare.com/pages/), Cloudflare's fullstack development deployment platform that is powered by Cloudflare Workers under the hood. Think of this platform having very similar capabilities (and constraints) as Vercel. To set the plugin up, you do something like so: ```javascript // functions/_middleware.js import * as Sentry from '@sentry/cloudflare'; export const onRequest = Sentry.sentryPagesPlugin({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 1.0, }); ``` We have to use the middleware instead of a global init because we need to call `init` for every single new incoming request to make sure the sentry instance does not get stale with redeployments. While implementing `sentryPagesPlugin`, I noticed that there was a logic that was redundant between it and `withSentry`, the API for cloudflare workers. This led me to refactor this into a common helper, `wrapRequestHandler`, which is contained in `packages/cloudflare/src/request.ts`. That is why there is diffs in this PR for `packages/cloudflare/src/handler.ts`. --- 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 [![npm version](https://img.shields.io/npm/v/@sentry/cloudflare.svg)](https://www.npmjs.com/package/@sentry/cloudflare) [![npm dm](https://img.shields.io/npm/dm/@sentry/cloudflare.svg)](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(), + }; +}