Skip to content

feat(nextjs): Include server action transaction in next tracing #14048

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Oct 23, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,34 @@ test('Should send a transaction for instrumented server actions', async ({ page
expect(Object.keys(transactionEvent.request?.headers || {}).length).toBeGreaterThan(0);
});

test('Should send a wrapped server action as a child of a nextjs transaction', async ({ page }) => {
const nextjsVersion = packageJson.dependencies.next;
const nextjsMajor = Number(nextjsVersion.split('.')[0]);
test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14');
test.skip(process.env.TEST_ENV === 'development', 'this magically only works in production');

const nextjsPostTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => {
return (
transactionEvent?.transaction === 'POST /server-action' && transactionEvent.contexts?.trace?.origin === 'auto'
);
});

const serverActionTransactionPromise = waitForTransaction('nextjs-app-dir', async transactionEvent => {
return transactionEvent?.transaction === 'serverAction/myServerAction';
});

await page.goto('/server-action');
await page.getByText('Run Action').click();

const nextjsTransaction = await nextjsPostTransactionPromise;
const serverActionTransaction = await serverActionTransactionPromise;

expect(nextjsTransaction).toBeDefined();
expect(serverActionTransaction).toBeDefined();

expect(nextjsTransaction.contexts?.trace?.span_id).toBe(serverActionTransaction.contexts?.trace?.parent_span_id);
});

test('Should set not_found status for server actions calling notFound()', async ({ page }) => {
const nextjsVersion = packageJson.dependencies.next;
const nextjsMajor = Number(nextjsVersion.split('.')[0]);
Expand Down
152 changes: 74 additions & 78 deletions packages/nextjs/src/common/withServerActionInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { logger, vercelWaitUntil } from '@sentry/utils';
import { DEBUG_BUILD } from './debug-build';
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
import { flushSafelyWithTimeout } from './utils/responseEnd';
import { dropNextjsRootContext, escapeNextjsTracing } from './utils/tracingUtils';

interface Options {
formData?: FormData;
Expand Down Expand Up @@ -68,89 +67,86 @@ async function withServerActionInstrumentationImplementation<A extends (...args:
options: Options,
callback: A,
): Promise<ReturnType<A>> {
dropNextjsRootContext();
return escapeNextjsTracing(() => {
return withIsolationScope(async isolationScope => {
const sendDefaultPii = getClient()?.getOptions().sendDefaultPii;
return withIsolationScope(async isolationScope => {
const sendDefaultPii = getClient()?.getOptions().sendDefaultPii;

let sentryTraceHeader;
let baggageHeader;
const fullHeadersObject: Record<string, string> = {};
try {
const awaitedHeaders: Headers = await options.headers;
sentryTraceHeader = awaitedHeaders?.get('sentry-trace') ?? undefined;
baggageHeader = awaitedHeaders?.get('baggage');
awaitedHeaders?.forEach((value, key) => {
fullHeadersObject[key] = value;
});
} catch (e) {
DEBUG_BUILD &&
logger.warn(
"Sentry wasn't able to extract the tracing headers for a server action. Will not trace this request.",
);
}

isolationScope.setTransactionName(`serverAction/${serverActionName}`);
isolationScope.setSDKProcessingMetadata({
request: {
headers: fullHeadersObject,
},
let sentryTraceHeader;
let baggageHeader;
const fullHeadersObject: Record<string, string> = {};
try {
const awaitedHeaders: Headers = await options.headers;
sentryTraceHeader = awaitedHeaders?.get('sentry-trace') ?? undefined;
baggageHeader = awaitedHeaders?.get('baggage');
awaitedHeaders?.forEach((value, key) => {
fullHeadersObject[key] = value;
});
} catch (e) {
DEBUG_BUILD &&
logger.warn(
"Sentry wasn't able to extract the tracing headers for a server action. Will not trace this request.",
);
}

return continueTrace(
{
sentryTrace: sentryTraceHeader,
baggage: baggageHeader,
},
async () => {
try {
return await startSpan(
{
op: 'function.server_action',
name: `serverAction/${serverActionName}`,
forceTransaction: true,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (isNotFoundNavigationError(error)) {
// We don't want to report "not-found"s
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
} else if (isRedirectNavigationError(error)) {
// Don't do anything for redirects
} else {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
},
});
}
});

if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) {
getIsolationScope().setExtra('server_action_result', result);
}
isolationScope.setTransactionName(`serverAction/${serverActionName}`);
isolationScope.setSDKProcessingMetadata({
request: {
headers: fullHeadersObject,
},
});

if (options.formData) {
options.formData.forEach((value, key) => {
getIsolationScope().setExtra(
`server_action_form_data.${key}`,
typeof value === 'string' ? value : '[non-string value]',
);
return continueTrace(
{
sentryTrace: sentryTraceHeader,
baggage: baggageHeader,
},
async () => {
try {
return await startSpan(
{
op: 'function.server_action',
name: `serverAction/${serverActionName}`,
forceTransaction: true,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (isNotFoundNavigationError(error)) {
// We don't want to report "not-found"s
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
} else if (isRedirectNavigationError(error)) {
// Don't do anything for redirects
} else {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
},
});
}
});

return result;
},
);
} finally {
vercelWaitUntil(flushSafelyWithTimeout());
}
},
);
});
if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) {
getIsolationScope().setExtra('server_action_result', result);
}

if (options.formData) {
options.formData.forEach((value, key) => {
getIsolationScope().setExtra(
`server_action_form_data.${key}`,
typeof value === 'string' ? value : '[non-string value]',
);
});
}

return result;
},
);
} finally {
vercelWaitUntil(flushSafelyWithTimeout());
}
},
);
});
}
Loading