Skip to content

Commit 7c50cd4

Browse files
authored
feat(cloudflare): Add support for durable objects (#16180)
This PR introduces a new `instrumentDurableObjectWithSentry` method to the SDK, which instruments durable objects. We capture both traces and errors automatically. Usage: ```ts class MyDurableObjectBase extends DurableObject<Env> { // impl } // Export your named class as defined in your wrangler config export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( // need to define options again because durable objects can be in a separate instance // to the cloudflare worker (env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, }), MyDurableObjectBase, ); ``` This should work with websockets, and thus https://github.com/getsentry/sentry-mcp as well. This PR also refactors some types in `packages/cloudflare/src/handler.ts` with the `withSentry` method, which should prevent type errors in some situations. resolves #15975 resolves https://linear.app/getsentry/issue/JS-285 resolves #16182 resolves https://linear.app/getsentry/issue/JS-398
1 parent b94062f commit 7c50cd4

File tree

5 files changed

+318
-80
lines changed

5 files changed

+318
-80
lines changed
+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/* eslint-disable @typescript-eslint/unbound-method */
2+
import {
3+
captureException,
4+
flush,
5+
getClient,
6+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
7+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
8+
startSpan,
9+
withIsolationScope,
10+
withScope,
11+
} from '@sentry/core';
12+
import type { DurableObject } from 'cloudflare:workers';
13+
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
14+
import type { CloudflareOptions } from './client';
15+
import { isInstrumented, markAsInstrumented } from './instrument';
16+
import { wrapRequestHandler } from './request';
17+
import { init } from './sdk';
18+
19+
type MethodWrapperOptions = {
20+
spanName?: string;
21+
spanOp?: string;
22+
options: CloudflareOptions;
23+
context: ExecutionContext | DurableObjectState;
24+
};
25+
26+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
27+
function wrapMethodWithSentry<T extends (...args: any[]) => any>(
28+
wrapperOptions: MethodWrapperOptions,
29+
handler: T,
30+
callback?: (...args: Parameters<T>) => void,
31+
): T {
32+
if (isInstrumented(handler)) {
33+
return handler;
34+
}
35+
36+
markAsInstrumented(handler);
37+
38+
return new Proxy(handler, {
39+
apply(target, thisArg, args: Parameters<T>) {
40+
const currentClient = getClient();
41+
// if a client is already set, use withScope, otherwise use withIsolationScope
42+
const sentryWithScope = currentClient ? withScope : withIsolationScope;
43+
return sentryWithScope(async scope => {
44+
// In certain situations, the passed context can become undefined.
45+
// For example, for Astro while prerendering pages at build time.
46+
// see: https://github.com/getsentry/sentry-javascript/issues/13217
47+
const context = wrapperOptions.context as ExecutionContext | undefined;
48+
49+
const currentClient = scope.getClient();
50+
if (!currentClient) {
51+
const client = init(wrapperOptions.options);
52+
scope.setClient(client);
53+
}
54+
55+
if (!wrapperOptions.spanName) {
56+
try {
57+
if (callback) {
58+
callback(...args);
59+
}
60+
return await Reflect.apply(target, thisArg, args);
61+
} catch (e) {
62+
captureException(e, {
63+
mechanism: {
64+
type: 'cloudflare_durableobject',
65+
handled: false,
66+
},
67+
});
68+
throw e;
69+
} finally {
70+
context?.waitUntil(flush(2000));
71+
}
72+
}
73+
74+
const attributes = wrapperOptions.spanOp
75+
? {
76+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp,
77+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare_durableobjects',
78+
}
79+
: {};
80+
81+
// Only create these spans if they have a parent span.
82+
return startSpan({ name: wrapperOptions.spanName, attributes, onlyIfParent: true }, async () => {
83+
try {
84+
return await Reflect.apply(target, thisArg, args);
85+
} catch (e) {
86+
captureException(e, {
87+
mechanism: {
88+
type: 'cloudflare_durableobject',
89+
handled: false,
90+
},
91+
});
92+
throw e;
93+
} finally {
94+
context?.waitUntil(flush(2000));
95+
}
96+
});
97+
});
98+
},
99+
});
100+
}
101+
102+
/**
103+
* Instruments a Durable Object class to capture errors and performance data.
104+
*
105+
* Instruments the following methods:
106+
* - fetch
107+
* - alarm
108+
* - webSocketMessage
109+
* - webSocketClose
110+
* - webSocketError
111+
*
112+
* as well as any other public RPC methods on the Durable Object instance.
113+
*
114+
* @param optionsCallback Function that returns the options for the SDK initialization.
115+
* @param DurableObjectClass The Durable Object class to instrument.
116+
* @returns The instrumented Durable Object class.
117+
*
118+
* @example
119+
* ```ts
120+
* class MyDurableObjectBase extends DurableObject {
121+
* constructor(ctx: DurableObjectState, env: Env) {
122+
* super(ctx, env);
123+
* }
124+
* }
125+
*
126+
* export const MyDurableObject = instrumentDurableObjectWithSentry(
127+
* env => ({
128+
* dsn: env.SENTRY_DSN,
129+
* tracesSampleRate: 1.0,
130+
* }),
131+
* MyDurableObjectBase,
132+
* );
133+
* ```
134+
*/
135+
export function instrumentDurableObjectWithSentry<E, T extends DurableObject<E>>(
136+
optionsCallback: (env: E) => CloudflareOptions,
137+
DurableObjectClass: new (state: DurableObjectState, env: E) => T,
138+
): new (state: DurableObjectState, env: E) => T {
139+
return new Proxy(DurableObjectClass, {
140+
construct(target, [context, env]) {
141+
setAsyncLocalStorageAsyncContextStrategy();
142+
143+
const options = optionsCallback(env);
144+
145+
const obj = new target(context, env);
146+
147+
// These are the methods that are available on a Durable Object
148+
// ref: https://developers.cloudflare.com/durable-objects/api/base/
149+
// obj.alarm
150+
// obj.fetch
151+
// obj.webSocketError
152+
// obj.webSocketClose
153+
// obj.webSocketMessage
154+
155+
// Any other public methods on the Durable Object instance are RPC calls.
156+
157+
if (obj.fetch && typeof obj.fetch === 'function' && !isInstrumented(obj.fetch)) {
158+
obj.fetch = new Proxy(obj.fetch, {
159+
apply(target, thisArg, args) {
160+
return wrapRequestHandler({ options, request: args[0], context }, () =>
161+
Reflect.apply(target, thisArg, args),
162+
);
163+
},
164+
});
165+
166+
markAsInstrumented(obj.fetch);
167+
}
168+
169+
if (obj.alarm && typeof obj.alarm === 'function') {
170+
obj.alarm = wrapMethodWithSentry({ options, context, spanName: 'alarm' }, obj.alarm);
171+
}
172+
173+
if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') {
174+
obj.webSocketMessage = wrapMethodWithSentry(
175+
{ options, context, spanName: 'webSocketMessage' },
176+
obj.webSocketMessage,
177+
);
178+
}
179+
180+
if (obj.webSocketClose && typeof obj.webSocketClose === 'function') {
181+
obj.webSocketClose = wrapMethodWithSentry({ options, context, spanName: 'webSocketClose' }, obj.webSocketClose);
182+
}
183+
184+
if (obj.webSocketError && typeof obj.webSocketError === 'function') {
185+
obj.webSocketError = wrapMethodWithSentry(
186+
{ options, context, spanName: 'webSocketError' },
187+
obj.webSocketError,
188+
(_, error) =>
189+
captureException(error, {
190+
mechanism: {
191+
type: 'cloudflare_durableobject_websocket',
192+
handled: false,
193+
},
194+
}),
195+
);
196+
}
197+
198+
for (const method of Object.getOwnPropertyNames(obj)) {
199+
if (
200+
method === 'fetch' ||
201+
method === 'alarm' ||
202+
method === 'webSocketError' ||
203+
method === 'webSocketClose' ||
204+
method === 'webSocketMessage'
205+
) {
206+
continue;
207+
}
208+
209+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
210+
const value = (obj as any)[method] as unknown;
211+
if (typeof value === 'function') {
212+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
213+
(obj as any)[method] = wrapMethodWithSentry(
214+
{ options, context, spanName: method, spanOp: 'rpc' },
215+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
216+
value as (...args: any[]) => any,
217+
);
218+
}
219+
}
220+
221+
return obj;
222+
},
223+
});
224+
}

