Skip to content

Commit d2ab51c

Browse files
authored
feat(cloudflare): Add withSentry method (#13025)
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!
1 parent 03257e0 commit d2ab51c

File tree

13 files changed

+697
-72
lines changed

13 files changed

+697
-72
lines changed

packages/cloudflare/README.md

+78-2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,82 @@
1818
**Note: This SDK is unreleased. Please follow the
1919
[tracking GH issue](https://github.com/getsentry/sentry-javascript/issues/12620) for updates.**
2020

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

23-
TODO: Add usage instructions here.
23+
## Setup (Cloudflare Workers)
24+
25+
To get started, first install the `@sentry/cloudflare` package:
26+
27+
```bash
28+
npm install @sentry/cloudflare
29+
```
30+
31+
Then set either the `nodejs_compat` or `nodejs_als` compatibility flags in your `wrangler.toml`. This is because the SDK
32+
needs access to the `AsyncLocalStorage` API to work correctly.
33+
34+
```toml
35+
compatibility_flags = ["nodejs_compat"]
36+
# compatibility_flags = ["nodejs_als"]
37+
```
38+
39+
To use this SDK, wrap your handler with the `withSentry` function. This will initialize the SDK and hook into the
40+
environment. Note that you can turn off almost all side effects using the respective options.
41+
42+
Currently only ESM handlers are supported.
43+
44+
```javascript
45+
import * as Sentry from '@sentry/cloudflare';
46+
47+
export default withSentry(
48+
(env) => ({
49+
dsn: env.SENTRY_DSN,
50+
// Set tracesSampleRate to 1.0 to capture 100% of spans for tracing.
51+
tracesSampleRate: 1.0,
52+
}),
53+
{
54+
async fetch(request, env, ctx) {
55+
return new Response('Hello World!');
56+
},
57+
} satisfies ExportedHandler<Env>
58+
);
59+
```
60+
61+
### Sourcemaps (Cloudflare Workers)
62+
63+
Configure uploading sourcemaps via the Sentry Wizard:
64+
65+
```bash
66+
npx @sentry/wizard@latest -i sourcemaps
67+
```
68+
69+
See more details in our [docs](https://docs.sentry.io/platforms/javascript/sourcemaps/).
70+
71+
## Usage (Cloudflare Workers)
72+
73+
To set context information or send manual events, use the exported functions of `@sentry/cloudflare`. Note that these
74+
functions will require your exported handler to be wrapped in `withSentry`.
75+
76+
```javascript
77+
import * as Sentry from '@sentry/cloudflare';
78+
79+
// Set user information, as well as tags and further extras
80+
Sentry.setExtra('battery', 0.7);
81+
Sentry.setTag('user_mode', 'admin');
82+
Sentry.setUser({ id: '4711' });
83+
84+
// Add a breadcrumb for future events
85+
Sentry.addBreadcrumb({
86+
message: 'My Breadcrumb',
87+
// ...
88+
});
89+
90+
// Capture exceptions, messages or manual events
91+
Sentry.captureMessage('Hello, world!');
92+
Sentry.captureException(new Error('Good bye'));
93+
Sentry.captureEvent({
94+
message: 'Manual',
95+
stacktrace: [
96+
// ...
97+
],
98+
});
99+
```

packages/cloudflare/package.json

+6-3
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@
4343
"@sentry/types": "8.20.0",
4444
"@sentry/utils": "8.20.0"
4545
},
46+
"optionalDependencies": {
47+
"@cloudflare/workers-types": "^4.x"
48+
},
4649
"devDependencies": {
47-
"@cloudflare/workers-types": "^4.20240712.0",
50+
"@cloudflare/workers-types": "^4.20240722.0",
4851
"@types/node": "^14.18.0",
49-
"miniflare": "^3.20240701.0",
50-
"wrangler": "^3.64.0"
52+
"miniflare": "^3.20240718.0",
53+
"wrangler": "^3.65.1"
5154
},
5255
"scripts": {
5356
"build": "run-p build:transpile build:types",

packages/cloudflare/src/client.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export class CloudflareClient extends ServerRuntimeClient<CloudflareClientOption
1616
* @param options Configuration options for this SDK.
1717
*/
1818
public constructor(options: CloudflareClientOptions) {
19-
applySdkMetadata(options, 'options');
19+
applySdkMetadata(options, 'cloudflare');
2020
options._metadata = options._metadata || {};
2121

2222
const clientOptions: ServerRuntimeClientOptions = {

packages/cloudflare/src/handler.ts

+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type {
2+
ExportedHandler,
3+
ExportedHandlerFetchHandler,
4+
IncomingRequestCfProperties,
5+
} from '@cloudflare/workers-types';
6+
import {
7+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
8+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
9+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
10+
captureException,
11+
continueTrace,
12+
flush,
13+
setHttpStatus,
14+
startSpan,
15+
withIsolationScope,
16+
} from '@sentry/core';
17+
import type { Options, Scope, SpanAttributes } from '@sentry/types';
18+
import { stripUrlQueryAndFragment, winterCGRequestToRequestData } from '@sentry/utils';
19+
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
20+
import { init } from './sdk';
21+
22+
/**
23+
* Extract environment generic from exported handler.
24+
*/
25+
type ExtractEnv<P> = P extends ExportedHandler<infer Env> ? Env : never;
26+
27+
/**
28+
* Wrapper for Cloudflare handlers.
29+
*
30+
* Initializes the SDK and wraps the handler with Sentry instrumentation.
31+
*
32+
* Automatically instruments the `fetch` method of the handler.
33+
*
34+
* @param optionsCallback Function that returns the options for the SDK initialization.
35+
* @param handler {ExportedHandler} The handler to wrap.
36+
* @returns The wrapped handler.
37+
*/
38+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
39+
export function withSentry<E extends ExportedHandler<any>>(
40+
optionsCallback: (env: ExtractEnv<E>) => Options,
41+
handler: E,
42+
): E {
43+
setAsyncLocalStorageAsyncContextStrategy();
44+
45+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
46+
if ('fetch' in handler && typeof handler.fetch === 'function' && !(handler.fetch as any).__SENTRY_INSTRUMENTED__) {
47+
handler.fetch = new Proxy(handler.fetch, {
48+
apply(target, thisArg, args: Parameters<ExportedHandlerFetchHandler<ExtractEnv<E>>>) {
49+
const [request, env, context] = args;
50+
return withIsolationScope(isolationScope => {
51+
const options = optionsCallback(env);
52+
const client = init(options);
53+
isolationScope.setClient(client);
54+
55+
const attributes: SpanAttributes = {
56+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.cloudflare-worker',
57+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
58+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
59+
['http.request.method']: request.method,
60+
['url.full']: request.url,
61+
};
62+
63+
const contentLength = request.headers.get('content-length');
64+
if (contentLength) {
65+
attributes['http.request.body.size'] = parseInt(contentLength, 10);
66+
}
67+
68+
let pathname = '';
69+
try {
70+
const url = new URL(request.url);
71+
pathname = url.pathname;
72+
attributes['server.address'] = url.hostname;
73+
attributes['url.scheme'] = url.protocol.replace(':', '');
74+
} catch {
75+
// skip
76+
}
77+
78+
addRequest(isolationScope, request);
79+
addCloudResourceContext(isolationScope);
80+
if (request.cf) {
81+
addCultureContext(isolationScope, request.cf);
82+
attributes['network.protocol.name'] = request.cf.httpProtocol;
83+
}
84+
85+
const routeName = `${request.method} ${pathname ? stripUrlQueryAndFragment(pathname) : '/'}`;
86+
87+
return continueTrace(
88+
{ sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') },
89+
() => {
90+
// Note: This span will not have a duration unless I/O happens in the handler. This is
91+
// because of how the cloudflare workers runtime works.
92+
// See: https://developers.cloudflare.com/workers/runtime-apis/performance/
93+
return startSpan(
94+
{
95+
name: routeName,
96+
attributes,
97+
},
98+
async span => {
99+
try {
100+
const res = await (target.apply(thisArg, args) as ReturnType<typeof target>);
101+
setHttpStatus(span, res.status);
102+
return res;
103+
} catch (e) {
104+
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
105+
throw e;
106+
} finally {
107+
context.waitUntil(flush(2000));
108+
}
109+
},
110+
);
111+
},
112+
);
113+
});
114+
},
115+
});
116+
117+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
118+
(handler.fetch as any).__SENTRY_INSTRUMENTED__ = true;
119+
}
120+
121+
return handler;
122+
}
123+
124+
function addCloudResourceContext(isolationScope: Scope): void {
125+
isolationScope.setContext('cloud_resource', {
126+
'cloud.provider': 'cloudflare',
127+
});
128+
}
129+
130+
function addCultureContext(isolationScope: Scope, cf: IncomingRequestCfProperties): void {
131+
isolationScope.setContext('culture', {
132+
timezone: cf.timezone,
133+
});
134+
}
135+
136+
function addRequest(isolationScope: Scope, request: Request): void {
137+
isolationScope.setSDKProcessingMetadata({ request: winterCGRequestToRequestData(request) });
138+
}

packages/cloudflare/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export {
8484
spanToBaggageHeader,
8585
} from '@sentry/core';
8686

87+
export { withSentry } from './handler';
88+
8789
export { CloudflareClient } from './client';
8890
export { getDefaultIntegrations } from './sdk';
8991

packages/cloudflare/src/integrations/fetch.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,12 @@ const _fetchIntegration = ((options: Partial<Options> = {}) => {
8989
return;
9090
}
9191

92-
instrumentFetchRequest(
93-
handlerData,
94-
_shouldCreateSpan,
95-
_shouldAttachTraceData,
96-
spans,
97-
'auto.http.wintercg_fetch',
98-
);
92+
instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, 'auto.http.fetch');
9993

10094
if (breadcrumbs) {
10195
createBreadcrumb(handlerData);
10296
}
103-
});
97+
}, true);
10498
},
10599
setup(client) {
106100
HAS_CLIENT_MAP.set(client, true);

packages/cloudflare/src/sdk.ts

+25-5
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,47 @@
11
import {
22
dedupeIntegration,
33
functionToStringIntegration,
4+
getIntegrationsToSetup,
45
inboundFiltersIntegration,
6+
initAndBind,
57
linkedErrorsIntegration,
68
requestDataIntegration,
79
} from '@sentry/core';
810
import type { Integration, Options } from '@sentry/types';
11+
import { stackParserFromStackParserOptions } from '@sentry/utils';
12+
import type { CloudflareClientOptions } from './client';
13+
import { CloudflareClient } from './client';
914

1015
import { fetchIntegration } from './integrations/fetch';
16+
import { makeCloudflareTransport } from './transport';
17+
import { defaultStackParser } from './vendor/stacktrace';
1118

1219
/** Get the default integrations for the Cloudflare SDK. */
13-
export function getDefaultIntegrations(options: Options): Integration[] {
14-
const integrations = [
20+
export function getDefaultIntegrations(_options: Options): Integration[] {
21+
return [
1522
dedupeIntegration(),
1623
inboundFiltersIntegration(),
1724
functionToStringIntegration(),
1825
linkedErrorsIntegration(),
1926
fetchIntegration(),
27+
requestDataIntegration(),
2028
];
29+
}
2130

22-
if (options.sendDefaultPii) {
23-
integrations.push(requestDataIntegration());
31+
/**
32+
* Initializes the cloudflare SDK.
33+
*/
34+
export function init(options: Options): CloudflareClient | undefined {
35+
if (options.defaultIntegrations === undefined) {
36+
options.defaultIntegrations = getDefaultIntegrations(options);
2437
}
2538

26-
return integrations;
39+
const clientOptions: CloudflareClientOptions = {
40+
...options,
41+
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
42+
integrations: getIntegrationsToSetup(options),
43+
transport: options.transport || makeCloudflareTransport,
44+
};
45+
46+
return initAndBind(CloudflareClient, clientOptions) as CloudflareClient;
2747
}

0 commit comments

Comments
 (0)