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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

Server transaction names are now parametrized automatically (e.g., `GET /users/123` becomes `GET /users/$userId`), improving transaction grouping in Sentry.

- **feat(tanstackstart-react): Show readable server function names in traces ([#21190](https://github.com/getsentry/sentry-javascript/pull/21190))**

Server function spans now show human-readable names (e.g., `GET /_serverFn/greet` instead of `GET /_serverFn/a10e70b3...`). The `tanstackstart.function.hash.sha256` span attribute has been renamed to `tanstackstart.function.id`.

## 10.54.0

### Important Changes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ test('Sends a server function transaction with span from wrapFetchWithSentry', a
expect(transactionEvent?.spans).toHaveLength(1);
expect(transactionEvent?.spans).toEqual([
expect.objectContaining({
description: expect.stringContaining('GET /_serverFn/'),
description: 'GET /_serverFn/testLog',
op: 'function.tanstackstart',
origin: 'auto.function.tanstackstart.server',
data: expect.objectContaining({
data: {
'sentry.op': 'function.tanstackstart',
'sentry.origin': 'auto.function.tanstackstart.server',
'tanstackstart.function.hash.sha256': expect.any(String),
}),
'sentry.source': 'route',
'tanstackstart.function.id': expect.any(String),
'tanstackstart.function.filename': 'src/routes/test-serverFn.tsx',
},
}),
]);
});
Comment on lines 26 to 40
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.

Keep in mind that .objectContaining might ignore fields that matter as it does not to an equality check (is successful when one item is missing). You can either do toEqual or toBe on each individual item you want to check.

Same for all other checks in this PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes that's why I removed the .objectContaining, so this should now do an exact match

Expand Down Expand Up @@ -62,14 +64,16 @@ test('Sends a server function transaction for a nested server function with manu
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: expect.stringContaining('GET /_serverFn/'),
description: 'GET /_serverFn/testNestedLog',
op: 'function.tanstackstart',
origin: 'auto.function.tanstackstart.server',
data: expect.objectContaining({
data: {
'sentry.op': 'function.tanstackstart',
'sentry.origin': 'auto.function.tanstackstart.server',
'tanstackstart.function.hash.sha256': expect.any(String),
}),
'sentry.source': 'route',
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Using this because from what I can see in my experiments the name here never has parameters so we don't need relay-side route parametrization

'tanstackstart.function.id': expect.any(String),
'tanstackstart.function.filename': 'src/routes/test-serverFn.tsx',
},
}),
expect.objectContaining({
description: 'testNestedLog',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ test('Sends a server function transaction with auto-instrumentation', async ({ p
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: expect.stringContaining('GET /_serverFn/'),
description: 'GET /_serverFn/testLog',
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),
'sentry.source': 'route',
'tanstackstart.function.id': expect.any(String),
'tanstackstart.function.filename': 'src/routes/test-serverFn.tsx',
},
status: 'ok',
}),
Expand Down Expand Up @@ -65,13 +67,15 @@ test('Sends a server function transaction for a nested server function only if i
expect(transactionEvent?.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: expect.stringContaining('GET /_serverFn/'),
description: 'GET /_serverFn/testNestedLog',
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),
'sentry.source': 'route',
'tanstackstart.function.id': expect.any(String),
'tanstackstart.function.filename': 'src/routes/test-serverFn.tsx',
},
status: 'ok',
}),
Expand Down
55 changes: 52 additions & 3 deletions packages/tanstackstart-react/src/server/globalMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { addNonEnumerableProperty, captureException } from '@sentry/core';
import {
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
addNonEnumerableProperty,
captureException,
getActiveSpan,
spanToJSON,
updateSpanName,
} from '@sentry/core';
import type { TanStackMiddlewareBase } from '../common/types';
import { SENTRY_INTERNAL } from './middleware';

type ServerFnMeta = {
id?: string;
name?: string;
filename?: string;
};