packages/cloudflare/src/handler.ts

+56-80
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
import type {
2-
ExportedHandler,
3-
ExportedHandlerFetchHandler,
4-
ExportedHandlerScheduledHandler,
5-
} from '@cloudflare/workers-types';
61
import {
72
captureException,
83
flush,
@@ -13,15 +8,11 @@ import {
138
} from '@sentry/core';
149
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
1510
import type { CloudflareOptions } from './client';
11+
import { isInstrumented, markAsInstrumented } from './instrument';
1612
import { wrapRequestHandler } from './request';
1713
import { addCloudResourceContext } from './scope-utils';
1814
import { init } from './sdk';
1915

20-
/**
21-
* Extract environment generic from exported handler.
22-
*/
23-
type ExtractEnv<P> = P extends ExportedHandler<infer Env> ? Env : never;
24-
2516
/**
2617
* Wrapper for Cloudflare handlers.
2718
*
@@ -33,85 +24,70 @@ type ExtractEnv<P> = P extends ExportedHandler<infer Env> ? Env : never;
3324
* @param handler {ExportedHandler} The handler to wrap.
3425
* @returns The wrapped handler.
3526
*/
36-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
37-
export function withSentry<E extends ExportedHandler<any>>(
38-
optionsCallback: (env: ExtractEnv<E>) => CloudflareOptions,
39-
handler: E,
40-
): E {
27+
export function withSentry<Env = unknown, QueueHandlerMessage = unknown, CfHostMetadata = unknown>(
28+
optionsCallback: (env: Env) => CloudflareOptions,
29+
handler: ExportedHandler<Env, QueueHandlerMessage, CfHostMetadata>,
30+
): ExportedHandler<Env, QueueHandlerMessage, CfHostMetadata> {
4131
setAsyncLocalStorageAsyncContextStrategy();
4232

43-
if ('fetch' in handler && typeof handler.fetch === 'function' && !isInstrumented(handler.fetch)) {
44-
handler.fetch = new Proxy(handler.fetch, {
45-
apply(target, thisArg, args: Parameters<ExportedHandlerFetchHandler<ExtractEnv<E>>>) {
46-
const [request, env, context] = args;
47-
const options = optionsCallback(env);
48-
return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args));
49-
},
50-
});
33+
try {
34+
if ('fetch' in handler && typeof handler.fetch === 'function' && !isInstrumented(handler.fetch)) {
35+
handler.fetch = new Proxy(handler.fetch, {
36+
apply(target, thisArg, args: Parameters<ExportedHandlerFetchHandler<Env, CfHostMetadata>>) {
37+
const [request, env, context] = args;
38+
const options = optionsCallback(env);
39+
return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args));
40+
},
41+
});
5142

52-
markAsInstrumented(handler.fetch);
53-
}
43+
markAsInstrumented(handler.fetch);
44+
}
5445

