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
@@ -1,24 +1,25 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';
import type { RpcTarget } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
MY_DURABLE_OBJECT: DurableObjectNamespace<MyDurableObjectBase>;
}

class MyDurableObjectBase extends DurableObject<Env> implements RpcTarget {
async sayHello(name: string): Promise<string> {
return `Hello, ${name}!`;
class MyDurableObjectBase extends DurableObject<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/hello') {
return new Response('Hello, World!');
}
return new Response('Not found', { status: 404 });
}
}

// enableRpcTracePropagation is NOT enabled, so RPC methods won't be instrumented
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
// enableRpcTracePropagation: false (default)
}),
MyDurableObjectBase,
);
Expand All @@ -34,9 +35,11 @@ export default Sentry.withSentry(
const id = env.MY_DURABLE_OBJECT.idFromName('test');
const stub = env.MY_DURABLE_OBJECT.get(id);

if (url.pathname === '/rpc/hello') {
const result = await stub.sayHello('World');
return new Response(result);
if (url.pathname === '/do/hello') {
// Call DO via fetch instead of RPC
const doResponse = await stub.fetch(new Request('http://do/hello'));
const text = await doResponse.text();
return new Response(text);
}

return new Response('Not found', { status: 404 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,65 @@ import { expect, it } from 'vitest';
import type { Event } from '@sentry/core';
import { createRunner } from '../../../../runner';

it('does not create RPC transaction when enableRpcTracePropagation is disabled', async ({ signal }) => {
let receivedTransactions: string[] = [];
it('does not propagate trace when enableRpcTracePropagation is disabled', async ({ signal }) => {
let workerTraceId: string | undefined;
let doTraceId: string | undefined;

const runner = createRunner(__dirname)
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as Event;

// Should only receive the worker HTTP transaction, not the DO RPC transaction
expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
data: expect.objectContaining({
'sentry.origin': 'auto.http.cloudflare',
}),
origin: 'auto.http.cloudflare',
}),
}),
transaction: 'GET /rpc/hello',
}),
);
receivedTransactions.push(transactionEvent.transaction as string);

const txName = transactionEvent.transaction as string;
const traceId = transactionEvent.contexts?.trace?.trace_id as string;

if (txName === 'GET /do/hello') {
workerTraceId = traceId;
} else if (txName === 'GET /hello') {
doTraceId = traceId;
}
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as Event;

expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
}),
}),
}),
);

const txName = transactionEvent.transaction as string;
const traceId = transactionEvent.contexts?.trace?.trace_id as string;

if (txName === 'GET /do/hello') {
workerTraceId = traceId;
} else if (txName === 'GET /hello') {
doTraceId = traceId;
}
})
.unordered()
.start(signal);

// The RPC call should still work, just not be instrumented
const response = await runner.makeRequest<string>('get', '/rpc/hello');
const response = await runner.makeRequest<string>('get', '/do/hello');
expect(response).toBe('Hello, World!');

await runner.completed();

// Verify we only got the worker transaction, no RPC transaction
expect(receivedTransactions).toEqual(['GET /rpc/hello']);
expect(receivedTransactions).not.toContain('sayHello');
// Both transactions should exist but have different trace IDs (no propagation)
expect(workerTraceId).toBeDefined();
expect(doTraceId).toBeDefined();
expect(workerTraceId).not.toBe(doTraceId);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as Sentry from '@sentry/cloudflare';
import { WorkerEntrypoint } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
}

class MySubWorkerEntrypointBase extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/answer') {
return new Response('The answer is 42');
}

if (url.pathname === '/greet') {
const name = url.searchParams.get('name') || 'Anonymous';
return new Response(`Hello, ${name}!`);
}

return new Response('Not found', { status: 404 });
}
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
}),
MySubWorkerEntrypointBase,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
SUB_WORKER: Fetcher;
}

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

if (url.pathname === '/call-entrypoint') {
const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/answer'));
const text = await response.text();
return new Response(text);
}

if (url.pathname === '/call-entrypoint-greet') {
const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/greet?name=World'));
const text = await response.text();
return new Response(text);
}

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

