diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/__root.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/__root.tsx index bc3a376d7eba..539af1fa9ace 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/__root.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/__root.tsx @@ -1,31 +1,21 @@ import type { ReactNode } from 'react'; import { Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router'; -import { getTraceData } from '@sentry/tanstackstart-react'; export const Route = createRootRoute({ - head: () => { - const traceData = getTraceData(); - const sentryMeta = Object.entries(traceData).map(([key, value]) => ({ - name: key, - content: value, - })); - - return { - meta: [ - { - charSet: 'utf-8', - }, - { - name: 'viewport', - content: 'width=device-width, initial-scale=1', - }, - { - title: 'TanStack Start Cloudflare E2E Test', - }, - ...sentryMeta, - ], - }; - }, + head: () => ({ + meta: [ + { + charSet: 'utf-8', + }, + { + name: 'viewport', + content: 'width=device-width, initial-scale=1', + }, + { + title: 'TanStack Start Cloudflare E2E Test', + }, + ], + }), component: RootComponent, }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/trace-propagation.test.ts new file mode 100644 index 000000000000..f96ea3ce5f71 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/trace-propagation.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('Trace propagation', () => { + test('should inject metatags in ssr pageload', async ({ page }) => { + await page.goto('/'); + + const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + expect(sentryTraceContent).toBeDefined(); + expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + + const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content'); + expect(baggageContent).toBeDefined(); + expect(baggageContent).toContain('sentry-environment=qa'); + expect(baggageContent).toContain('sentry-public_key='); + expect(baggageContent).toContain('sentry-trace_id='); + expect(baggageContent).toContain('sentry-sampled='); + }); + + test('should have trace connection between server and client', async ({ page }) => { + const serverTxPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /'; + }); + + const clientTxPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(clientTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts index e790b49f6d37..a47cfdfa298e 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts @@ -95,25 +95,3 @@ test('Sends server-side transaction for page request', async ({ baseURL }) => { status: 'ok', }); }); - -test('Propagates trace from server to client', async ({ page }) => { - const serverTransactionPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => { - return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /'; - }); - - const clientTransactionPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => { - return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; - }); - - await page.goto('/'); - - const serverTransaction = await serverTransactionPromise; - const clientTransaction = await clientTransactionPromise; - - const serverTraceId = serverTransaction.contexts?.trace?.trace_id; - const clientTraceId = clientTransaction.contexts?.trace?.trace_id; - - expect(serverTraceId).toMatch(/[a-f0-9]{32}/); - expect(clientTraceId).toMatch(/[a-f0-9]{32}/); - expect(clientTraceId).toBe(serverTraceId); -}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/trace-propagation.test.ts new file mode 100644 index 000000000000..88c4be62c120 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/trace-propagation.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + +test.describe('Trace propagation', () => { + test('should inject metatags in ssr pageload', async ({ page }) => { + await page.goto('/'); + + const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content'); + expect(sentryTraceContent).toBeDefined(); + expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + + const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content'); + expect(baggageContent).toBeDefined(); + expect(baggageContent).toContain('sentry-environment=qa'); + expect(baggageContent).toContain('sentry-public_key='); + expect(baggageContent).toContain('sentry-trace_id='); + expect(baggageContent).toContain('sentry-sampled='); + }); + + test('should have trace connection between server and client', async ({ page }) => { + const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /'; + }); + + const clientTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/'; + }); + + await page.goto('/'); + + const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(clientTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id); + }); +}); diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index d8861ffa8e48..516604a94db1 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -1,11 +1,110 @@ -import { flushIfServerless } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node'; +import { flushIfServerless, getTraceMetaTags } from '@sentry/core'; +import { + captureException, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, +} from '@sentry/node'; import { extractServerFunctionSha256 } from './utils'; export type ServerEntry = { fetch: (request: Request, opts?: unknown) => Promise | Response; }; +/** + * This function optimistically assumes that the HTML coming in chunks will not be split + * within the tag. If this still happens, we simply won't replace anything. + */ +function addMetaTagToHead(htmlChunk: string, metaTagsStr: string): string { + if (typeof htmlChunk !== 'string' || !metaTagsStr) { + return htmlChunk; + } + + if (htmlChunk.includes('"sentry-trace"')) { + return htmlChunk; + } + + // Skip quoted attribute values so we don't match inside e.g. data-code="......" + let replaced = false; + return htmlChunk.replace(/"[^"]*"|'[^']*'|()/g, (match, headTag) => { + if (headTag && !replaced) { + replaced = true; + return `${metaTagsStr}`; + } + return match; + }); +} + +function injectMetaTagsInResponse(originalResponse: Response): Response { + try { + const contentType = originalResponse.headers.get('content-type'); + + const isPageloadRequest = contentType?.startsWith('text/html'); + if (!isPageloadRequest) { + return originalResponse; + } + + // Type case necessary b/c the body's ReadableStream type doesn't include + // the async iterator that is actually available in Node + // We later on use the async iterator to read the body chunks + // see https://github.com/microsoft/TypeScript/issues/39051 + const originalBody = originalResponse.body as NodeJS.ReadableStream | null; + if (!originalBody) { + return originalResponse; + } + + const metaTagsStr = getTraceMetaTags(); + const decoder = new TextDecoder(); + + const newResponseStream = new ReadableStream({ + start: async controller => { + // Assign to a new variable to avoid TS losing the narrower type checked above. + const body = originalBody; + + async function* bodyReporter(): AsyncGenerator { + try { + for await (const chunk of body) { + yield chunk; + } + } catch (e) { + captureException(e, { + mechanism: { type: 'auto.http.tanstackstart', handled: false }, + }); + throw e; + } + } + + let errored = false; + try { + for await (const chunk of bodyReporter()) { + const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); + const modifiedHtml = addMetaTagToHead(html, metaTagsStr); + controller.enqueue(new TextEncoder().encode(modifiedHtml)); + } + } catch (e) { + errored = true; + controller.error(e); + } finally { + if (!errored) { + controller.close(); + } + } + }, + }); + + return new Response(newResponseStream, { + status: originalResponse.status, + statusText: originalResponse.statusText, + headers: new Headers(originalResponse.headers), + }); + } catch (e) { + captureException(e, { + mechanism: { type: 'auto.http.tanstackstart', handled: false }, + }); + throw e; + } +} + /** * This function can be used to wrap the server entry request handler to add tracing to server-side functionality. * You must explicitly define a server entry point in your application for this to work. This is done by passing the request handler to the `createServerEntry` function. @@ -62,7 +161,7 @@ export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry { ); } - return await target.apply(thisArg, args); + return injectMetaTagsInResponse(await target.apply(thisArg, args)); } finally { await flushIfServerless(); } diff --git a/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts b/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts index 68761782a1c6..fb123659d2fe 100644 --- a/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts +++ b/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts @@ -11,11 +11,18 @@ vi.mock('@sentry/node', async importOriginal => { }; }); +const getTraceMetaTagsSpy = vi + .fn() + .mockReturnValue( + '\n', + ); + vi.mock('@sentry/core', async importOriginal => { const original = await importOriginal(); return { ...original, flushIfServerless: (...args: unknown[]) => flushIfServerlessSpy(...args), + getTraceMetaTags: () => getTraceMetaTagsSpy(), }; }); @@ -53,6 +60,94 @@ describe('wrapFetchWithSentry', () => { expect(flushIfServerlessSpy).toHaveBeenCalledTimes(1); }); + it('injects meta tags into HTML responses', async () => { + const mockResponse = new Response('', { + headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }), + }); + const fetchFn = vi.fn().mockResolvedValue(mockResponse); + + const serverEntry = wrapFetchWithSentry({ fetch: fetchFn }); + const request = new Request('http://localhost:3000/'); + + const response = await serverEntry.fetch(request); + const html = await response.text(); + + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('does not inject meta tags into non-HTML responses', async () => { + const mockResponse = new Response('{"data": "value"}', { + headers: new Headers({ 'content-type': 'application/json' }), + }); + const fetchFn = vi.fn().mockResolvedValue(mockResponse); + + const serverEntry = wrapFetchWithSentry({ fetch: fetchFn }); + const request = new Request('http://localhost:3000/_serverFn/abc123'); + + const response = await serverEntry.fetch(request); + const body = await response.text(); + + expect(body).toBe('{"data": "value"}'); + expect(body).not.toContain('sentry-trace'); + }); + + it('does not inject duplicate meta tags if sentry-trace already exists', async () => { + const existingHtml = + ''; + const mockResponse = new Response(existingHtml, { + headers: new Headers({ 'content-type': 'text/html' }), + }); + const fetchFn = vi.fn().mockResolvedValue(mockResponse); + + const serverEntry = wrapFetchWithSentry({ fetch: fetchFn }); + const request = new Request('http://localhost:3000/'); + + const response = await serverEntry.fetch(request); + const html = await response.text(); + + expect(html).toBe(existingHtml); + }); + + it('preserves response status and headers when injecting meta tags', async () => { + const mockResponse = new Response('', { + status: 201, + statusText: 'Created', + headers: new Headers({ + 'content-type': 'text/html', + 'X-Custom-Header': 'custom-value', + }), + }); + const fetchFn = vi.fn().mockResolvedValue(mockResponse); + + const serverEntry = wrapFetchWithSentry({ fetch: fetchFn }); + const request = new Request('http://localhost:3000/'); + + const response = await serverEntry.fetch(request); + + expect(response.status).toBe(201); + expect(response.statusText).toBe('Created'); + expect(response.headers.get('content-type')).toBe('text/html'); + expect(response.headers.get('X-Custom-Header')).toBe('custom-value'); + }); + + it('does not inject meta tags into inside quoted attribute values', async () => { + const mockResponse = new Response('
', { + headers: new Headers({ 'content-type': 'text/html' }), + }); + const fetchFn = vi.fn().mockResolvedValue(mockResponse); + + const serverEntry = wrapFetchWithSentry({ fetch: fetchFn }); + const request = new Request('http://localhost:3000/'); + + const response = await serverEntry.fetch(request); + const html = await response.text(); + + expect(html).toContain(' { const fetchFn = vi.fn().mockRejectedValue(new Error('handler error'));