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 number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
TEST_DURABLE_OBJECT: DurableObjectNamespace<TestDurableObjectBase>;
}

class TestDurableObjectBase extends DurableObject<Env> {
private overlappingCalls = 0;
private releaseOverlappingCalls: () => void = () => {};
private readonly allOverlappingCallsArrived = new Promise<void>(resolve => {
this.releaseOverlappingCalls = resolve;
});

async failingRpcMethod(): Promise<void> {
throw new Error('Test error from Durable Object RPC method');
}

// Each call waits until the other one has arrived, so both are in flight at the same time.
async overlappingFailingRpcMethod(label: string): Promise<void> {
this.overlappingCalls++;
if (this.overlappingCalls === 2) {
this.releaseOverlappingCalls();
}

await this.allOverlappingCallsArrived;
throw new Error(`Overlapping RPC call ${label}`);
}
}

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

// The caller is not instrumented, so its RPC calls carry no trace metadata.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/overlapping') {
const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName('overlapping'));
const results = await Promise.allSettled([
stub.overlappingFailingRpcMethod('a'),
stub.overlappingFailingRpcMethod('b'),
]);

return new Response(results.map(result => result.status).join(','));
}

const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName('test'));

try {
await stub.failingRpcMethod();
return new Response('no error');
} catch (error) {
return new Response(String((error as Error).message));
}
},
} satisfies ExportedHandler<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, it } from 'vitest';
import type { Event } from '@sentry/core';
import { createRunner } from '../../../runner';

it('captures errors thrown by a Durable Object RPC method called without trace metadata', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(envelope => {
const event = envelope[1]?.[0]?.[1] as Event;
expect(event.exception?.values?.[0]?.type).toBe('Error');
expect(event.exception?.values?.[0]?.value).toBe('Test error from Durable Object RPC method');
expect(event.exception?.values?.[0]?.mechanism).toEqual({
type: 'auto.faas.cloudflare.durable_object',
handled: false,
});
})
.start(signal);

const response = await runner.makeRequest<string>('get', '/');
expect(response).toBe('Test error from Durable Object RPC method');

await runner.completed();
});

it('gives overlapping Durable Object RPC calls without trace metadata separate traces', async ({ signal }) => {
const traceIds: Record<string, string | undefined> = {};

const runner = createRunner(__dirname)
.unordered()
.expect(envelope => {
const event = envelope[1]?.[0]?.[1] as Event;
expect(event.exception?.values?.[0]?.value).toBe('Overlapping RPC call a');
expect(event.contexts?.trace?.trace_id).toMatch(/^[\da-f]{32}$/);
traceIds.a = event.contexts?.trace?.trace_id;
})
.expect(envelope => {
const event = envelope[1]?.[0]?.[1] as Event;
expect(event.exception?.values?.[0]?.value).toBe('Overlapping RPC call b');
expect(event.contexts?.trace?.trace_id).toMatch(/^[\da-f]{32}$/);
traceIds.b = event.contexts?.trace?.trace_id;
})
.start(signal);

const response = await runner.makeRequest<string>('get', '/overlapping');
expect(response).toBe('rejected,rejected');

await runner.completed();

expect(traceIds.a).not.toBe(traceIds.b);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "durableobject-rpc-without-trace-worker",
"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_compat"],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
COUNTER: DurableObjectNamespace<Counter>;
}

