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
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;
SELF: Fetcher;
CONNECT_DO: DurableObjectNamespace<ConnectDurableObject>;
}

export class ConnectDurableObject extends DurableObject<Env> {}

function tryConnect(connect: () => Socket): string {
try {
const socket = connect();
socket.opened.catch(() => {});
socket.closed.catch(() => {});
return 'ok';
} catch (error) {
return (error as Error).message;
}
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}),
{
async fetch(request, env) {
const url = new URL(request.url);

if (url.pathname === '/connect') {
const stub = env.CONNECT_DO.get(env.CONNECT_DO.idFromName('connect'));

return Response.json({
durableObject: tryConnect(() => stub.connect('127.0.0.1:9')),
service: tryConnect(() => env.SELF.connect('127.0.0.1:9')),
});
}

return new Response('not found', { status: 404 });
},
} satisfies ExportedHandler<Env>,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../runner';

it('calls connect() on Durable Object stubs and service bindings with the binding as `this`', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect((envelope: Envelope) => {
const spanItem = envelope[1].find(item => item[0].type === 'span');
expect(spanItem).toBeDefined();
const segmentSpan = (spanItem![1] as SerializedStreamedSpanContainer).items.find(span => !!span.is_segment);
expect(segmentSpan).toMatchObject({
name: 'GET',
status: 'ok',
attributes: expect.objectContaining({ 'url.path': { type: 'string', value: '/connect' } }),
});
})
.start(signal);

const result = await runner.makeRequest<{ durableObject: string; service: string }>('get', '/connect');

expect(result?.durableObject).toBe('ok');
// Local workerd rejects CONNECT on a Worker after the `this` check, so only that check is asserted here.
expect(result?.service).not.toMatch(/Illegal invocation/);
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "binding-connect-worker",
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_compat"],
"migrations": [
{
"new_sqlite_classes": ["ConnectDurableObject"],
"tag": "v1",
},
],
"durable_objects": {
"bindings": [
{
"class_name": "ConnectDurableObject",
"name": "CONNECT_DO",
},
],
},
"services": [
{
"binding": "SELF",
"service": "binding-connect-worker",
},
],
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ function instrumentDurableObjectStub(stub: DurableObjectStub, propagateRpcTrace:
return instrumentFetcher((...args) => Reflect.apply(value, target, args));
}

if (prop === 'connect' && typeof value === 'function') {
return (...args: unknown[]) => Reflect.apply(value, target, args);
}

if (
propagateRpcTrace &&
typeof value === 'function' &&
Comment on lines 65 to 74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The dup() method on instrumented Durable Object stubs is returned unbound. Calling stub.dup() will cause an "Illegal invocation" error.
Severity: HIGH

Suggested Fix

Add a handler for the dup method in the instrumentDurableObjectStub proxy, similar to the existing handler for connect. This will ensure dup is correctly bound using Reflect.apply(value, target, args) before being returned, preventing the "Illegal invocation" error.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location:
packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts#L65-L74

Potential issue: The proxy handler for instrumented Durable Object stubs in
`instrumentDurableObjectStub` correctly handles the `connect` method by binding it, but
it fails to do the same for the `dup` method. Both methods are listed in
`STUB_NON_RPC_METHODS` and should be excluded from RPC tracing but still function
correctly. When `dup` is accessed, the proxy falls through and returns the method
unbound. Any subsequent call to `stub.dup()` will fail with an "Illegal invocation"
error because `this` is not correctly bound to the stub instance.

Also affects:

  • packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts:101~110

Did we get this right? 👍 / 👎 to inform future reviews.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt
return instrumentFetcher((...args) => Reflect.apply(value, target, args));
}

if (p === 'connect' && typeof value === 'function') {
return (...args: unknown[]) => Reflect.apply(value, target, args);
}

if (
propagateRpcTrace &&
typeof value === 'function' &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,11 +323,31 @@ describe('instrumentDurableObjectNamespace', () => {
const instrumented = instrumentDurableObjectNamespace(namespace, true);

const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any);
(stub as any).connect('127.0.0.1:9');

// connect and dup should be the original functions, not wrapped
expect((stub as any).connect).toBe(connectFn);
expect(connectFn).toHaveBeenCalledWith('127.0.0.1:9');
expect((stub as any).dup).toBe(dupFn);
});

it('calls connect with the underlying stub as `this`', () => {
const { namespace: originalNamespace } = createMockNamespace();
const rawStub = {
id: { toString: () => 'mock-id', equals: () => false, name: 'test' },
fetch: vi.fn(),
connect(this: unknown) {
if (this !== rawStub) {
throw new TypeError('Illegal invocation: function called with incorrect `this` reference.');
}
return 'socket';
},
};
const namespace = { ...originalNamespace, get: vi.fn().mockReturnValue(rawStub) };
const instrumented = instrumentDurableObjectNamespace(namespace);

const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any);

expect((stub as any).connect('127.0.0.1:9')).toBe('socket');
});
});

describe('non-function properties', () => {
Expand Down
24 changes: 24 additions & 0 deletions packages/cloudflare/test/instrumentations/instrumentEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,30 @@ describe('instrumentEnv', () => {
});
});

it('calls JSRPC connect with the underlying binding as `this`', () => {
const jsrpcTarget = {
fetch: vi.fn(),
connect(this: unknown, _address: string) {
if (this !== jsrpcProxy) {
throw new TypeError('Illegal invocation: function called with incorrect `this` reference.');
}
return 'socket';
},
};
const jsrpcProxy = new Proxy(jsrpcTarget, {
get(target, prop) {
if (prop in target) {
return Reflect.get(target, prop);
}
return () => {};
},
});
const env = { SERVICE: jsrpcProxy };
const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] });

expect(instrumented.SERVICE.connect('127.0.0.1:9')).toBe('socket');
});

it('does not inject meta into JSRPC fetch calls', () => {
vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({
'sentry-trace': 'abc-def-1',
Expand Down
Loading