diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts new file mode 100644 index 000000000000..0e687e8bda16 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts @@ -0,0 +1,73 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject { + #name: string | undefined; + + setName(name: string): string { + this.#name = name; + return this.#name; + } + + bootstrap(name: string): string { + // Regression for #23040 — native Durable Object RPC (facets, the Agents SDK bootstrap + // calling PartyServer's `setName()`) resolves the method on the prototype and invokes it + // with the stored Durable Object instance as the receiver. When the instrumented + // constructor returned a Proxy of the instance, native private field access failed: + // "TypeError: Cannot read private member #name from an object whose class did not declare + // it" — a Proxy never carries the target's private-field brand. + // + // Construct a fresh instrumented instance exactly as the runtime does (the raw + // DurableObjectState sits below the instrumented context's prototype), then dispatch the + // way native RPC does: prototype method, stored instance as receiver. + const rawCtx = Object.getPrototypeOf(this.ctx) as DurableObjectState; + const instance = new MyDurableObject(rawCtx, this.env); + const prototype = Object.getPrototypeOf(instance) as MyDurableObjectBase; + return prototype.setName.call(instance, name); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/prototype-dispatch') { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const name = await stub.bootstrap('agent-1'); + return new Response(name); + } + + if (url.pathname === '/rpc/set-name') { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const name = await stub.setName('agent-2'); + return new Response(name); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts new file mode 100644 index 000000000000..0d7609f1c772 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts @@ -0,0 +1,95 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +// Regression for #23040 — a Durable Object using native private fields must stay functional when +// instrumented with `enableRpcTracePropagation: true`. Native RPC dispatch (Durable Object facets, +// the Agents SDK bootstrap) invokes prototype methods with the stored instance as the receiver, +// so the instrumented instance must not be a Proxy: a Proxy does not carry the private-field +// brand and `this.#field` throws "Cannot read private member". +it('keeps native private fields working when a prototype method is invoked with the instance as receiver', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'bootstrap', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /prototype-dispatch', + }), + ); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/prototype-dispatch'); + expect(response).toBe('agent-1'); + + await runner.completed(); +}); + +it('propagates trace and preserves the result for a regular RPC method call', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'setName', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /rpc/set-name', + }), + ); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/set-name'); + expect(response).toBe('agent-2'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/wrangler.jsonc new file mode 100644 index 000000000000..ee78ed13794d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-do-rpc-private-fields", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 4c7985002dda..61f90e3033df 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -1,15 +1,15 @@ /* eslint-disable @typescript-eslint/unbound-method */ -import { captureException } from '@sentry/core'; +import { captureException, isObjectLike } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; -import { ensureInstrumented } from './instrument'; +import { ensureInstrumented, getInstrumented, markAsInstrumented } from './instrument'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; import { wrapRequestHandlerWithInit } from './request'; import { init } from './sdk'; import { instrumentContext } from './utils/instrumentContext'; -import { extractRpcMeta } from './utils/rpcMeta'; +import { hasRpcMeta } from './utils/rpcMeta'; import { instrumentCloudflareAgent } from './instrumentations/agents'; import { type UncheckedMethod, wrapMethodWithSentry } from './wrapMethodWithSentry'; @@ -33,8 +33,8 @@ type InstrumentedDurableObjectContext = any; * * This is the shared construction path used by both {@link instrumentDurableObjectWithSentry} * and {@link instrumentAgentWithSentry}. It intentionally does NOT apply the RPC prototype-method - * proxy — callers apply that last via {@link finalizeWithRpcInstrumentation}, after any additional - * per-instance instrumentation has been layered onto the returned object. + * instrumentation — callers apply that last via {@link finalizeWithRpcInstrumentation}, after any + * additional per-instance instrumentation has been layered onto the returned object. * * @internal */ @@ -44,7 +44,12 @@ export function constructInstrumentedDurableObject env: E, newTarget: NewableFunction, optionsCallback: (env: E) => CloudflareOptions, -): { obj: T; options: CloudflareOptions; context: InstrumentedDurableObjectContext } { +): { + obj: T; + options: CloudflareOptions; + context: InstrumentedDurableObjectContext; + frameworkManagedMethods: ReadonlySet; +} { setAsyncLocalStorageAsyncContextStrategy(); const options = getFinalOptions(optionsCallback(env), env); // See InstrumentedDurableObjectContext — `ctx` is widened to `any` so the concrete @@ -53,14 +58,85 @@ export function constructInstrumentedDurableObject const context = instrumentContext(ctx as any); const instrumentedEnv = instrumentEnv(env as Record, options); + const prototype = (newTarget as unknown as { prototype?: object }).prototype ?? target.prototype; + const cachedFrameworkManagedMethods = frameworkManagedMethodsCache.get(prototype); + const methodsBeforeConstruction = cachedFrameworkManagedMethods ? undefined : resolvePrototypeMethods(prototype); + // Pass `newTarget` so that subclasses of the instrumented class (e.g. the wrapper classes // created by wrangler's local dev tooling or `@cloudflare/vitest-pool-workers`) keep their // own prototype — otherwise subclass methods disappear and `instanceof` checks break. const obj = Reflect.construct(target, [context, instrumentedEnv], newTarget) as T; + const frameworkManagedMethods = resolveFrameworkManagedMethods( + prototype, + obj, + methodsBeforeConstruction, + cachedFrameworkManagedMethods, + ); + instrumentDurableObjectHandlers(obj, options, context); - return { obj, options, context }; + return { obj, options, context, frameworkManagedMethods }; +} + +const frameworkManagedMethodsCache = new WeakMap>(); + +/** + * Collects the methods visible from a prototype, using normal property lookup precedence. + * Methods inherited from `Object.prototype` are excluded because they cannot be Durable Object RPC methods. + */ +function resolvePrototypeMethods(prototype: object | null): Map { + const methods = new Map(); + + for (let current = prototype; current && current !== Object.prototype; current = Object.getPrototypeOf(current)) { + for (const name of Object.getOwnPropertyNames(current)) { + // The first occurrence wins, mirroring what a property lookup on the instance would find + if (name === 'constructor' || methods.has(name)) { + continue; + } + + const descriptor = Object.getOwnPropertyDescriptor(current, name); + + if (descriptor && typeof descriptor.value === 'function') { + methods.set(name, descriptor.value); + } + } + } + + return methods; +} + +/** + * Finds methods that a framework replaced while constructing the first instance. + * + * Some frameworks register methods by function identity, so replacing one of their wrappers would + * break dispatch. The result is cached because frameworks commonly install their wrappers only + * once; later constructions would no longer reveal which methods they manage. + */ +function resolveFrameworkManagedMethods( + prototype: object, + obj: object, + methodsBeforeConstruction: Map | undefined, + cached: ReadonlySet | undefined, +): ReadonlySet { + if (cached) { + return cached; + } + + const methodsAfterConstruction = resolvePrototypeMethods(Object.getPrototypeOf(obj) as object); + const managed = new Set(); + + for (const [name, method] of methodsAfterConstruction) { + const before = methodsBeforeConstruction?.get(name); + + if (before && before !== method) { + managed.add(name); + } + } + + frameworkManagedMethodsCache.set(prototype, managed); + + return managed; } /** @@ -145,76 +221,171 @@ function instrumentDurableObjectHandlers>( } } +type RpcInstanceState = { + options: CloudflareOptions; + context: InstrumentedDurableObjectContext; + /** Per-instance cache of the traced method wrappers, keyed by method name. Created on first use. */ + tracedMethods?: Map; +}; + +/** + * Method names the runtime never dispatches over RPC, so wrapping them buys no tracing. + * + * Mirrors `isReservedName` in workerd (`src/workerd/api/worker-rpc.c++`): the runtime rejects these + * before any property lookup happens. `fetch`, `alarm` and the `webSocket*` handlers are also + * instrumented per-instance as own properties, and `constructor` is on every prototype. + */ +const RESERVED_RPC_METHOD_NAMES: ReadonlySet = new Set([ + 'constructor', + 'fetch', + 'connect', + 'alarm', + 'webSocketMessage', + 'webSocketClose', + 'webSocketError', + 'dup', +]); + +// Prototype wrappers are shared by all instances, while SDK options and traced method caches are not. +const rpcInstanceStates = new WeakMap(); + /** - * Wraps a constructed (and already handler-instrumented) Durable Object instance with the RPC - * prototype-method proxy, when RPC trace propagation is enabled. Returns the object unchanged when - * RPC instrumentation is disabled. + * Adds trace propagation to a constructed Durable Object's RPC methods. * - * This must be applied last, so that any per-instance instrumentation (own properties such as - * `fetch`, `alarm`, or Agent-specific handlers) is excluded from RPC method tracing. + * RPC methods are wrapped on the prototype because Cloudflare dispatches them with the Durable + * Object instance as the receiver. This preserves native private-field access and keeps the methods + * visible to Cloudflare's RPC dispatcher. Built-in handlers, Agent handlers, and methods managed by + * another framework are left untouched. * + * Call this after all per-instance instrumentation has been applied. If RPC trace propagation is + * disabled, the object is returned unchanged. + * + * @param obj The constructed Durable Object instance. + * @param options The resolved SDK options for this instance. + * @param context The instrumented execution context for this instance. + * @param excludedMethods Method names owned by another framework and therefore not safe to wrap. + * @returns The same Durable Object instance, with eligible prototype methods instrumented. * @internal */ export function finalizeWithRpcInstrumentation( obj: T, options: CloudflareOptions, context: InstrumentedDurableObjectContext, + excludedMethods?: ReadonlySet, ): T { // Skip RPC instrumentation if not enabled if (!options.enableRpcTracePropagation) { return obj; } - // Return a Proxy that binds all methods to the original object and creates spans - // for RPC calls that have Sentry trace context propagated. - // Binding is required because frameworks may use private fields (babel WeakMap pattern), - // which fail if `this` is the Proxy instead of the original object. - const methodCache = new Map(); + rpcInstanceStates.set(obj, { options, context }); - return new Proxy(obj, { - get(proxyTarget, prop, receiver) { - const value = Reflect.get(proxyTarget, prop, receiver); + instrumentPrototypeRpcMethods(obj, excludedMethods); - if (typeof prop !== 'string' || typeof value !== 'function' || prop === 'constructor') { - return value; - } + return obj; +} - const cached = methodCache.get(prop); +/** + * Returns a prototype method when it is eligible for RPC instrumentation. + */ +function getRpcMethodDescriptor( + obj: object, + prototype: object, + methodName: string, + excludedMethods?: ReadonlySet, +): PropertyDescriptor | undefined { + if ( + RESERVED_RPC_METHOD_NAMES.has(methodName) || + Object.prototype.hasOwnProperty.call(obj, methodName) || + excludedMethods?.has(methodName) + ) { + return undefined; + } - if (cached) { - return cached; - } + const descriptor = Object.getOwnPropertyDescriptor(prototype, methodName); + + if (!descriptor || typeof descriptor.value !== 'function' || getInstrumented(descriptor.value)) { + return undefined; + } - const boundMethod = (value as UncheckedMethod).bind(proxyTarget); + return descriptor; +} + +/** + * Wraps eligible methods on the instance's prototype chain once per class. + */ +function instrumentPrototypeRpcMethods(obj: object, excludedMethods?: ReadonlySet): void { + let prototype: object | null = Object.getPrototypeOf(obj); - if (prop in Object.prototype || Object.prototype.hasOwnProperty.call(proxyTarget, prop)) { - methodCache.set(prop, boundMethod); + while (prototype && prototype !== Object.prototype) { + for (const methodName of Object.getOwnPropertyNames(prototype)) { + const descriptor = getRpcMethodDescriptor(obj, prototype, methodName, excludedMethods); - return boundMethod; + if (!descriptor) { + continue; } - // Pre-create the traced version - const tracedMethod = wrapMethodWithSentry( - { options, context, spanName: prop, spanOp: 'rpc', origin: 'auto.faas.cloudflare.durable_object' }, - boundMethod, + const wrapped = createRpcPrototypeWrapper(methodName, descriptor.value as UncheckedMethod); + Object.defineProperty(prototype, methodName, { ...descriptor, value: wrapped }); + // Only the wrapper is marked, not the original method: `wrapMethodWithSentry` resolves + // through the same global map and must not resolve the original to this wrapper, + // which would recurse. + markAsInstrumented(wrapped); + } + + prototype = Object.getPrototypeOf(prototype); + } +} + +/** + * Creates a prototype wrapper that traces RPC calls carrying Sentry metadata. + * + * The wrapper looks up SDK state from its receiver, allowing one prototype function to serve every + * instance. Calls without RPC metadata or instance state use the original method directly. The + * original function name and arity are preserved because frameworks may inspect them for dispatch. + */ +function createRpcPrototypeWrapper(methodName: string, originalMethod: UncheckedMethod): UncheckedMethod { + const wrapper = function (this: unknown, ...args: unknown[]): unknown { + // Untraced calls are the common case — every internal call the instance makes to one of its + // own methods lands here too — so check the arguments before touching per-instance state. + if (!hasRpcMeta(args)) { + return Reflect.apply(originalMethod, this, args); + } + + const state = isObjectLike(this) ? rpcInstanceStates.get(this) : undefined; + + if (!state) { + return Reflect.apply(originalMethod, this, args); + } + + const tracedMethods = (state.tracedMethods ??= new Map()); + let traced = tracedMethods.get(methodName); + + if (!traced) { + traced = wrapMethodWithSentry( + { + options: state.options, + context: state.context, + spanName: methodName, + spanOp: 'rpc', + origin: 'auto.faas.cloudflare.durable_object', + }, + originalMethod, undefined, true, ); + tracedMethods.set(methodName, traced); + } - // Wrapper that checks for Sentry RPC metadata at call time - const wrappedMethod = ((...args: unknown[]) => { - const { rpcMeta } = extractRpcMeta(args); - - // If Sentry RPC metadata is present, use the traced version (creates span) - // Otherwise, call the bound method directly (no span) - return rpcMeta ? tracedMethod(...args) : boundMethod(...args); - }) as UncheckedMethod; + return Reflect.apply(traced, this, args); + }; - methodCache.set(prop, wrappedMethod); - - return wrappedMethod; - }, + Object.defineProperties(wrapper, { + name: { value: originalMethod.name, configurable: true }, + length: { value: originalMethod.length, configurable: true }, }); + + return wrapper as UncheckedMethod; } /** @@ -257,7 +428,7 @@ export function instrumentDurableObjectWithSentry< >(optionsCallback: (env: E) => CloudflareOptions, DurableObjectClass: C): C { return new Proxy(DurableObjectClass, { construct(target, [ctx, env], newTarget) { - const { obj, options, context } = constructInstrumentedDurableObject( + const { obj, options, context, frameworkManagedMethods } = constructInstrumentedDurableObject( target, ctx, env, @@ -265,7 +436,7 @@ export function instrumentDurableObjectWithSentry< optionsCallback, ); - return finalizeWithRpcInstrumentation(obj, options, context); + return finalizeWithRpcInstrumentation(obj, options, context, frameworkManagedMethods); }, }); } @@ -317,7 +488,7 @@ export function instrumentAgentWithSentry< >(optionsCallback: (env: E) => CloudflareOptions, AgentClass: C): C { return new Proxy(AgentClass, { construct(target, [ctx, env], newTarget) { - const { obj, options, context } = constructInstrumentedDurableObject( + const { obj, options, context, frameworkManagedMethods } = constructInstrumentedDurableObject( target, ctx, env, @@ -328,8 +499,10 @@ export function instrumentAgentWithSentry< instrumentCloudflareAgent(obj); // Apply RPC prototype-method instrumentation last, so the Agent-specific own-property - // handlers we just installed are excluded from RPC method tracing. - return finalizeWithRpcInstrumentation(obj, options, context); + // handlers we just installed are excluded from RPC method tracing. Methods the Agent + // framework installed itself are excluded too — `instrumentCloudflareAgent` traces those by + // wrapping the dispatch instead of the method. + return finalizeWithRpcInstrumentation(obj, options, context, frameworkManagedMethods); }, }); } diff --git a/packages/cloudflare/src/utils/rpcMeta.ts b/packages/cloudflare/src/utils/rpcMeta.ts index b12ab5e74235..ad0e4b5ebb33 100644 --- a/packages/cloudflare/src/utils/rpcMeta.ts +++ b/packages/cloudflare/src/utils/rpcMeta.ts @@ -32,6 +32,17 @@ export function appendRpcMeta(args: unknown[]): unknown[] { return [...args, { [SENTRY_RPC_META_KEY]: traceData }]; } +/** + * Whether the trailing argument carries Sentry RPC metadata. + * + * Separate from {@link extractRpcMeta} because the RPC method wrappers run this check on every + * call — including the instance's own internal method calls, which never carry metadata — and + * must not allocate on that path. + */ +export function hasRpcMeta(args: unknown[]): boolean { + return args.length > 0 && isSentryRpcMeta(args[args.length - 1]); +} + /** * Extracts Sentry RPC metadata from the trailing argument of an args array. * Returns cleaned args (without meta) and the extracted trace data if found. diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 5e8325c8a497..ec36905ed12c 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -1,7 +1,7 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; import * as SentryCore from '@sentry/core'; import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'; -import { instrumentDurableObjectWithSentry } from '../src'; +import { instrumentAgentWithSentry, instrumentDurableObjectWithSentry } from '../src'; import { getInstrumented } from '../src/instrument'; describe('instrumentDurableObjectWithSentry', () => { @@ -136,7 +136,7 @@ describe('instrumentDurableObjectWithSentry', () => { expect(startSpanSpy).not.toHaveBeenCalled(); }); - it('Binds prototype methods to original object when enableRpcTracePropagation is true', () => { + it('Invokes prototype methods with the instance as receiver when enableRpcTracePropagation is true', () => { const testClass = class { method() { return this; @@ -148,10 +148,11 @@ describe('instrumentDurableObjectWithSentry', () => { ); const obj = Reflect.construct(instrumented, []); - // Method should be callable and return the original object (not the proxy) + // The instance is not proxied, so the receiver is the instance itself — this is what keeps + // native private fields working (#23040) const result = obj.method(); - expect(result).not.toBe(obj); // result is original object, obj is proxy - expect(typeof result.method).toBe('function'); // original object still has method + expect(result).toBe(obj); + expect(typeof result.method).toBe('function'); // Methods should be cached (same reference on repeated access) expect(obj.method).toBe(obj.method); @@ -208,7 +209,7 @@ describe('instrumentDurableObjectWithSentry', () => { expect(obj.rpcMethod()).toBe('rpc'); }); - it('preserves constructor identity on the proxy', () => { + it('preserves constructor identity', () => { const testClass = class MyDO { rpcMethod() { return 'result'; @@ -273,6 +274,9 @@ describe('instrumentDurableObjectWithSentry', () => { return 'rpc-result'; } }; + // Capture the original before construction wraps the prototype + const originalRpcMethod = testClass.prototype.rpcMethod; + const instrumented = instrumentDurableObjectWithSentry( vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), testClass as any, @@ -280,17 +284,219 @@ describe('instrumentDurableObjectWithSentry', () => { const obj = Reflect.construct(instrumented, []); // Object.prototype methods should NOT be wrapped with Sentry tracing. - // They are bound to the original object but still work correctly. expect(obj.toString()).toBe('[object Object]'); expect(obj.hasOwnProperty('rpcMethod')).toBe(false); // It's on prototype, not own - // valueOf returns the original object, not the proxy - expect(obj.valueOf()).not.toBe(obj); + // The instance is not proxied, so valueOf returns the instance itself + expect(obj.valueOf()).toBe(obj); - // Meanwhile, actual RPC methods SHOULD be wrapped (not equal to prototype method) - expect(obj.rpcMethod).not.toBe(testClass.prototype.rpcMethod); + // Meanwhile, actual RPC methods SHOULD be wrapped on the prototype + expect(obj.rpcMethod).not.toBe(originalRpcMethod); expect(obj.rpcMethod()).toBe('rpc-result'); }); + // Frameworks that dispatch methods themselves (the `agents` `@callable()` registry, for example) + // install their own function during construction and resolve the dispatch through that exact + // function instance. Replacing it makes the framework no longer recognize the method, so those + // methods must keep the function the framework installed. + describe('framework-managed methods', () => { + it('does not wrap methods a framework replaced during construction, but wraps the rest', () => { + const frameworkDispatch = new WeakSet(); + + class FrameworkLike { + constructor() { + const original = FrameworkLike.prototype.greet; + + if (!frameworkDispatch.has(original)) { + const dispatched = function (this: FrameworkLike, name: string): string { + return original.call(this, name); + }; + frameworkDispatch.add(dispatched); + FrameworkLike.prototype.greet = dispatched; + } + } + + greet(name: string): string { + return `Hello, ${name}!`; + } + + fetchData(): string { + return 'data'; + } + } + + const originalFetchData = FrameworkLike.prototype.fetchData; + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + FrameworkLike as any, + ); + const obj = Reflect.construct(instrumented, []) as FrameworkLike; + + // Left as the framework installed it, so its identity-keyed dispatch keeps resolving + expect(frameworkDispatch.has(FrameworkLike.prototype.greet)).toBe(true); + expect(obj.greet('World')).toBe('Hello, World!'); + + // Every other RPC method is still wrapped on the prototype + expect(FrameworkLike.prototype.fetchData).not.toBe(originalFetchData); + expect(obj.fetchData()).toBe('data'); + }); + + it('keeps excluding a framework-managed method for instances constructed later', () => { + const frameworkDispatch = new WeakSet(); + + class FrameworkLike { + constructor() { + const original = FrameworkLike.prototype.greet; + + // Frameworks typically install their dispatch once, for the first instance + if (!frameworkDispatch.has(original)) { + const dispatched = function (this: FrameworkLike, name: string): string { + return original.call(this, name); + }; + frameworkDispatch.add(dispatched); + FrameworkLike.prototype.greet = dispatched; + } + } + + greet(name: string): string { + return `Hello, ${name}!`; + } + } + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + FrameworkLike as any, + ); + + Reflect.construct(instrumented, []); + const second = Reflect.construct(instrumented, []) as FrameworkLike; + + expect(frameworkDispatch.has(FrameworkLike.prototype.greet)).toBe(true); + expect(second.greet('World')).toBe('Hello, World!'); + }); + }); + + // The wrapper replaces a method on a class the user owns, so it has to keep the parts of the + // function that are observable from the outside. + it('preserves the name and arity of the methods it wraps', () => { + const testClass = class { + rpcMethod(_a: string, _b: number): string { + return 'rpc-result'; + } + }; + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + Reflect.construct(instrumented, []); + + expect(testClass.prototype.rpcMethod.name).toBe('rpcMethod'); + expect(testClass.prototype.rpcMethod.length).toBe(2); + }); + + // The runtime rejects these before any property lookup (`isReservedName` in workerd's + // `worker-rpc.c++`), so wrapping them would mutate the user's class for no tracing. + it('leaves methods the runtime never dispatches over RPC untouched', () => { + const testClass = class { + connect(): string { + return 'connect'; + } + dup(): string { + return 'dup'; + } + webSocketClose(): string { + return 'closed'; + } + rpcMethod(): string { + return 'rpc-result'; + } + }; + + const originals = { + connect: testClass.prototype.connect, + dup: testClass.prototype.dup, + webSocketClose: testClass.prototype.webSocketClose, + rpcMethod: testClass.prototype.rpcMethod, + }; + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + Reflect.construct(instrumented, []); + + expect(testClass.prototype.connect).toBe(originals.connect); + expect(testClass.prototype.dup).toBe(originals.dup); + expect(testClass.prototype.webSocketClose).toBe(originals.webSocketClose); + + // A regular RPC method is still wrapped + expect(testClass.prototype.rpcMethod).not.toBe(originals.rpcMethod); + }); + + // Regression for #23040 — workerd's native RPC dispatch (Durable Object facets, the Agents + // SDK bootstrap calling `setName()` via `getAgentByName`/`subAgent`) resolves the method on + // the prototype and invokes it with the stored Durable Object instance as the receiver. When + // the instrumented constructor returned a Proxy of the instance, native private field access + // failed because a Proxy never carries the target's private brand. + describe('native private fields', () => { + it('invokes prototype RPC methods with the instance as receiver so native private fields work', () => { + class PartyServerLike { + #name?: string; + + setName(name: string): void { + this.#name = name; + } + + getName(): string | undefined { + return this.#name; + } + } + + const instrumented = instrumentAgentWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + PartyServerLike as any, + ); + const obj = Reflect.construct(instrumented, []) as PartyServerLike; + + // This is how native RPC invokes the method: resolved on the prototype, called with the + // instance as `this` — not fetched through a property access on the instance. + const prototypeSetName = Object.getPrototypeOf(obj).setName as PartyServerLike['setName']; + expect(() => Reflect.apply(prototypeSetName, obj, ['agent-1'])).not.toThrow(); + expect(obj.getName()).toBe('agent-1'); + }); + + it('preserves the instance receiver on the traced RPC path so native private fields work', () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan').mockImplementation((_, callback) => callback({} as any)); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + + class WithSecret { + #secret = 42; + + getSecret(): number { + return this.#secret; + } + } + + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + WithSecret as any, + ); + const obj = Reflect.construct(instrumented, []) as WithSecret; + + const rpcMeta = { + __sentry_rpc_meta__: { + 'sentry-trace': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-1', + baggage: '', + }, + }; + + const prototypeGetSecret = Object.getPrototypeOf(obj).getSecret as WithSecret['getSecret']; + expect(Reflect.apply(prototypeGetSecret, obj, [rpcMeta])).toBe(42); + expect(startSpanSpy).toHaveBeenCalled(); + }); + }); + it('flush performs after all waitUntil promises are finished', async () => { // Spy on Client.prototype.flush and mock it to resolve immediately to avoid timeout issues with fake timers const flush = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true);