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/instrument-fetcher/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts new file mode 100644 index 000000000000..6480d9e83770 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts @@ -0,0 +1,94 @@ +import { instrumentDurableObjectWithSentry, withSentry } from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + ECHO_HEADERS_DO: DurableObjectNamespace; +} + +class EchoHeadersDurableObjectBase extends DurableObject { + async fetch(incoming: Request): Promise { + return Response.json({ + sentryTrace: incoming.headers.get('sentry-trace'), + baggage: incoming.headers.get('baggage'), + authorization: incoming.headers.get('authorization'), + xFromInit: incoming.headers.get('x-from-init'), + xExtra: incoming.headers.get('x-extra'), + xMergeProbe: incoming.headers.get('x-merge-probe'), + }); + } +} + +export const EchoHeadersDurableObject = instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + EchoHeadersDurableObjectBase, +); + +export default withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + const id = env.ECHO_HEADERS_DO.idFromName('instrument-fetcher-echo'); + const stub = env.ECHO_HEADERS_DO.get(id); + const doUrl = new URL(request.url); + + let subResponse: Response; + + if (url.pathname === '/via-init') { + subResponse = await stub.fetch(doUrl, { + headers: { + Authorization: 'Bearer from-init', + 'X-Extra': 'init-extra', + 'X-Merge-Probe': 'via-init-probe', + }, + }); + } else if (url.pathname === '/via-request') { + subResponse = await stub.fetch( + new Request(doUrl, { + headers: { + Authorization: 'Bearer from-request', + 'X-Extra': 'request-extra', + 'X-Merge-Probe': 'via-request-probe', + }, + }), + ); + } else if (url.pathname === '/via-request-and-init') { + subResponse = await stub.fetch( + new Request(doUrl, { + headers: { + Authorization: 'Bearer from-request', + 'X-Extra': 'request-extra', + 'X-Merge-Probe': 'dropped-from-request', + }, + }), + { + headers: { + 'X-From-Init': '1', + 'X-Merge-Probe': 'via-init-wins', + }, + }, + ); + } else if (url.pathname === '/with-preset-sentry-baggage') { + subResponse = await stub.fetch( + new Request(doUrl, { + headers: { + baggage: 'sentry-environment=preset,acme=vendor', + }, + }), + ); + } else { + return new Response('not found', { status: 404 }); + } + + const payload: unknown = await subResponse.json(); + return Response.json(payload); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts new file mode 100644 index 000000000000..ae38568e34ab --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts @@ -0,0 +1,131 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +type EchoedHeaders = { + sentryTrace: string | null; + baggage: string | null; + authorization: string | null; + xFromInit: string | null; + xExtra: string | null; + xMergeProbe: string | null; +}; + +const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/; + +type ScenarioPath = '/via-init' | '/via-request' | '/via-request-and-init' | '/with-preset-sentry-baggage'; + +function startStubFetchScenario(path: ScenarioPath, signal: AbortSignal) { + let mainTraceId: string | undefined; + let mainSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const traceBase = { + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }; + + const { makeRequest, completed } = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining(traceBase), + }), + transaction: `GET ${path}`, + }), + ); + expect(parentSpanId).toBeUndefined(); + + mainTraceId = transactionEvent.contexts?.trace?.trace_id as string; + mainSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining(traceBase), + }), + transaction: `GET ${path}`, + }), + ); + expect(parentSpanId).toBeDefined(); + + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = parentSpanId as string; + }) + .unordered() + .start(signal); + + return { + makeRequest, + async completedWithTraceCheck(): Promise { + await completed(); + expect(mainTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(mainTraceId).toBe(doTraceId); + expect(mainSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(mainSpanId); + }, + }; +} + +it('stub.fetch: headers in init (URL string + init)', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-init', signal); + const body = await makeRequest('get', '/via-init'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id='); + expect(body?.authorization).toBe('Bearer from-init'); + expect(body?.xExtra).toBe('init-extra'); + expect(body?.xMergeProbe).toBe('via-init-probe'); + expect(body?.xFromInit).toBeNull(); +}); + +it('stub.fetch: headers on Request (URL from incoming request)', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request', signal); + const body = await makeRequest('get', '/via-request'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id='); + expect(body?.authorization).toBe('Bearer from-request'); + expect(body?.xExtra).toBe('request-extra'); + expect(body?.xMergeProbe).toBe('via-request-probe'); + expect(body?.xFromInit).toBeNull(); +}); + +it('stub.fetch: Request + init — only init headers are sent', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request-and-init', signal); + const body = await makeRequest('get', '/via-request-and-init'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id='); + expect(body?.authorization).toBeNull(); + expect(body?.xExtra).toBeNull(); + expect(body?.xMergeProbe).toBe('via-init-wins'); + expect(body?.xFromInit).toBe('1'); +}); + +it('stub.fetch: does not append SDK baggage when the Request already includes Sentry baggage', async ({ signal }) => { + const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/with-preset-sentry-baggage', signal); + const body = await makeRequest('get', '/with-preset-sentry-baggage'); + await completedWithTraceCheck(); + + expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE)); + // Dynamic SDK baggage includes `sentry-trace_id=…`; appending it again would change this string. + expect(body?.baggage).toBe('sentry-environment=preset,acme=vendor'); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc new file mode 100644 index 000000000000..28d4a0a81f19 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-instrument-fetcher", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["EchoHeadersDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "EchoHeadersDurableObject", + "name": "ECHO_HEADERS_DO", + }, + ], + }, +} 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 af60ab5e59e0..8f6788c67748 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 { ensureInstrumented, getInstrumented, 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 ed4c7779b251..318eeb0e5364 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. @@ -74,6 +75,7 @@ export function instrumentExportedHandlerEmail>) { const [emailMessage, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts new file mode 100644 index 000000000000..6a2e83214093 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -0,0 +1,66 @@ +import { isDurableObjectNamespace, isJSRPC } from '../../utils/isBinding'; +import { instrumentDurableObjectNamespace } from '../instrumentDurableObjectNamespace'; +import { instrumentFetcher } from './instrumentFetcher'; + +function isProxyable(item: unknown): item is object { + return item !== null && (typeof item === 'object' || typeof item === 'function'); +} + +const instrumentedBindings = new WeakMap(); + +/** + * 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 eb681bb8230e..d24ed1e72849 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts @@ -4,6 +4,7 @@ import { ensureInstrumented } 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). @@ -24,6 +25,7 @@ export function instrumentExportedHandlerFetch>) { const [request, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts new file mode 100644 index 000000000000..ca9a3cdfcbc4 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts @@ -0,0 +1,34 @@ +import type { Fetcher } from '@cloudflare/workers-types'; +import { getTracingHeadersForFetchRequest } from '@sentry/core'; + +/** + * Wraps a fetch-like function to create a span and propagate trace headers + * (`sentry-trace` and `baggage`) on the outgoing request. + * + * Useful for instrumenting Cloudflare bindings that expose a `fetch` method + * (e.g. Durable Object stubs, Service bindings). + */ +export function instrumentFetcher(fetchFn: Fetcher['fetch']): Fetcher['fetch'] { + return function (input: RequestInfo | URL, init?: RequestInit): Promise { + const headers = getTracingHeadersForFetchRequest(input, { headers: init?.headers }); + + if (input instanceof Request && init === undefined) { + if (!headers) { + return fetchFn(input); + } + + // Newly created headers already include the previous headers from the original request + // so we can clone the request and pass in all headers. + const requestWithTracing = new Request(input, { headers: headers as HeadersInit }); + + return fetchFn(requestWithTracing); + } + + const mergedInit = { + ...init, + ...(headers ? { headers } : {}), + } as NonNullable[1]>; + + return fetchFn(input, mergedInit); + }; +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index 08435db8f90a..2a647dee921b 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. @@ -80,6 +81,7 @@ export function instrumentExportedHandlerQueue>) { const [batch, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index 58f06e8aa5eb..98c22b47891d 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.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 scheduled handler logic - wraps execution with Sentry instrumentation. @@ -76,6 +77,7 @@ export function instrumentExportedHandlerScheduled>) { const [controller, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts index 5a03965ba969..f798523bac1d 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts @@ -7,6 +7,7 @@ import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; import { instrumentContext } from '../../utils/instrumentContext'; +import { instrumentEnv } from './instrumentEnv'; /** * Core tail handler logic - wraps execution with Sentry instrumentation. @@ -51,6 +52,7 @@ export function instrumentExportedHandlerTail>) { const [, env, ctx] = args; const context = instrumentContext(ctx); + args[1] = instrumentEnv(env); args[2] = context; const options = getFinalOptions(optionsCallback(env), env); 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..cb981ec79daf --- /dev/null +++ b/packages/cloudflare/test/utils/instrumentFetcher.test.ts @@ -0,0 +1,328 @@ +import type { RequestInfo } from '@cloudflare/workers-types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentFetcher } from '../../src/instrumentations/worker/instrumentFetcher'; + +const { getTraceDataMock } = vi.hoisted(() => ({ + getTraceDataMock: vi.fn(), +})); + +/** + * `getTracingHeadersForFetchRequest` imports `getTraceData` from this module, not from the + * `@sentry/core` barrel — spying on `SentryCore.getTraceData` does not affect it. + */ +vi.mock('../../../core/build/esm/utils/traceData.js', () => ({ + getTraceData: getTraceDataMock, +})); +vi.mock('../../../core/build/cjs/utils/traceData.js', () => ({ + getTraceData: getTraceDataMock, +})); + +/** Vitest's `Request` is not typed identically to Workers `RequestInfo`. */ +function workerRequest(r: Request): RequestInfo { + return r as unknown as RequestInfo; +} + +describe('instrumentFetcher', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls the original fetch with the input and init', async () => { + getTraceDataMock.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 () => { + getTraceDataMock.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 () => { + getTraceDataMock.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 () => { + getTraceDataMock.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 () => { + getTraceDataMock.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(workerRequest(request)); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [passed] = mockFetch.mock.calls[0]!; + expect(passed).toBeInstanceOf(Request); + expect(mockFetch.mock.calls[0]).toHaveLength(1); + expect((passed as Request).headers.get('Authorization')).toBe('Bearer request-token'); + expect((passed as Request).headers.get('X-Request-Id')).toBe('123'); + expect((passed as Request).headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect((passed as Request).headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('does not overwrite sentry-trace from a Request object', async () => { + getTraceDataMock.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(workerRequest(request)); + + const [passed] = mockFetch.mock.calls[0]!; + expect(passed).toBeInstanceOf(Request); + expect(mockFetch.mock.calls[0]).toHaveLength(1); + expect((passed as Request).headers.get('sentry-trace')).toBe('request-trace-value'); + }); + + it('preserves custom headers alongside existing sentry-trace in init', async () => { + getTraceDataMock.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 () => { + getTraceDataMock.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 () => { + getTraceDataMock.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 () => { + getTraceDataMock.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(workerRequest(request)); + + const [passed] = mockFetch.mock.calls[0]!; + expect(passed).toBeInstanceOf(Request); + expect(mockFetch.mock.calls[0]).toHaveLength(1); + expect((passed as Request).headers.get('baggage')).toBe('custom-key=custom-value,sentry-environment=production'); + }); + + it('when Request and init are both passed, tracing headers are merged into init', async () => { + getTraceDataMock.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 from-request' }, + }); + await wrapped(workerRequest(request), { + headers: { 'X-From-Init': '1' }, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [arg0, arg1] = mockFetch.mock.calls[0]!; + expect(arg0).toBe(request); + const headers = new Headers(arg1?.headers); + expect(headers.get('X-From-Init')).toBe('1'); + expect(headers.get('Authorization')).toBeNull(); + expect(headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(headers.get('baggage')).toBe('sentry-environment=production'); + }); + + it('appends baggage to existing non-sentry baggage', async () => { + getTraceDataMock.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 () => { + getTraceDataMock.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 () => { + getTraceDataMock.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 () => { + getTraceDataMock.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) {} diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 4a26a5f06d80..520f06bcdccd 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -21,6 +21,7 @@ import { type PolymorphicRequestHeaders = | Record | Array<[string, string]> + | Iterable> // the below is not precisely the Header type used in Request, but it'll pass duck-typing | { append: (key: string, value: string) => void; @@ -124,7 +125,7 @@ export function instrumentFetchRequest( // Examples: users re-using same options object for multiple fetch calls, frozen objects const options: { [key: string]: unknown } = { ...(handlerData.args[1] || {}) }; - const headers = _addTracingHeadersToFetchRequest( + const headers = getTracingHeadersForFetchRequest( request, options, // If performance is disabled (TWP) or there's no active root span (pageload/navigation/interaction), @@ -176,17 +177,22 @@ export function _callOnRequestSpanEnd( } /** - * Adds sentry-trace and baggage headers to the various forms of fetch headers. - * exported only for testing purposes + * Builds merged fetch headers that include `sentry-trace` and `baggage` (and optionally `traceparent`) + * for the given request and init, without mutating the original request or options. + * Returns `undefined` when there is no `sentry-trace` value to attach. * - * When we determine if we should add a baggage header, there are 3 cases: - * 1. No previous baggage header -> add baggage - * 2. Previous baggage header has no sentry baggage values -> add our baggage - * 3. Previous baggage header has sentry baggage values -> do nothing (might have been added manually by users) + * @internal Exported for cross-package instrumentation (for example Cloudflare Workers fetcher bindings) + * and unit tests + * @hidden + * + * Baggage handling: + * 1. No previous baggage header → include Sentry baggage + * 2. Previous baggage has no Sentry entries → merge Sentry baggage in + * 3. Previous baggage already has Sentry entries → leave as-is (may be user-defined) */ // eslint-disable-next-line complexity -- yup it's this complicated :( -export function _addTracingHeadersToFetchRequest( - request: string | Request, +export function getTracingHeadersForFetchRequest( + request: string | URL | Request, fetchOptionsObj: { headers?: | { @@ -234,28 +240,28 @@ export function _addTracingHeadersToFetchRequest( } return newHeaders; - } else if (Array.isArray(originalHeaders)) { - const newHeaders = [...originalHeaders]; + } else if (Array.isArray(originalHeaders) || isIterable(originalHeaders)) { + const headersArray: [string, string][] = Array.isArray(originalHeaders) + ? [...originalHeaders] + : Array.from(originalHeaders); - if (!originalHeaders.find(header => header[0] === 'sentry-trace')) { - newHeaders.push(['sentry-trace', sentryTrace]); + if (!headersArray.find(header => header[0] === 'sentry-trace')) { + headersArray.push(['sentry-trace', sentryTrace]); } - if (propagateTraceparent && traceparent && !originalHeaders.find(header => header[0] === 'traceparent')) { - newHeaders.push(['traceparent', traceparent]); + if (propagateTraceparent && traceparent && !headersArray.find(header => header[0] === 'traceparent')) { + headersArray.push(['traceparent', traceparent]); } - const prevBaggageHeaderWithSentryValues = originalHeaders.find( + const prevBaggageHeaderWithSentryValues = headersArray.find( header => header[0] === 'baggage' && baggageHeaderHasSentryBaggageValues(header[1]), ); if (baggage && !prevBaggageHeaderWithSentryValues) { - // If there are multiple entries with the same key, the browser will merge the values into a single request header. - // Its therefore safe to simply push a "baggage" entry, even though there might already be another baggage header. - newHeaders.push(['baggage', baggage]); + headersArray.push(['baggage', baggage]); } - return newHeaders as PolymorphicRequestHeaders; + return headersArray as PolymorphicRequestHeaders; } else { const existingSentryTraceHeader = 'sentry-trace' in originalHeaders ? originalHeaders['sentry-trace'] : undefined; const existingTraceparentHeader = 'traceparent' in originalHeaders ? originalHeaders.traceparent : undefined; @@ -321,6 +327,10 @@ function isHeaders(headers: unknown): headers is Headers { return typeof Headers !== 'undefined' && isInstanceOf(headers, Headers); } +function isIterable(headers: unknown): headers is Iterable<[string, string]> { + return typeof headers === 'object' && headers !== null && Symbol.iterator in headers; +} + function getSpanStartOptions( url: string, method: string, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7018ff63fa8a..cd4651ea4713 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -142,7 +142,7 @@ export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) // Therefore: // eslint-disable-next-line deprecation/deprecation -export { instrumentFetchRequest } from './fetch'; +export { instrumentFetchRequest, getTracingHeadersForFetchRequest } from './fetch'; export { trpcMiddleware } from './trpc'; export { wrapMcpServerWithSentry } from './integrations/mcp-server'; export { captureFeedback } from './feedback'; diff --git a/packages/core/test/lib/fetch.test.ts b/packages/core/test/lib/fetch.test.ts index 86087bcd167b..404ada0b181d 100644 --- a/packages/core/test/lib/fetch.test.ts +++ b/packages/core/test/lib/fetch.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { HandlerDataFetch } from '../../src'; -import { _addTracingHeadersToFetchRequest, instrumentFetchRequest } from '../../src/fetch'; +import { getTracingHeadersForFetchRequest, instrumentFetchRequest } from '../../src/fetch'; import type { Span } from '../../src/types-hoist/span'; const { DEFAULT_SENTRY_TRACE, DEFAULT_BAGGAGE, hasSpansEnabled } = vi.hoisted(() => ({ @@ -31,7 +31,7 @@ vi.mock('../../src/utils/hasSpansEnabled', () => { }; }); -describe('_addTracingHeadersToFetchRequest', () => { +describe('getTracingHeadersForFetchRequest', () => { beforeEach(() => { vi.clearAllMocks(); hasSpansEnabled.mockReturnValue(false); @@ -47,7 +47,7 @@ describe('_addTracingHeadersToFetchRequest', () => { options: { headers: {} }, }, ])('attaches sentry headers (options: $options)', ({ options }) => { - expect(_addTracingHeadersToFetchRequest('/api/test', options)).toEqual({ + expect(getTracingHeadersForFetchRequest('/api/test', options)).toEqual({ 'sentry-trace': DEFAULT_SENTRY_TRACE, baggage: DEFAULT_BAGGAGE, }); @@ -56,7 +56,7 @@ describe('_addTracingHeadersToFetchRequest', () => { describe('and request headers are set in options', () => { it('attaches sentry headers to headers object', () => { - expect(_addTracingHeadersToFetchRequest('/api/test', { headers: { 'custom-header': 'custom-value' } })).toEqual( + expect(getTracingHeadersForFetchRequest('/api/test', { headers: { 'custom-header': 'custom-value' } })).toEqual( { 'sentry-trace': DEFAULT_SENTRY_TRACE, baggage: DEFAULT_BAGGAGE, @@ -66,7 +66,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('attaches sentry headers to a Headers instance', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { headers: new Headers({ 'custom-header': 'custom-value' }), }); @@ -81,13 +81,45 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('attaches sentry headers to headers array', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { headers: [['custom-header', 'custom-value']], }); + // expect(Array.isArray(returnedHeaders)).toBe(true); + expect(returnedHeaders).toEqual([ + ['custom-header', 'custom-value'], + ['sentry-trace', DEFAULT_SENTRY_TRACE], + ['baggage', DEFAULT_BAGGAGE], + ]); + }); + + it('attaches sentry headers to an iterable (Map entries)', () => { + const headersMap = new Map([['custom-header', 'custom-value']]); + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { + headers: headersMap.entries(), + }); + + // expect(Array.isArray(returnedHeaders)).toBe(true); + expect(returnedHeaders).toEqual([ + ['custom-header', 'custom-value'], + ['sentry-trace', DEFAULT_SENTRY_TRACE], + ['baggage', DEFAULT_BAGGAGE], + ]); + }); + + it('attaches sentry headers to a generator iterable', () => { + function* headerGenerator(): Generator<[string, string]> { + yield ['custom-header', 'custom-value']; + yield ['another-header', 'another-value']; + } + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { + headers: headerGenerator(), + }); + expect(Array.isArray(returnedHeaders)).toBe(true); expect(returnedHeaders).toEqual([ ['custom-header', 'custom-value'], + ['another-header', 'another-value'], ['sentry-trace', DEFAULT_SENTRY_TRACE], ['baggage', DEFAULT_BAGGAGE], ]); @@ -96,7 +128,7 @@ describe('_addTracingHeadersToFetchRequest', () => { describe('and 3rd party baggage header is set', () => { it('adds additional sentry baggage values to Headers instance', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { headers: new Headers({ baggage: 'custom-baggage=1,someVal=bar', }), @@ -112,7 +144,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('adds additional sentry baggage values to headers array', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { headers: [['baggage', 'custom-baggage=1,someVal=bar']], }); @@ -125,8 +157,40 @@ describe('_addTracingHeadersToFetchRequest', () => { ]); }); + it('adds additional sentry baggage values to an iterable (Map entries)', () => { + const headersMap = new Map([['baggage', 'custom-baggage=1,someVal=bar']]); + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { + headers: headersMap.entries(), + }); + + expect(Array.isArray(returnedHeaders)).toBe(true); + expect(returnedHeaders).toEqual([ + ['baggage', 'custom-baggage=1,someVal=bar'], + ['sentry-trace', DEFAULT_SENTRY_TRACE], + ['baggage', DEFAULT_BAGGAGE], + ]); + }); + + it('adds additional sentry baggage values to a generator iterable', () => { + function* headerGenerator(): Generator<[string, string]> { + yield ['baggage', 'custom-baggage=1,someVal=bar']; + yield ['custom-header', 'custom-value']; + } + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { + headers: headerGenerator(), + }); + + expect(Array.isArray(returnedHeaders)).toBe(true); + expect(returnedHeaders).toEqual([ + ['baggage', 'custom-baggage=1,someVal=bar'], + ['custom-header', 'custom-value'], + ['sentry-trace', DEFAULT_SENTRY_TRACE], + ['baggage', DEFAULT_BAGGAGE], + ]); + }); + it('adds additional sentry baggage values to headers object', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { headers: { baggage: 'custom-baggage=1,someVal=bar', }, @@ -141,7 +205,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('adds additional sentry baggage values to headers object with arrays', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { headers: { baggage: ['custom-baggage=1,someVal=bar', 'other-vendor-key=value'], }, @@ -158,7 +222,7 @@ describe('_addTracingHeadersToFetchRequest', () => { describe('and Sentry values are already set', () => { it('does not override them (Headers instance)', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { headers: new Headers({ 'sentry-trace': CUSTOM_SENTRY_TRACE, baggage: CUSTOM_BAGGAGE, @@ -177,7 +241,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }); it('does not override them (headers array)', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { headers: [ ['sentry-trace', CUSTOM_SENTRY_TRACE], ['baggage', CUSTOM_BAGGAGE], @@ -194,8 +258,44 @@ describe('_addTracingHeadersToFetchRequest', () => { ]); }); + it('does not override them (Map entries iterable)', () => { + const headersMap = new Map([ + ['sentry-trace', CUSTOM_SENTRY_TRACE], + ['baggage', CUSTOM_BAGGAGE], + ['custom-header', 'custom-value'], + ]); + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { + headers: headersMap.entries(), + }); + + expect(Array.isArray(returnedHeaders)).toBe(true); + expect(returnedHeaders).toEqual([ + ['sentry-trace', CUSTOM_SENTRY_TRACE], + ['baggage', CUSTOM_BAGGAGE], + ['custom-header', 'custom-value'], + ]); + }); + + it('does not override them (generator iterable)', () => { + function* headerGenerator(): Generator<[string, string]> { + yield ['sentry-trace', CUSTOM_SENTRY_TRACE]; + yield ['baggage', CUSTOM_BAGGAGE]; + yield ['custom-header', 'custom-value']; + } + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { + headers: headerGenerator(), + }); + + expect(Array.isArray(returnedHeaders)).toBe(true); + expect(returnedHeaders).toEqual([ + ['sentry-trace', CUSTOM_SENTRY_TRACE], + ['baggage', CUSTOM_BAGGAGE], + ['custom-header', 'custom-value'], + ]); + }); + it('does not override them (headers object)', () => { - const returnedHeaders = _addTracingHeadersToFetchRequest('/api/test', { + const returnedHeaders = getTracingHeadersForFetchRequest('/api/test', { headers: { 'sentry-trace': CUSTOM_SENTRY_TRACE, baggage: CUSTOM_BAGGAGE, @@ -218,7 +318,7 @@ describe('_addTracingHeadersToFetchRequest', () => { describe('and no request headers are set', () => { it('attaches sentry headers', () => { const request = new Request('http://locahlost:3000/api/test'); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -236,7 +336,7 @@ describe('_addTracingHeadersToFetchRequest', () => { headers: new Headers({ 'custom-header': 'custom-value' }), }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -253,7 +353,7 @@ describe('_addTracingHeadersToFetchRequest', () => { headers: { 'custom-header': 'custom-value' }, }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -270,7 +370,7 @@ describe('_addTracingHeadersToFetchRequest', () => { headers: [['custom-header', 'custom-value']], }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -292,7 +392,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }), }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -309,7 +409,7 @@ describe('_addTracingHeadersToFetchRequest', () => { headers: [['baggage', 'custom-baggage=1,someVal=bar']], }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -327,7 +427,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }, }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -345,7 +445,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }, }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -367,7 +467,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }), }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -388,7 +488,7 @@ describe('_addTracingHeadersToFetchRequest', () => { ], }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers); @@ -409,7 +509,7 @@ describe('_addTracingHeadersToFetchRequest', () => { }, }); - const returnedHeaders = _addTracingHeadersToFetchRequest(request, {}); + const returnedHeaders = getTracingHeadersForFetchRequest(request, {}); expect(returnedHeaders).toBeInstanceOf(Headers);