Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
105 changes: 102 additions & 3 deletions packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts
Original file line number Diff line number Diff line change
@@ -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> | Response;
};

/**
* This function optimistically assumes that the HTML coming in chunks will not be split
* within the <head> 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;
}
Comment thread
nicohrubec marked this conversation as resolved.
Comment thread
nicohrubec marked this conversation as resolved.

// Skip quoted attribute values so we don't match <head> inside e.g. data-code="...<head>..."
let replaced = false;
return htmlChunk.replace(/"[^"]*"|'[^']*'|(<head>)/g, (match, headTag) => {
if (headTag && !replaced) {
replaced = true;
return `<head>${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<string | Buffer> {
try {
for await (const chunk of body) {
yield chunk;
}
} catch (e) {
captureException(e, {
mechanism: { type: 'auto.http.tanstackstart', handled: false },
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you could also test that those exceptions are correctly captured.

});
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));
}
Comment thread
nicohrubec marked this conversation as resolved.
Comment thread
nicohrubec marked this conversation as resolved.
} catch (e) {
errored = true;
controller.error(e);
} finally {
if (!errored) {
controller.close();
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
},
});

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;
}
}
Comment thread
nicohrubec marked this conversation as resolved.

/**
* 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.
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading