diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts index a8645550bbc2..eadfbf084d32 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts @@ -51,7 +51,7 @@ test('Should send a span for instrumented server actions', async ({ page }) => { test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14'); const serverActionSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => { - return span.name === 'serverAction/myServerAction' && span.is_segment; + return span.name === 'myServerAction' && span.is_segment; }); await page.goto('/server-action'); @@ -60,6 +60,13 @@ test('Should send a span for instrumented server actions', async ({ page }) => { const span = await serverActionSpanPromise; expect(span).toBeDefined(); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.nextjs.server_action', type: 'string' }, + 'sentry.description': { value: 'serverAction/myServerAction', type: 'string' }, + 'code.function.name': { value: 'myServerAction', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + }); }); test('Should send a wrapped server action as a child of a nextjs span', async ({ page }) => { @@ -70,14 +77,14 @@ test('Should send a wrapped server action as a child of a nextjs span', async ({ test.skip(isDevMode, 'this magically only works in production'); // Both spans must come from the same trace. Other specs on this page produce identically shaped - // `POST /server-action` and `serverAction/myServerAction` spans, so requiring both within one + // `POST /server-action` and `myServerAction` spans, so requiring both within one // `collectStreamedSpans` - which evaluates a single trace at a time - keeps them paired. const spansPromise = collectStreamedSpans('nextjs-app-dir', spans => { return ( spans.some( span => span.name === 'POST /server-action' && span.is_segment && span.attributes['sentry.origin']?.value === 'auto', - ) && spans.some(span => span.name === 'serverAction/myServerAction' && span.is_segment) + ) && spans.some(span => span.name === 'myServerAction' && span.is_segment) ); }); @@ -89,7 +96,7 @@ test('Should send a wrapped server action as a child of a nextjs span', async ({ span => span.name === 'POST /server-action' && span.is_segment && span.attributes['sentry.origin']?.value === 'auto', )!; - const serverActionSpan = spans.find(span => span.name === 'serverAction/myServerAction' && span.is_segment)!; + const serverActionSpan = spans.find(span => span.name === 'myServerAction' && span.is_segment)!; expect(nextjsSpan).toBeDefined(); expect(serverActionSpan).toBeDefined(); @@ -103,7 +110,7 @@ test('Should set not_found status for server actions calling notFound()', async test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14'); const serverActionSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => { - return span.name === 'serverAction/notFoundServerAction' && span.is_segment; + return span.name === 'notFoundServerAction' && span.is_segment; }); await page.goto('/server-action'); @@ -123,7 +130,7 @@ test('Should not capture "NEXT_REDIRECT" control-flow errors for server actions test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14'); const serverActionSpanPromise = waitForStreamedSpan('nextjs-app-dir', span => { - return span.name === 'serverAction/redirectServerAction' && span.is_segment; + return span.name === 'redirectServerAction' && span.is_segment; }); let controlFlowErrorCaptured = false; diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index fb32aaf6ca90..29b2ac5c757a 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -7,6 +7,7 @@ import { getClient, getIsolationScope, handleCallbackErrors, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, @@ -20,7 +21,13 @@ import { isPrerenderControlFlowError, isRedirectNavigationError, } from './nextNavigationErrorUtils'; -import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_KIND, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + CODE_FUNCTION_NAME, + SENTRY_DESCRIPTION, + SENTRY_KIND, + SENTRY_OP, + SENTRY_SEGMENT_NAME_SOURCE, +} from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; interface Options { @@ -115,14 +122,21 @@ async function withServerActionInstrumentationImplementation { try { + const client = getClient(); + const description = `serverAction/${serverActionName}`; + return await startSpan( { - name: `serverAction/${serverActionName}`, + name: client && hasSpanStreamingEnabled(client) ? serverActionName : description, // oxlint-disable-next-line typescript/no-deprecated forceTransaction: true, attributes: { [SENTRY_KIND]: 'server', [SENTRY_OP]: FUNCTION, + // Overriding span description inference in Relay, because we can't express the serverAction/ prefix + // as a sentry-conventions span name rule for `function` spans. + [SENTRY_DESCRIPTION]: description, + [CODE_FUNCTION_NAME]: serverActionName, [SENTRY_SEGMENT_NAME_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs.server_action', }, diff --git a/packages/nextjs/test/common/withServerActionInstrumentation.test.ts b/packages/nextjs/test/common/withServerActionInstrumentation.test.ts new file mode 100644 index 000000000000..d10f3c9e3dfe --- /dev/null +++ b/packages/nextjs/test/common/withServerActionInstrumentation.test.ts @@ -0,0 +1,63 @@ +import type { Client, Span, StartSpanOptions } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { withServerActionInstrumentation } from '../../src/common/withServerActionInstrumentation'; + +function mockClient(traceLifecycle: 'stream' | 'static'): void { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ traceLifecycle }), + getDataCollectionOptions: () => ({ httpBodies: [] }), + } as unknown as Client); +} + +function mockStartSpan() { + return vi + .spyOn(SentryCore, 'startSpan') + .mockImplementation((_options: StartSpanOptions, callback: (span: Span) => T): T => + callback({ setStatus: vi.fn(), end: vi.fn() } as unknown as Span), + ); +} + +describe('withServerActionInstrumentation', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses the bare server action name as span name when span streaming is enabled', async () => { + mockClient('stream'); + const startSpan = mockStartSpan(); + + await withServerActionInstrumentation('myServerAction', {}, () => 'result'); + + expect(startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'myServerAction', + attributes: expect.objectContaining({ + 'sentry.description': 'serverAction/myServerAction', + 'code.function.name': 'myServerAction', + 'sentry.op': 'function', + 'sentry.origin': 'auto.function.nextjs.server_action', + }), + }), + expect.any(Function), + ); + }); + + it('keeps the prefixed span name when span streaming is disabled', async () => { + mockClient('static'); + const startSpan = mockStartSpan(); + + await withServerActionInstrumentation('myServerAction', {}, () => 'result'); + + expect(startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'serverAction/myServerAction', + attributes: expect.objectContaining({ + 'sentry.description': 'serverAction/myServerAction', + 'code.function.name': 'myServerAction', + }), + }), + expect.any(Function), + ); + }); +});