Skip to content

Commit 3e424c7

Browse files
committed
feat(cloudflare): Add withSentry method
1 parent 03257e0 commit 3e424c7

File tree

13 files changed

+688
-72
lines changed

13 files changed

+688
-72
lines changed

packages/cloudflare/README.md

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,80 @@
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`:
32+
33+
```toml
34+
compatibility_flags = ["nodejs_compat"]
35+
# compatibility_flags = ["nodejs_als"]
36+
```
37+
38+
To use this SDK, wrap your handler with the `withSentry` function. This will initialize the SDK and hook into the
39+
environment. Note that you can turn off almost all side effects using the respective options.
40+
41+
Currently only ESM handlers are supported.
42+
43+
```javascript
44+
import * as Sentry from '@sentry/cloudflare';
45+
46+
export default withSentry(
47+
(env) => ({
48+
dsn: env.SENTRY_DSN,
49+
tracesSampleRate: 1.0,
50+
}),
51+
{
52+
async fetch(request, env, ctx) {
53+
return new Response('Hello World!');
54+
},
55+
} satisfies ExportedHandler<Env>
56+
);
57+
```
58+
59+
### Sourcemaps (Cloudflare Workers)
60+
61+
Configure uploading sourcemaps via the Sentry Wizard:
62+
63+
```bash
64+
npx @sentry/wizard@latest -i sourcemaps
65+
```
66+
67+
See more details in our [docs](https://docs.sentry.io/platforms/javascript/sourcemaps/).
68+
69+
## Usage (Cloudflare Workers)
70+
71+
To set context information or send manual events, use the exported functions of `@sentry/cloudflare`. Note that these
72+
functions will require your exported handler to be wrapped in `withSentry`.
73+
74+
```javascript
75+
import * as Sentry from '@sentry/cloudflare';
76+
77+
// Set user information, as well as tags and further extras
78+
Sentry.setExtra('battery', 0.7);
79+
Sentry.setTag('user_mode', 'admin');
80+
Sentry.setUser({ id: '4711' });
81+
82+
// Add a breadcrumb for future events
83+
Sentry.addBreadcrumb({
84+
message: 'My Breadcrumb',
85+
// ...
86+
});
87+
88+
// Capture exceptions, messages or manual events
89+
Sentry.captureMessage('Hello, world!');
90+
Sentry.captureException(new Error('Good bye'));
91+
Sentry.captureEvent({
92+
message: 'Manual',
93+
stacktrace: [
94+
// ...
95+
],
96+
});
97+
```

packages/cloudflare/package.json

Lines changed: 6 additions & 3 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 138 additions & 0 deletions
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 } });
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

Lines changed: 2 additions & 0 deletions
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

Lines changed: 2 additions & 8 deletions
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

Lines changed: 25 additions & 5 deletions
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)