Skip to content

Commit dd57025

Browse files
authored
feat(nextjs): Include server action transaction in next tracing (#14048)
1 parent e77cb72 commit dd57025

File tree

2 files changed

+102
-78
lines changed

2 files changed

+102
-78
lines changed

dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,34 @@ test('Should send a transaction for instrumented server actions', async ({ page
7676
expect(Object.keys(transactionEvent.request?.headers || {}).length).toBeGreaterThan(0);
7777
});
7878

79+
test('Should send a wrapped server action as a child of a nextjs transaction', async ({ page }) => {
80+
const nextjsVersion = packageJson.dependencies.next;
81+
const nextjsMajor = Number(nextjsVersion.split('.')[0]);
82+
test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14');
83+
test.skip(process.env.TEST_ENV === 'development', 'this magically only works in production');
84+
85+
const nextjsPostTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => {
86+
return (
87+
transactionEvent?.transaction === 'POST /server-action' && transactionEvent.contexts?.trace?.origin === 'auto'
88+
);
89+
});
90+
91+
const serverActionTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => {
92+
return transactionEvent?.transaction === 'serverAction/myServerAction';
93+
});
94+
95+
await page.goto('/server-action');
96+
await page.getByText('Run Action').click();
97+
98+
const nextjsTransaction = await nextjsPostTransactionPromise;
99+
const serverActionTransaction = await serverActionTransactionPromise;
100+
101+
expect(nextjsTransaction).toBeDefined();
102+
expect(serverActionTransaction).toBeDefined();
103+
104+
expect(nextjsTransaction.contexts?.trace?.span_id).toBe(serverActionTransaction.contexts?.trace?.parent_span_id);
105+
});
106+
79107
test('Should set not_found status for server actions calling notFound()', async ({ page }) => {
80108
const nextjsVersion = packageJson.dependencies.next;
81109
const nextjsMajor = Number(nextjsVersion.split('.')[0]);

packages/nextjs/src/common/withServerActionInstrumentation.ts

Lines changed: 74 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { logger, vercelWaitUntil } from '@sentry/utils';
1414
import { DEBUG_BUILD } from './debug-build';
1515
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
1616
import { flushSafelyWithTimeout } from './utils/responseEnd';
17-
import { dropNextjsRootContext, escapeNextjsTracing } from './utils/tracingUtils';
1817

1918
interface Options {
2019
formData?: FormData;
@@ -68,89 +67,86 @@ async function withServerActionInstrumentationImplementation<A extends (...args:
6867
options: Options,
6968
callback: A,
7069
): Promise<ReturnType<A>> {
71-
dropNextjsRootContext();
72-
return escapeNextjsTracing(() => {
73-
return withIsolationScope(async isolationScope => {
74-
const sendDefaultPii = getClient()?.getOptions().sendDefaultPii;
70+
return withIsolationScope(async isolationScope => {
71+
const sendDefaultPii = getClient()?.getOptions().sendDefaultPii;
7572

76-
let sentryTraceHeader;
77-
let baggageHeader;
78-
const fullHeadersObject: Record<string, string> = {};
79-
try {
80-
const awaitedHeaders: Headers = await options.headers;
81-
sentryTraceHeader = awaitedHeaders?.get('sentry-trace') ?? undefined;
82-
baggageHeader = awaitedHeaders?.get('baggage');
83-
awaitedHeaders?.forEach((value, key) => {
84-
fullHeadersObject[key] = value;
85-
});
86-
} catch (e) {
87-
DEBUG_BUILD &&
88-
logger.warn(
89-
"Sentry wasn't able to extract the tracing headers for a server action. Will not trace this request.",
90-
);
91-
}
92-
93-
isolationScope.setTransactionName(`serverAction/${serverActionName}`);
94-
isolationScope.setSDKProcessingMetadata({
95-
request: {
96-
headers: fullHeadersObject,
97-
},
73+
let sentryTraceHeader;
74+
let baggageHeader;
75+
const fullHeadersObject: Record<string, string> = {};
76+
try {
77+
const awaitedHeaders: Headers = await options.headers;
78+
sentryTraceHeader = awaitedHeaders?.get('sentry-trace') ?? undefined;
79+
baggageHeader = awaitedHeaders?.get('baggage');
80+
awaitedHeaders?.forEach((value, key) => {
81+
fullHeadersObject[key] = value;
9882
});
83+
} catch (e) {
84+
DEBUG_BUILD &&
85+
logger.warn(
86+
"Sentry wasn't able to extract the tracing headers for a server action. Will not trace this request.",
87+
);
88+
}
9989

100-
return continueTrace(
101-
{
102-
sentryTrace: sentryTraceHeader,
103-
baggage: baggageHeader,
104-
},
105-
async () => {
106-
try {
107-
return await startSpan(
108-
{
109-
op: 'function.server_action',
110-
name: `serverAction/${serverActionName}`,
111-
forceTransaction: true,
112-
attributes: {
113-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
114-
},
115-
},
116-
async span => {
117-
const result = await handleCallbackErrors(callback, error => {
118-
if (isNotFoundNavigationError(error)) {
119-
// We don't want to report "not-found"s
120-
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
121-
} else if (isRedirectNavigationError(error)) {
122-
// Don't do anything for redirects
123-
} else {
124-
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
125-
captureException(error, {
126-
mechanism: {
127-
handled: false,
128-
},
129-
});
130-
}
131-
});
132-
133-
if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) {
134-
getIsolationScope().setExtra('server_action_result', result);
135-
}
90+
isolationScope.setTransactionName(`serverAction/${serverActionName}`);
91+
isolationScope.setSDKProcessingMetadata({
92+
request: {
93+
headers: fullHeadersObject,
94+
},
95+
});
13696

137-
if (options.formData) {
138-
options.formData.forEach((value, key) => {
139-
getIsolationScope().setExtra(
140-
`server_action_form_data.${key}`,
141-
typeof value === 'string' ? value : '[non-string value]',
142-
);
97+
return continueTrace(
98+
{
99+
sentryTrace: sentryTraceHeader,
100+
baggage: baggageHeader,
101+
},
102+
async () => {
103+
try {
104+
return await startSpan(
105+
{
106+
op: 'function.server_action',
107+
name: `serverAction/${serverActionName}`,
108+
forceTransaction: true,
109+
attributes: {
110+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
111+
},
112+
},
113+
async span => {
114+
const result = await handleCallbackErrors(callback, error => {
115+
if (isNotFoundNavigationError(error)) {
116+
// We don't want to report "not-found"s
117+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
118+
} else if (isRedirectNavigationError(error)) {
119+
// Don't do anything for redirects
120+
} else {
121+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
122+
captureException(error, {
123+
mechanism: {
124+
handled: false,
125+
},
143126
});
144127
}
128+
});
145129

146-
return result;
147-
},
148-
);
149-
} finally {
150-
vercelWaitUntil(flushSafelyWithTimeout());
151-
}
152-
},
153-
);
154-
});
130+
if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) {
131+
getIsolationScope().setExtra('server_action_result', result);
132+
}
133+
134+
if (options.formData) {
135+
options.formData.forEach((value, key) => {
136+
getIsolationScope().setExtra(
137+
`server_action_form_data.${key}`,
138+
typeof value === 'string' ? value : '[non-string value]',
139+
);
140+
});
141+
}
142+
143+
return result;
144+
},
145+
);
146+
} finally {
147+
vercelWaitUntil(flushSafelyWithTimeout());
148+
}
149+
},
150+
);
155151
});
156152
}

0 commit comments

Comments
 (0)