Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
},
"dependencies": {
"@sentry/tanstackstart-react": "latest || *",
"@tanstack/react-start": "^1.139.12",
"@tanstack/react-router": "^1.139.12",
"@tanstack/react-start": "^1.136.0",
"@tanstack/react-router": "^1.136.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { startSpan } from '@sentry/tanstackstart-react';

const testLog = createServerFn().handler(async () => {
console.log('Test log from server function');
return { message: 'Log created' };
});

const testNestedLog = createServerFn().handler(async () => {
await startSpan({ name: 'testNestedLog' }, async () => {
await testLog();
});

console.log('Outer test log from server function');
return { message: 'Nested log created' };
});

export const Route = createFileRoute('/test-serverFn')({
component: TestLog,
});

function TestLog() {
return (
<div>
<h1>Test Log Page</h1>
<button
type="button"
onClick={async () => {
await testLog();
}}
>
Call server function
</button>
<button
type="button"
onClick={async () => {
await testNestedLog();
}}
>
Call server function nested
</button>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { wrapFetchWithSentry } from '@sentry/tanstackstart-react';

import handler, { createServerEntry } from '@tanstack/react-start/server-entry';
import type { ServerEntry } from '@tanstack/react-start/server-entry';

const requestHandler: ServerEntry = wrapFetchWithSentry({
fetch(request: Request) {
return handler.fetch(request);
},
});

export default createServerEntry(requestHandler);
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('Sends a server function transaction with auto-instrumentation', async ({ page }) => {
const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
!!transactionEvent?.transaction?.startsWith('GET /_serverFn')
);
});

await page.goto('/test-serverFn');

await expect(page.getByText('Call server function', { exact: true })).toBeVisible();

await page.getByText('Call server function', { exact: true }).click();

const transactionEvent = await transactionEventPromise;

// Check for the auto-instrumented server function span
expect(Array.isArray(transactionEvent?.spans)).toBe(true);
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: expect.stringContaining('GET /_serverFn/'),
op: 'function.tanstackstart',
origin: 'auto.function.tanstackstart.server',
data: {
'sentry.op': 'function.tanstackstart',
'sentry.origin': 'auto.function.tanstackstart.server',
'tanstackstart.function.hash.sha256': expect.any(String),
},
status: 'ok',
}),
]),
);
});

test('Sends a server function transaction for a nested server function only if it is manually instrumented', async ({
page,
}) => {
const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
return (
transactionEvent?.contexts?.trace?.op === 'http.server' &&
!!transactionEvent?.transaction?.startsWith('GET /_serverFn')
);
});

await page.goto('/test-serverFn');

await expect(page.getByText('Call server function nested')).toBeVisible();

await page.getByText('Call server function nested').click();

const transactionEvent = await transactionEventPromise;

expect(Array.isArray(transactionEvent?.spans)).toBe(true);

// Check for the auto-instrumented server function span
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: expect.stringContaining('GET /_serverFn/'),
op: 'function.tanstackstart',
origin: 'auto.function.tanstackstart.server',
data: {
'sentry.op': 'function.tanstackstart',
'sentry.origin': 'auto.function.tanstackstart.server',
'tanstackstart.function.hash.sha256': expect.any(String),
},
status: 'ok',
}),
]),
);

// Check for the manually instrumented nested span
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'testNestedLog',
origin: 'manual',
status: 'ok',
}),
]),
);

// Verify that the auto span is the parent of the nested span
const autoSpan = transactionEvent?.spans?.find(
(span: { op?: string; origin?: string }) =>
span.op === 'function.tanstackstart' && span.origin === 'auto.function.tanstackstart.server',
);
const nestedSpan = transactionEvent?.spans?.find(
(span: { description?: string; origin?: string }) =>
span.description === 'testNestedLog' && span.origin === 'manual',
);

expect(autoSpan).toBeDefined();
expect(nestedSpan).toBeDefined();
expect(nestedSpan?.parent_span_id).toBe(autoSpan?.span_id);
});
1 change: 1 addition & 0 deletions packages/tanstackstart-react/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
export * from '@sentry/node';