// Nothing is wrapped manually, the Vite plugin wraps both exports and enables RPC trace
// propagation for `COUNTER`.
export class Counter extends DurableObject<Env> {
private calls = 0;
private releaseCalls: () => void = () => {};
private readonly allCallsArrived = new Promise<void>(resolve => {
this.releaseCalls = resolve;
});

// Each call waits until the other one has arrived, so both are in flight at the same time.
async work(label: string): Promise<string> {
this.calls++;
if (this.calls === 2) {
this.releaseCalls();
}

await this.allCallsArrived;
Sentry.getActiveSpan()?.setAttribute('test.label', label);
return label;
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/overlapping') {
const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e'));
const labels = await Promise.all([stub.work('a'), stub.work('b')]);
return new Response(labels.join(','));
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
traceLifecycle: 'static',
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { TransactionEvent } from '@sentry/core';
import { expect, it } from 'vitest';
import { createRunner } from '../../../../runner';

it('propagates the worker trace into each of two overlapping Durable Object RPC calls', async ({ signal }) => {
const doTraces: Record<string, TransactionEvent['contexts']> = {};
let workerTraceId: string | undefined;
let workerSpanId: string | undefined;

const runner = createRunner(__dirname)
.unordered()
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
expect(transactionEvent.transaction).toBe('work');
expect(transactionEvent.contexts?.trace?.op).toBe('rpc');
expect(transactionEvent.contexts?.trace?.data?.['test.label']).toBe('a');
doTraces.a = transactionEvent.contexts;
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
expect(transactionEvent.transaction).toBe('work');
expect(transactionEvent.contexts?.trace?.op).toBe('rpc');
expect(transactionEvent.contexts?.trace?.data?.['test.label']).toBe('b');
doTraces.b = transactionEvent.contexts;
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent;
expect(transactionEvent.transaction).toBe('GET /overlapping');
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
workerTraceId = transactionEvent.contexts?.trace?.trace_id;
workerSpanId = transactionEvent.contexts?.trace?.span_id;
})
.start(signal);

const response = await runner.makeRequest<string>('get', '/overlapping');
expect(response).toBe('a,b');

await runner.completed();

expect(workerTraceId).toMatch(/^[\da-f]{32}$/);
expect(workerSpanId).toMatch(/^[\da-f]{16}$/);

expect(doTraces.a?.trace?.trace_id).toBe(workerTraceId);
expect(doTraces.a?.trace?.parent_span_id).toBe(workerSpanId);
expect(doTraces.b?.trace?.trace_id).toBe(workerTraceId);
expect(doTraces.b?.trace?.parent_span_id).toBe(workerSpanId);

expect(doTraces.a?.trace?.span_id).not.toBe(doTraces.b?.trace?.span_id);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "../../../../node_modules/wrangler/config-schema.json",
"name": "cloudflare-worker-do-rpc-overlapping",
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
}
83 changes: 54 additions & 29 deletions packages/cloudflare/src/durableobject.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable max-lines */
/* eslint-disable @typescript-eslint/unbound-method */
import { RPC } from '@sentry/conventions/op';
import { isObjectLike } from '@sentry/core';
import { getDefaultIsolationScope, getIsolationScope, isObjectLike, startNewTrace } from '@sentry/core';
import type { DurableObject } from 'cloudflare:workers';
import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels';
import type { CloudflareOptions } from './client';
Expand Down Expand Up @@ -144,8 +144,8 @@ function resolveFrameworkManagedMethods(
type RpcInstanceState = {
options: CloudflareOptions;
context: InstrumentedDurableObjectContext;
/** Per-instance cache of the traced method wrappers, keyed by method name. Created on first use. */
tracedMethods?: Map<string, UncheckedMethod>;
/** Per-instance cache of the instrumented method wrappers, keyed by method name. Created on first use. */
instrumentedMethods?: Map<string, UncheckedMethod>;
};

/**
Expand All @@ -170,7 +170,7 @@ const RESERVED_RPC_METHOD_NAMES: ReadonlySet<string> = new Set([
const rpcInstanceStates = new WeakMap<object, RpcInstanceState>();

/**
* Adds trace propagation to a constructed Durable Object's RPC methods.
* Instruments a constructed Durable Object's RPC methods.
*
* 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
Expand Down Expand Up @@ -256,17 +256,52 @@ function instrumentPrototypeRpcMethods(obj: object, excludedMethods?: ReadonlySe
}

/**
* Creates a prototype wrapper that traces RPC calls carrying Sentry metadata.
* Returns the instance's instrumented wrapper for an RPC method, creating it on first use.
*/
function getInstrumentedRpcMethod(
state: RpcInstanceState,
methodName: string,
originalMethod: UncheckedMethod,
): UncheckedMethod {
const instrumentedMethods = (state.instrumentedMethods ??= new Map());
let instrumented = instrumentedMethods.get(methodName);

if (!instrumented) {
instrumented = wrapMethodWithSentry(
{
options: state.options,
context: state.context,
spanName: rpcMeta => (rpcMeta ? methodName : undefined),
spanOp: RPC,
origin: 'auto.faas.cloudflare.durable_object',
},
originalMethod,
undefined,
true,
);
instrumentedMethods.set(methodName, instrumented);
}

return instrumented;
}

/**
* Creates a prototype wrapper that instruments external RPC calls.
*
* 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.
* instance. A call carrying Sentry metadata continues that trace in an `rpc` span. A call without it
* still gets a client, so its errors, logs and metrics are captured, but no span. Calls the instance
* makes to its own methods run 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)) {
const traced = hasRpcMeta(args);

// workerd dispatches an incoming RPC call outside any async context, so a call made while an
// invocation is already in flight comes from the instance itself (`this.helper()` inside
// `fetch`, `alarm` or another RPC method). Check that before touching per-instance state.
if (!traced && getIsolationScope() !== getDefaultIsolationScope()) {
return Reflect.apply(originalMethod, this, args);
}

Expand All @@ -276,26 +311,15 @@ function createRpcPrototypeWrapper(methodName: string, originalMethod: Unchecked
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);
const instrumented = getInstrumentedRpcMethod(state, methodName, originalMethod);

if (traced) {
return Reflect.apply(instrumented, this, args);
}

return Reflect.apply(traced, this, args);
// Unlike a WorkerEntrypoint, a Durable Object instance is long-lived and serves overlapping
// calls. Without a new trace, every untraced call would share the trace of the default scope.
return startNewTrace(() => Reflect.apply(instrumented, this, args));
};

Object.defineProperties(wrapper, {
Expand All @@ -316,7 +340,8 @@ function createRpcPrototypeWrapper(methodName: string, originalMethod: Unchecked
* - webSocketClose
* - webSocketError
*
* RPC methods (prototype methods) are instrumented too, so an incoming trace continues into them.
* RPC methods (prototype methods) are instrumented too: an incoming trace continues into them, and
* errors, logs and metrics are captured whether or not the caller propagates a trace.
*
* @param optionsCallback Function that returns the options for the SDK initialization.
* @param DurableObjectClass The Durable Object class to instrument.
Expand Down
Loading
Loading