Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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 { withSentry } 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 = withSentry({
fetch(request: Request) {
return handler.fetch(request);
},
});

export default createServerEntry(requestHandler);
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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('/_serverFn/'),
op: 'function.tanstackstart',
origin: 'auto.function.tanstackstart.serverFn',
data: {
'sentry.op': 'function.tanstackstart',
'sentry.origin': 'auto.function.tanstackstart.serverFn',
},
}),
]),
);
});

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('/_serverFn/'),
op: 'function.tanstackstart',
origin: 'auto.function.tanstackstart.serverFn',
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.serverFn',
);
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 { withSentry } from './withSentry';

/**
* A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors
Expand Down
60 changes: 60 additions & 0 deletions packages/tanstackstart-react/src/server/withSentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node';

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 { withSentry } 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 = withSentry({
* fetch(request: Request) {
* return handler.fetch(request);
* },
* });
*
* export default serverEntry = createServerEntry(requestHandler);
* ```
*
* @param serverEntry - request handler to wrap
* @returns - wrapped request handler
*/
export function withSentry(serverEntry: ServerEntry): ServerEntry {
if (serverEntry.fetch) {
serverEntry.fetch = new Proxy<typeof serverEntry.fetch>(serverEntry.fetch, {
apply: async (target, thisArg, args) => {
const request: Request = args[0];

// instrument server functions
if (request.url?.includes('_serverFn') || request.url?.includes('createServerFn')) {
const op = 'function.tanstackstart';
return startSpan(
{
op: op,
name: request.url,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.tanstackstart.serverFn',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
},
},
() => {
return target.apply(thisArg, args);
},
);
}

return target.apply(thisArg, args);
},
});
}
return serverEntry;
}
Loading