Skip to content

fix(cloudflare): Avoid breaking rpc calls when wrapping DurableObjects #17424

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

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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,
beforeSendTransaction: transaction => {
console.log('beforeSendTransaction', transaction);
return transaction;
},
}),
TestDurableObjectBase,
);

export default {
async fetch(request, 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://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552"
}
}
108 changes: 74 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,87 @@ 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,
});

instrumentPrototype(target);

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): 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[name] === 'function') {
methodNames.add(name);
}
});
current = Object.getPrototypeOf(current);
}

// Instrument each method on the prototype
methodNames.forEach(methodName => {
const originalMethod = proto[methodName];

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

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

if (!instanceOptions) {
// Fallback to original method if no Sentry data found
return originalMethod.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,
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.apply(this, args);
};

markAsInstrumented(wrappedMethod);

// Replace the prototype method
Object.defineProperty(proto, methodName, {
value: wrappedMethod,
enumerable: false,
writable: true,
configurable: true,
});
});
}
15 changes: 11 additions & 4 deletions packages/cloudflare/test/durableobject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,12 @@ describe('instrumentDurableObjectWithSentry', () => {
});

it('Instruments prototype methods without "sticking" to the options', () => {
const mockContext = {
waitUntil: vi.fn(),
} as any;
const mockEnv = {} as any; // Environment mock
const initCore = vi.spyOn(SentryCore, 'initAndBind');
vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined);
const getClientSpy = vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined);
const options = vi
.fn()
.mockReturnValueOnce({
Expand All @@ -59,8 +63,12 @@ describe('instrumentDurableObjectWithSentry', () => {
const testClass = class {
method() {}
};
(Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), []) as any).method();
(Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), []) as any).method();
const instance1 = Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), [mockContext, mockEnv]) as any;
instance1.method();

const instance2 = Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), [mockContext, mockEnv]) as any;
instance2.method();

expect(initCore).nthCalledWith(1, expect.any(Function), expect.objectContaining({ orgId: 1 }));
expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 }));
});
Expand All @@ -83,7 +91,6 @@ describe('instrumentDurableObjectWithSentry', () => {
};
const instrumented = instrumentDurableObjectWithSentry(vi.fn(), testClass as any);
const obj = Reflect.construct(instrumented, []);
expect(Object.getPrototypeOf(obj), 'Prototype is instrumented').not.toBe(testClass.prototype);
for (const method_name of [
'propertyFunction',
'fetch',
Expand Down
Loading