diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 5df2d237445e..0076ccf22dc8 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -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" }, diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-serverFn.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-serverFn.tsx new file mode 100644 index 000000000000..894ff4e85ee3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-serverFn.tsx @@ -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 ( +
+

Test Log Page

+ + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts new file mode 100644 index 000000000000..b10a3bc1e37b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/server.ts @@ -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); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts new file mode 100644 index 000000000000..d2ebbffb0ec0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts @@ -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); +}); diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index b91a89ed9482..299f1cd85ea9 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -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 diff --git a/packages/tanstackstart-react/src/server/utils.ts b/packages/tanstackstart-react/src/server/utils.ts new file mode 100644 index 000000000000..a3ebbd118910 --- /dev/null +++ b/packages/tanstackstart-react/src/server/utils.ts @@ -0,0 +1,12 @@ +/** + * Extracts the SHA-256 hash from a server function pathname. + * Server function pathnames are structured as `/_serverFn/`. + * 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'; +} diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts new file mode 100644 index 000000000000..22d218ef0b48 --- /dev/null +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -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; +}; + +/** + * 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(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; +} diff --git a/packages/tanstackstart-react/test/server/utils.test.ts b/packages/tanstackstart-react/test/server/utils.test.ts new file mode 100644 index 000000000000..f9dd652bd9b1 --- /dev/null +++ b/packages/tanstackstart-react/test/server/utils.test.ts @@ -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'); + }); +});