Skip to content

Commit

Permalink
feat(cloudflare): Add plugin for cloudflare pages
Browse files Browse the repository at this point in the history
  • Loading branch information
AbhiPrasad committed Jul 30, 2024
1 parent 2d43b64 commit a793bd5
Show file tree
Hide file tree
Showing 9 changed files with 506 additions and 355 deletions.
52 changes: 45 additions & 7 deletions packages/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
</a>
</p>

# 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)
Expand All @@ -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:

Expand All @@ -36,6 +34,45 @@ 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,
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.

Expand All @@ -58,7 +95,7 @@ export default withSentry(
);
```

### Sourcemaps (Cloudflare Workers)
### Sourcemaps

Configure uploading sourcemaps via the Sentry Wizard:

Expand All @@ -68,10 +105,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';
Expand Down
104 changes: 5 additions & 99 deletions packages/cloudflare/src/handler.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -47,70 +31,8 @@ export function withSentry<E extends ExportedHandler<any>>(
handler.fetch = new Proxy(handler.fetch, {
apply(target, thisArg, args: Parameters<ExportedHandlerFetchHandler<ExtractEnv<E>>>) {
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<typeof target>);
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));
},
});

Expand All @@ -120,19 +42,3 @@ export function withSentry<E extends ExportedHandler<any>>(

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) });
}
1 change: 1 addition & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
18 changes: 18 additions & 0 deletions packages/cloudflare/src/pages-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import type { CloudflareOptions } from './client';
import { wrapRequestHandler } from './request';

/**
*
* @param _options
* @returns
*/
export function sentryPagesPlugin<
Env = unknown,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Params extends string = any,
Data extends Record<string, unknown> = Record<string, unknown>,
>(options: CloudflareOptions): PagesPluginFunction<Env, Params, Data, CloudflareOptions> {
setAsyncLocalStorageAsyncContextStrategy();
return context => wrapRequestHandler({ options, request: context.request, context }, () => context.next());
}
123 changes: 123 additions & 0 deletions packages/cloudflare/src/request.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, IncomingRequestCfProperties<unknown>>;
context: ExecutionContext;
}

/**
* Wraps a cloudflare request handler in Sentry instrumentation
*/
export function wrapRequestHandler(
wrapperOptions: RequestHandlerWrapperOptions,
handler: (...args: unknown[]) => Response | Promise<Response>,
): Promise<Response> {
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-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
}

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) });
}
5 changes: 3 additions & 2 deletions packages/cloudflare/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }),
];
}

Expand Down
Loading

0 comments on commit a793bd5

Please sign in to comment.