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 [![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(), + }; +}