Skip to content

Commit 0a8adc4

Browse files
nicohrubecclaude
andauthored
feat(tanstackstart-react): Add distributed tracing (#21144)
Adds distributed tracing to the TanStack Start SDK by injecting `sentry-trace` and `baggage` meta tags into HTML responses in `wrapFetchWithSentry`. This connects server and client traces automatically without requiring any additional user setup. The added functions that do the meta tag injections are essentially copies from the astro package ([addMetaTagToHead](https://github.com/getsentry/sentry-javascript/blob/develop/packages/astro/src/server/middleware.ts#L277-L291), [injectMetaTagsInResponse](https://github.com/getsentry/sentry-javascript/blob/develop/packages/astro/src/server/middleware.ts#L454-L514)). Added a `trace-propagation` test to the main TSS e2e test application and adjusted the existing cloudflare TSS e2e test application to the new pattern (i.e. remove the manual injection of meta tags). Closes #18286 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 556f99f commit 0a8adc4

7 files changed

Lines changed: 323 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
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.
1010

11+
- **feat(tanstackstart-react): Add distributed tracing ([#21144](https://github.com/getsentry/sentry-javascript/pull/21144))**
12+
13+
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.
14+
1115
## 10.54.0
1216

1317
### Important Changes

dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/src/routes/__root.tsx

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,21 @@
11
import type { ReactNode } from 'react';
22
import { Outlet, createRootRoute, HeadContent, Scripts } from '@tanstack/react-router';
3-
import { getTraceData } from '@sentry/tanstackstart-react';
43

54
export const Route = createRootRoute({
6-
head: () => {
7-
const traceData = getTraceData();
8-
const sentryMeta = Object.entries(traceData).map(([key, value]) => ({
9-
name: key,
10-
content: value,
11-
}));
12-
13-
return {
14-
meta: [
15-
{
16-
charSet: 'utf-8',
17-
},
18-
{
19-
name: 'viewport',
20-
content: 'width=device-width, initial-scale=1',
21-
},
22-
{
23-
title: 'TanStack Start Cloudflare E2E Test',
24-
},
25-
...sentryMeta,
26-
],
27-
};
28-
},
5+
head: () => ({
6+
meta: [
7+
{
8+
charSet: 'utf-8',
9+
},
10+
{
11+
name: 'viewport',
12+
content: 'width=device-width, initial-scale=1',
13+
},
14+
{
15+
title: 'TanStack Start Cloudflare E2E Test',
16+
},
17+
],
18+
}),
2919
component: RootComponent,
3020
});
3121

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test.describe('Trace propagation', () => {
5+
test('should inject metatags in ssr pageload', async ({ page }) => {
6+
await page.goto('/');
7+
8+
const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content');
9+
expect(sentryTraceContent).toBeDefined();
10+
expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/);
11+
12+
const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content');
13+
expect(baggageContent).toBeDefined();
14+
expect(baggageContent).toContain('sentry-environment=qa');
15+
expect(baggageContent).toContain('sentry-public_key=');
16+
expect(baggageContent).toContain('sentry-trace_id=');
17+
expect(baggageContent).toContain('sentry-sampled=');
18+
});
19+
20+
test('should have trace connection between server and client', async ({ page }) => {
21+
const serverTxPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => {
22+
return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /';
23+
});
24+
25+
const clientTxPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => {
26+
return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/';
27+
});
28+
29+
await page.goto('/');
30+
31+
const serverTx = await serverTxPromise;
32+
const clientTx = await clientTxPromise;
33+
34+
expect(clientTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id);
35+
});
36+
});

dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -95,25 +95,3 @@ test('Sends server-side transaction for page request', async ({ baseURL }) => {
9595
status: 'ok',
9696
});
9797
});
98-
99-
test('Propagates trace from server to client', async ({ page }) => {
100-
const serverTransactionPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => {
101-
return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /';
102-
});
103-
104-
const clientTransactionPromise = waitForTransaction('tanstackstart-react-cloudflare', transactionEvent => {
105-
return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/';
106-
});
107-
108-
await page.goto('/');
109-
110-
const serverTransaction = await serverTransactionPromise;
111-
const clientTransaction = await clientTransactionPromise;
112-
113-
const serverTraceId = serverTransaction.contexts?.trace?.trace_id;
114-
const clientTraceId = clientTransaction.contexts?.trace?.trace_id;
115-
116-
expect(serverTraceId).toMatch(/[a-f0-9]{32}/);
117-
expect(clientTraceId).toMatch(/[a-f0-9]{32}/);
118-
expect(clientTraceId).toBe(serverTraceId);
119-
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
const usesManagedTunnelRoute =
5+
(process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1';
6+
7+
test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant');
8+
9+
test.describe('Trace propagation', () => {
10+
test('should inject metatags in ssr pageload', async ({ page }) => {
11+
await page.goto('/');
12+
13+
const sentryTraceContent = await page.getAttribute('meta[name="sentry-trace"]', 'content');
14+
expect(sentryTraceContent).toBeDefined();
15+
expect(sentryTraceContent).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/);
16+
17+
const baggageContent = await page.getAttribute('meta[name="baggage"]', 'content');
18+
expect(baggageContent).toBeDefined();
19+
expect(baggageContent).toContain('sentry-environment=qa');
20+
expect(baggageContent).toContain('sentry-public_key=');
21+
expect(baggageContent).toContain('sentry-trace_id=');
22+
expect(baggageContent).toContain('sentry-sampled=');
23+
});
24+
25+
test('should have trace connection between server and client', async ({ page }) => {
26+
const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
27+
return transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /';
28+
});
29+
30+
const clientTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
31+
return transactionEvent?.contexts?.trace?.op === 'pageload' && transactionEvent?.transaction === '/';
32+
});
33+
34+
await page.goto('/');
35+
36+
const serverTx = await serverTxPromise;
37+
const clientTx = await clientTxPromise;
38+
39+
expect(clientTx.contexts?.trace?.trace_id).toBe(serverTx.contexts?.trace?.trace_id);
40+
});
41+
});

packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,110 @@
1-
import { flushIfServerless } from '@sentry/core';
2-
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node';
1+
import { flushIfServerless, getTraceMetaTags } from '@sentry/core';
2+
import {
3+
captureException,
4+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
5+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
6+
startSpan,
7+
} from '@sentry/node';
38
import { extractServerFunctionSha256 } from './utils';
49

510
export type ServerEntry = {
611
fetch: (request: Request, opts?: unknown) => Promise<Response> | Response;
712
};
813

14+
/**
15+
* This function optimistically assumes that the HTML coming in chunks will not be split
16+
* within the <head> tag. If this still happens, we simply won't replace anything.
17+
*/
18+
function addMetaTagToHead(htmlChunk: string, metaTagsStr: string): string {
19+
if (typeof htmlChunk !== 'string' || !metaTagsStr) {
20+
return htmlChunk;
21+
}
22+
23+
if (htmlChunk.includes('"sentry-trace"')) {
24+
return htmlChunk;
25+
}
26+
27+
// Skip quoted attribute values so we don't match <head> inside e.g. data-code="...<head>..."
28+
let replaced = false;
29+
return htmlChunk.replace(/"[^"]*"|'[^']*'|(<head>)/g, (match, headTag) => {
30+
if (headTag && !replaced) {
31+
replaced = true;
32+
return `<head>${metaTagsStr}`;
33+
}
34+
return match;
35+
});
36+
}
37+
38+
function injectMetaTagsInResponse(originalResponse: Response): Response {
39+
try {
40+
const contentType = originalResponse.headers.get('content-type');
41+
42+
const isPageloadRequest = contentType?.startsWith('text/html');
43+
if (!isPageloadRequest) {
44+
return originalResponse;
45+
}
46+
47+
// Type case necessary b/c the body's ReadableStream type doesn't include
48+
// the async iterator that is actually available in Node
49+
// We later on use the async iterator to read the body chunks
50+
// see https://github.com/microsoft/TypeScript/issues/39051
51+
const originalBody = originalResponse.body as NodeJS.ReadableStream | null;
52+
if (!originalBody) {
53+
return originalResponse;
54+
}
55+
56+
const metaTagsStr = getTraceMetaTags();
57+
const decoder = new TextDecoder();
58+
59+
const newResponseStream = new ReadableStream({
60+
start: async controller => {
61+
// Assign to a new variable to avoid TS losing the narrower type checked above.
62+
const body = originalBody;
63+
64+
async function* bodyReporter(): AsyncGenerator<string | Buffer> {
65+
try {
66+
for await (const chunk of body) {
67+
yield chunk;
68+
}
69+
} catch (e) {
70+
captureException(e, {
71+
mechanism: { type: 'auto.http.tanstackstart', handled: false },
72+
});
73+
throw e;
74+
}
75+
}
76+
77+
let errored = false;
78+
try {
79+
for await (const chunk of bodyReporter()) {
80+
const html = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
81+
const modifiedHtml = addMetaTagToHead(html, metaTagsStr);
82+
controller.enqueue(new TextEncoder().encode(modifiedHtml));
83+
}
84+
} catch (e) {
85+
errored = true;
86+
controller.error(e);
87+
} finally {
88+
if (!errored) {
89+
controller.close();
90+
}
91+
}
92+
},
93+
});
94+
95+
return new Response(newResponseStream, {
96+
status: originalResponse.status,
97+
statusText: originalResponse.statusText,
98+
headers: new Headers(originalResponse.headers),
99+
});
100+
} catch (e) {
101+
captureException(e, {
102+
mechanism: { type: 'auto.http.tanstackstart', handled: false },
103+
});
104+
throw e;
105+
}
106+
}
107+
9108
/**
10109
* This function can be used to wrap the server entry request handler to add tracing to server-side functionality.
11110
* 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 {
62161
);
63162
}
64163

65-
return await target.apply(thisArg, args);
164+
return injectMetaTagsInResponse(await target.apply(thisArg, args));
66165
} finally {
67166
await flushIfServerless();
68167
}

0 commit comments

Comments
 (0)