Skip to content

Commit 2c45b73

Browse files
authored
feat(tanstackstart-react): Trace server functions (#18500)
This PR adds tracing for tss server functions. To achieve this I added a new `wrapFetchWithSentry` wrapper that can be used to instrument the tss server entry point: ``` import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'; import handler, { createServerEntry } from '@tanstack/react-start/server-entry'; const requestHandler = wrapFetchWithSentry({ fetch(request: Request) { return handler.fetch(request); }, }); export default createServerEntry(requestHandler); ``` With this we get spans for server functions executed via `fetch` calls to the server. A limitation of this approach is that out-of-the-box this will only start a single span for the initial request made to the server. So for instance if a server function calls another server function, we will still only get a single span for the outer server function and users would need to wrap the inner call manually. Tests added: - E2E: Basic transaction test to verify that we get spans if a server function is executed. - E2E: Another transaction test documenting that users need to manually wrap "nested" server functions. - Unit: Tests to verify the sha256 extraction. Closes #18287
1 parent 85b4812 commit 2c45b73

File tree

8 files changed

+281
-2
lines changed

8 files changed

+281
-2
lines changed

dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
},
1414
"dependencies": {
1515
"@sentry/tanstackstart-react": "latest || *",
16-
"@tanstack/react-start": "^1.139.12",
17-
"@tanstack/react-router": "^1.139.12",
16+
"@tanstack/react-start": "^1.136.0",
17+
"@tanstack/react-router": "^1.136.0",
1818
"react": "^19.2.0",
1919
"react-dom": "^19.2.0"
2020
},
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { createFileRoute } from '@tanstack/react-router';
2+
import { createServerFn } from '@tanstack/react-start';
3+
import { startSpan } from '@sentry/tanstackstart-react';
4+
5+
const testLog = createServerFn().handler(async () => {
6+
console.log('Test log from server function');
7+
return { message: 'Log created' };
8+
});
9+
10+
const testNestedLog = createServerFn().handler(async () => {
11+
await startSpan({ name: 'testNestedLog' }, async () => {
12+
await testLog();
13+
});
14+
15+
console.log('Outer test log from server function');
16+
return { message: 'Nested log created' };
17+
});
18+
19+
export const Route = createFileRoute('/test-serverFn')({
20+
component: TestLog,
21+
});
22+
23+
function TestLog() {
24+
return (
25+
<div>
26+
<h1>Test Log Page</h1>
27+
<button
28+
type="button"
29+
onClick={async () => {
30+
await testLog();
31+
}}
32+
>
33+
Call server function
34+
</button>
35+
<button
36+
type="button"
37+
onClick={async () => {
38+
await testNestedLog();
39+
}}
40+
>
41+
Call server function nested
42+
</button>
43+
</div>
44+
);
45+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { wrapFetchWithSentry } from '@sentry/tanstackstart-react';
2+
3+
import handler, { createServerEntry } from '@tanstack/react-start/server-entry';
4+
import type { ServerEntry } from '@tanstack/react-start/server-entry';
5+
6+
const requestHandler: ServerEntry = wrapFetchWithSentry({
7+
fetch(request: Request) {
8+
return handler.fetch(request);
9+
},
10+
});
11+
12+
export default createServerEntry(requestHandler);
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Sends a server function transaction with auto-instrumentation', async ({ page }) => {
5+
const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
6+
return (
7+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
8+
!!transactionEvent?.transaction?.startsWith('GET /_serverFn')
9+
);
10+
});
11+
12+
await page.goto('/test-serverFn');
13+
14+
await expect(page.getByText('Call server function', { exact: true })).toBeVisible();
15+
16+
await page.getByText('Call server function', { exact: true }).click();
17+
18+
const transactionEvent = await transactionEventPromise;
19+
20+
// Check for the auto-instrumented server function span
21+
expect(Array.isArray(transactionEvent?.spans)).toBe(true);
22+
expect(transactionEvent?.spans).toEqual(
23+
expect.arrayContaining([
24+
expect.objectContaining({
25+
description: expect.stringContaining('GET /_serverFn/'),
26+
op: 'function.tanstackstart',
27+
origin: 'auto.function.tanstackstart.server',
28+
data: {
29+
'sentry.op': 'function.tanstackstart',
30+
'sentry.origin': 'auto.function.tanstackstart.server',
31+
'tanstackstart.function.hash.sha256': expect.any(String),
32+
},
33+
status: 'ok',
34+
}),
35+
]),
36+
);
37+
});
38+
39+
test('Sends a server function transaction for a nested server function only if it is manually instrumented', async ({
40+
page,
41+
}) => {
42+
const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
43+
return (
44+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
45+
!!transactionEvent?.transaction?.startsWith('GET /_serverFn')
46+
);
47+
});
48+
49+
await page.goto('/test-serverFn');
50+
51+
await expect(page.getByText('Call server function nested')).toBeVisible();
52+
53+
await page.getByText('Call server function nested').click();
54+
55+
const transactionEvent = await transactionEventPromise;
56+
57+
expect(Array.isArray(transactionEvent?.spans)).toBe(true);
58+
59+
// Check for the auto-instrumented server function span
60+
expect(transactionEvent?.spans).toEqual(
61+
expect.arrayContaining([
62+
expect.objectContaining({
63+
description: expect.stringContaining('GET /_serverFn/'),
64+
op: 'function.tanstackstart',
65+
origin: 'auto.function.tanstackstart.server',
66+
data: {
67+
'sentry.op': 'function.tanstackstart',
68+
'sentry.origin': 'auto.function.tanstackstart.server',
69+
'tanstackstart.function.hash.sha256': expect.any(String),
70+
},
71+
status: 'ok',
72+
}),
73+
]),
74+
);
75+
76+
// Check for the manually instrumented nested span
77+
expect(transactionEvent?.spans).toEqual(
78+
expect.arrayContaining([
79+
expect.objectContaining({
80+
description: 'testNestedLog',
81+
origin: 'manual',
82+
status: 'ok',
83+
}),
84+
]),
85+
);
86+
87+
// Verify that the auto span is the parent of the nested span
88+
const autoSpan = transactionEvent?.spans?.find(
89+
(span: { op?: string; origin?: string }) =>
90+
span.op === 'function.tanstackstart' && span.origin === 'auto.function.tanstackstart.server',
91+
);
92+
const nestedSpan = transactionEvent?.spans?.find(
93+
(span: { description?: string; origin?: string }) =>
94+
span.description === 'testNestedLog' && span.origin === 'manual',
95+
);
96+
97+
expect(autoSpan).toBeDefined();
98+
expect(nestedSpan).toBeDefined();
99+
expect(nestedSpan?.parent_span_id).toBe(autoSpan?.span_id);
100+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
export * from '@sentry/node';
55

