Skip to content

Commit 11e0c2d

Browse files
author
Luca Forstner
authored
feat(nextjs): Add instrumentation utility for server actions (#9553)
1 parent f1ed0e9 commit 11e0c2d

File tree

5 files changed

+191
-0
lines changed

5 files changed

+191
-0
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as Sentry from '@sentry/nextjs';
2+
import { headers } from 'next/headers';
3+
4+
export default function ServerComponent() {
5+
async function myServerAction(formData: FormData) {
6+
'use server';
7+
return await Sentry.withServerActionInstrumentation(
8+
'myServerAction',
9+
{ formData, headers: headers(), recordResponse: true },
10+
async () => {
11+
await fetch('http://example.com/');
12+
return { city: 'Vienna' };
13+
},
14+
);
15+
}
16+
17+
return (
18+
// @ts-ignore
19+
<form action={myServerAction}>
20+
<input type="text" defaultValue={'some-default-value'} name="some-text-value" />
21+
<button type="submit">Run Action</button>
22+
</form>
23+
);
24+
}

packages/e2e-tests/test-applications/nextjs-app-dir/next.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const { withSentryConfig } = require('@sentry/nextjs');
88
const moduleExports = {
99
experimental: {
1010
appDir: true,
11+
serverActions: true,
1112
},
1213
};
1314

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { test, expect } from '@playwright/test';
22
import { waitForTransaction } from '../event-proxy-server';
33
import axios, { AxiosError } from 'axios';
44

5+
const packageJson = require('../package.json');
6+
57
const authToken = process.env.E2E_TEST_AUTH_TOKEN;
68
const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG;
79
const sentryTestProject = process.env.E2E_TEST_SENTRY_TEST_PROJECT;
@@ -112,3 +114,26 @@ if (process.env.TEST_ENV === 'production') {
112114
expect((await serverComponentTransactionPromise).contexts?.trace?.status).toBe('not_found');
113115
});
114116
}
117+
118+
test('Should send a transaction for instrumented server actions', async ({ page }) => {
119+
const nextjsVersion = packageJson.dependencies.next;
120+
const nextjsMajor = Number(nextjsVersion.split('.')[0]);
121+
test.skip(!isNaN(nextjsMajor) && nextjsMajor < 14, 'only applies to nextjs apps >= version 14');
122+
123+
const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
124+
return transactionEvent?.transaction === 'serverAction/myServerAction';
125+
});
126+
127+
await page.goto('/server-action');
128+
await page.getByText('Run Action').click();
129+
130+
expect(await serverComponentTransactionPromise).toBeDefined();
131+
expect((await serverComponentTransactionPromise).contexts?.trace?.data?.['server_action_form_data']).toEqual(
132+
expect.objectContaining({ 'some-text-value': 'some-default-value' }),
133+
);
134+
expect((await serverComponentTransactionPromise).contexts?.trace?.data?.['server_action_result']).toEqual({
135+
city: 'Vienna',
136+
});
137+
138+
expect(Object.keys((await serverComponentTransactionPromise).request?.headers || {}).length).toBeGreaterThan(0);
139+
});

packages/nextjs/src/common/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,5 @@ export { wrapApiHandlerWithSentryVercelCrons } from './wrapApiHandlerWithSentryV
4343
export { wrapMiddlewareWithSentry } from './wrapMiddlewareWithSentry';
4444

