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',