function createSentryMiddlewareHandler(mechanismType: string) {
return async function sentryMiddlewareHandler({ next }: { next: () => Promise<unknown> }): Promise<unknown> {
try {
Expand All @@ -15,6 +28,41 @@ function createSentryMiddlewareHandler(mechanismType: string) {
};
}

function createSentryFunctionMiddlewareHandler(mechanismType: string) {
return async function sentryFunctionMiddlewareHandler({
next,
serverFnMeta,
}: {
next: () => Promise<unknown>;
serverFnMeta?: ServerFnMeta;
}): Promise<unknown> {
const activeSpan = getActiveSpan();
const spanData = activeSpan ? spanToJSON(activeSpan) : undefined;
if (activeSpan && spanData?.op === 'function.tanstackstart') {
if (serverFnMeta?.name) {
const method = spanData.description?.split(' ')[0] || 'GET';
updateSpanName(activeSpan, `${method} /_serverFn/${serverFnMeta.name}`);
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route');
}
if (serverFnMeta?.id) {
activeSpan.setAttribute('tanstackstart.function.id', serverFnMeta.id);
}
if (serverFnMeta?.filename) {
activeSpan.setAttribute('tanstackstart.function.filename', serverFnMeta.filename);
}
}

try {
return await next();
} catch (e) {
captureException(e, {
mechanism: { type: mechanismType, handled: false },
});
throw e;
}
};
}

/**
* Global request middleware that captures errors from API route requests.
* Should be added as the first entry in the `requestMiddleware` array of `createStart()`.
Expand All @@ -36,8 +84,9 @@ export const sentryGlobalFunctionMiddleware: TanStackMiddlewareBase = {
'~types': undefined,

options: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
server: createSentryMiddlewareHandler('auto.middleware.tanstackstart.server_function') as (...args: any[]) => any,
server: createSentryFunctionMiddlewareHandler('auto.middleware.tanstackstart.server_function') as (
...args: any[] // eslint-disable-line @typescript-eslint/no-explicit-any
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
Comment on lines +87 to +89
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.

Q: Why is the createSentryMiddlewareHandler removed from here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

createSentryMiddlewareHandler only does a try catch essentially, so this is replaced with the new handler that also includes the logic to update the span with the metadata. createSentryMiddlewareHandler is still used for the global middleware that captures exceptions from API routes

},
};

Expand Down
13 changes: 0 additions & 13 deletions packages/tanstackstart-react/src/server/utils.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
import type { StartSpanOptions } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/node';

/**
* 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';
}

/**
* Returns span options for TanStack Start middleware spans.
*/
Expand Down
15 changes: 5 additions & 10 deletions packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
startSpan,
} from '@sentry/node';
import { updateSpanWithRouteParametrization } from './routeParametrization';
import { extractServerFunctionSha256 } from './utils';

declare const __SENTRY_ROUTE_PATTERNS__: string[] | undefined;

Expand Down Expand Up @@ -143,20 +142,16 @@ export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry {

// 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 await startSpan(
{
op: op,
op,
name: `${method} ${url.pathname}`,
attributes: serverFunctionSpanAttributes,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.tanstackstart.server',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: op,
},
Comment thread
cursor[bot] marked this conversation as resolved.
},
async () => {
return target.apply(thisArg, args);
Expand Down
49 changes: 12 additions & 37 deletions packages/tanstackstart-react/test/server/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,16 @@
import { describe, expect, it } from 'vitest';
import { extractServerFunctionSha256 } from '../../src/server/utils';
import { getMiddlewareSpanOptions } 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');
describe('getMiddlewareSpanOptions', () => {
it('returns correct span options', () => {
const options = getMiddlewareSpanOptions('testMiddleware');
expect(options).toEqual({
op: 'middleware.tanstackstart',
name: 'testMiddleware',
attributes: {
'sentry.op': 'middleware.tanstackstart',
'sentry.origin': 'auto.middleware.tanstackstart',
},
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('wrapFetchWithSentry', () => {
expect(flushIfServerlessSpy).toHaveBeenCalledTimes(1);
});

it('calls flushIfServerless after a server function request', async () => {
it('creates a function.tanstackstart span for server function requests', async () => {
const mockResponse = new Response('ok');
const fetchFn = vi.fn().mockResolvedValue(mockResponse);

Expand All @@ -59,7 +59,13 @@ describe('wrapFetchWithSentry', () => {

await serverEntry.fetch(request);

expect(startSpanSpy).toHaveBeenCalled();
expect(startSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
op: 'function.tanstackstart',
name: 'GET /_serverFn/abc123',
}),
expect.any(Function),
);
expect(flushIfServerlessSpy).toHaveBeenCalledTimes(1);
});

Expand Down
Loading