diff --git a/MIGRATION.md b/MIGRATION.md index 0bea28105dad..55be1d869f77 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1038,6 +1038,7 @@ The following span names were adjusted: | `function` (Ember route hooks) | The full route name | `slow-loading-route.index` | The hook the span wraps, matching its `code.function.name`. The route moves to `sentry.description` | `beforeModel`, `model`, `setupController` | | `function` (React Router route hooks) | The route the hook ran for, the raw URL path if React Router matched no pattern, or the fetcher key | `/users/:id`, `/users/123`, `Fetcher fetcher-1` | The hook the span wraps, matching its `code.function.name`. The previous name moves to `sentry.description` | `loader`, `action`, `clientLoader`, `fetcher` | | `function` (NestJS `@OnEvent` handlers) | The event the handler listens to, prefixed with `event ` | `event user.created` | The event the handler listens to, which is also its `code.function.name` | `user.created` | +| `function` (TanStack Start server functions) | The request method and the server function's request path, or its name once the middleware resolves it | `GET /_serverFn/abc123`, `GET /_serverFn/testLog` | The server function's name, or `serverFn` until the global function middleware resolves it | `testLog`, `serverFn` | | `function.gcp` | The request method and path for HTTP functions, otherwise the trigger's event or trigger type | `POST /users`, `google.pubsub.topic.publish`, `firebase.function.http.request` | The function name, or `Serverless function execution` if the SDK cannot resolve one | `myFunction`, `Serverless function execution` | | `function.aws` | The Lambda function name | `my-function` | Unchanged, except that the SDK now falls back to `Serverless function execution` if it cannot resolve the function name | `my-function`, `Serverless function execution` | | `graphql` | The graphql phase and, for operations, the operation name | `query GetUser`, `graphql.parse`, `graphql.resolve user.0.name` | The operation type, or the processing type where there is none | `GraphQL query`, `GraphQL parse`, `GraphQL resolve` | diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts index d6365da67be8..fcef57fdc2b3 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/tests/transaction.test.ts @@ -12,7 +12,12 @@ function isServerFnSegment(span: Parameters[0]): boolean { test('Sends a server function span with wrapFetchWithSentry', async ({ page }) => { const spansPromise = collectStreamedSpans( 'tanstackstart-react-cloudflare', - spans => spans.some(isServerFnSegment) && spans.some(span => span.name === 'GET /_serverFn/testLog'), + spans => + spans.some(isServerFnSegment) && + spans.some( + span => + span.name === 'testLog' && span.attributes['sentry.origin']?.value === 'auto.function.tanstackstart.server', + ), ); await page.goto('/test-serverFn'); @@ -32,10 +37,11 @@ test('Sends a server function span with wrapFetchWithSentry', async ({ page }) = expect(spans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'GET /_serverFn/testLog', + name: 'testLog', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'function' }, 'sentry.origin': { type: 'string', value: 'auto.function.tanstackstart.server' }, + 'sentry.description': { type: 'string', value: 'GET /_serverFn/testLog' }, 'tanstackstart.function.id': { type: 'string', value: expect.any(String) }, 'tanstackstart.function.filename': { type: 'string', value: 'src/routes/test-serverFn.tsx' }, }), @@ -49,7 +55,11 @@ test('Sends a server function span for a nested server function with manual span 'tanstackstart-react-cloudflare', spans => spans.some(isServerFnSegment) && - spans.some(span => span.name === 'GET /_serverFn/testNestedLog') && + spans.some( + span => + span.name === 'testNestedLog' && + span.attributes['sentry.origin']?.value === 'auto.function.tanstackstart.server', + ) && spans.some(span => span.name === 'testNestedLog'), ); @@ -70,10 +80,11 @@ test('Sends a server function span for a nested server function with manual span expect(spans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'GET /_serverFn/testNestedLog', + name: 'testNestedLog', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'function' }, 'sentry.origin': { type: 'string', value: 'auto.function.tanstackstart.server' }, + 'sentry.description': { type: 'string', value: 'GET /_serverFn/testNestedLog' }, 'tanstackstart.function.id': { type: 'string', value: expect.any(String) }, 'tanstackstart.function.filename': { type: 'string', value: 'src/routes/test-serverFn.tsx' }, }), diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/transaction.test.ts index ad8cd5e9dfa9..f13413b3077d 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-static/tests/transaction.test.ts @@ -33,6 +33,8 @@ test('Sends a server function transaction with auto-instrumentation', async ({ p data: { 'sentry.op': 'function', 'sentry.origin': 'auto.function.tanstackstart.server', + 'code.function.name': 'testLog', + 'http.request.method': 'GET', 'tanstackstart.function.id': expect.any(String), 'tanstackstart.function.filename': 'src/routes/test-serverFn.tsx', }, @@ -72,6 +74,8 @@ test('Sends a server function transaction for a nested server function only if i data: { 'sentry.op': 'function', 'sentry.origin': 'auto.function.tanstackstart.server', + 'code.function.name': 'testNestedLog', + 'http.request.method': 'GET', 'tanstackstart.function.id': expect.any(String), 'tanstackstart.function.filename': 'src/routes/test-serverFn.tsx', }, 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 index 8b4adf5f2ecc..47020c2ecbb7 100644 --- 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 @@ -17,7 +17,12 @@ function isServerFnSegment(span: Parameters[0]): boolean { test('Sends a server function span with auto-instrumentation', async ({ page }) => { const spansPromise = collectStreamedSpans( 'tanstackstart-react', - spans => spans.some(isServerFnSegment) && spans.some(span => span.name === 'GET /_serverFn/testLog'), + spans => + spans.some(isServerFnSegment) && + spans.some( + span => + span.name === 'testLog' && span.attributes['sentry.origin']?.value === 'auto.function.tanstackstart.server', + ), ); await page.goto('/test-serverFn'); @@ -31,11 +36,12 @@ test('Sends a server function span with auto-instrumentation', async ({ page }) expect(spans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'GET /_serverFn/testLog', + name: 'testLog', status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'function' }, 'sentry.origin': { type: 'string', value: 'auto.function.tanstackstart.server' }, + 'sentry.description': { type: 'string', value: 'GET /_serverFn/testLog' }, 'tanstackstart.function.filename': { type: 'string', value: 'src/routes/test-serverFn.tsx' }, }), }), @@ -50,7 +56,11 @@ test('Sends a server function span for a nested server function only if it is ma 'tanstackstart-react', spans => spans.some(isServerFnSegment) && - spans.some(span => span.name === 'GET /_serverFn/testNestedLog') && + spans.some( + span => + span.name === 'testNestedLog' && + span.attributes['sentry.origin']?.value === 'auto.function.tanstackstart.server', + ) && spans.some(span => span.name === 'testNestedLog') && spans.some(span => span.name === 'globalFunctionMiddleware'), ); @@ -66,11 +76,12 @@ test('Sends a server function span for a nested server function only if it is ma expect(spans).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'GET /_serverFn/testNestedLog', + name: 'testNestedLog', status: 'ok', attributes: expect.objectContaining({ 'sentry.op': { type: 'string', value: 'function' }, 'sentry.origin': { type: 'string', value: 'auto.function.tanstackstart.server' }, + 'sentry.description': { type: 'string', value: 'GET /_serverFn/testNestedLog' }, 'tanstackstart.function.filename': { type: 'string', value: 'src/routes/test-serverFn.tsx' }, }), }), diff --git a/packages/tanstackstart-react/src/server/globalMiddleware.ts b/packages/tanstackstart-react/src/server/globalMiddleware.ts index 394b750bb81f..9a232041d245 100644 --- a/packages/tanstackstart-react/src/server/globalMiddleware.ts +++ b/packages/tanstackstart-react/src/server/globalMiddleware.ts @@ -1,7 +1,21 @@ -import { addNonEnumerableProperty, captureException, getActiveSpan, spanToJSON, updateSpanName } from '@sentry/core'; +import { + addNonEnumerableProperty, + captureException, + getActiveSpan, + getClient, + hasSpanStreamingEnabled, + spanToJSON, + updateSpanName, +} from '@sentry/core'; import type { SentryGlobalFunctionMiddleware, SentryGlobalRequestMiddleware } from '../common/types'; import { SENTRY_INTERNAL } from './middleware'; -import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { + CODE_FUNCTION_NAME, + HTTP_REQUEST_METHOD, + SENTRY_DESCRIPTION, + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; type ServerFnMeta = { id?: string; @@ -34,8 +48,19 @@ function createSentryFunctionMiddlewareHandler(mechanismType: string) { const spanData = activeSpan ? spanToJSON(activeSpan) : undefined; if (activeSpan && spanData?.attributes[SENTRY_ORIGIN] === 'auto.function.tanstackstart.server') { if (serverFnMeta?.name) { - const method = spanData.name.split(' ')[0] || 'GET'; - updateSpanName(activeSpan, `${method} /_serverFn/${serverFnMeta.name}`); + // Read off the attribute rather than the span name, which is low cardinality with span streaming. + const method = (spanData.attributes[HTTP_REQUEST_METHOD] as string | undefined) || 'GET'; + const description = `${method} /_serverFn/${serverFnMeta.name}`; + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + + // With span streaming, a `function` span is named after the function it wraps. + updateSpanName(activeSpan, hasSpanStreaming ? serverFnMeta.name : description); + activeSpan.setAttribute(CODE_FUNCTION_NAME, serverFnMeta.name); + if (hasSpanStreaming) { + // Relay infers a `function` span's description from `code.function.name` alone. + activeSpan.setAttribute(SENTRY_DESCRIPTION, description); + } activeSpan.setAttribute(SENTRY_SEGMENT_NAME_SOURCE, 'route'); } if (serverFnMeta?.id) { diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index 935c03fb76e0..77029ebbf907 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -1,7 +1,7 @@ -import { getTraceMetaTags } from '@sentry/core'; +import { getClient, getTraceMetaTags, hasSpanStreamingEnabled } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import { captureException, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/node'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { CODE_FUNCTION_NAME, HTTP_REQUEST_METHOD, SENTRY_DESCRIPTION, SENTRY_OP } from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; import { updateSpanWithRouteParametrization } from './routeParametrization'; @@ -144,12 +144,24 @@ export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry { // instrument server functions if (url.pathname.includes('_serverFn') || url.pathname.includes('createServerFn')) { + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + const description = `${method} ${url.pathname}`; + return await startSpan( { - name: `${method} ${url.pathname}`, + // With span streaming, a `function` span is named after the function it wraps. The + // request path carries the generated server function id, which is high cardinality. + name: hasSpanStreaming ? 'serverFn' : description, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.tanstackstart.server', [SENTRY_OP]: FUNCTION, + [CODE_FUNCTION_NAME]: 'serverFn', + // The global function middleware renames this span and needs the method, which it + // can no longer read off a low-cardinality span name. + [HTTP_REQUEST_METHOD]: method, + // Relay infers a `function` span's description from `code.function.name` alone, which drops the path. + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: description }), }, }, async () => { diff --git a/packages/tanstackstart-react/test/server/globalMiddleware.test.ts b/packages/tanstackstart-react/test/server/globalMiddleware.test.ts index 2be10f01bea9..e0361e8afb33 100644 --- a/packages/tanstackstart-react/test/server/globalMiddleware.test.ts +++ b/packages/tanstackstart-react/test/server/globalMiddleware.test.ts @@ -1,12 +1,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const captureExceptionSpy = vi.fn(); +const updateSpanNameSpy = vi.fn(); +const getActiveSpanSpy = vi.fn<() => unknown>(() => undefined); +const spanToJSONSpy = vi.fn<() => { name: string; attributes: Record }>(); +// Span streaming is the default trace lifecycle; `undefined` stands for a not-yet-initialized SDK. +const getClientSpy = vi.fn<() => { getOptions: () => { traceLifecycle: string } } | undefined>(() => undefined); vi.mock('@sentry/core', async importOriginal => { const original = await importOriginal(); return { ...original, captureException: (...args: unknown[]) => captureExceptionSpy(...args), + getActiveSpan: () => getActiveSpanSpy(), + getClient: () => getClientSpy(), + spanToJSON: () => spanToJSONSpy(), + updateSpanName: (...args: unknown[]) => updateSpanNameSpy(...args), }; }); @@ -68,4 +77,46 @@ describe('sentryGlobalFunctionMiddleware', () => { it('has __SENTRY_INTERNAL__ flag set', () => { expect((sentryGlobalFunctionMiddleware as unknown as Record)['__SENTRY_INTERNAL__']).toBe(true); }); + + describe('server function span naming', () => { + const setUpActiveSpan = (): { setAttribute: ReturnType } => { + const span = { setAttribute: vi.fn() }; + getActiveSpanSpy.mockReturnValue(span); + spanToJSONSpy.mockReturnValue({ + name: 'GET /_serverFn/abc123', + attributes: { 'sentry.origin': 'auto.function.tanstackstart.server', 'http.request.method': 'GET' }, + }); + return span; + }; + + afterEach(() => { + getActiveSpanSpy.mockReturnValue(undefined); + getClientSpy.mockReturnValue(undefined); + }); + + it('names the span after the server function with span streaming', async () => { + const span = setUpActiveSpan(); + getClientSpy.mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }) }); + + const serverFn = sentryGlobalFunctionMiddleware.options.server!; + await serverFn({ next: vi.fn().mockResolvedValue('ok'), serverFnMeta: { name: 'testLog' } }); + + expect(updateSpanNameSpy).toHaveBeenCalledWith(span, 'testLog'); + expect(span.setAttribute).toHaveBeenCalledWith('code.function.name', 'testLog'); + expect(span.setAttribute).toHaveBeenCalledWith('sentry.description', 'GET /_serverFn/testLog'); + }); + + it('keeps the request path in the span name without span streaming', async () => { + const span = setUpActiveSpan(); + getClientSpy.mockReturnValue({ getOptions: () => ({ traceLifecycle: 'static' }) }); + + const serverFn = sentryGlobalFunctionMiddleware.options.server!; + await serverFn({ next: vi.fn().mockResolvedValue('ok'), serverFnMeta: { name: 'testLog' } }); + + expect(updateSpanNameSpy).toHaveBeenCalledWith(span, 'GET /_serverFn/testLog'); + // The resolved function name is useful in both lifecycles; only the description is a streaming workaround. + expect(span.setAttribute).toHaveBeenCalledWith('code.function.name', 'testLog'); + expect(span.setAttribute).not.toHaveBeenCalledWith('sentry.description', expect.anything()); + }); + }); }); diff --git a/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts b/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts index cb1a809cafb2..7e9234ab55da 100644 --- a/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts +++ b/packages/tanstackstart-react/test/server/wrapFetchWithSentry.test.ts @@ -20,11 +20,15 @@ const getTraceMetaTagsSpy = vi '', ); +// Span streaming is the default trace lifecycle; `undefined` stands for a not-yet-initialized SDK. +const getClientSpy = vi.fn<() => { getOptions: () => { traceLifecycle: string } } | undefined>(() => undefined); + vi.mock('@sentry/core', async importOriginal => { const original = await importOriginal(); return { ...original, getTraceMetaTags: () => getTraceMetaTagsSpy(), + getClient: () => getClientSpy(), }; }); @@ -42,6 +46,8 @@ const { wrapFetchWithSentry } = await import('../../src/server/wrapFetchWithSent describe('wrapFetchWithSentry', () => { afterEach(() => { vi.clearAllMocks(); + // `vi.clearAllMocks()` clears calls but not implementations, so this would leak into later tests. + getClientSpy.mockReturnValue(undefined); }); it('calls flushIfServerless after a regular request', async () => { @@ -78,6 +84,27 @@ describe('wrapFetchWithSentry', () => { expect(flushIfServerlessSpy).toHaveBeenCalledTimes(1); }); + it('keeps the server function path out of the span name with span streaming', async () => { + getClientSpy.mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }) }); + + const fetchFn = vi.fn().mockResolvedValue(new Response('ok')); + const serverEntry = wrapFetchWithSentry({ fetch: fetchFn }); + + await serverEntry.fetch(new Request('http://localhost:3000/_serverFn/abc123')); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'serverFn', + attributes: expect.objectContaining({ + 'sentry.op': 'function', + 'code.function.name': 'serverFn', + 'sentry.description': 'GET /_serverFn/abc123', + }), + }), + expect.any(Function), + ); + }); + it('injects meta tags into HTML responses', async () => { const mockResponse = new Response('', { headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),