Skip to content

Commit

Permalink
feat(cloudflare): Add withSentry method (#13025)
Browse files Browse the repository at this point in the history
Before reviewing this patch, I recommend reading through a writeup I did:
#13007

This PR adds `withSentry`, a method that wraps your cloudflare worker
handler to add Sentry instrumentation. The writeup above explains why we
need to do this over just a regular `Sentry.init` call.

The implementation of `withSentry` is fairly straightforward, wrapping
the fetch handler in the cloudflare worker with:

1. `withIsolationScope` to isolate it from other concurrent requests
2. helpers to update scope with relevant contexts/request
3. `continueTrace` to continue distributed tracing
4. `startSpan` to track spans

Usage looks something like so:

```ts
import * as Sentry from '@sentry/cloudflare';

export default withSentry(
  (env) => ({
    dsn: env.SENTRY_DSN,
    tracesSampleRate: 1.0,
  }),
  {
    async fetch(request, env, ctx) {
      return new Response('Hello World!');
    },
  } satisfies ExportedHandler<Env>,
);
```

Next step here is to add more robust e2e tests, and then release an
initial version!
  • Loading branch information
AbhiPrasad authored Jul 29, 2024
1 parent 03257e0 commit d2ab51c
Show file tree
Hide file tree
Showing 13 changed files with 697 additions and 72 deletions.
80 changes: 78 additions & 2 deletions packages/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,82 @@
**Note: This SDK is unreleased. Please follow the
[tracking GH issue](https://github.com/getsentry/sentry-javascript/issues/12620) for updates.**

## Usage
Below details the setup for the Cloudflare Workers. Cloudflare Pages support is in active development.

TODO: Add usage instructions here.
## Setup (Cloudflare Workers)

To get started, first install the `@sentry/cloudflare` package:

```bash
npm install @sentry/cloudflare
```

Then set either the `nodejs_compat` or `nodejs_als` compatibility flags in your `wrangler.toml`. This is because the SDK
needs access to the `AsyncLocalStorage` API to work correctly.

```toml
compatibility_flags = ["nodejs_compat"]
# compatibility_flags = ["nodejs_als"]
```

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.

Currently only ESM handlers are supported.

```javascript
import * as Sentry from '@sentry/cloudflare';

export default withSentry(
(env) => ({
dsn: env.SENTRY_DSN,
// Set tracesSampleRate to 1.0 to capture 100% of spans for tracing.
tracesSampleRate: 1.0,
}),
{
async fetch(request, env, ctx) {
return new Response('Hello World!');
},
} satisfies ExportedHandler<Env>
);
```

### Sourcemaps (Cloudflare Workers)

Configure uploading sourcemaps via the Sentry Wizard:

```bash
npx @sentry/wizard@latest -i sourcemaps
```

See more details in our [docs](https://docs.sentry.io/platforms/javascript/sourcemaps/).

## Usage (Cloudflare Workers)

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`.

```javascript
import * as Sentry from '@sentry/cloudflare';

// Set user information, as well as tags and further extras
Sentry.setExtra('battery', 0.7);
Sentry.setTag('user_mode', 'admin');
Sentry.setUser({ id: '4711' });

// Add a breadcrumb for future events
Sentry.addBreadcrumb({
message: 'My Breadcrumb',
// ...
});

// Capture exceptions, messages or manual events
Sentry.captureMessage('Hello, world!');
Sentry.captureException(new Error('Good bye'));
Sentry.captureEvent({
message: 'Manual',
stacktrace: [
// ...
],
});
```
9 changes: 6 additions & 3 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@
"@sentry/types": "8.20.0",
"@sentry/utils": "8.20.0"
},
"optionalDependencies": {
"@cloudflare/workers-types": "^4.x"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240712.0",
"@cloudflare/workers-types": "^4.20240722.0",
"@types/node": "^14.18.0",
"miniflare": "^3.20240701.0",
"wrangler": "^3.64.0"
"miniflare": "^3.20240718.0",
"wrangler": "^3.65.1"
},
"scripts": {
"build": "run-p build:transpile build:types",
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class CloudflareClient extends ServerRuntimeClient<CloudflareClientOption
* @param options Configuration options for this SDK.
*/
public constructor(options: CloudflareClientOptions) {
applySdkMetadata(options, 'options');
applySdkMetadata(options, 'cloudflare');
options._metadata = options._metadata || {};

const clientOptions: ServerRuntimeClientOptions = {
Expand Down
138 changes: 138 additions & 0 deletions packages/cloudflare/src/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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 { setAsyncLocalStorageAsyncContextStrategy } from './async';
import { init } from './sdk';

/**
* Extract environment generic from exported handler.
*/
type ExtractEnv<P> = P extends ExportedHandler<infer Env> ? Env : never;

/**
* Wrapper for Cloudflare handlers.
*
* Initializes the SDK and wraps the handler with Sentry instrumentation.
*
* Automatically instruments the `fetch` method of the handler.
*
* @param optionsCallback Function that returns the options for the SDK initialization.
* @param handler {ExportedHandler} The handler to wrap.
* @returns The wrapped handler.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function withSentry<E extends ExportedHandler<any>>(
optionsCallback: (env: ExtractEnv<E>) => Options,
handler: E,
): E {
setAsyncLocalStorageAsyncContextStrategy();

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
if ('fetch' in handler && typeof handler.fetch === 'function' && !(handler.fetch as any).__SENTRY_INSTRUMENTED__) {
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));
}
},
);
},
);
});
},
});

// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(handler.fetch as any).__SENTRY_INSTRUMENTED__ = true;
}

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) });
}
2 changes: 2 additions & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export {
spanToBaggageHeader,
} from '@sentry/core';

export { withSentry } from './handler';

export { CloudflareClient } from './client';
export { getDefaultIntegrations } from './sdk';

Expand Down
10 changes: 2 additions & 8 deletions packages/cloudflare/src/integrations/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,12 @@ const _fetchIntegration = ((options: Partial<Options> = {}) => {
return;
}

instrumentFetchRequest(
handlerData,
_shouldCreateSpan,
_shouldAttachTraceData,
spans,
'auto.http.wintercg_fetch',
);
instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, 'auto.http.fetch');

if (breadcrumbs) {
createBreadcrumb(handlerData);
}
});
}, true);
},
setup(client) {
HAS_CLIENT_MAP.set(client, true);
Expand Down
30 changes: 25 additions & 5 deletions packages/cloudflare/src/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,47 @@
import {
dedupeIntegration,
functionToStringIntegration,
getIntegrationsToSetup,
inboundFiltersIntegration,
initAndBind,
linkedErrorsIntegration,
requestDataIntegration,
} from '@sentry/core';
import type { Integration, Options } from '@sentry/types';
import { stackParserFromStackParserOptions } from '@sentry/utils';
import type { CloudflareClientOptions } from './client';
import { CloudflareClient } from './client';

import { fetchIntegration } from './integrations/fetch';
import { makeCloudflareTransport } from './transport';
import { defaultStackParser } from './vendor/stacktrace';

/** Get the default integrations for the Cloudflare SDK. */
export function getDefaultIntegrations(options: Options): Integration[] {
const integrations = [
export function getDefaultIntegrations(_options: Options): Integration[] {
return [
dedupeIntegration(),
inboundFiltersIntegration(),
functionToStringIntegration(),
linkedErrorsIntegration(),
fetchIntegration(),
requestDataIntegration(),
];
}

if (options.sendDefaultPii) {
integrations.push(requestDataIntegration());
/**
* Initializes the cloudflare SDK.
*/
export function init(options: Options): CloudflareClient | undefined {
if (options.defaultIntegrations === undefined) {
options.defaultIntegrations = getDefaultIntegrations(options);
}

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

return initAndBind(CloudflareClient, clientOptions) as CloudflareClient;
}
Loading

0 comments on commit d2ab51c

Please sign in to comment.