it('propagates trace from Worker (ExportedHandler) to WorkerEntrypoint via service binding fetch', async ({
signal,
}) => {
let workerTraceId: string | undefined;
let workerSpanId: string | undefined;
let entrypointTraceId: string | undefined;
let entrypointParentSpanId: string | undefined;

const runner = createRunner(__dirname)
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as Event;

// Main worker HTTP server transaction
expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
data: expect.objectContaining({
'sentry.origin': 'auto.http.cloudflare',
}),
origin: 'auto.http.cloudflare',
}),
}),
transaction: 'GET /call-entrypoint',
}),
);
workerTraceId = transactionEvent.contexts?.trace?.trace_id as string;
workerSpanId = transactionEvent.contexts?.trace?.span_id as string;
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as Event;

// WorkerEntrypoint HTTP server transaction (from service binding fetch)
expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
data: expect.objectContaining({
'sentry.origin': 'auto.http.cloudflare',
}),
origin: 'auto.http.cloudflare',
}),
}),
transaction: 'GET /answer',
}),
);
entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string;
entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string;
})
.unordered()
.start(signal);

const response = await runner.makeRequest<string>('get', '/call-entrypoint');
expect(response).toBe('The answer is 42');

await runner.completed();

// Both transactions should share the same trace_id
expect(workerTraceId).toBeDefined();
expect(entrypointTraceId).toBeDefined();
expect(workerTraceId).toBe(entrypointTraceId);

// Verify the parent-child relationship: Worker -> WorkerEntrypoint
expect(workerSpanId).toBeDefined();
expect(entrypointParentSpanId).toBeDefined();
expect(entrypointParentSpanId).toBe(workerSpanId);
});

it('propagates trace for request with query params from Worker to WorkerEntrypoint', async ({ signal }) => {
let workerTraceId: string | undefined;
let workerSpanId: string | undefined;
let entrypointTraceId: string | undefined;
let entrypointParentSpanId: string | undefined;

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: 'http.server',
}),
}),
transaction: 'GET /call-entrypoint-greet',
}),
);
workerTraceId = transactionEvent.contexts?.trace?.trace_id as string;
workerSpanId = transactionEvent.contexts?.trace?.span_id as string;
})
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1] as Event;

expect(transactionEvent).toEqual(
expect.objectContaining({
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
}),
}),
transaction: 'GET /greet',
}),
);
entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string;
entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string;
})
.unordered()
.start(signal);

const response = await runner.makeRequest<string>('get', '/call-entrypoint-greet');
expect(response).toBe('Hello, World!');

await runner.completed();

expect(workerTraceId).toBeDefined();
expect(entrypointTraceId).toBeDefined();
expect(workerTraceId).toBe(entrypointTraceId);

expect(workerSpanId).toBeDefined();
expect(entrypointParentSpanId).toBeDefined();
expect(entrypointParentSpanId).toBe(workerSpanId);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "cloudflare-worker-workerentrypoint-rpc-sub",
"main": "index-sub-worker.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_als"],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "cloudflare-worker-workerentrypoint-rpc",
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_als"],
"services": [
{
"binding": "SUB_WORKER",
"service": "cloudflare-worker-workerentrypoint-rpc-sub",
},
],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
MY_DURABLE_OBJECT: DurableObjectNamespace<MyDurableObjectBase>;
}

class MyDurableObjectBase extends DurableObject<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/hello') {
return new Response('Hello, World!');
}
return new Response('Not found', { status: 404 });
}
}

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

class MyWorkerEntrypointBase extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const id = this.env.MY_DURABLE_OBJECT.idFromName('test');
const stub = this.env.MY_DURABLE_OBJECT.get(id);

if (url.pathname === '/do/hello') {
const doResponse = await stub.fetch(new Request('http://do/hello'));
const text = await doResponse.text();
return new Response(text);
}

return new Response('Not found', { status: 404 });
}
}

export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}),
MyWorkerEntrypointBase,
);
Loading
Loading