55-
if ('scheduled' in handler && typeof handler.scheduled === 'function' && !isInstrumented(handler.scheduled)) {
56-
handler.scheduled = new Proxy(handler.scheduled, {
57-
apply(target, thisArg, args: Parameters<ExportedHandlerScheduledHandler<ExtractEnv<E>>>) {
58-
const [event, env, context] = args;
59-
return withIsolationScope(isolationScope => {
60-
const options = optionsCallback(env);
61-
const client = init(options);
62-
isolationScope.setClient(client);
46+
if ('scheduled' in handler && typeof handler.scheduled === 'function' && !isInstrumented(handler.scheduled)) {
47+
handler.scheduled = new Proxy(handler.scheduled, {
48+
apply(target, thisArg, args: Parameters<ExportedHandlerScheduledHandler<Env>>) {
49+
const [event, env, context] = args;
50+
return withIsolationScope(isolationScope => {
51+
const options = optionsCallback(env);
52+
const client = init(options);
53+
isolationScope.setClient(client);
6354

64-
addCloudResourceContext(isolationScope);
55+
addCloudResourceContext(isolationScope);
6556

66-
return startSpan(
67-
{
68-
op: 'faas.cron',
69-
name: `Scheduled Cron ${event.cron}`,
70-
attributes: {
71-
'faas.cron': event.cron,
72-
'faas.time': new Date(event.scheduledTime).toISOString(),
73-
'faas.trigger': 'timer',
74-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare',
75-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task',
57+
return startSpan(
58+
{
59+
op: 'faas.cron',
60+
name: `Scheduled Cron ${event.cron}`,
61+
attributes: {
62+
'faas.cron': event.cron,
63+
'faas.time': new Date(event.scheduledTime).toISOString(),
64+
'faas.trigger': 'timer',
65+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare',
66+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'task',
67+
},
7668
},
77-
},
78-
async () => {
79-
try {
80-
return await (target.apply(thisArg, args) as ReturnType<typeof target>);
81-
} catch (e) {
82-
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
83-
throw e;
84-
} finally {
85-
context.waitUntil(flush(2000));
86-
}
87-
},
88-
);
89-
});
90-
},
91-
});
69+
async () => {
70+
try {
71+
return await (target.apply(thisArg, args) as ReturnType<typeof target>);
72+
} catch (e) {
73+
captureException(e, { mechanism: { handled: false, type: 'cloudflare' } });
74+
throw e;
75+
} finally {
76+
context.waitUntil(flush(2000));
77+
}
78+
},
79+
);
80+
});
81+
},
82+
});
9283

93-
markAsInstrumented(handler.scheduled);
84+
markAsInstrumented(handler.scheduled);
85+
}
86+
// This is here because Miniflare sometimes cannot get instrumented
87+
//
88+
} catch (e) {
89+
// Do not console anything here, we don't want to spam the console with errors
9490
}
9591

9692
return handler;
9793
}
98-
99-
type SentryInstrumented<T> = T & {
100-
__SENTRY_INSTRUMENTED__?: boolean;
101-
};
102-
103-
function markAsInstrumented<T>(handler: T): void {
104-
try {
105-
(handler as SentryInstrumented<T>).__SENTRY_INSTRUMENTED__ = true;
106-
} catch {
107-
// ignore errors here
108-
}
109-
}
110-
111-
function isInstrumented<T>(handler: T): boolean | undefined {
112-
try {
113-
return (handler as SentryInstrumented<T>).__SENTRY_INSTRUMENTED__;
114-
} catch {
115-
return false;
116-
}
117-
}

packages/cloudflare/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export {
9292
} from '@sentry/core';
9393

9494
export { withSentry } from './handler';
95+
export { instrumentDurableObjectWithSentry } from './durableobject';
9596
export { sentryPagesPlugin } from './pages-plugin';
9697

9798
export { wrapRequestHandler } from './request';

0 commit comments

Comments
 (0)