diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index b0b439eb122a..e0a48dd33ff5 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -68,12 +68,17 @@ export function createRunner(...paths: string[]) { // By default, we ignore session & sessions const ignored: Set = new Set(['session', 'sessions', 'client_report']); let serverUrl: string | undefined; + const extraWranglerArgs: string[] = []; return { withServerUrl: function (url: string) { serverUrl = url; return this; }, + withWranglerArgs: function (...args: string[]) { + extraWranglerArgs.push(...args); + return this; + }, expect: function (expected: Expected) { expectedEnvelopes.push(expected); return this; @@ -237,6 +242,7 @@ export function createRunner(...paths: string[]) { `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, '--var', `SERVER_URL:${serverUrl}`, + ...extraWranglerArgs, ], { stdio, signal }, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts new file mode 100644 index 000000000000..22551809d210 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts @@ -0,0 +1,60 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; + MY_QUEUE: Queue; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request) { + return new Response('DO is fine'); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/queue/send') { + await env.MY_QUEUE.send({ action: 'test' }); + return new Response('Queued'); + } + + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const response = await stub.fetch(new Request('http://fake-host/hello')); + const text = await response.text(); + return new Response(text); + }, + + async queue(batch, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + for (const message of batch.messages) { + await stub.fetch(new Request('http://fake-host/hello')); + message.ack(); + } + }, + + async scheduled(controller, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + await stub.fetch(new Request('http://fake-host/hello')); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts new file mode 100644 index 000000000000..d1c5385f8fbf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/test.ts @@ -0,0 +1,206 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to durable object', 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: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + 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 /', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/'); + 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 from queue handler to durable object', async ({ signal }) => { + let queueTraceId: string | undefined; + let queueSpanId: 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: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + 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: 'queue.process', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.queue', + }), + origin: 'auto.faas.cloudflare.queue', + }), + }), + transaction: 'process my-queue', + }), + ); + queueTraceId = transactionEvent.contexts?.trace?.trace_id as string; + queueSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + // Also expect the fetch transaction from the /queue/send request + .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 /queue/send', + }), + ); + }) + .unordered() + .start(signal); + // The fetch handler sends a message to the queue, which triggers the queue consumer + await runner.makeRequest('get', '/queue/send'); + await runner.completed(); + + expect(queueTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(queueTraceId).toBe(doTraceId); + + expect(queueSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(queueSpanId); +}); + +it('propagates trace from scheduled handler to durable object', async ({ signal }) => { + let scheduledTraceId: string | undefined; + let scheduledSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .withWranglerArgs('--test-scheduled') + .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 /hello', + }), + ); + 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: 'faas.cron', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.scheduled', + }), + origin: 'auto.faas.cloudflare.scheduled', + }), + }), + }), + ); + scheduledTraceId = transactionEvent.contexts?.trace?.trace_id as string; + scheduledSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/__scheduled?cron=*+*+*+*+*'); + await runner.completed(); + + expect(scheduledTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(scheduledTraceId).toBe(doTraceId); + + expect(scheduledSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(scheduledSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc new file mode 100644 index 000000000000..b6dc58439427 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "name": "cloudflare-durable-objects", + "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", + }, + ], + }, + "queues": { + "producers": [ + { + "binding": "MY_QUEUE", + "queue": "my-queue", + }, + ], + "consumers": [ + { + "queue": "my-queue", + }, + ], + }, + "triggers": { + "crons": ["* * * * *"], + }, + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index-sub-worker.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index-sub-worker.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts similarity index 59% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts index fd64c0d31d27..878b307ca5f4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts @@ -1,8 +1,13 @@ import { expect, it } from 'vitest'; import type { Event } from '@sentry/core'; -import { createRunner } from '../../../runner'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to worker via service binding', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let subWorkerTraceId: string | undefined; + let subWorkerParentSpanId: string | undefined; -it('adds a trace to a worker via service binding', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; @@ -20,6 +25,8 @@ it('adds a trace to a worker via service binding', async ({ signal }) => { transaction: 'GET /', }), ); + 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; @@ -37,9 +44,19 @@ it('adds a trace to a worker via service binding', async ({ signal }) => { transaction: 'GET /hello', }), ); + subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; }) .unordered() .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(subWorkerTraceId).toBeDefined(); + expect(workerTraceId).toBe(subWorkerTraceId); + + expect(workerSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBe(workerSpanId); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler-sub-worker.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler-sub-worker.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler-sub-worker.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/worker-service-binding/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts new file mode 100644 index 000000000000..06c846afc378 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts @@ -0,0 +1,75 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; + MY_WORKFLOW: Workflow; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(request: Request) { + return new Response('DO is fine'); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +class MyWorkflowBase extends WorkflowEntrypoint { + async run(_event: WorkflowEvent, step: WorkflowStep): Promise { + await step.do('workflow-env-test', async () => { + const id = this.env.MY_DURABLE_OBJECT.idFromName('workflow-test'); + const stub = this.env.MY_DURABLE_OBJECT.get(id); + const response = await stub.fetch(new Request('http://fake-host/workflow-test')); + return response.text(); + }); + } +} + +export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyWorkflowBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === '/workflow/trigger') { + const instance = await env.MY_WORKFLOW.create(); + // Poll until workflow completes (or timeout after 15s) + for (let i = 0; i < 15; i++) { + try { + const s = await instance.status(); + if (s.status === 'complete' || s.status === 'errored') { + return new Response(JSON.stringify({ id: instance.id, ...s }), { + headers: { 'content-type': 'application/json' }, + }); + } + } catch { + // status() may not be available in local dev + } + await new Promise(r => setTimeout(r, 1000)); + } + return new Response(JSON.stringify({ id: instance.id, status: 'timeout' }), { + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('Hello World!'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts new file mode 100644 index 000000000000..818e92d8d677 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/test.ts @@ -0,0 +1,63 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('traces a workflow that calls a durable object with the same trace id', async ({ signal }) => { + let workflowTraceId: string | undefined; + let workflowSpanId: 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: 'function.step.do', + data: expect.objectContaining({ + 'sentry.op': 'function.step.do', + 'sentry.origin': 'auto.faas.cloudflare.workflow', + }), + origin: 'auto.faas.cloudflare.workflow', + }), + }), + transaction: 'workflow-env-test', + }), + ); + workflowTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workflowSpanId = 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', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /workflow-test', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/workflow/trigger'); + await runner.completed(); + + expect(workflowTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workflowTraceId).toBe(doTraceId); + + expect(workflowSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workflowSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc new file mode 100644 index 000000000000..fd8a63daf3f5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/wrangler.jsonc @@ -0,0 +1,30 @@ +{ + "name": "cloudflare-durable-objects", + "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", + }, + ], + }, + "workflows": [ + { + "name": "my-workflow", + "binding": "MY_WORKFLOW", + "class_name": "MyWorkflow", + }, + ], + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index fc07cb46ca00..ccd08ee8893c 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -4,6 +4,7 @@ import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { isInstrumented, markAsInstrumented } from './instrument'; +import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { instrumentContext } from './utils/instrumentContext'; @@ -52,10 +53,10 @@ export function instrumentDurableObjectWithSentry< construct(target, [ctx, env]) { setAsyncLocalStorageAsyncContextStrategy(); const context = instrumentContext(ctx); - const options = getFinalOptions(optionsCallback(env), env); + const instrumentedEnv = instrumentEnv(env); - const obj = new target(context, env); + const obj = new target(context, instrumentedEnv); // These are the methods that are available on a Durable Object // ref: https://developers.cloudflare.com/durable-objects/api/base/ diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts new file mode 100644 index 000000000000..886b14a6ab5c --- /dev/null +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -0,0 +1,48 @@ +import type { DurableObjectNamespace, DurableObjectStub } from '@cloudflare/workers-types'; +import { instrumentFetcher } from './worker/instrumentFetcher'; + +/** + * Instruments a DurableObjectNamespace binding to create spans for DO interactions. + * + * Wraps: + * - `namespace.get(id)` / `namespace.getByName(name)` with a span + instruments returned stub + * - `namespace.idFromName(name)` / `namespace.idFromString(id)` / `namespace.newUniqueId()` with breadcrumbs + */ +export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespace): DurableObjectNamespace { + return new Proxy(namespace, { + get(target, prop, _receiver) { + const value = Reflect.get(target, prop) as unknown; + + if (typeof value !== 'function') { + return value; + } + + if (prop === 'get' || prop === 'getByName') { + return function (this: unknown, ...args: unknown[]) { + const stub = Reflect.apply(value, target, args); + + return instrumentDurableObjectStub(stub); + }; + } + + return value.bind(target); + }, + }); +} + +/** + * Instruments a DurableObjectStub to create spans for outgoing fetch calls. + */ +function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub { + return new Proxy(stub, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + + if (prop === 'fetch' && typeof value === 'function') { + return instrumentFetcher((input, init) => Reflect.apply(value, target, [input, init])); + } + + return value; + }, + }); +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts index 8c91bf2cb3d2..1120de008e43 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts @@ -13,6 +13,7 @@ import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; import { instrumentContext } from '../../utils/instrumentContext'; +import { instrumentEnv } from './instrumentEnv'; /** * Core email handler logic - wraps execution with Sentry instrumentation. @@ -72,6 +73,7 @@ export function instrumentExportedHandlerEmail(); + +/** + * Wraps the Cloudflare `env` object in a Proxy that detects binding types + * on property access and returns instrumented versions. + * + * Currently detects: + * - DurableObjectNamespace (via `idFromName` duck-typing) + * - Service bindings / JSRPC proxies (wraps `fetch` for trace propagation) + * + * Extensible for future binding types (KV, D1, Queue, etc.). + */ +export function instrumentEnv>(env: Env): Env { + if (!env || typeof env !== 'object') { + return env; + } + + return new Proxy(env, { + get(target, prop, receiver) { + const item = Reflect.get(target, prop, receiver); + + if (!isProxyable(item)) { + return item; + } + + const cached = instrumentedBindings.get(item); + + if (cached) { + return cached; + } + + if (isDurableObjectNamespace(item)) { + const instrumented = instrumentDurableObjectNamespace(item); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + + if (isJSRPC(item)) { + const instrumented = new Proxy(item, { + get(target, p, rcv) { + const value = Reflect.get(target, p, rcv); + + if (p === 'fetch' && typeof value === 'function') { + return instrumentFetcher(value.bind(target)); + } + + return value; + }, + }); + + instrumentedBindings.set(item, instrumented); + return instrumented; + } + + return item; + }, + }); +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts index be58fa07e18f..f7f7db1f4e1b 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts @@ -4,6 +4,7 @@ import { isInstrumented, markAsInstrumented } from '../../instrument'; import { getFinalOptions } from '../../options'; import { wrapRequestHandler } from '../../request'; import { instrumentContext } from '../../utils/instrumentContext'; +import { instrumentEnv } from './instrumentEnv'; /** * Instruments a fetch handler for ExportedHandler (env/ctx come from args). @@ -22,6 +23,7 @@ export function instrumentExportedHandlerFetch { + const newInit = addTraceHeaders(input, init); + + return fetchFn(input, newInit); + }; +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index 366fb7e98f51..50916eb71f2c 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -14,6 +14,7 @@ import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; import { instrumentContext } from '../../utils/instrumentContext'; +import { instrumentEnv } from './instrumentEnv'; /** * Core queue handler logic - wraps execution with Sentry instrumentation. @@ -78,6 +79,7 @@ export function instrumentExportedHandlerQueue entry.trim().startsWith(SENTRY_BAGGAGE_KEY_PREFIX))) { + headers.set('baggage', `${existing},${baggage}`); + } + } + + return { ...init, headers }; +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts new file mode 100644 index 000000000000..7a950e691f81 --- /dev/null +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -0,0 +1,29 @@ +import type { DurableObjectNamespace } from '@cloudflare/workers-types'; + +/** + * Checks if a value is a JSRPC proxy (service binding). + * + * JSRPC proxies return a truthy value for ANY property access, including + * properties that don't exist. This makes other duck-type checks unreliable + * unless we exclude JSRPC first. + * + * Must be checked before other binding type checks. + * Kudos to https://github.com/evanderkoogh/otel-cf-workers/blob/effeb549f0a4ed1c55ea0c4f0d8e8e37e5494fb3/src/instrumentation/env.ts#L11 + */ +export function isJSRPC(item: unknown): item is Service { + try { + return !!(item as Record)[`__some_property_that_will_never_exist__${Math.random()}`]; + } catch { + return false; + } +} + +const isNotJSRPC = (item: unknown): item is Record => !isJSRPC(item); + +/** + * Duck-type check for DurableObjectNamespace bindings. + * DurableObjectNamespace has `idFromName`, `idFromString`, `get`, `newUniqueId`. + */ +export function isDurableObjectNamespace(item: unknown): item is DurableObjectNamespace { + return item != null && isNotJSRPC(item) && typeof item.idFromName === 'function'; +} diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 6515a330ca99..c44a9c436bcf 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -21,6 +21,7 @@ import type { import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; +import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; import { instrumentContext } from './utils/instrumentContext'; @@ -164,6 +165,7 @@ export function instrumentWorkflowWithSentry< const [ctx, env] = args; const context = instrumentContext(ctx); args[0] = context; + args[1] = instrumentEnv(env as Record) as E; const options = optionsCallback(env); const instance = Reflect.construct(target, args, newTarget) as T; diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts new file mode 100644 index 000000000000..8d0dfe5f8f07 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -0,0 +1,170 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; + +describe('instrumentDurableObjectNamespace', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function createMockNamespace() { + const mockStub = { + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + name: 'test-name', + fetch: vi.fn().mockResolvedValue(new Response('ok')), + }; + + return { + namespace: { + idFromName: vi.fn().mockReturnValue({ toString: () => 'id-from-name', equals: () => false, name: 'test' }), + idFromString: vi.fn().mockReturnValue({ toString: () => 'id-from-string', equals: () => false }), + newUniqueId: vi.fn().mockReturnValue({ toString: () => 'unique-id', equals: () => false }), + get: vi.fn().mockReturnValue(mockStub), + getByName: vi.fn().mockReturnValue(mockStub), + jurisdiction: vi.fn(), + }, + mockStub, + }; + } + + describe('idFromName', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const result = instrumented.idFromName('global-counter'); + + expect(namespace.idFromName).toHaveBeenCalledWith('global-counter'); + expect(result).toEqual({ toString: expect.any(Function), equals: expect.any(Function), name: 'test' }); + }); + }); + + describe('idFromString', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + instrumented.idFromString('some-hex-id'); + + expect(namespace.idFromString).toHaveBeenCalledWith('some-hex-id'); + }); + }); + + describe('newUniqueId', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + instrumented.newUniqueId(); + + expect(namespace.newUniqueId).toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + instrumented.get(mockId as any); + + expect(namespace.get).toHaveBeenCalledWith(mockId); + }); + + it('returns an instrumented stub', async () => { + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + await (stub as any).fetch('https://example.com/path'); + + expect(mockStub.fetch).toHaveBeenCalledWith('https://example.com/path', expect.any(Object)); + }); + }); + + describe('getByName', () => { + it('delegates to original', () => { + const { namespace } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + instrumented.getByName('my-counter'); + + expect(namespace.getByName).toHaveBeenCalledWith('my-counter'); + }); + }); + + describe('stub instrumentation', () => { + it('calls stub.fetch with URL object', async () => { + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + const url = new URL('https://example.com/api/test'); + await (stub as any).fetch(url); + + expect(mockStub.fetch).toHaveBeenCalledWith(url, expect.any(Object)); + }); + + it('calls stub.fetch with Request object', async () => { + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + const request = new Request('https://example.com/api/data'); + await (stub as any).fetch(request); + + expect(mockStub.fetch).toHaveBeenCalledWith(request, expect.any(Object)); + }); + + it('propagates trace headers on stub.fetch', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + await (stub as any).fetch('https://example.com/api'); + + const [, init] = mockStub.fetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('passes non-fetch properties through', () => { + const { namespace, mockStub } = createMockNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const mockId = { toString: () => 'test-id', equals: () => false }; + const stub = instrumented.get(mockId as any); + + expect((stub as any).id).toBe(mockStub.id); + expect((stub as any).name).toBe(mockStub.name); + }); + }); + + describe('non-function properties', () => { + it('returns non-function properties unchanged', () => { + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + someProperty: 'value', + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + expect((instrumented as any).someProperty).toBe('value'); + }); + }); +}); diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts new file mode 100644 index 000000000000..ef713eadcea4 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentEnv } from '../../src/instrumentations/worker/instrumentEnv'; + +vi.mock('../../src/instrumentations/instrumentDurableObjectNamespace', () => ({ + instrumentDurableObjectNamespace: vi.fn((namespace: unknown) => ({ + __instrumented: true, + __original: namespace, + })), +})); + +import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; + +describe('instrumentEnv', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns primitive values unchanged', () => { + const env = { SENTRY_DSN: 'https://key@sentry.io/123', PORT: 8080, DEBUG: true }; + const instrumented = instrumentEnv(env); + + expect(instrumented.SENTRY_DSN).toBe('https://key@sentry.io/123'); + expect(instrumented.PORT).toBe(8080); + expect(instrumented.DEBUG).toBe(true); + }); + + it('passes through unknown object bindings unchanged', () => { + const unknownBinding = { someMethod: () => 'value' }; + const env = { UNKNOWN: unknownBinding }; + const instrumented = instrumentEnv(env); + + expect(instrumented.UNKNOWN).toBe(unknownBinding); + }); + + it('detects and instruments DurableObjectNamespace bindings', () => { + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const env = { COUNTER: doNamespace }; + const instrumented = instrumentEnv(env); + + const result = instrumented.COUNTER; + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); + expect((result as any).__instrumented).toBe(true); + }); + + it('caches instrumented bindings across repeated access', () => { + const doNamespace = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const env = { COUNTER: doNamespace }; + const instrumented = instrumentEnv(env); + + const first = instrumented.COUNTER; + const second = instrumented.COUNTER; + + expect(first).toBe(second); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledTimes(1); + }); + + it('instruments multiple DO bindings independently', () => { + const doNamespace1 = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const doNamespace2 = { + idFromName: vi.fn(), + idFromString: vi.fn(), + get: vi.fn(), + newUniqueId: vi.fn(), + }; + const env = { COUNTER: doNamespace1, SESSIONS: doNamespace2 }; + const instrumented = instrumentEnv(env); + + instrumented.COUNTER; + instrumented.SESSIONS; + + expect(instrumentDurableObjectNamespace).toHaveBeenCalledTimes(2); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace1); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace2); + }); + + it('wraps JSRPC proxy with a Proxy that instruments fetch', () => { + const mockFetch = vi.fn(); + const jsrpcProxy = new Proxy( + { fetch: mockFetch }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + // JSRPC behavior: return truthy for any property + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env); + + const result = instrumented.SERVICE; + // Should NOT be the same reference — it's wrapped in a Proxy + expect(result).not.toBe(jsrpcProxy); + expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); + }); + + it('does not instrument JSRPC proxies as DurableObjectNamespace', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env); + + instrumented.SERVICE; + expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); + }); + + it('returns null and undefined values unchanged', () => { + const env = { NULL_VAL: null, UNDEF_VAL: undefined } as Record; + const instrumented = instrumentEnv(env); + + expect(instrumented.NULL_VAL).toBeNull(); + expect(instrumented.UNDEF_VAL).toBeUndefined(); + }); +}); diff --git a/packages/cloudflare/test/utils/instrumentFetcher.test.ts b/packages/cloudflare/test/utils/instrumentFetcher.test.ts new file mode 100644 index 000000000000..83a4ac4f248c --- /dev/null +++ b/packages/cloudflare/test/utils/instrumentFetcher.test.ts @@ -0,0 +1,278 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentFetcher } from '../../src/instrumentations/worker/instrumentFetcher'; + +describe('instrumentFetcher', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls the original fetch with the input and init', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com/path'); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com/path', {}); + }); + + it('adds sentry-trace and baggage headers', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com'); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('does not overwrite existing sentry-trace header', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'auto-generated-trace', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { 'sentry-trace': 'manual-trace' }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('sentry-trace')).toBe('manual-trace'); + }); + + it('preserves existing custom headers when adding sentry headers', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { + Authorization: 'Bearer my-token', + 'Content-Type': 'application/json', + 'X-Custom-Header': 'custom-value', + }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBe('Bearer my-token'); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('X-Custom-Header')).toBe('custom-value'); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('preserves headers from a Request object when init has no headers', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const request = new Request('https://example.com', { + headers: { + Authorization: 'Bearer request-token', + 'X-Request-Id': '123', + }, + }); + await wrapped(request); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBe('Bearer request-token'); + expect(headers.get('X-Request-Id')).toBe('123'); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('does not overwrite sentry-trace from a Request object', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'auto-generated-trace', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const request = new Request('https://example.com', { + headers: { 'sentry-trace': 'request-trace-value' }, + }); + await wrapped(request); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('sentry-trace')).toBe('request-trace-value'); + }); + + it('preserves custom headers alongside existing sentry-trace in init', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'auto-generated-trace', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { + 'sentry-trace': 'manual-trace', + Authorization: 'Bearer my-token', + 'X-Custom': 'value', + }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('sentry-trace')).toBe('manual-trace'); + expect(headers.get('Authorization')).toBe('Bearer my-token'); + expect(headers.get('X-Custom')).toBe('value'); + }); + + it('works with Headers object in init', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const existingHeaders = new Headers({ + Authorization: 'Bearer headers-obj-token', + 'X-Custom': 'from-headers-obj', + }); + await wrapped('https://example.com', { headers: existingHeaders }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBe('Bearer headers-obj-token'); + expect(headers.get('X-Custom')).toBe('from-headers-obj'); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('works with array-of-tuples headers in init', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: [ + ['Authorization', 'Bearer tuple-token'], + ['X-Custom', 'from-tuple'], + ], + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBe('Bearer tuple-token'); + expect(headers.get('X-Custom')).toBe('from-tuple'); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('preserves baggage from Request object and appends sentry baggage', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const request = new Request('https://example.com', { + headers: { baggage: 'custom-key=custom-value' }, + }); + await wrapped(request); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('baggage')).toBe('custom-key=custom-value,sentry-environment=production'); + }); + + it('appends baggage to existing non-sentry baggage', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { baggage: 'custom-key=custom-value' }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('baggage')).toBe('custom-key=custom-value,sentry-environment=production'); + }); + + it('does not duplicate sentry baggage values', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { + headers: { baggage: 'sentry-environment=staging' }, + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers); + expect(headers.get('baggage')).toBe('sentry-environment=staging'); + }); + + it('passes through original init options', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com', { method: 'POST', body: 'test' }); + + const [, init] = mockFetch.mock.calls[0]!; + expect(init.method).toBe('POST'); + expect(init.body).toBe('test'); + }); + + it('works when getTraceData returns empty object', async () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + await wrapped('https://example.com'); + + expect(mockFetch).toHaveBeenCalledWith('https://example.com', {}); + }); +}); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts new file mode 100644 index 000000000000..2c6599ed2e42 --- /dev/null +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { isDurableObjectNamespace, isJSRPC } from '../../src/utils/isBinding'; + +describe('isJSRPC', () => { + it('returns false for a plain object', () => { + expect(isJSRPC({ foo: 'bar' })).toBe(false); + }); + + it('returns false for null', () => { + expect(isJSRPC(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isJSRPC(undefined)).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isJSRPC(42)).toBe(false); + expect(isJSRPC('string')).toBe(false); + expect(isJSRPC(true)).toBe(false); + expect(isJSRPC(false)).toBe(false); + expect(isJSRPC(0)).toBe(false); + expect(isJSRPC('')).toBe(false); + expect(isJSRPC(BigInt(42))).toBe(false); + expect(isJSRPC(Symbol('test'))).toBe(false); + }); + + it('returns false for functions, arrays, and other object types', () => { + expect(isJSRPC(() => {})).toBe(false); + expect(isJSRPC(function named() {})).toBe(false); + expect(isJSRPC([1, 2, 3])).toBe(false); + expect(isJSRPC(new Date())).toBe(false); + expect(isJSRPC(/regex/)).toBe(false); + expect(isJSRPC(new Map())).toBe(false); + expect(isJSRPC(new Set())).toBe(false); + expect(isJSRPC(new Error('test'))).toBe(false); + }); + + it('returns false for a DurableObjectNamespace-like object', () => { + const doNamespace = { + idFromName: () => ({}), + idFromString: () => ({}), + get: () => ({}), + newUniqueId: () => ({}), + }; + expect(isJSRPC(doNamespace)).toBe(false); + }); + + it('returns true for a JSRPC proxy that returns truthy for any property', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isJSRPC(jsrpcProxy)).toBe(true); + }); +}); + +describe('isDurableObjectNamespace', () => { + it('returns true for an object with idFromName method', () => { + const doNamespace = { + idFromName: () => ({}), + idFromString: () => ({}), + get: () => ({}), + newUniqueId: () => ({}), + }; + expect(isDurableObjectNamespace(doNamespace)).toBe(true); + }); + + it('returns false for a plain object without idFromName', () => { + expect(isDurableObjectNamespace({ foo: 'bar' })).toBe(false); + }); + + it('returns false for null', () => { + expect(isDurableObjectNamespace(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isDurableObjectNamespace(undefined)).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isDurableObjectNamespace(42)).toBe(false); + expect(isDurableObjectNamespace('string')).toBe(false); + expect(isDurableObjectNamespace(true)).toBe(false); + expect(isDurableObjectNamespace(false)).toBe(false); + expect(isDurableObjectNamespace(0)).toBe(false); + expect(isDurableObjectNamespace('')).toBe(false); + expect(isDurableObjectNamespace(BigInt(42))).toBe(false); + expect(isDurableObjectNamespace(Symbol('test'))).toBe(false); + }); + + it('returns false for functions, arrays, and other object types', () => { + expect(isDurableObjectNamespace(() => {})).toBe(false); + expect(isDurableObjectNamespace(function named() {})).toBe(false); + expect(isDurableObjectNamespace([1, 2, 3])).toBe(false); + expect(isDurableObjectNamespace(new Date())).toBe(false); + expect(isDurableObjectNamespace(/regex/)).toBe(false); + expect(isDurableObjectNamespace(new Map())).toBe(false); + expect(isDurableObjectNamespace(new Set())).toBe(false); + expect(isDurableObjectNamespace(new Error('test'))).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it has idFromName', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isDurableObjectNamespace(jsrpcProxy)).toBe(false); + }); + + it('returns false when idFromName is not a function', () => { + expect(isDurableObjectNamespace({ idFromName: 'not-a-function' })).toBe(false); + }); +}); diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index f21bee8612a8..14bb7e78a90e 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -4,6 +4,12 @@ import type { WorkflowEvent, WorkflowStep, WorkflowStepConfig } from 'cloudflare import { beforeEach, describe, expect, test, vi } from 'vitest'; import { deterministicTraceIdFromInstanceId, instrumentWorkflowWithSentry } from '../src/workflows'; +vi.mock('../src/instrumentations/worker/instrumentEnv', () => ({ + instrumentEnv: vi.fn((env: unknown) => env), +})); + +import { instrumentEnv } from '../src/instrumentations/worker/instrumentEnv'; + const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0]!); const MOCK_STEP_CTX = { attempt: 1 }; @@ -146,6 +152,25 @@ describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => { ]); }); + test('Wraps env with instrumentEnv', async () => { + class EnvTestWorkflow { + constructor(_ctx: ExecutionContext, _env: unknown) {} + + async run(_event: Readonly>, step: WorkflowStep): Promise { + await step.do('first step', async () => { + return { ok: true }; + }); + } + } + + const mockEnv = { SENTRY_DSN: 'https://key@sentry.io/123', MY_SERVICE: {} }; + const TestWorkflowInstrumented = instrumentWorkflowWithSentry(getSentryOptions, EnvTestWorkflow as any); + new TestWorkflowInstrumented(mockContext, mockEnv as any); + + expect(instrumentEnv).toHaveBeenCalledTimes(1); + expect(instrumentEnv).toHaveBeenCalledWith(mockEnv); + }); + test('Calls expected functions with non-uuid instance id', async () => { class BasicTestWorkflow { constructor(_ctx: ExecutionContext, _env: unknown) {}