diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts index eb21c2918155..941f988971bc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts @@ -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; } -class MyDurableObjectBase extends DurableObject implements RpcTarget { - async sayHello(name: string): Promise { - return `Hello, ${name}!`; +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request): Promise { + 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, ); @@ -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 }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts index cba40af5a43d..4fe2b98956d5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts @@ -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('get', '/rpc/hello'); + const response = await runner.makeRequest('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); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts new file mode 100644 index 000000000000..5e59441803e5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts @@ -0,0 +1,32 @@ +import * as Sentry from '@sentry/cloudflare'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; +} + +class MySubWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + 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, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts new file mode 100644 index 000000000000..e46d7ffd4daf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts @@ -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, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts new file mode 100644 index 000000000000..3b76e28e9e88 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts @@ -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('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('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); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..13de99007e1f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler-sub-worker.jsonc @@ -0,0 +1,6 @@ +{ + "name": "cloudflare-worker-workerentrypoint-rpc-sub", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc new file mode 100644 index 000000000000..1638b8a00a18 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc @@ -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", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts new file mode 100644 index 000000000000..222ac72599b2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts @@ -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; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request): Promise { + 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 { + async fetch(request: Request): Promise { + 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, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts new file mode 100644 index 000000000000..4882f09ccaaa --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts @@ -0,0 +1,66 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('does not propagate trace when enableRpcTracePropagation is disabled (WorkerEntrypoint)', async ({ signal }) => { + let workerTraceId: string | undefined; + let doTraceId: 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', + }), + }), + }), + ); + + 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); + + const response = await runner.makeRequest('get', '/do/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + // Both transactions should exist but have different trace IDs (no propagation) + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).not.toBe(doTraceId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc new file mode 100644 index 000000000000..78303e091bf4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-workerentrypoint-do-rpc-disabled", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts new file mode 100644 index 000000000000..63876045722b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts @@ -0,0 +1,55 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } + + async multiply(a: number, b: number): Promise { + return a * b; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +class MyWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + 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 === '/rpc/hello') { + const result = await stub.sayHello('World'); + return new Response(result); + } + + if (url.pathname === '/rpc/multiply') { + const result = await stub.multiply(6, 7); + return new Response(String(result)); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts new file mode 100644 index 000000000000..2dd17269ae23 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/test.ts @@ -0,0 +1,123 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from WorkerEntrypoint to durable object via this.env RPC call', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: 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: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'sayHello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_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', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /rpc/hello', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace for RPC method with multiple arguments via this.env', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: 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: 'rpc', + }), + }), + transaction: 'multiply', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_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 /rpc/multiply', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/multiply'); + expect(response).toBe('42'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..e0d4024f8b8b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-workerentrypoint-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts new file mode 100644 index 000000000000..4ff513ccfd03 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts @@ -0,0 +1,47 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async computeAnswer(): Promise { + return 42; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +class MySubWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/call-do') { + const id = this.env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + const result = await stub.computeAnswer(); + return new Response(`The answer is ${result}`); + } + + return new Response('Not found', { status: 404 }); + } +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MySubWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts new file mode 100644 index 000000000000..19ebc32abc55 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/cloudflare'; +import { WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + SUB_WORKER: Fetcher; +} + +class MyWorkerEntrypointBase extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + if (url.pathname === '/chain') { + const response = await this.env.SUB_WORKER.fetch(new Request('http://fake-host/call-do')); + const text = await response.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, + enableRpcTracePropagation: true, + }), + MyWorkerEntrypointBase, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts new file mode 100644 index 000000000000..474624fa2145 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/test.ts @@ -0,0 +1,105 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from WorkerEntrypoint to WorkerEntrypoint to durable object (3 levels deep)', async ({ + signal, +}) => { + let mainWorkerTraceId: string | undefined; + let mainWorkerSpanId: string | undefined; + let subWorkerTraceId: string | undefined; + let subWorkerSpanId: string | undefined; + let subWorkerParentSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: 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 /chain', + }), + ); + mainWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + mainWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Sub-worker 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 /call-do', + }), + ); + subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + subWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Durable Object RPC transaction + 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: 'computeAnswer', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/chain'); + expect(response).toBe('The answer is 42'); + + await runner.completed(); + + // All three transactions should share the same trace_id + expect(mainWorkerTraceId).toBeDefined(); + expect(subWorkerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(mainWorkerTraceId).toBe(subWorkerTraceId); + expect(subWorkerTraceId).toBe(doTraceId); + + // Verify the parent-child relationships form a chain: + // Main WorkerEntrypoint -> Sub WorkerEntrypoint -> DO + expect(mainWorkerSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBe(mainWorkerSpanId); + + expect(subWorkerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(subWorkerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..873f66317fc8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler-sub-worker.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-workerentrypoint-workerentrypoint-do-rpc-sub", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..45dfacf580a1 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-workerentrypoint-workerentrypoint-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "services": [ + { + "binding": "SUB_WORKER", + "service": "cloudflare-workerentrypoint-workerentrypoint-do-rpc-sub", + }, + ], +} diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index 6a9daf83ec1c..e8b2466da821 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -7,6 +7,7 @@ import { instrumentWorkerEntrypointScheduled } from './worker/instrumentSchedule import { instrumentWorkerEntrypointTail } from './worker/instrumentTail'; import { getFinalOptions } from '../options'; import { instrumentContext } from '../utils/instrumentContext'; +import { instrumentEnv } from './worker/instrumentEnv'; export type WorkerEntrypointConstructor = new ( ctx: ExecutionContext, @@ -63,7 +64,8 @@ export function instrumentWorkerEntrypoint { expect(obj.methodTwo()).toBe('two'); }); }); + + describe('env instrumentation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes instrumented env to the constructor when enableRpcTracePropagation is enabled', () => { + const mockContext = createMockExecutionContext(); + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace, SENTRY_DSN: 'dsn' }; + + let constructorEnv: unknown; + const TestClass = class extends WorkerEntrypoint { + constructor(ctx: ExecutionContext, env: typeof mockEnv) { + super(); + constructorEnv = env; + } + fetch() { + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + Reflect.construct(instrumented, [mockContext, mockEnv]); + + expect(constructorEnv).not.toBe(mockEnv); + }); + + it('passes original env to the constructor when enableRpcTracePropagation is disabled', () => { + const mockContext = createMockExecutionContext(); + const mockEnv = { SENTRY_DSN: 'dsn' }; + + let constructorEnv: unknown; + const TestClass = class extends WorkerEntrypoint { + constructor(ctx: ExecutionContext, env: typeof mockEnv) { + super(); + constructorEnv = env; + } + fetch() { + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + Reflect.construct(instrumented, [mockContext, mockEnv]); + + expect(constructorEnv).toBe(mockEnv); + }); + + it('exposes instrumented DurableObjectNamespace via this.env when enableRpcTracePropagation is enabled', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const mockStub = { + id: { toString: () => 'stub-id' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }; + const doNamespace = { + idFromName: vi.fn().mockReturnValue({ toString: () => 'id-1' }), + idFromString: vi.fn(), + get: vi.fn().mockReturnValue(mockStub), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + const stub = this.env.COUNTER.get(this.env.COUNTER.idFromName('test')); + (stub as any).myRpcMethod('arg1'); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('returns original DurableObjectNamespace via this.env when enableRpcTracePropagation is disabled', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const mockStub = { + id: { toString: () => 'stub-id' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }; + const doNamespace = { + idFromName: vi.fn().mockReturnValue({ toString: () => 'id-1' }), + idFromString: vi.fn(), + get: vi.fn().mockReturnValue(mockStub), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + const stub = this.env.COUNTER.get(this.env.COUNTER.idFromName('test')); + (stub as any).myRpcMethod('arg1'); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + + it('injects Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is enabled', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const mockEnv = { SERVICE: jsrpcProxy }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + (this.env.SERVICE as any).myRpcMethod('arg1', 42); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not inject Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is disabled', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockContext = createMockExecutionContext(); + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const mockEnv = { SERVICE: jsrpcProxy }; + + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + (this.env.SERVICE as any).myRpcMethod('arg1', 42); + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); + }); + + it('caches instrumented bindings across multiple accesses via this.env', () => { + const mockContext = createMockExecutionContext(); + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const mockEnv = { COUNTER: doNamespace }; + + let firstAccess: unknown; + let secondAccess: unknown; + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + firstAccess = this.env.COUNTER; + secondAccess = this.env.COUNTER; + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(firstAccess).toBe(secondAccess); + }); + + it('primitive env values are returned unchanged', () => { + const mockContext = createMockExecutionContext(); + const mockEnv = { SENTRY_DSN: 'https://key@sentry.io/123', PORT: 8080, DEBUG: true }; + + let capturedDsn: unknown; + let capturedPort: unknown; + let capturedDebug: unknown; + const TestClass = class extends WorkerEntrypoint { + env = {} as typeof mockEnv; + fetch() { + capturedDsn = this.env.SENTRY_DSN; + capturedPort = this.env.PORT; + capturedDebug = this.env.DEBUG; + return new Response('ok'); + } + }; + + const instrumented = instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ); + const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); + obj.fetch(new Request('https://example.com')); + + expect(capturedDsn).toBe('https://key@sentry.io/123'); + expect(capturedPort).toBe(8080); + expect(capturedDebug).toBe(true); + }); + }); });