4545
export { wrapPageComponentWithSentry } from './wrapPageComponentWithSentry';
46+
47+
export { withServerActionInstrumentation } from './withServerActionInstrumentation';
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core';
2+
import { addExceptionMechanism, logger, tracingContextFromHeaders } from '@sentry/utils';
3+
4+
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';
5+
6+
interface Options {
7+
formData?: FormData;
8+
// TODO: Whenever we decide to drop support for Next.js <= 12 we can automatically pick up the headers becauase "next/headers" will be resolvable.
9+
headers?: Headers;
10+
recordResponse?: boolean;
11+
}
12+
13+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
14+
export function withServerActionInstrumentation<A extends (...args: any[]) => any>(
15+
serverActionName: string,
16+
callback: A,
17+
): Promise<ReturnType<A>>;
18+
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
export function withServerActionInstrumentation<A extends (...args: any[]) => any>(
21+
serverActionName: string,
22+
options: Options,
23+
callback: A,
24+
): Promise<ReturnType<A>>;
25+
26+
/**
27+
* Wraps a Next.js Server Action implementation with Sentry Error and Performance instrumentation.
28+
*/
29+
export function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
30+
...args: [string, Options, A] | [string, A]
31+
): Promise<ReturnType<A>> {
32+
if (typeof args[1] === 'function') {
33+
const [serverActionName, callback] = args;
34+
return withServerActionInstrumentationImplementation(serverActionName, {}, callback);
35+
} else {
36+
const [serverActionName, options, callback] = args;
37+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
38+
return withServerActionInstrumentationImplementation(serverActionName, options, callback!);
39+
}
40+
}
41+
42+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
43+
async function withServerActionInstrumentationImplementation<A extends (...args: any[]) => any>(
44+
serverActionName: string,
45+
options: Options,
46+
callback: A,
47+
): Promise<ReturnType<A>> {
48+
addTracingExtensions();
49+
return runWithAsyncContext(async () => {
50+
const hub = getCurrentHub();
51+
const sendDefaultPii = hub.getClient()?.getOptions().sendDefaultPii;
52+
53+
let sentryTraceHeader;
54+
let baggageHeader;
55+
const fullHeadersObject: Record<string, string> = {};
56+
try {
57+
sentryTraceHeader = options.headers?.get('sentry-trace') ?? undefined;
58+
baggageHeader = options.headers?.get('baggage');
59+
options.headers?.forEach((value, key) => {
60+
fullHeadersObject[key] = value;
61+
});
62+
} catch (e) {
63+
__DEBUG_BUILD__ &&
64+
logger.warn(
65+
"Sentry wasn't able to extract the tracing headers for a server action. Will not trace this request.",
66+
);
67+
}
68+
69+
const currentScope = hub.getScope();
70+
const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
71+
sentryTraceHeader,
72+
baggageHeader,
73+
);
74+
currentScope.setPropagationContext(propagationContext);
75+
76+
let res;
77+
try {
78+
res = await trace(
79+
{
80+
op: 'function.server_action',
81+
name: `serverAction/${serverActionName}`,
82+
status: 'ok',
83+
...traceparentData,
84+
metadata: {
85+
source: 'route',
86+
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
87+
request: {
88+
headers: fullHeadersObject,
89+
},
90+
},
91+
},
92+
async span => {
93+
const result = await callback();
94+
95+
if (options.recordResponse !== undefined ? options.recordResponse : sendDefaultPii) {
96+
span?.setData('server_action_result', result);
97+
}
98+
99+
if (options.formData) {
100+
const formDataObject: Record<string, unknown> = {};
101+
options.formData.forEach((value, key) => {
102+
if (typeof value === 'string') {
103+
formDataObject[key] = value;
104+
} else {
105+
formDataObject[key] = '[non-string value]';
106+
}
107+
});
108+
span?.setData('server_action_form_data', formDataObject);
109+
}
110+
111+
return result;
112+
},
113+
error => {
114+
captureException(error, scope => {
115+
scope.addEventProcessor(event => {
116+
addExceptionMechanism(event, {
117+
handled: false,
118+
});
119+
return event;
120+
});
121+
122+
return scope;
123+
});
124+
},
125+
);
126+
} finally {
127+
if (!platformSupportsStreaming()) {
128+
// Lambdas require manual flushing to prevent execution freeze before the event is sent
129+
await flush(1000);
130+
}
131+
132+
if (process.env.NEXT_RUNTIME === 'edge') {
133+
void flush();
134+
}
135+
}
136+
137+
return res;
138+
});
139+
}

0 commit comments

Comments
 (0)