Skip to content

Commit 71af30f

Browse files
authored
feat(solidstart): Add server action instrumentation helper (#13035)
Can be used like this: ```js const getUserData = async () => { 'use server'; return await withServerActionInstrumentation('getData', () => { return { prefecture: 'Kanagawa' }; }); }; ``` Can also be used for api routes like this: ```js export async function GET() { return await withServerActionInstrumentation('getUser', () => { return json({ prefecture: 'Akita' }) }) } ```
1 parent 7dc6a25 commit 71af30f

16 files changed

+392
-7
lines changed

dev-packages/e2e-tests/test-applications/solidstart/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"This is currently not an issue outside of our repo. See: https://github.com/nksaraf/vinxi/issues/177"
1212
],
1313
"preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev",
14+
"start": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start",
1415
"test:prod": "TEST_ENV=production playwright test",
1516
"test:build": "pnpm install && npx playwright install && pnpm build",
1617
"test:assert": "pnpm test:prod"
@@ -31,7 +32,7 @@
3132
"jsdom": "^24.0.0",
3233
"solid-js": "1.8.17",
3334
"typescript": "^5.4.5",
34-
"vinxi": "^0.3.12",
35+
"vinxi": "^0.4.0",
3536
"vite": "^5.2.8",
3637
"vite-plugin-solid": "^2.10.2",
3738
"vitest": "^1.5.0"

dev-packages/e2e-tests/test-applications/solidstart/src/entry-client.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Sentry.init({
1212
tunnel: 'http://localhost:3031/', // proxy server
1313
// Performance Monitoring
1414
tracesSampleRate: 1.0, // Capture 100% of the transactions
15+
debug: !!import.meta.env.DEBUG,
1516
});
1617

1718
mount(() => <StartClient />, document.getElementById('app')!);

dev-packages/e2e-tests/test-applications/solidstart/src/instrument.server.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ Sentry.init({
55
environment: 'qa', // dynamic sampling bias to keep transactions
66
tracesSampleRate: 1.0, // Capture 100% of the transactions
77
tunnel: 'http://localhost:3031/', // proxy server
8+
debug: !!process.env.DEBUG,
89
});

dev-packages/e2e-tests/test-applications/solidstart/src/routes/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export default function Home() {
1111
<li>
1212
<A href="/client-error">Client error</A>
1313
</li>
14+
<li>
15+
<A href="/server-error">Server error</A>
16+
</li>
1417
<li>
1518
<A id="navLink" href="/users/5">
1619
User 5
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { withServerActionInstrumentation } from '@sentry/solidstart';
2+
import { createAsync } from '@solidjs/router';
3+
4+
const getPrefecture = async () => {
5+
'use server';
6+
return await withServerActionInstrumentation('getPrefecture', () => {
7+
throw new Error('Error thrown from Solid Start E2E test app server route');
8+
9+
return { prefecture: 'Kanagawa' };
10+
});
11+
};
12+
13+
export default function ServerErrorPage() {
14+
const data = createAsync(() => getPrefecture());
15+
16+
return <div>Prefecture: {data()?.prefecture}</div>;
17+
}
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1-
import { useParams } from '@solidjs/router';
1+
import { withServerActionInstrumentation } from '@sentry/solidstart';
2+
import { createAsync, useParams } from '@solidjs/router';
23

4+
const getPrefecture = async () => {
5+
'use server';
6+
return await withServerActionInstrumentation('getPrefecture', () => {
7+
return { prefecture: 'Ehime' };
8+
});
9+
};
310
export default function User() {
411
const params = useParams();
5-
return <div>User ID: {params.id}</div>;
12+
const userData = createAsync(() => getPrefecture());
13+
14+
return (
15+
<div>
16+
User ID: {params.id}
17+
<br />
18+
Prefecture: {userData()?.prefecture}
19+
</div>
20+
);
621
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
test.describe('server-side errors', () => {
5+
test('captures server action error', async ({ page }) => {
6+
const errorEventPromise = waitForError('solidstart', errorEvent => {
7+
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route';
8+
});
9+
10+
await page.goto(`/server-error`);
11+
12+
const error = await errorEventPromise;
13+
14+
expect(error.tags).toMatchObject({ runtime: 'node' });
15+
expect(error).toMatchObject({
16+
exception: {
17+
values: [
18+
{
19+
type: 'Error',
20+
value: 'Error thrown from Solid Start E2E test app server route',
21+
mechanism: {
22+
type: 'solidstart',
23+
handled: false,
24+
},
25+
},
26+
],
27+
},
28+
transaction: 'GET /server-error',
29+
});
30+
});
31+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
import {
4+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
5+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
6+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
7+
} from '@sentry/core';
8+
9+
test('sends a server action transaction on pageload', async ({ page }) => {
10+
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
11+
return transactionEvent?.transaction === 'GET /users/6';
12+
});
13+
14+
await page.goto('/users/6');
15+
16+
const transaction = await transactionPromise;
17+
18+
expect(transaction.spans).toEqual(
19+
expect.arrayContaining([
20+
expect.objectContaining({
21+
description: 'getPrefecture',
22+
data: {
23+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
24+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
25+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
26+
},
27+
}),
28+
]),
29+
);
30+
});
31+
32+
test('sends a server action transaction on client navigation', async ({ page }) => {
33+
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
34+
return transactionEvent?.transaction === 'POST getPrefecture';
35+
});
36+
37+
await page.goto('/');
38+
await page.locator('#navLink').click();
39+
await page.waitForURL('/users/5');
40+
41+
const transaction = await transactionPromise;
42+
43+
expect(transaction.spans).toEqual(
44+
expect.arrayContaining([
45+
expect.objectContaining({
46+
description: 'getPrefecture',
47+
data: {
48+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
49+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
50+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
51+
},
52+
}),
53+
]),
54+
);
55+
});

