From 694bff8f6e3b6e406ab34efe9c4d7412336c8e87 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 23 May 2026 15:40:01 +0200 Subject: [PATCH 1/8] feat(tanstackstart-react): Add distributed tracing via meta tag injection Adds distributed tracing to the TanStack Start SDK by injecting `sentry-trace` and `baggage` meta tags into HTML responses in `wrapFetchWithSentry`, connecting server and client traces automatically. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/routes/__root.tsx | 38 +++----- .../tests/trace-propagation.test.ts | 36 +++++++ .../tests/transaction.test.ts | 22 ----- .../tests/trace-propagation.test.ts | 41 ++++++++ .../src/server/wrapFetchWithSentry.ts | 71 +++++++++++++- .../test/server/wrapFetchWithSentry.test.ts | 95 +++++++++++++++++++ 6 files changed, 255 insertions(+), 48 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/trace-propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/trace-propagation.test.ts 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..6d8f123dafbd 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -1,4 +1,4 @@ -import { flushIfServerless } from '@sentry/core'; +import { flushIfServerless, getTraceMetaTags } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node'; import { extractServerFunctionSha256 } from './utils'; @@ -6,6 +6,73 @@ 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'); + if (!contentType?.startsWith('text/html')) { + return originalResponse; + } + + // Type cast necessary b/c the body's ReadableStream type doesn't include + // the async iterator that is actually available in Node + // 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 => { + try { + for await (const chunk of originalBody) { + const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk as BufferSource, { stream: true }); + controller.enqueue(new TextEncoder().encode(addMetaTagToHead(html, metaTagsStr))); + } + } catch (e) { + controller.error(e); + } finally { + controller.close(); + } + }, + }); + + return new Response(newResponseStream, { + status: originalResponse.status, + statusText: originalResponse.statusText, + headers: new Headers(originalResponse.headers), + }); + } catch { + return originalResponse; + } +} + /** * 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 +129,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')); From c8e927c95e50f9a541b96c53e46dc8c3a8cdc2e8 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 23 May 2026 15:53:09 +0200 Subject: [PATCH 2/8] fix: Match original comment from Astro middleware Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index 6d8f123dafbd..4ca7320f58ec 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -37,8 +37,9 @@ function injectMetaTagsInResponse(originalResponse: Response): Response { return originalResponse; } - // Type cast necessary b/c the body's ReadableStream type doesn't include + // 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) { From 295b57a1bbec3c3183a8b3ca6c1a9147721a1a71 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 23 May 2026 15:54:56 +0200 Subject: [PATCH 3/8] fix: Match Astro injectMetaTagsInResponse implementation 1:1 Restore bodyIterator generator for stream error reporting, captureException in outer catch, TS narrowing reassignment, and comments. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/server/wrapFetchWithSentry.ts | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index 4ca7320f58ec..93ea0bbb9e28 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -1,5 +1,10 @@ import { flushIfServerless, getTraceMetaTags } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node'; +import { + captureException, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, +} from '@sentry/node'; import { extractServerFunctionSha256 } from './utils'; export type ServerEntry = { @@ -33,7 +38,9 @@ function addMetaTagToHead(htmlChunk: string, metaTagsStr: string): string { function injectMetaTagsInResponse(originalResponse: Response): Response { try { const contentType = originalResponse.headers.get('content-type'); - if (!contentType?.startsWith('text/html')) { + + const isPageloadRequest = contentType?.startsWith('text/html'); + if (!isPageloadRequest) { return originalResponse; } @@ -51,10 +58,25 @@ function injectMetaTagsInResponse(originalResponse: Response): Response { 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* bodyIterator(): AsyncGenerator { + try { + for await (const chunk of body) { + yield chunk; + } + } catch (e) { + captureException(e); + throw e; + } + } + try { - for await (const chunk of originalBody) { - const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk as BufferSource, { stream: true }); - controller.enqueue(new TextEncoder().encode(addMetaTagToHead(html, metaTagsStr))); + for await (const chunk of bodyIterator()) { + const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); + const modifiedHtml = addMetaTagToHead(html, metaTagsStr); + controller.enqueue(new TextEncoder().encode(modifiedHtml)); } } catch (e) { controller.error(e); @@ -69,8 +91,9 @@ function injectMetaTagsInResponse(originalResponse: Response): Response { statusText: originalResponse.statusText, headers: new Headers(originalResponse.headers), }); - } catch { - return originalResponse; + } catch (e) { + captureException(e); + throw e; } } From c5b8f10e07174136351b0d413aaa5091ed9e3657 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 23 May 2026 16:02:46 +0200 Subject: [PATCH 4/8] fix: Use inline captureException with auto.http.tanstackstart mechanism Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tanstackstart-react/src/server/wrapFetchWithSentry.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index 93ea0bbb9e28..7af10f2a9d05 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -67,7 +67,9 @@ function injectMetaTagsInResponse(originalResponse: Response): Response { yield chunk; } } catch (e) { - captureException(e); + captureException(e, { + mechanism: { type: 'auto.http.tanstackstart', handled: false }, + }); throw e; } } From 0c8b1189952c824c68029e22525c716c515017f5 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 23 May 2026 16:06:23 +0200 Subject: [PATCH 5/8] fix: Rename bodyIterator to bodyReporter, add mechanism to outer catch Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tanstackstart-react/src/server/wrapFetchWithSentry.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index 7af10f2a9d05..8cae0b3310e3 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -61,7 +61,7 @@ function injectMetaTagsInResponse(originalResponse: Response): Response { // Assign to a new variable to avoid TS losing the narrower type checked above. const body = originalBody; - async function* bodyIterator(): AsyncGenerator { + async function* bodyReporter(): AsyncGenerator { try { for await (const chunk of body) { yield chunk; @@ -75,7 +75,7 @@ function injectMetaTagsInResponse(originalResponse: Response): Response { } try { - for await (const chunk of bodyIterator()) { + 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)); @@ -94,7 +94,9 @@ function injectMetaTagsInResponse(originalResponse: Response): Response { headers: new Headers(originalResponse.headers), }); } catch (e) { - captureException(e); + captureException(e, { + mechanism: { type: 'auto.http.tanstackstart', handled: false }, + }); throw e; } } From 94fb6a0b4acf7558750e4597586d35e91f201237 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 23 May 2026 16:41:54 +0200 Subject: [PATCH 6/8] fix: Avoid calling controller.close() after controller.error() Closing an already-errored ReadableStream controller throws TypeError. Track error state and skip close() in that case. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tanstackstart-react/src/server/wrapFetchWithSentry.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index 8cae0b3310e3..516604a94db1 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -74,6 +74,7 @@ function injectMetaTagsInResponse(originalResponse: Response): Response { } } + let errored = false; try { for await (const chunk of bodyReporter()) { const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true }); @@ -81,9 +82,12 @@ function injectMetaTagsInResponse(originalResponse: Response): Response { controller.enqueue(new TextEncoder().encode(modifiedHtml)); } } catch (e) { + errored = true; controller.error(e); } finally { - controller.close(); + if (!errored) { + controller.close(); + } } }, }); From 6fc3d112d2cec79cf544bc1bdd0df742f568109b Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 13:39:06 +0200 Subject: [PATCH 7/8] test: Add unit test for stream error captureException, add changelog Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 +++ .../test/server/wrapFetchWithSentry.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b737ba882ce1..f5caa55f6c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Change the recommended setup for the SDK to do `Sentry.init()` in the client entry file to capture telemetry that is emitted ahead of page hydration. +- **feat(tanstackstart-react): Add distributed tracing ([#21144](https://github.com/getsentry/sentry-javascript/pull/21144))** + + Server and client traces are now automatically connected, allowing you to see the full request lifecycle from server-side rendering through client-side hydration in a single trace. + ## 10.54.0 ### Important Changes diff --git a/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts b/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts index fb123659d2fe..90300a5d52ba 100644 --- a/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts +++ b/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts @@ -3,11 +3,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const startSpanSpy = vi.fn((_, callback) => callback()); const flushIfServerlessSpy = vi.fn().mockResolvedValue(undefined); +const captureExceptionSpy = vi.fn(); + vi.mock('@sentry/node', async importOriginal => { const original = await importOriginal(); return { ...original, startSpan: (...args: unknown[]) => startSpanSpy(...args), + captureException: (...args: unknown[]) => captureExceptionSpy(...args), }; }); @@ -148,6 +151,34 @@ describe('wrapFetchWithSentry', () => { expect(html).toContain('data-content="ignore"'); }); + it('captures exception when HTML response body stream errors', async () => { + const streamError = new Error('stream read error'); + const body = new ReadableStream({ + start(controller) { + controller.error(streamError); + }, + }); + const mockResponse = new Response(body, { + 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); + + try { + await response.text(); + } catch { + // expected — the stream errors + } + + expect(captureExceptionSpy).toHaveBeenCalledWith(streamError, { + mechanism: { type: 'auto.http.tanstackstart', handled: false }, + }); + }); + it('calls flushIfServerless even if the handler throws', async () => { const fetchFn = vi.fn().mockRejectedValue(new Error('handler error')); From 53a6c2bff44f3bd81c490a19203c4657ece50288 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 15:11:16 +0200 Subject: [PATCH 8/8] ci: retrigger