Skip to content
Merged
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 numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "^10.68.0",
"@sentry/core": "^10.68.0",
"agents": "^0.20.0",
Comment thread
JPeer264 marked this conversation as resolved.
"agents": "latest",
"ai": "^6.0.235",
"react": "^19.2.8",
"react-dom": "^19.2.8",
Expand All@@ -41,6 +41,9 @@
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"sentryTest": {
"optional": true
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
Expand Down
109 changes: 4 additions & 105 deletions packages/cloudflare/src/durableobject.ts
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { FUNCTION, RPC, WEBSOCKET } from '@sentry/conventions/op';
import { captureException, isObjectLike } from '@sentry/core';
import { RPC } from '@sentry/conventions/op';
import { 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, getInstrumented, markAsInstrumented } from './instrument';
import { getInstrumented, markAsInstrumented } from './instrument';
import { instrumentDurableObjectHandlers } from './instrumentations/instrumentDurableObjectHandlers';
import { instrumentEnv } from './instrumentations/worker/instrumentEnv';
import { getFinalOptions } from './options';
import { wrapRequestHandlerWithInit } from './wrapRequestHandlerWithInit';
import { init } from './sdk';
import { instrumentContext } from './utils/instrumentContext';
import { hasRpcMeta } from './utils/rpcMeta';
import { instrumentCloudflareAgent } from './instrumentations/agents';
Expand DownExpand Up@@ -141,106 +140,6 @@ function resolveFrameworkManagedMethods(
return managed;
}

/**
* Instruments the built-in Durable Object handler methods on a constructed instance.
*
* 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.
*/
function instrumentDurableObjectHandlers<E, T extends DurableObject<E>>(
obj: T,
options: CloudflareOptions,
context: InstrumentedDurableObjectContext,
): void {
// Bind each built-in handler to this instance before wrapping.
// See https://github.com/getsentry/sentry-javascript/issues/22328
if (obj.fetch && typeof obj.fetch === 'function') {
obj.fetch = ensureInstrumented(
obj.fetch.bind(obj),
original =>
new Proxy(original, {
apply(target, thisArg, args) {
return wrapRequestHandlerWithInit(
{ options, request: args[0], context },
() => {
return Reflect.apply(target, thisArg, args);
},
init,
);
},
}),
);
}

if (obj.alarm && typeof obj.alarm === 'function') {
// Alarms are independent invocations, so we start a new trace and link to the previous alarm
obj.alarm = wrapMethodWithSentry(
{
options,
context,
spanName: 'alarm',
spanOp: FUNCTION,
startNewTrace: true,
origin: 'auto.faas.cloudflare.durable_object',
},
obj.alarm.bind(obj),
);
}

if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') {
obj.webSocketMessage = wrapMethodWithSentry(
{
options,
context,
spanName: 'webSocketMessage',
spanOp: WEBSOCKET,
origin: 'auto.faas.cloudflare.durable_object',
},
obj.webSocketMessage.bind(obj),
);
}

if (obj.webSocketClose && typeof obj.webSocketClose === 'function') {
obj.webSocketClose = wrapMethodWithSentry(
{
options,
context,
spanName: 'webSocketClose',
spanOp: WEBSOCKET,
origin: 'auto.faas.cloudflare.durable_object',
},
obj.webSocketClose.bind(obj),
);
}

if (obj.webSocketError && typeof obj.webSocketError === 'function') {
obj.webSocketError = wrapMethodWithSentry(
{
options,
context,
spanName: 'webSocketError',
spanOp: WEBSOCKET,
origin: 'auto.faas.cloudflare.durable_object',
},
obj.webSocketError.bind(obj),
(_, error) =>
captureException(error, {
mechanism: {
type: 'auto.faas.cloudflare.durable_object_websocket',
handled: false,
},
}),
);
}
}

