From 51e8413db6687628f0b5518b32dd927529b09499 Mon Sep 17 00:00:00 2001 From: Vivek1898 Date: Fri, 13 Nov 2026 23:34:21 +0530 Subject: [PATCH] fix(cloudflare): Forward method, headers and body when a Request is passed as fetch init `instrumentFetcher` built the outgoing init with an object spread (`{ ...init }`). When `init` is itself a Request (the common `stub.fetch(url, request)` Workers idiom), its method, headers and body are prototype getters rather than own properties, so the spread copied none of them. Behind an instrumented Durable Object or service binding the call arrived as a bare GET with no headers and no body, breaking WebSocket upgrades and forwarded POSTs that work without Sentry. Handle a Request-valued `init` the same way the existing single-Request branch does: rebuild through the Request constructor (which reads those members by property access) and layer the tracing headers, which already include the request's own headers, on top. Fixes #26367 --- .../worker/instrumentFetcher.ts | 16 +++++++ .../test/utils/instrumentFetcher.test.ts | 46 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts index 3c2a8116942a..ee378ff79a55 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetcher.ts @@ -24,6 +24,22 @@ export function instrumentFetcher(fetchFn: Fetcher['fetch']): Fetcher['fetch'] { return fetchFn(requestWithTracing); } + if (init instanceof Request) { + // `init` is itself a Request (the `stub.fetch(url, request)` Workers idiom). A Request's + // `method`, `headers`, `body` and the rest are prototype getters, not own properties, so the + // object spread below would drop them and the call would reach the fetcher as a bare GET with + // no headers or body. Rebuild through the Request constructor, which reads those members by + // property access, then layer the tracing headers (which already include the request's own + // headers) on top. + const request = new Request(input, init); + + if (!headers) { + return fetchFn(request); + } + + return fetchFn(new Request(request, { headers: headers as HeadersInit })); + } + const mergedInit = { ...init, ...(headers ? { headers } : {}), diff --git a/packages/cloudflare/test/utils/instrumentFetcher.test.ts b/packages/cloudflare/test/utils/instrumentFetcher.test.ts index 8db5674faaad..3e26d58e0294 100644 --- a/packages/cloudflare/test/utils/instrumentFetcher.test.ts +++ b/packages/cloudflare/test/utils/instrumentFetcher.test.ts @@ -260,6 +260,52 @@ describe('instrumentFetcher', () => { expect(headers.get('baggage')).toBe('sentry-environment=production'); }); + it('forwards method, headers and body when init is a Request', async () => { + getTraceDataMock.mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + // The `stub.fetch(url, request)` Workers idiom: `init` is itself a Request. + const incoming = new Request('https://example.com/room/ABC/websocket', { + method: 'POST', + headers: { Upgrade: 'websocket', 'Content-Type': 'application/json' }, + body: '{"a":1}', + }); + + await wrapped('https://internal/websocket', incoming as unknown as RequestInit); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const forwarded = mockFetch.mock.calls[0]![0] as Request; + expect(forwarded.method).toBe('POST'); + expect(forwarded.headers.get('upgrade')).toBe('websocket'); + expect(forwarded.headers.get('content-type')).toBe('application/json'); + expect(forwarded.headers.get('sentry-trace')).toBe('12345678901234567890123456789012-1234567890123456-1'); + expect(await forwarded.text()).toBe('{"a":1}'); + }); + + it('forwards method and body when init is a Request and tracing is disabled', async () => { + getTraceDataMock.mockReturnValue({}); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const wrapped = instrumentFetcher(mockFetch); + + const incoming = new Request('https://example.com/upload', { + method: 'POST', + body: 'payload', + }); + + await wrapped('https://internal/upload', incoming as unknown as RequestInit); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const forwarded = mockFetch.mock.calls[0]![0] as Request; + expect(forwarded.method).toBe('POST'); + expect(await forwarded.text()).toBe('payload'); + }); + it('appends baggage to existing non-sentry baggage', async () => { getTraceDataMock.mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',