66
export { init } from './sdk';
7+
export { wrapFetchWithSentry } from './wrapFetchWithSentry';
78

89
/**
910
* A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Extracts the SHA-256 hash from a server function pathname.
3+
* Server function pathnames are structured as `/_serverFn/<hash>`.
4+
* This function matches the pattern and returns the hash if found.
5+
*
6+
* @param pathname - the pathname of the server function
7+
* @returns the sha256 of the server function
8+
*/
9+
export function extractServerFunctionSha256(pathname: string): string {
10+
const serverFnMatch = pathname.match(/\/_serverFn\/([a-f0-9]{64})/i);
11+
return serverFnMatch?.[1] ?? 'unknown';
12+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node';
2+
import { extractServerFunctionSha256 } from './utils';
3+
4+
export type ServerEntry = {
5+
fetch: (request: Request, opts?: unknown) => Promise<Response> | Response;
6+
};
7+
8+
/**
9+
* This function can be used to wrap the server entry request handler to add tracing to server-side functionality.
10+
* 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.
11+
* For more information about the server entry point, see the [TanStack Start documentation](https://tanstack.com/start/docs/server-entry).
12+
*
13+
* @example
14+
* ```ts
15+
* import { wrapFetchWithSentry } from '@sentry/tanstackstart-react';
16+
*
17+
* import handler, { createServerEntry } from '@tanstack/react-start/server-entry';
18+
* import type { ServerEntry } from '@tanstack/react-start/server-entry';
19+
*
20+
* const requestHandler: ServerEntry = wrapFetchWithSentry({
21+
* fetch(request: Request) {
22+
* return handler.fetch(request);
23+
* },
24+
* });
25+
*
26+
* export default serverEntry = createServerEntry(requestHandler);
27+
* ```
28+
*
29+
* @param serverEntry - request handler to wrap
30+
* @returns - wrapped request handler
31+
*/
32+
export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry {
33+
if (serverEntry.fetch) {
34+
serverEntry.fetch = new Proxy<typeof serverEntry.fetch>(serverEntry.fetch, {
35+
apply: (target, thisArg, args) => {
36+
const request: Request = args[0];
37+
const url = new URL(request.url);
38+
const method = request.method || 'GET';
39+
40+
// instrument server functions
41+
if (url.pathname.includes('_serverFn') || url.pathname.includes('createServerFn')) {
42+
const functionSha256 = extractServerFunctionSha256(url.pathname);
43+
const op = 'function.tanstackstart';
44+
45+
const serverFunctionSpanAttributes = {
46+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.tanstackstart.server',
47+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
48+
'tanstackstart.function.hash.sha256': functionSha256,
49+
};
50+
51+
return startSpan(
52+
{
53+
op: op,
54+
name: `${method} ${url.pathname}`,
55+
attributes: serverFunctionSpanAttributes,
56+
},
57+
() => {
58+
return target.apply(thisArg, args);
59+
},
60+
);
61+
}
62+
63+
return target.apply(thisArg, args);
64+
},
65+
});
66+
}
67+
return serverEntry;
68+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { extractServerFunctionSha256 } from '../../src/server/utils';
3+
4+
describe('extractServerFunctionSha256', () => {
5+
it('extracts SHA256 hash from valid server function pathname', () => {
6+
const pathname = '/_serverFn/1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf';
7+
const result = extractServerFunctionSha256(pathname);
8+
expect(result).toBe('1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf');
9+
});
10+
11+
it('extracts SHA256 hash from valid server function pathname that is a subpath', () => {
12+
const pathname = '/api/_serverFn/1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf';
13+
const result = extractServerFunctionSha256(pathname);
14+
expect(result).toBe('1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf');
15+
});
16+
17+
it('extracts SHA256 hash from valid server function pathname with query parameters', () => {
18+
const pathname = '/_serverFn/1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf?param=value';
19+
const result = extractServerFunctionSha256(pathname);
20+
expect(result).toBe('1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf');
21+
});
22+
23+
it('extracts SHA256 hash with uppercase hex characters', () => {
24+
const pathname = '/_serverFn/1AC31C23F613EC7E58631CF789642E2FEB86C58E3128324CF00D746474A044BF';
25+
const result = extractServerFunctionSha256(pathname);
26+
expect(result).toBe('1AC31C23F613EC7E58631CF789642E2FEB86C58E3128324CF00D746474A044BF');
27+
});
28+
29+
it('returns unknown for pathname without server function pattern', () => {
30+
const pathname = '/api/users/123';
31+
const result = extractServerFunctionSha256(pathname);
32+
expect(result).toBe('unknown');
33+
});
34+
35+
it('returns unknown for pathname with incomplete hash', () => {
36+
// Hash is too short (only 32 chars instead of 64)
37+
const pathname = '/_serverFn/1ac31c23f613ec7e58631cf789642e2f';
38+
const result = extractServerFunctionSha256(pathname);
39+
expect(result).toBe('unknown');
40+
});
41+
});

0 commit comments

Comments
 (0)