type RpcInstanceState = {
options: CloudflareOptions;
context: InstrumentedDurableObjectContext;
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { FUNCTION, WEBSOCKET } from '@sentry/conventions/op';
import { captureException, debug } from '@sentry/core';
import type { DurableObject } from 'cloudflare:workers';
import type { CloudflareOptions } from '../client';
import { DEBUG_BUILD } from '../debug-build';
import { ensureInstrumented } from '../instrument';
import { init } from '../sdk';
import { wrapMethodWithSentry } from '../wrapMethodWithSentry';
import { wrapRequestHandlerWithInit } from '../wrapRequestHandlerWithInit';

/**
* The instrumented context of the Durable Object being wrapped.
*
* Kept as `any` for the same reason as in `durableobject.ts`: a concrete `DurableObjectState` here
* makes `tsc` relate its `SqlStorage` graph against the parameter union of `wrapMethodWithSentry`,
* which hangs the type build.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type InstrumentedDurableObjectContext = any;

/**
* Instruments the built-in Durable Object handler methods on a constructed instance.
*
* 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.
*
* @internal
*/
export function instrumentDurableObjectHandlers<E, T extends DurableObject<E>>(
obj: T,
options: CloudflareOptions,
context: InstrumentedDurableObjectContext,
): void {
// Bind each built-in handler to this instance before wrapping.
// See https://github.com/getsentry/sentry-javascript/issues/22328
if (obj.fetch && typeof obj.fetch === 'function') {
setInstanceHandler(
obj,
'fetch',
ensureInstrumented(
obj.fetch.bind(obj),
original =>
new Proxy(original, {
apply(target, thisArg, args) {
return wrapRequestHandlerWithInit(
{ options, request: args[0], context },
() => {
return Reflect.apply(target, thisArg, args);
},
init,
);
},
}),
),
);
}

if (obj.alarm && typeof obj.alarm === 'function') {
// Alarms are independent invocations, so we start a new trace and link to the previous alarm
setInstanceHandler(
obj,
'alarm',
wrapMethodWithSentry(
{
options,
context,
spanName: 'alarm',
spanOp: FUNCTION,
startNewTrace: true,
origin: 'auto.faas.cloudflare.durable_object',
},
obj.alarm.bind(obj),
),
);
}

if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') {
setInstanceHandler(
obj,
'webSocketMessage',
wrapMethodWithSentry(
{
options,
context,
spanName: 'webSocketMessage',
spanOp: WEBSOCKET,
origin: 'auto.faas.cloudflare.durable_object',
},
obj.webSocketMessage.bind(obj),
),
);
}

if (obj.webSocketClose && typeof obj.webSocketClose === 'function') {
setInstanceHandler(
obj,
'webSocketClose',
wrapMethodWithSentry(
{
options,
context,
spanName: 'webSocketClose',
spanOp: WEBSOCKET,
origin: 'auto.faas.cloudflare.durable_object',
},
obj.webSocketClose.bind(obj),
),
);
}

if (obj.webSocketError && typeof obj.webSocketError === 'function') {
setInstanceHandler(
obj,
'webSocketError',
wrapMethodWithSentry(
{
options,
context,
spanName: 'webSocketError',
spanOp: WEBSOCKET,
origin: 'auto.faas.cloudflare.durable_object',
},
obj.webSocketError.bind(obj),
(_, error) =>
captureException(error, {
mechanism: {
type: 'auto.faas.cloudflare.durable_object_websocket',
handled: false,
},
}),
),
);
}
}

/**
* Installs an instrumented handler as an own property of the Durable Object instance.
*
* A plain assignment is not always possible. The `agents` package installs its handlers with
* `Object.defineProperty(instance, name, { value, configurable: true })`, and `defineProperty`
* leaves `writable` at `false`. Assigning to such a property throws a `TypeError` in strict mode,
* so a read-only property is redefined instead. When the property can be neither assigned nor
* redefined, the handler stays uninstrumented rather than breaking the object.
*/
function setInstanceHandler(obj: object, name: string, handler: unknown): void {
const descriptor = Object.getOwnPropertyDescriptor(obj, name);

try {
if (descriptor?.writable === false) {
Object.defineProperty(obj, name, {
value: handler,
writable: true,
enumerable: descriptor.enumerable,
configurable: descriptor.configurable,
});
} else {
(obj as Record<string, unknown>)[name] = handler;
}
} catch (error) {
DEBUG_BUILD && debug.warn(`Failed to instrument Durable Object handler "${name}"`, error);
}
}
51 changes: 51 additions & 0 deletions packages/cloudflare/test/durableobject.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -381,6 +381,57 @@ describe('instrumentDurableObjectWithSentry', () => {
expect(obj.rpcMethod()).toBe('rpc-result');
});

it('instruments built-in handlers installed as read-only own properties', () => {
// Shape installed by `agents` >= 0.22: `defineProperty` without `writable`, so the handlers
// are read-only and a plain assignment would throw in strict mode.
const testClass = class {
constructor() {
for (const name of ['fetch', 'alarm', 'webSocketMessage', 'webSocketClose', 'webSocketError']) {
Object.defineProperty(this, name, {
value: () => name,
configurable: true,
});
}
}
};

const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any);

let obj: any;
expect(() => {
obj = Reflect.construct(instrumented, [{ waitUntil: vi.fn() }, {}]);
}).not.toThrow();

for (const name of ['fetch', 'alarm', 'webSocketMessage', 'webSocketClose', 'webSocketError']) {
expect(getInstrumented(obj[name]), `Handler ${name} is instrumented`).toBeTruthy();
}

expect(obj.webSocketMessage()).toBe('webSocketMessage');
});

it('leaves sealed own-property handlers untouched instead of failing construction', () => {
const originalHandler = (): string => 'sealed-result';
const testClass = class {
constructor() {
Object.defineProperty(this, 'webSocketMessage', {
value: originalHandler,
writable: false,
configurable: false,
});
}
};

const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any);

let obj: any;
expect(() => {
obj = Reflect.construct(instrumented, [{ waitUntil: vi.fn() }, {}]);
}).not.toThrow();

expect(obj.webSocketMessage).toBe(originalHandler);
expect(obj.webSocketMessage()).toBe('sealed-result');
});

it('does not wrap Object.prototype methods as RPC methods', () => {
const testClass = class {
rpcMethod() {
Expand Down
Loading