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
1 change: 1 addition & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ function isServerFnSegment(span: Parameters<typeof getSpanOp>[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');
Expand All @@ -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' },
}),
Expand All @@ -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'),
);

Expand All @@ -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' },
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down Expand Up @@ -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',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ function isServerFnSegment(span: Parameters<typeof getSpanOp>[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');
Expand All @@ -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' },
}),
}),
Expand All @@ -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'),
);
Expand All @@ -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' },
}),
}),
Expand Down
33 changes: 29 additions & 4 deletions packages/tanstackstart-react/src/server/globalMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
Comment thread
cursor[bot] marked this conversation as resolved.
activeSpan.setAttribute(SENTRY_SEGMENT_NAME_SOURCE, 'route');
}
if (serverFnMeta?.id) {
Expand Down
18 changes: 15 additions & 3 deletions packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }>();
// 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),
};
});

Expand Down Expand Up @@ -68,4 +77,46 @@ describe('sentryGlobalFunctionMiddleware', () => {
it('has __SENTRY_INTERNAL__ flag set', () => {
expect((sentryGlobalFunctionMiddleware as unknown as Record<string, unknown>)['__SENTRY_INTERNAL__']).toBe(true);
});

describe('server function span naming', () => {
const setUpActiveSpan = (): { setAttribute: ReturnType<typeof vi.fn> } => {
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());
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ const getTraceMetaTagsSpy = vi
'<meta name="sentry-trace" content="abc123-def456-1"/><meta name="baggage" content="sentry-trace_id=abc123"/>',
);

// 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(),
};
});

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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('<head><meta charset="utf-8"/></head><body></body>', {
headers: new Headers({ 'content-type': 'text/html; charset=utf-8' }),
Expand Down
Loading