export { init } from './sdk';
export { wrapFetchWithSentry } from './wrapFetchWithSentry';

/**
* A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors
Expand Down
12 changes: 12 additions & 0 deletions packages/tanstackstart-react/src/server/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Extracts the SHA-256 hash from a server function pathname.
* Server function pathnames are structured as `/_serverFn/<hash>`.
* This function matches the pattern and returns the hash if found.
*
* @param pathname - the pathname of the server function
* @returns the sha256 of the server function
*/
export function extractServerFunctionSha256(pathname: string): string {
const serverFnMatch = pathname.match(/\/_serverFn\/([a-f0-9]{64})/i);
return serverFnMatch?.[1] ?? 'unknown';
}
68 changes: 68 additions & 0 deletions packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { 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 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.
* For more information about the server entry point, see the [TanStack Start documentation](https://tanstack.com/start/docs/server-entry).
*
* @example
* ```ts
* import { wrapFetchWithSentry } from '@sentry/tanstackstart-react';
*
* import handler, { createServerEntry } from '@tanstack/react-start/server-entry';
* import type { ServerEntry } from '@tanstack/react-start/server-entry';
*
* const requestHandler: ServerEntry = wrapFetchWithSentry({
* fetch(request: Request) {
* return handler.fetch(request);
* },
* });
*
* export default serverEntry = createServerEntry(requestHandler);
* ```
*
* @param serverEntry - request handler to wrap
* @returns - wrapped request handler
*/
export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry {
if (serverEntry.fetch) {
serverEntry.fetch = new Proxy<typeof serverEntry.fetch>(serverEntry.fetch, {
apply: (target, thisArg, args) => {
const request: Request = args[0];
const url = new URL(request.url);
const method = request.method || 'GET';

// instrument server functions
if (url.pathname.includes('_serverFn') || url.pathname.includes('createServerFn')) {
const functionSha256 = extractServerFunctionSha256(url.pathname);
const op = 'function.tanstackstart';

const serverFunctionSpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.tanstackstart.server',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
'tanstackstart.function.hash.sha256': functionSha256,
};

return startSpan(
{
op: op,
name: `${method} ${url.pathname}`,
attributes: serverFunctionSpanAttributes,
},
() => {
return target.apply(thisArg, args);
},
);
}

return target.apply(thisArg, args);
},
});
}
return serverEntry;
}
41 changes: 41 additions & 0 deletions packages/tanstackstart-react/test/server/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { extractServerFunctionSha256 } from '../../src/server/utils';

describe('extractServerFunctionSha256', () => {
it('extracts SHA256 hash from valid server function pathname', () => {
const pathname = '/_serverFn/1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf';
const result = extractServerFunctionSha256(pathname);
expect(result).toBe('1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf');
});

it('extracts SHA256 hash from valid server function pathname that is a subpath', () => {
const pathname = '/api/_serverFn/1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf';
const result = extractServerFunctionSha256(pathname);
expect(result).toBe('1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf');
});

it('extracts SHA256 hash from valid server function pathname with query parameters', () => {
const pathname = '/_serverFn/1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf?param=value';
const result = extractServerFunctionSha256(pathname);
expect(result).toBe('1ac31c23f613ec7e58631cf789642e2feb86c58e3128324cf00d746474a044bf');
});

it('extracts SHA256 hash with uppercase hex characters', () => {
const pathname = '/_serverFn/1AC31C23F613EC7E58631CF789642E2FEB86C58E3128324CF00D746474A044BF';
const result = extractServerFunctionSha256(pathname);
expect(result).toBe('1AC31C23F613EC7E58631CF789642E2FEB86C58E3128324CF00D746474A044BF');
});

it('returns unknown for pathname without server function pattern', () => {
const pathname = '/api/users/123';
const result = extractServerFunctionSha256(pathname);
expect(result).toBe('unknown');
});

it('returns unknown for pathname with incomplete hash', () => {
// Hash is too short (only 32 chars instead of 64)
const pathname = '/_serverFn/1ac31c23f613ec7e58631cf789642e2f';
const result = extractServerFunctionSha256(pathname);
expect(result).toBe('unknown');
});
});
Loading