Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,37 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

### Important Changes

- feat(cloudflare): Add `instrumentPrototypeMethods` option to instrument RPC methods for DurableObjects ([#17424](https://github.com/getsentry/sentry-javascript/pull/17424))

By default, `Sentry.instrumentDurableObjectWithSentry` will not wrap any RPC methods on the prototype. To enable wrapping for RPC methods, set `instrumentPrototypeMethods` to `true` or a list of methods you want to instrument:

```js
class MyDurableObjectBase extends DurableObject<Env> {
method1() {
// ...
}

method2() {
// ...
}

method3() {
// ...
}
}
// Export your named class as defined in your wrangler config
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
(env: Env) => ({
dsn: "https://ac49b7af3017c458bd12dab9b3328bfc@o4508482761982032.ingest.de.sentry.io/4508482780987481",
tracesSampleRate: 1.0,
instrumentPrototypeMethods: ['method1', 'method3'],
}),
MyDurableObjectBase,
);
```

Work in this release was contributed by @Karibash. Thank you for your contribution!

## 10.5.0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
TEST_DURABLE_OBJECT: DurableObjectNamespace;
}

class TestDurableObjectBase extends DurableObject<Env> {
public constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}

// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
async sayHello(name: string): Promise<string> {
return `Hello, ${name}`;
}
}

export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
instrumentPrototypeMethods: true,
}),
TestDurableObjectBase,
);

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const id: DurableObjectId = env.TEST_DURABLE_OBJECT.idFromName('test');
const stub = env.TEST_DURABLE_OBJECT.get(id) as unknown as TestDurableObjectBase;

if (request.url.includes('hello')) {
const greeting = await stub.sayHello('world');
return new Response(greeting);
}

return new Response('Usual response');
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';

it('traces a durable object method', async () => {
const runner = createRunner(__dirname)
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1];
expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'rpc',
data: expect.objectContaining({
'sentry.op': 'rpc',
'sentry.origin': 'auto.faas.cloudflare_durableobjects',
}),
origin: 'auto.faas.cloudflare_durableobjects',
}),
}),
transaction: 'sayHello',
}),
);
})
.start();
await runner.makeRequest('get', '/hello');
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "worker-name",
"main": "index.ts",
"compatibility_date": "2025-06-17",
"migrations": [
{
"new_sqlite_classes": ["TestDurableObject"],
"tag": "v1"
}
],
"durable_objects": {
"bindings": [
{
"class_name": "TestDurableObject",
"name": "TEST_DURABLE_OBJECT"
}
]
},
"compatibility_flags": ["nodejs_als"],
"vars": {
"SENTRY_DSN": "https://[email protected]/4509553159831552"
}
}
23 changes: 23 additions & 0 deletions packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,29 @@ interface BaseCloudflareOptions {
* @default false
*/
skipOpenTelemetrySetup?: boolean;

/**
* Enable instrumentation of prototype methods for DurableObjects.
*
* When `true`, the SDK will wrap all methods on the DurableObject prototype chain
* to automatically create spans and capture errors for RPC method calls.
*
* When an array of strings is provided, only the specified method names will be instrumented.
*
* This feature adds runtime overhead as it wraps methods at the prototype level.
* Only enable this if you need automatic instrumentation of prototype methods.
*
* @default false
* @example
* ```ts
* // Instrument all prototype methods
* instrumentPrototypeMethods: true
*
* // Instrument only specific methods
* instrumentPrototypeMethods: ['myMethod', 'anotherMethod']
* ```
*/
instrumentPrototypeMethods?: boolean | string[];
}