packages/solidstart/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"@sentry/solid": "8.20.0",
7373
"@sentry/types": "8.20.0",
7474
"@sentry/utils": "8.20.0",
75-
"@sentry/vite-plugin": "2.19.0"
75+
"@sentry/vite-plugin": "2.19.0",
76+
"@opentelemetry/instrumentation": "^0.52.1"
7677
},
7778
"devDependencies": {
7879
"@solidjs/router": "^0.13.4",

packages/solidstart/rollup.npm.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default makeNPMConfigVariants(
1616
// prevent this internal code from ending up in our built package (this doesn't happen automatially because
1717
// the name doesn't match an SDK dependency)
1818
packageSpecificConfig: {
19-
external: ['solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
19+
external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
2020
output: {
2121
dynamicImportInCjs: true,
2222
},

packages/solidstart/src/index.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// We export everything from both the client part of the SDK and from the server part.
2-
// Some of the exports collide, which is not allowed, unless we redifine the colliding
2+
// Some of the exports collide, which is not allowed, unless we redefine the colliding
33
// exports in this file - which we do below.
44
export * from './client';
55
export * from './server';

packages/solidstart/src/server/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,5 @@ export { withSentryErrorBoundary } from '@sentry/solid';
126126
// -------------------------
127127
// Solid Start SDK exports:
128128
export { init } from './sdk';
129+
130+
export * from './withServerActionInstrumentation';
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { flush } from '@sentry/node';
2+
import { logger } from '@sentry/utils';
3+
import { DEBUG_BUILD } from '../common/debug-build';
4+
5+
/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
6+
export async function flushIfServerless(): Promise<void> {
7+
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;
8+
9+
if (isServerless) {
10+
try {
11+
DEBUG_BUILD && logger.log('Flushing events...');
12+
await flush(2000);
13+
DEBUG_BUILD && logger.log('Done flushing events');
14+
} catch (e) {
15+
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
16+
}
17+
}
18+
}
19+
20+
/**
21+
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
22+
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
23+
* @param error the potential redirect error
24+
*/
25+
export function isRedirect(error: unknown): boolean {
26+
if (error == null || !(error instanceof Response)) {
27+
return false;
28+
}
29+
30+
const hasValidLocation = typeof error.headers.get('location') === 'string';
31+
const hasValidStatus = error.status >= 300 && error.status <= 308;
32+
return hasValidLocation && hasValidStatus;
33+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
2+
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node';
3+
import { flushIfServerless, isRedirect } from './utils';
4+
5+
/**
6+
* Wraps a server action (functions that use the 'use server' directive)
7+
* function body with Sentry Error and Performance instrumentation.
8+
*/
9+
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
10+
serverActionName: string,
11+
callback: A,
12+
): Promise<ReturnType<A>> {
13+
const activeSpan = getActiveSpan();
14+
15+
if (activeSpan) {
16+
const spanData = spanToJSON(activeSpan).data;
17+
18+
// In solid start, server function calls are made to `/_server` which doesn't tell us
19+
// a lot. We rewrite the span's route to be that of the sever action name but only
20+
// if the target is `/_server`, otherwise we'd overwrite pageloads on routes that use
21+
// server actions (which are more meaningful, e.g. a request to `GET /users/5` is more
22+
// meaningful than overwriting it with `GET doSomeFunctionCall`).
23+
if (spanData && !spanData['http.route'] && spanData['http.target'] === '/_server') {
24+
activeSpan.setAttribute('http.route', serverActionName);
25+
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component');
26+
}
27+
}
28+
29+
try {
30+
return await startSpan(
31+
{
32+
op: 'function.server_action',
33+
name: serverActionName,
34+
attributes: {
35+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
36+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
37+
},
38+
},
39+
async span => {
40+
const result = await handleCallbackErrors(callback, error => {
41+
if (!isRedirect(error)) {
42+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
43+
captureException(error, {
44+
mechanism: {
45+
handled: false,
46+
type: 'solidstart',
47+
},
48+
});
49+
}
50+
});
51+
52+
return result;
53+
},
54+
);
55+
} finally {
56+
await flushIfServerless();
57+
}
58+
}

0 commit comments

Comments
 (0)