Skip to content

Commit 281463d

Browse files
committed
feat(solidstart): Add server action instrumentation helper
1 parent eb23dc4 commit 281463d

14 files changed

+430
-5
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: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
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+
Prefecture: {userData()?.prefecture}
18+
</div>
19+
);
620
}
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: 'getPrefecture',
29+
});
30+
});
31+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('sends a server action transaction', async ({ page }) => {
5+
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
6+
return transactionEvent?.transaction === 'getPrefecture';
7+
});
8+
9+
await page.goto('/users/6');
10+
11+
const transaction = await transactionPromise;
12+
13+
expect(transaction).toMatchObject({
14+
transaction: 'getPrefecture',
15+
tags: { runtime: 'node' },
16+
transaction_info: { source: 'url' },
17+
type: 'transaction',
18+
contexts: {
19+
trace: {
20+
op: 'function.server_action',
21+
origin: 'manual',
22+
},
23+
},
24+
});
25+
});

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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { flush } from '@sentry/node';
2+
import { logger } from '@sentry/utils';
3+
import type { RequestEvent } from 'solid-js/web';
4+
import { DEBUG_BUILD } from '../common/debug-build';
5+
6+
/**
7+
* Takes a request event and extracts traceparent and DSC data
8+
* from the `sentry-trace` and `baggage` DSC headers.
9+
*/
10+
export function getTracePropagationData(event: RequestEvent | undefined): {
11+
sentryTrace: string | undefined;
12+
baggage: string | null;
13+
} {
14+
const request = event && event.request;
15+
const headers = request && request.headers;
16+
const sentryTrace = (headers && headers.get('sentry-trace')) || undefined;
17+
const baggage = (headers && headers.get('baggage')) || null;
18+
19+
return { sentryTrace, baggage };
20+
}
21+
22+
/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
23+
export async function flushIfServerless(): Promise<void> {
24+
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;
25+
26+
if (isServerless) {
27+
try {
28+
DEBUG_BUILD && logger.log('Flushing events...');
29+
await flush(2000);
30+
DEBUG_BUILD && logger.log('Done flushing events');
31+
} catch (e) {
32+
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
33+
}
34+
}
35+
}
36+
37+
/**
38+
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
39+
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
40+
* @param error the potential redirect error
41+
*/
42+
export function isRedirect(error: unknown): boolean {
43+
if (error == null || !(error instanceof Response)) {
44+
return false;
45+
}
46+
47+
const hasValidLocation = typeof error.headers.get('location') === 'string';
48+
const hasValidStatus = error.status >= 300 && error.status <= 308;
49+
return hasValidLocation && hasValidStatus;
50+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
2+
import {
3+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
4+
captureException,
5+
continueTrace,
6+
startSpan,
7+
withIsolationScope,
8+
} from '@sentry/node';
9+
import { winterCGRequestToRequestData } from '@sentry/utils';
10+
import { getRequestEvent } from 'solid-js/web';
11+
import { flushIfServerless, getTracePropagationData, isRedirect } from './utils';
12+
13+
/**
14+
* Wraps a server action (functions that use the 'use server' directive) function body with Sentry Error and Performance instrumentation.
15+
*/
16+
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
17+
serverActionName: string,
18+
callback: A,
19+
): Promise<ReturnType<A>> {
20+
return withIsolationScope(isolationScope => {
21+
const event = getRequestEvent();
22+
23+
if (event && event.request) {
24+
isolationScope.setSDKProcessingMetadata({ request: winterCGRequestToRequestData(event.request) });
25+
}
26+
isolationScope.setTransactionName(serverActionName);
27+
28+
return continueTrace(getTracePropagationData(event), () => instrumentServerAction(serverActionName, callback));
29+
});
30+
}
31+
32+
async function instrumentServerAction<A extends (...args: unknown[]) => unknown>(
33+
name: string,
34+
callback: A,
35+
): Promise<ReturnType<A>> {
36+
try {
37+
return await startSpan(
38+
{
39+
op: 'function.server_action',
40+
name,
41+
forceTransaction: true,
42+
attributes: {
43+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
44+
},
45+
},
46+
async span => {
47+
const result = await handleCallbackErrors(callback, error => {
48+
if (!isRedirect(error)) {
49+
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
50+
captureException(error, {
51+
mechanism: {
52+
handled: false,
53+
type: 'solidstart',
54+
},
55+
});
56+
}
57+
});
58+
59+
return result;
60+
},
61+
);
62+
} finally {
63+
await flushIfServerless();
64+
}
65+
}

0 commit comments

Comments
 (0)