/**
Expand Down
122 changes: 88 additions & 34 deletions packages/cloudflare/src/durableobject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,7 @@ function wrapMethodWithSentry<T extends OriginalMethod>(
}
: {};

// Only create these spans if they have a parent span.
return startSpan({ name: wrapperOptions.spanName, attributes, onlyIfParent: true }, () => {
return startSpan({ name: wrapperOptions.spanName, attributes }, () => {
try {
const result = Reflect.apply(target, thisArg, args);

Expand Down Expand Up @@ -273,46 +272,101 @@ export function instrumentDurableObjectWithSentry<
);
}
}
const instrumentedPrototype = instrumentPrototype(target, options, context);
Object.setPrototypeOf(obj, instrumentedPrototype);

// Store context and options on the instance for prototype methods to access
Object.defineProperty(obj, '__SENTRY_CONTEXT__', {
value: context,
enumerable: false,
writable: false,
configurable: false,
});

Object.defineProperty(obj, '__SENTRY_OPTIONS__', {
value: options,
enumerable: false,
writable: false,
configurable: false,
});

if (options?.instrumentPrototypeMethods) {
instrumentPrototype(target, options.instrumentPrototypeMethods);
}

return obj;
},
});
}

function instrumentPrototype<T extends NewableFunction>(
target: T,
options: CloudflareOptions,
context: MethodWrapperOptions['context'],
): T {
return new Proxy(target.prototype, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
if (prop === 'constructor' || typeof value !== 'function') {
return value;
function instrumentPrototype<T extends NewableFunction>(target: T, methodsToInstrument: boolean | string[]): void {
const proto = target.prototype;

// Get all methods from the prototype chain
const methodNames = new Set<string>();
let current = proto;

while (current && current !== Object.prototype) {
Object.getOwnPropertyNames(current).forEach(name => {
if (name !== 'constructor' && typeof (current as Record<string, unknown>)[name] === 'function') {
methodNames.add(name);
}
});
current = Object.getPrototypeOf(current);
}

// Create a set for efficient lookups when methodsToInstrument is an array
const methodsToInstrumentSet = Array.isArray(methodsToInstrument) ? new Set(methodsToInstrument) : null;

// Instrument each method on the prototype
methodNames.forEach(methodName => {
const originalMethod = (proto as Record<string, unknown>)[methodName];

if (!originalMethod || isInstrumented(originalMethod)) {
return;
}

// If methodsToInstrument is an array, only instrument methods in that set
if (methodsToInstrumentSet && !methodsToInstrumentSet.has(methodName)) {
return;
}

// Create a wrapper that gets context/options from the instance at runtime
const wrappedMethod = function (this: any, ...args: any[]): unknown {
const thisWithSentry = this as {
__SENTRY_CONTEXT__: DurableObjectState;
__SENTRY_OPTIONS__: CloudflareOptions;
};
const instanceContext = thisWithSentry.__SENTRY_CONTEXT__;
const instanceOptions = thisWithSentry.__SENTRY_OPTIONS__;

if (!instanceOptions) {
// Fallback to original method if no Sentry data found
return (originalMethod as (...args: any[]) => any).apply(this, args);
}
const wrapped = wrapMethodWithSentry(
{ options, context, spanName: prop.toString(), spanOp: 'rpc' },
value,

// Use the existing wrapper but with instance-specific context/options
const wrapper = wrapMethodWithSentry(
{
options: instanceOptions,
context: instanceContext,
spanName: methodName,
spanOp: 'rpc',
},
originalMethod as (...args: any[]) => any,
undefined,
true,
true, // noMark = true since we'll mark the prototype method
);
const instrumented = new Proxy(wrapped, {
get(target, p, receiver) {
if ('__SENTRY_INSTRUMENTED__' === p) {
return true;
}
return Reflect.get(target, p, receiver);
},
});
Object.defineProperty(receiver, prop, {
value: instrumented,
enumerable: true,
writable: true,
configurable: true,
});
return instrumented;
},

return (wrapper as (...args: any[]) => any).apply(this, args);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Proxy Overhead in Prototype Instrumentation

The wrappedMethod in the prototype instrumentation calls wrapMethodWithSentry() on every invocation. This creates a new Proxy wrapper and performs instrumentation setup on each call, causing significant performance degradation for frequently used methods.

Fix in Cursor Fix in Web

};

markAsInstrumented(wrappedMethod);

// Replace the prototype method
Object.defineProperty(proto, methodName, {
value: wrappedMethod,
enumerable: false,
writable: true,
configurable: true,
});
});
}
Loading
Loading