-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(cloudflare): Add support for durable objects #16180
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,224 @@ | ||
/* eslint-disable @typescript-eslint/unbound-method */ | ||
import { | ||
captureException, | ||
flush, | ||
getClient, | ||
SEMANTIC_ATTRIBUTE_SENTRY_OP, | ||
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, | ||
startSpan, | ||
withIsolationScope, | ||
withScope, | ||
} from '@sentry/core'; | ||
import type { DurableObject } from 'cloudflare:workers'; | ||
import { setAsyncLocalStorageAsyncContextStrategy } from './async'; | ||
import type { CloudflareOptions } from './client'; | ||
import { isInstrumented, markAsInstrumented } from './instrument'; | ||
import { wrapRequestHandler } from './request'; | ||
import { init } from './sdk'; | ||
|
||
type MethodWrapperOptions = { | ||
spanName?: string; | ||
spanOp?: string; | ||
options: CloudflareOptions; | ||
context: ExecutionContext | DurableObjectState; | ||
}; | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
function wrapMethodWithSentry<T extends (...args: any[]) => any>( | ||
wrapperOptions: MethodWrapperOptions, | ||
handler: T, | ||
callback?: (...args: Parameters<T>) => void, | ||
): T { | ||
if (isInstrumented(handler)) { | ||
return handler; | ||
} | ||
|
||
markAsInstrumented(handler); | ||
|
||
return new Proxy(handler, { | ||
apply(target, thisArg, args: Parameters<T>) { | ||
const currentClient = getClient(); | ||
// if a client is already set, use withScope, otherwise use withIsolationScope | ||
const sentryWithScope = currentClient ? withScope : withIsolationScope; | ||
return sentryWithScope(async scope => { | ||
// In certain situations, the passed context can become undefined. | ||
// For example, for Astro while prerendering pages at build time. | ||
// see: https://github.com/getsentry/sentry-javascript/issues/13217 | ||
const context = wrapperOptions.context as ExecutionContext | undefined; | ||
|
||
const currentClient = scope.getClient(); | ||
if (!currentClient) { | ||
const client = init(wrapperOptions.options); | ||
scope.setClient(client); | ||
} | ||
|
||
if (!wrapperOptions.spanName) { | ||
try { | ||
if (callback) { | ||
callback(...args); | ||
} | ||
return await Reflect.apply(target, thisArg, args); | ||
} catch (e) { | ||
captureException(e, { | ||
mechanism: { | ||
type: 'cloudflare_durableobject', | ||
handled: false, | ||
}, | ||
}); | ||
throw e; | ||
} finally { | ||
context?.waitUntil(flush(2000)); | ||
} | ||
} | ||
|
||
const attributes = wrapperOptions.spanOp | ||
? { | ||
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp, | ||
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare_durableobjects', | ||
} | ||
: {}; | ||
|
||
// Only create these spans if they have a parent span. | ||
return startSpan({ name: wrapperOptions.spanName, attributes, onlyIfParent: true }, async () => { | ||
try { | ||
return await Reflect.apply(target, thisArg, args); | ||
} catch (e) { | ||
captureException(e, { | ||
mechanism: { | ||
type: 'cloudflare_durableobject', | ||
handled: false, | ||
}, | ||
}); | ||
throw e; | ||
} finally { | ||
context?.waitUntil(flush(2000)); | ||
} | ||
}); | ||
}); | ||
}, | ||
}); | ||
} | ||
|
||
/** | ||
* Instruments a Durable Object class to capture errors and performance data. | ||
* | ||
* Instruments the following methods: | ||
* - fetch | ||
* - alarm | ||
* - webSocketMessage | ||
* - webSocketClose | ||
* - webSocketError | ||
* | ||
* as well as any other public RPC methods on the Durable Object instance. | ||
* | ||
* @param optionsCallback Function that returns the options for the SDK initialization. | ||
* @param DurableObjectClass The Durable Object class to instrument. | ||
* @returns The instrumented Durable Object class. | ||
* | ||
* @example | ||
* ```ts | ||
* class MyDurableObjectBase extends DurableObject { | ||
* constructor(ctx: DurableObjectState, env: Env) { | ||
* super(ctx, env); | ||
* } | ||
* } | ||
* | ||
* export const MyDurableObject = instrumentDurableObjectWithSentry( | ||
* env => ({ | ||
* dsn: env.SENTRY_DSN, | ||
* tracesSampleRate: 1.0, | ||
* }), | ||
* MyDurableObjectBase, | ||
* ); | ||
* ``` | ||
*/ | ||
export function instrumentDurableObjectWithSentry<E, T extends DurableObject<E>>( | ||
optionsCallback: (env: E) => CloudflareOptions, | ||
DurableObjectClass: { new (state: DurableObjectState, env: E): T }, | ||
): typeof DurableObjectClass { | ||
return new Proxy(DurableObjectClass, { | ||
construct(target, [context, env]) { | ||
setAsyncLocalStorageAsyncContextStrategy(); | ||
|
||
const options = optionsCallback(env); | ||
|
||
const obj = new target(context, env); | ||
|
||
// These are the methods that are available on a Durable Object | ||
// ref: https://developers.cloudflare.com/durable-objects/api/base/ | ||
// obj.alarm | ||
// obj.fetch | ||
// obj.webSocketError | ||
// obj.webSocketClose | ||
// obj.webSocketMessage | ||
|
||
// Any other public methods on the Durable Object instance are RPC calls. | ||
|
||
if (obj.fetch && typeof obj.fetch === 'function' && !isInstrumented(obj.fetch)) { | ||
obj.fetch = new Proxy(obj.fetch, { | ||
apply(target, thisArg, args) { | ||
return wrapRequestHandler({ options, request: args[0], context }, () => | ||
Reflect.apply(target, thisArg, args), | ||
); | ||
}, | ||
}); | ||
|
||
markAsInstrumented(obj.fetch); | ||
} | ||
|
||
if (obj.alarm && typeof obj.alarm === 'function') { | ||
obj.alarm = wrapMethodWithSentry({ options, context, spanName: 'alarm' }, obj.alarm); | ||
} | ||
|
||
if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { | ||
obj.webSocketMessage = wrapMethodWithSentry( | ||
{ options, context, spanName: 'webSocketMessage' }, | ||
obj.webSocketMessage, | ||
); | ||
} | ||
|
||
if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { | ||
obj.webSocketClose = wrapMethodWithSentry({ options, context, spanName: 'webSocketClose' }, obj.webSocketClose); | ||
} | ||
|
||
if (obj.webSocketError && typeof obj.webSocketError === 'function') { | ||
obj.webSocketError = wrapMethodWithSentry( | ||
{ options, context, spanName: 'webSocketError' }, | ||
obj.webSocketError, | ||
(_, error) => | ||
captureException(error, { | ||
mechanism: { | ||
type: 'cloudflare_durableobject_websocket', | ||
handled: false, | ||
}, | ||
}), | ||
); | ||
} | ||
|
||
for (const method of Object.getOwnPropertyNames(obj)) { | ||
if ( | ||
method === 'fetch' || | ||
method === 'alarm' || | ||
method === 'webSocketError' || | ||
method === 'webSocketClose' || | ||
method === 'webSocketMessage' | ||
) { | ||
continue; | ||
} | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any | ||
const value = (obj as any)[method] as unknown; | ||
if (typeof value === 'function') { | ||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any | ||
(obj as any)[method] = wrapMethodWithSentry( | ||
{ options, context, spanName: method, spanOp: 'rpc' }, | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
value as (...args: any[]) => any, | ||
); | ||
} | ||
} | ||
|
||
return obj; | ||
}, | ||
}); | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
type SentryInstrumented<T> = T & { | ||
__SENTRY_INSTRUMENTED__?: boolean; | ||
}; | ||
|
||
/** | ||
* Mark an object as instrumented. | ||
*/ | ||
export function markAsInstrumented<T>(obj: T): void { | ||
try { | ||
(obj as SentryInstrumented<T>).__SENTRY_INSTRUMENTED__ = true; | ||
} catch { | ||
// ignore errors here | ||
} | ||
} | ||
|
||
/** | ||
* Check if an object is instrumented. | ||
*/ | ||
export function isInstrumented<T>(obj: T): boolean | undefined { | ||
try { | ||
return (obj as SentryInstrumented<T>).__SENTRY_INSTRUMENTED__; | ||
} catch { | ||
return false; | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is fine for now but since we have stuff like this in multiple sdks, it's probably worth extracting it in the future