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 numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
"This is currently not an issue outside of our repo. See: https://github.com/nksaraf/vinxi/issues/177"
],
"preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev",
"start": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start",
"test:prod": "TEST_ENV=production playwright test",
"test:build": "pnpm install && npx playwright install && pnpm build",
"test:assert": "pnpm test:prod"
Expand All@@ -31,7 +32,7 @@
"jsdom": "^24.0.0",
"solid-js": "1.8.17",
"typescript": "^5.4.5",
"vinxi": "^0.3.12",
"vinxi": "^0.4.0",
"vite": "^5.2.8",
"vite-plugin-solid": "^2.10.2",
"vitest": "^1.5.0"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ Sentry.init({
tunnel: 'http://localhost:3031/', // proxy server
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
debug: !!import.meta.env.DEBUG,
});

mount(() => <StartClient />, document.getElementById('app')!);
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,4 +5,5 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0, // Capture 100% of the transactions
tunnel: 'http://localhost:3031/', // proxy server
debug: !!process.env.DEBUG,
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ export default function Home() {
<li>
<A href="/client-error">Client error</A>
</li>
<li>
<A href="/server-error">Server error</A>
</li>
<li>
<A id="navLink" href="/users/5">
User 5
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
throw new Error('Error thrown from Solid Start E2E test app server route');

return { prefecture: 'Kanagawa' };
});
};

export default function ServerErrorPage() {
const data = createAsync(() => getPrefecture());

return <div>Prefecture: {data()?.prefecture}</div>;
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
import { useParams } from '@solidjs/router';
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync, useParams } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
return { prefecture: 'Ehime' };
});
};
export default function User() {
const params = useParams();
return <div>User ID: {params.id}</div>;
const userData = createAsync(() => getPrefecture());

return (
<div>
User ID: {params.id}
<br />
Prefecture: {userData()?.prefecture}
</div>
);
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('server-side errors', () => {
test('captures server action error', async ({ page }) => {
const errorEventPromise = waitForError('solidstart', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route';
});

await page.goto(`/server-error`);

const error = await errorEventPromise;

expect(error.tags).toMatchObject({ runtime: 'node' });
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid Start E2E test app server route',
mechanism: {
type: 'solidstart',
handled: false,
},
},
],
},
transaction: 'GET /server-error',
});
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';

test('sends a server action transaction on pageload', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'GET /users/6';
});

await page.goto('/users/6');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});

test('sends a server action transaction on client navigation', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'POST getPrefecture';
});

await page.goto('/');
await page.locator('#navLink').click();
await page.waitForURL('/users/5');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});
3 changes: 2 additions & 1 deletion packages/solidstart/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"@sentry/solid": "8.20.0",
"@sentry/types": "8.20.0",
"@sentry/utils": "8.20.0",
"@sentry/vite-plugin": "2.19.0"
"@sentry/vite-plugin": "2.19.0",
"@opentelemetry/instrumentation": "^0.52.1"
},
"devDependencies": {
"@solidjs/router": "^0.13.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/rollup.npm.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ export default makeNPMConfigVariants(
// prevent this internal code from ending up in our built package (this doesn't happen automatially because
// the name doesn't match an SDK dependency)
packageSpecificConfig: {
external: ['solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
output: {
dynamicImportInCjs: true,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/src/index.types.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
// We export everything from both the client part of the SDK and from the server part.
// Some of the exports collide, which is not allowed, unless we redifine the colliding
// Some of the exports collide, which is not allowed, unless we redefine the colliding
// exports in this file - which we do below.
export * from './client';
export * from './server';
Expand Down
2 changes: 2 additions & 0 deletions packages/solidstart/src/server/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,3 +126,5 @@ export { withSentryErrorBoundary } from '@sentry/solid';
// -------------------------
// Solid Start SDK exports:
export { init } from './sdk';

export * from './withServerActionInstrumentation';
33 changes: 33 additions & 0 deletions packages/solidstart/src/server/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import { flush } from '@sentry/node';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../common/debug-build';

/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
export async function flushIfServerless(): Promise<void> {
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;

if (isServerless) {
try {
DEBUG_BUILD && logger.log('Flushing events...');
await flush(2000);
DEBUG_BUILD && logger.log('Done flushing events');
} catch (e) {
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
}
}
}

/**
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
* @param error the potential redirect error
*/
export function isRedirect(error: unknown): boolean {
if (error == null || !(error instanceof Response)) {
return false;
}

const hasValidLocation = typeof error.headers.get('location') === 'string';
const hasValidStatus = error.status >= 300 && error.status <= 308;
return hasValidLocation && hasValidStatus;
Comment on lines +30 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity/no action required: the status code makes total sense to me. Why is thelocation header important?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose people might set some weird status codes on errors that aren't redirects and we wouldn't want to ignore those? Not sure tbh, no strong feelings on removing this check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nevermind, I read up on the location header. Makes sense to me!

}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node';
import { flushIfServerless, isRedirect } from './utils';

/**
* Wraps a server action (functions that use the 'use server' directive)
* function body with Sentry Error and Performance instrumentation.
*/
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
serverActionName: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way to infer the name, possibly? That would be ideal... if not, this is fine for now!

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so. Accessing a parent's function name isn't available in strict mode, also it wouldn't work for minifed code either. Unfortunately I think we have to live with this :(

callback: A,
): Promise<ReturnType<A>> {
const activeSpan = getActiveSpan();

if (activeSpan) {
const spanData = spanToJSON(activeSpan).data;

// In solid start, server function calls are made to `/_server` which doesn't tell us
// a lot. We rewrite the span's route to be that of the sever action name but only
// if the target is `/_server`, otherwise we'd overwrite pageloads on routes that use
// server actions (which are more meaningful, e.g. a request to `GET /users/5` is more
// meaningful than overwriting it with `GET doSomeFunctionCall`).
if (spanData && !spanData['http.route'] && spanData['http.target'] === '/_server') {
activeSpan.setAttribute('http.route', serverActionName);
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component');
}
}

try {
return await startSpan(
{
op: 'function.server_action',
name: serverActionName,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (!isRedirect(error)) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
type: 'solidstart',
},
});
}
});

return result;
},
);
} finally {
await flushIfServerless();
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
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 numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
"This is currently not an issue outside of our repo. See: https://github.com/nksaraf/vinxi/issues/177"
],
"preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev",
"start": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start",
"test:prod": "TEST_ENV=production playwright test",
"test:build": "pnpm install && npx playwright install && pnpm build",
"test:assert": "pnpm test:prod"
Expand All@@ -31,7 +32,7 @@
"jsdom": "^24.0.0",
"solid-js": "1.8.17",
"typescript": "^5.4.5",
"vinxi": "^0.3.12",
"vinxi": "^0.4.0",
"vite": "^5.2.8",
"vite-plugin-solid": "^2.10.2",
"vitest": "^1.5.0"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ Sentry.init({
tunnel: 'http://localhost:3031/', // proxy server
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
debug: !!import.meta.env.DEBUG,
});

mount(() => <StartClient />, document.getElementById('app')!);
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,4 +5,5 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0, // Capture 100% of the transactions
tunnel: 'http://localhost:3031/', // proxy server
debug: !!process.env.DEBUG,
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ export default function Home() {
<li>
<A href="/client-error">Client error</A>
</li>
<li>
<A href="/server-error">Server error</A>
</li>
<li>
<A id="navLink" href="/users/5">
User 5
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
throw new Error('Error thrown from Solid Start E2E test app server route');

return { prefecture: 'Kanagawa' };
});
};

export default function ServerErrorPage() {
const data = createAsync(() => getPrefecture());

return <div>Prefecture: {data()?.prefecture}</div>;
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
import { useParams } from '@solidjs/router';
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync, useParams } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
return { prefecture: 'Ehime' };
});
};
export default function User() {
const params = useParams();
return <div>User ID: {params.id}</div>;
const userData = createAsync(() => getPrefecture());

return (
<div>
User ID: {params.id}
<br />
Prefecture: {userData()?.prefecture}
</div>
);
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('server-side errors', () => {
test('captures server action error', async ({ page }) => {
const errorEventPromise = waitForError('solidstart', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route';
});

await page.goto(`/server-error`);

const error = await errorEventPromise;

expect(error.tags).toMatchObject({ runtime: 'node' });
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid Start E2E test app server route',
mechanism: {
type: 'solidstart',
handled: false,
},
},
],
},
transaction: 'GET /server-error',
});
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';

test('sends a server action transaction on pageload', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'GET /users/6';
});

await page.goto('/users/6');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});

test('sends a server action transaction on client navigation', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'POST getPrefecture';
});

await page.goto('/');
await page.locator('#navLink').click();
await page.waitForURL('/users/5');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});
3 changes: 2 additions & 1 deletion packages/solidstart/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"@sentry/solid": "8.20.0",
"@sentry/types": "8.20.0",
"@sentry/utils": "8.20.0",
"@sentry/vite-plugin": "2.19.0"
"@sentry/vite-plugin": "2.19.0",
"@opentelemetry/instrumentation": "^0.52.1"
},
"devDependencies": {
"@solidjs/router": "^0.13.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/rollup.npm.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ export default makeNPMConfigVariants(
// prevent this internal code from ending up in our built package (this doesn't happen automatially because
// the name doesn't match an SDK dependency)
packageSpecificConfig: {
external: ['solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
output: {
dynamicImportInCjs: true,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/src/index.types.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
// We export everything from both the client part of the SDK and from the server part.
// Some of the exports collide, which is not allowed, unless we redifine the colliding
// Some of the exports collide, which is not allowed, unless we redefine the colliding
// exports in this file - which we do below.
export * from './client';
export * from './server';
Expand Down
2 changes: 2 additions & 0 deletions packages/solidstart/src/server/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,3 +126,5 @@ export { withSentryErrorBoundary } from '@sentry/solid';
// -------------------------
// Solid Start SDK exports:
export { init } from './sdk';

export * from './withServerActionInstrumentation';
33 changes: 33 additions & 0 deletions packages/solidstart/src/server/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import { flush } from '@sentry/node';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../common/debug-build';

/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
export async function flushIfServerless(): Promise<void> {
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;

if (isServerless) {
try {
DEBUG_BUILD && logger.log('Flushing events...');
await flush(2000);
DEBUG_BUILD && logger.log('Done flushing events');
} catch (e) {
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
}
}
}

/**
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
* @param error the potential redirect error
*/
export function isRedirect(error: unknown): boolean {
if (error == null || !(error instanceof Response)) {
return false;
}

const hasValidLocation = typeof error.headers.get('location') === 'string';
const hasValidStatus = error.status >= 300 && error.status <= 308;
return hasValidLocation && hasValidStatus;
Comment on lines +30 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity/no action required: the status code makes total sense to me. Why is thelocation header important?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose people might set some weird status codes on errors that aren't redirects and we wouldn't want to ignore those? Not sure tbh, no strong feelings on removing this check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nevermind, I read up on the location header. Makes sense to me!

}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node';
import { flushIfServerless, isRedirect } from './utils';

/**
* Wraps a server action (functions that use the 'use server' directive)
* function body with Sentry Error and Performance instrumentation.
*/
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
serverActionName: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way to infer the name, possibly? That would be ideal... if not, this is fine for now!

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so. Accessing a parent's function name isn't available in strict mode, also it wouldn't work for minifed code either. Unfortunately I think we have to live with this :(

callback: A,
): Promise<ReturnType<A>> {
const activeSpan = getActiveSpan();

if (activeSpan) {
const spanData = spanToJSON(activeSpan).data;

// In solid start, server function calls are made to `/_server` which doesn't tell us
// a lot. We rewrite the span's route to be that of the sever action name but only
// if the target is `/_server`, otherwise we'd overwrite pageloads on routes that use
// server actions (which are more meaningful, e.g. a request to `GET /users/5` is more
// meaningful than overwriting it with `GET doSomeFunctionCall`).
if (spanData && !spanData['http.route'] && spanData['http.target'] === '/_server') {
activeSpan.setAttribute('http.route', serverActionName);
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component');
}
}

try {
return await startSpan(
{
op: 'function.server_action',
name: serverActionName,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (!isRedirect(error)) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
type: 'solidstart',
},
});
}
});

return result;
},
);
} finally {
await flushIfServerless();
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
"This is currently not an issue outside of our repo. See: https://github.com/nksaraf/vinxi/issues/177"
],
"preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev",
"start": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start",
"test:prod": "TEST_ENV=production playwright test",
"test:build": "pnpm install && npx playwright install && pnpm build",
"test:assert": "pnpm test:prod"
Expand All@@ -31,7 +32,7 @@
"jsdom": "^24.0.0",
"solid-js": "1.8.17",
"typescript": "^5.4.5",
"vinxi": "^0.3.12",
"vinxi": "^0.4.0",
"vite": "^5.2.8",
"vite-plugin-solid": "^2.10.2",
"vitest": "^1.5.0"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ Sentry.init({
tunnel: 'http://localhost:3031/', // proxy server
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
debug: !!import.meta.env.DEBUG,
});

mount(() => <StartClient />, document.getElementById('app')!);
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,4 +5,5 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0, // Capture 100% of the transactions
tunnel: 'http://localhost:3031/', // proxy server
debug: !!process.env.DEBUG,
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ export default function Home() {
<li>
<A href="/client-error">Client error</A>
</li>
<li>
<A href="/server-error">Server error</A>
</li>
<li>
<A id="navLink" href="/users/5">
User 5
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
throw new Error('Error thrown from Solid Start E2E test app server route');

return { prefecture: 'Kanagawa' };
});
};

export default function ServerErrorPage() {
const data = createAsync(() => getPrefecture());

return <div>Prefecture: {data()?.prefecture}</div>;
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
import { useParams } from '@solidjs/router';
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync, useParams } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
return { prefecture: 'Ehime' };
});
};
export default function User() {
const params = useParams();
return <div>User ID: {params.id}</div>;
const userData = createAsync(() => getPrefecture());

return (
<div>
User ID: {params.id}
<br />
Prefecture: {userData()?.prefecture}
</div>
);
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('server-side errors', () => {
test('captures server action error', async ({ page }) => {
const errorEventPromise = waitForError('solidstart', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route';
});

await page.goto(`/server-error`);

const error = await errorEventPromise;

expect(error.tags).toMatchObject({ runtime: 'node' });
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid Start E2E test app server route',
mechanism: {
type: 'solidstart',
handled: false,
},
},
],
},
transaction: 'GET /server-error',
});
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';

test('sends a server action transaction on pageload', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'GET /users/6';
});

await page.goto('/users/6');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});

test('sends a server action transaction on client navigation', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'POST getPrefecture';
});

await page.goto('/');
await page.locator('#navLink').click();
await page.waitForURL('/users/5');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});
3 changes: 2 additions & 1 deletion packages/solidstart/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"@sentry/solid": "8.20.0",
"@sentry/types": "8.20.0",
"@sentry/utils": "8.20.0",
"@sentry/vite-plugin": "2.19.0"
"@sentry/vite-plugin": "2.19.0",
"@opentelemetry/instrumentation": "^0.52.1"
},
"devDependencies": {
"@solidjs/router": "^0.13.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/rollup.npm.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ export default makeNPMConfigVariants(
// prevent this internal code from ending up in our built package (this doesn't happen automatially because
// the name doesn't match an SDK dependency)
packageSpecificConfig: {
external: ['solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
output: {
dynamicImportInCjs: true,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/src/index.types.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
// We export everything from both the client part of the SDK and from the server part.
// Some of the exports collide, which is not allowed, unless we redifine the colliding
// Some of the exports collide, which is not allowed, unless we redefine the colliding
// exports in this file - which we do below.
export * from './client';
export * from './server';
Expand Down
2 changes: 2 additions & 0 deletions packages/solidstart/src/server/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,3 +126,5 @@ export { withSentryErrorBoundary } from '@sentry/solid';
// -------------------------
// Solid Start SDK exports:
export { init } from './sdk';

export * from './withServerActionInstrumentation';
33 changes: 33 additions & 0 deletions packages/solidstart/src/server/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import { flush } from '@sentry/node';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../common/debug-build';

/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
export async function flushIfServerless(): Promise<void> {
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;

if (isServerless) {
try {
DEBUG_BUILD && logger.log('Flushing events...');
await flush(2000);
DEBUG_BUILD && logger.log('Done flushing events');
} catch (e) {
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
}
}
}

/**
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
* @param error the potential redirect error
*/
export function isRedirect(error: unknown): boolean {
if (error == null || !(error instanceof Response)) {
return false;
}

const hasValidLocation = typeof error.headers.get('location') === 'string';
const hasValidStatus = error.status >= 300 && error.status <= 308;
return hasValidLocation && hasValidStatus;
Comment on lines +30 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity/no action required: the status code makes total sense to me. Why is thelocation header important?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose people might set some weird status codes on errors that aren't redirects and we wouldn't want to ignore those? Not sure tbh, no strong feelings on removing this check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nevermind, I read up on the location header. Makes sense to me!

}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node';
import { flushIfServerless, isRedirect } from './utils';

/**
* Wraps a server action (functions that use the 'use server' directive)
* function body with Sentry Error and Performance instrumentation.
*/
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
serverActionName: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way to infer the name, possibly? That would be ideal... if not, this is fine for now!

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so. Accessing a parent's function name isn't available in strict mode, also it wouldn't work for minifed code either. Unfortunately I think we have to live with this :(

callback: A,
): Promise<ReturnType<A>> {
const activeSpan = getActiveSpan();

if (activeSpan) {
const spanData = spanToJSON(activeSpan).data;

// In solid start, server function calls are made to `/_server` which doesn't tell us
// a lot. We rewrite the span's route to be that of the sever action name but only
// if the target is `/_server`, otherwise we'd overwrite pageloads on routes that use
// server actions (which are more meaningful, e.g. a request to `GET /users/5` is more
// meaningful than overwriting it with `GET doSomeFunctionCall`).
if (spanData && !spanData['http.route'] && spanData['http.target'] === '/_server') {
activeSpan.setAttribute('http.route', serverActionName);
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component');
}
}

try {
return await startSpan(
{
op: 'function.server_action',
name: serverActionName,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (!isRedirect(error)) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
type: 'solidstart',
},
});
}
});

return result;
},
);
} finally {
await flushIfServerless();
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
"This is currently not an issue outside of our repo. See: https://github.com/nksaraf/vinxi/issues/177"
],
"preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev",
"start": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start",
"test:prod": "TEST_ENV=production playwright test",
"test:build": "pnpm install && npx playwright install && pnpm build",
"test:assert": "pnpm test:prod"
Expand All@@ -31,7 +32,7 @@
"jsdom": "^24.0.0",
"solid-js": "1.8.17",
"typescript": "^5.4.5",
"vinxi": "^0.3.12",
"vinxi": "^0.4.0",
"vite": "^5.2.8",
"vite-plugin-solid": "^2.10.2",
"vitest": "^1.5.0"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ Sentry.init({
tunnel: 'http://localhost:3031/', // proxy server
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
debug: !!import.meta.env.DEBUG,
});

mount(() => <StartClient />, document.getElementById('app')!);
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,4 +5,5 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0, // Capture 100% of the transactions
tunnel: 'http://localhost:3031/', // proxy server
debug: !!process.env.DEBUG,
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ export default function Home() {
<li>
<A href="/client-error">Client error</A>
</li>
<li>
<A href="/server-error">Server error</A>
</li>
<li>
<A id="navLink" href="/users/5">
User 5
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
throw new Error('Error thrown from Solid Start E2E test app server route');

return { prefecture: 'Kanagawa' };
});
};

export default function ServerErrorPage() {
const data = createAsync(() => getPrefecture());

return <div>Prefecture: {data()?.prefecture}</div>;
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
import { useParams } from '@solidjs/router';
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync, useParams } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
return { prefecture: 'Ehime' };
});
};
export default function User() {
const params = useParams();
return <div>User ID: {params.id}</div>;
const userData = createAsync(() => getPrefecture());

return (
<div>
User ID: {params.id}
<br />
Prefecture: {userData()?.prefecture}
</div>
);
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('server-side errors', () => {
test('captures server action error', async ({ page }) => {
const errorEventPromise = waitForError('solidstart', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route';
});

await page.goto(`/server-error`);

const error = await errorEventPromise;

expect(error.tags).toMatchObject({ runtime: 'node' });
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid Start E2E test app server route',
mechanism: {
type: 'solidstart',
handled: false,
},
},
],
},
transaction: 'GET /server-error',
});
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';

test('sends a server action transaction on pageload', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'GET /users/6';
});

await page.goto('/users/6');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});

test('sends a server action transaction on client navigation', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'POST getPrefecture';
});

await page.goto('/');
await page.locator('#navLink').click();
await page.waitForURL('/users/5');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});
3 changes: 2 additions & 1 deletion packages/solidstart/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"@sentry/solid": "8.20.0",
"@sentry/types": "8.20.0",
"@sentry/utils": "8.20.0",
"@sentry/vite-plugin": "2.19.0"
"@sentry/vite-plugin": "2.19.0",
"@opentelemetry/instrumentation": "^0.52.1"
},
"devDependencies": {
"@solidjs/router": "^0.13.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/rollup.npm.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ export default makeNPMConfigVariants(
// prevent this internal code from ending up in our built package (this doesn't happen automatially because
// the name doesn't match an SDK dependency)
packageSpecificConfig: {
external: ['solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
output: {
dynamicImportInCjs: true,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/src/index.types.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
// We export everything from both the client part of the SDK and from the server part.
// Some of the exports collide, which is not allowed, unless we redifine the colliding
// Some of the exports collide, which is not allowed, unless we redefine the colliding
// exports in this file - which we do below.
export * from './client';
export * from './server';
Expand Down
2 changes: 2 additions & 0 deletions packages/solidstart/src/server/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,3 +126,5 @@ export { withSentryErrorBoundary } from '@sentry/solid';
// -------------------------
// Solid Start SDK exports:
export { init } from './sdk';

export * from './withServerActionInstrumentation';
33 changes: 33 additions & 0 deletions packages/solidstart/src/server/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import { flush } from '@sentry/node';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../common/debug-build';

/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
export async function flushIfServerless(): Promise<void> {
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;

if (isServerless) {
try {
DEBUG_BUILD && logger.log('Flushing events...');
await flush(2000);
DEBUG_BUILD && logger.log('Done flushing events');
} catch (e) {
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
}
}
}

/**
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
* @param error the potential redirect error
*/
export function isRedirect(error: unknown): boolean {
if (error == null || !(error instanceof Response)) {
return false;
}

const hasValidLocation = typeof error.headers.get('location') === 'string';
const hasValidStatus = error.status >= 300 && error.status <= 308;
return hasValidLocation && hasValidStatus;
Comment on lines +30 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity/no action required: the status code makes total sense to me. Why is thelocation header important?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose people might set some weird status codes on errors that aren't redirects and we wouldn't want to ignore those? Not sure tbh, no strong feelings on removing this check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nevermind, I read up on the location header. Makes sense to me!

}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node';
import { flushIfServerless, isRedirect } from './utils';

/**
* Wraps a server action (functions that use the 'use server' directive)
* function body with Sentry Error and Performance instrumentation.
*/
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
serverActionName: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way to infer the name, possibly? That would be ideal... if not, this is fine for now!

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so. Accessing a parent's function name isn't available in strict mode, also it wouldn't work for minifed code either. Unfortunately I think we have to live with this :(

callback: A,
): Promise<ReturnType<A>> {
const activeSpan = getActiveSpan();

if (activeSpan) {
const spanData = spanToJSON(activeSpan).data;

// In solid start, server function calls are made to `/_server` which doesn't tell us
// a lot. We rewrite the span's route to be that of the sever action name but only
// if the target is `/_server`, otherwise we'd overwrite pageloads on routes that use
// server actions (which are more meaningful, e.g. a request to `GET /users/5` is more
// meaningful than overwriting it with `GET doSomeFunctionCall`).
if (spanData && !spanData['http.route'] && spanData['http.target'] === '/_server') {
activeSpan.setAttribute('http.route', serverActionName);
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component');
}
}

try {
return await startSpan(
{
op: 'function.server_action',
name: serverActionName,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (!isRedirect(error)) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
type: 'solidstart',
},
});
}
});

return result;
},
);
} finally {
await flushIfServerless();
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
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 numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
"This is currently not an issue outside of our repo. See: https://github.com/nksaraf/vinxi/issues/177"
],
"preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev",
"start": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start",
"test:prod": "TEST_ENV=production playwright test",
"test:build": "pnpm install && npx playwright install && pnpm build",
"test:assert": "pnpm test:prod"
Expand All@@ -31,7 +32,7 @@
"jsdom": "^24.0.0",
"solid-js": "1.8.17",
"typescript": "^5.4.5",
"vinxi": "^0.3.12",
"vinxi": "^0.4.0",
"vite": "^5.2.8",
"vite-plugin-solid": "^2.10.2",
"vitest": "^1.5.0"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ Sentry.init({
tunnel: 'http://localhost:3031/', // proxy server
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
debug: !!import.meta.env.DEBUG,
});

mount(() => <StartClient />, document.getElementById('app')!);
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,4 +5,5 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0, // Capture 100% of the transactions
tunnel: 'http://localhost:3031/', // proxy server
debug: !!process.env.DEBUG,
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ export default function Home() {
<li>
<A href="/client-error">Client error</A>
</li>
<li>
<A href="/server-error">Server error</A>
</li>
<li>
<A id="navLink" href="/users/5">
User 5
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
throw new Error('Error thrown from Solid Start E2E test app server route');

return { prefecture: 'Kanagawa' };
});
};

export default function ServerErrorPage() {
const data = createAsync(() => getPrefecture());

return <div>Prefecture: {data()?.prefecture}</div>;
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
import { useParams } from '@solidjs/router';
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync, useParams } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
return { prefecture: 'Ehime' };
});
};
export default function User() {
const params = useParams();
return <div>User ID: {params.id}</div>;
const userData = createAsync(() => getPrefecture());

return (
<div>
User ID: {params.id}
<br />
Prefecture: {userData()?.prefecture}
</div>
);
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('server-side errors', () => {
test('captures server action error', async ({ page }) => {
const errorEventPromise = waitForError('solidstart', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route';
});

await page.goto(`/server-error`);

const error = await errorEventPromise;

expect(error.tags).toMatchObject({ runtime: 'node' });
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid Start E2E test app server route',
mechanism: {
type: 'solidstart',
handled: false,
},
},
],
},
transaction: 'GET /server-error',
});
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';

test('sends a server action transaction on pageload', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'GET /users/6';
});

await page.goto('/users/6');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});

test('sends a server action transaction on client navigation', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'POST getPrefecture';
});

await page.goto('/');
await page.locator('#navLink').click();
await page.waitForURL('/users/5');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});
3 changes: 2 additions & 1 deletion packages/solidstart/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"@sentry/solid": "8.20.0",
"@sentry/types": "8.20.0",
"@sentry/utils": "8.20.0",
"@sentry/vite-plugin": "2.19.0"
"@sentry/vite-plugin": "2.19.0",
"@opentelemetry/instrumentation": "^0.52.1"
},
"devDependencies": {
"@solidjs/router": "^0.13.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/rollup.npm.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ export default makeNPMConfigVariants(
// prevent this internal code from ending up in our built package (this doesn't happen automatially because
// the name doesn't match an SDK dependency)
packageSpecificConfig: {
external: ['solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
output: {
dynamicImportInCjs: true,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/src/index.types.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
// We export everything from both the client part of the SDK and from the server part.
// Some of the exports collide, which is not allowed, unless we redifine the colliding
// Some of the exports collide, which is not allowed, unless we redefine the colliding
// exports in this file - which we do below.
export * from './client';
export * from './server';
Expand Down
2 changes: 2 additions & 0 deletions packages/solidstart/src/server/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,3 +126,5 @@ export { withSentryErrorBoundary } from '@sentry/solid';
// -------------------------
// Solid Start SDK exports:
export { init } from './sdk';

export * from './withServerActionInstrumentation';
33 changes: 33 additions & 0 deletions packages/solidstart/src/server/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import { flush } from '@sentry/node';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../common/debug-build';

/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
export async function flushIfServerless(): Promise<void> {
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;

if (isServerless) {
try {
DEBUG_BUILD && logger.log('Flushing events...');
await flush(2000);
DEBUG_BUILD && logger.log('Done flushing events');
} catch (e) {
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
}
}
}

/**
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
* @param error the potential redirect error
*/
export function isRedirect(error: unknown): boolean {
if (error == null || !(error instanceof Response)) {
return false;
}

const hasValidLocation = typeof error.headers.get('location') === 'string';
const hasValidStatus = error.status >= 300 && error.status <= 308;
return hasValidLocation && hasValidStatus;
Comment on lines +30 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity/no action required: the status code makes total sense to me. Why is thelocation header important?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose people might set some weird status codes on errors that aren't redirects and we wouldn't want to ignore those? Not sure tbh, no strong feelings on removing this check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nevermind, I read up on the location header. Makes sense to me!

}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node';
import { flushIfServerless, isRedirect } from './utils';

/**
* Wraps a server action (functions that use the 'use server' directive)
* function body with Sentry Error and Performance instrumentation.
*/
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
serverActionName: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way to infer the name, possibly? That would be ideal... if not, this is fine for now!

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so. Accessing a parent's function name isn't available in strict mode, also it wouldn't work for minifed code either. Unfortunately I think we have to live with this :(

callback: A,
): Promise<ReturnType<A>> {
const activeSpan = getActiveSpan();

if (activeSpan) {
const spanData = spanToJSON(activeSpan).data;

// In solid start, server function calls are made to `/_server` which doesn't tell us
// a lot. We rewrite the span's route to be that of the sever action name but only
// if the target is `/_server`, otherwise we'd overwrite pageloads on routes that use
// server actions (which are more meaningful, e.g. a request to `GET /users/5` is more
// meaningful than overwriting it with `GET doSomeFunctionCall`).
if (spanData && !spanData['http.route'] && spanData['http.target'] === '/_server') {
activeSpan.setAttribute('http.route', serverActionName);
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component');
}
}

try {
return await startSpan(
{
op: 'function.server_action',
name: serverActionName,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (!isRedirect(error)) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
type: 'solidstart',
},
});
}
});

return result;
},
);
} finally {
await flushIfServerless();
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
"This is currently not an issue outside of our repo. See: https://github.com/nksaraf/vinxi/issues/177"
],
"preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev",
"start": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start",
"test:prod": "TEST_ENV=production playwright test",
"test:build": "pnpm install && npx playwright install && pnpm build",
"test:assert": "pnpm test:prod"
Expand All@@ -31,7 +32,7 @@
"jsdom": "^24.0.0",
"solid-js": "1.8.17",
"typescript": "^5.4.5",
"vinxi": "^0.3.12",
"vinxi": "^0.4.0",
"vite": "^5.2.8",
"vite-plugin-solid": "^2.10.2",
"vitest": "^1.5.0"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ Sentry.init({
tunnel: 'http://localhost:3031/', // proxy server
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
debug: !!import.meta.env.DEBUG,
});

mount(() => <StartClient />, document.getElementById('app')!);
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,4 +5,5 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0, // Capture 100% of the transactions
tunnel: 'http://localhost:3031/', // proxy server
debug: !!process.env.DEBUG,
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ export default function Home() {
<li>
<A href="/client-error">Client error</A>
</li>
<li>
<A href="/server-error">Server error</A>
</li>
<li>
<A id="navLink" href="/users/5">
User 5
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
throw new Error('Error thrown from Solid Start E2E test app server route');

return { prefecture: 'Kanagawa' };
});
};

export default function ServerErrorPage() {
const data = createAsync(() => getPrefecture());

return <div>Prefecture: {data()?.prefecture}</div>;
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
import { useParams } from '@solidjs/router';
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync, useParams } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
return { prefecture: 'Ehime' };
});
};
export default function User() {
const params = useParams();
return <div>User ID: {params.id}</div>;
const userData = createAsync(() => getPrefecture());

return (
<div>
User ID: {params.id}
<br />
Prefecture: {userData()?.prefecture}
</div>
);
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('server-side errors', () => {
test('captures server action error', async ({ page }) => {
const errorEventPromise = waitForError('solidstart', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route';
});

await page.goto(`/server-error`);

const error = await errorEventPromise;

expect(error.tags).toMatchObject({ runtime: 'node' });
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid Start E2E test app server route',
mechanism: {
type: 'solidstart',
handled: false,
},
},
],
},
transaction: 'GET /server-error',
});
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';

test('sends a server action transaction on pageload', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'GET /users/6';
});

await page.goto('/users/6');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});

test('sends a server action transaction on client navigation', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'POST getPrefecture';
});

await page.goto('/');
await page.locator('#navLink').click();
await page.waitForURL('/users/5');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});
3 changes: 2 additions & 1 deletion packages/solidstart/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"@sentry/solid": "8.20.0",
"@sentry/types": "8.20.0",
"@sentry/utils": "8.20.0",
"@sentry/vite-plugin": "2.19.0"
"@sentry/vite-plugin": "2.19.0",
"@opentelemetry/instrumentation": "^0.52.1"
},
"devDependencies": {
"@solidjs/router": "^0.13.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/rollup.npm.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ export default makeNPMConfigVariants(
// prevent this internal code from ending up in our built package (this doesn't happen automatially because
// the name doesn't match an SDK dependency)
packageSpecificConfig: {
external: ['solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
output: {
dynamicImportInCjs: true,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/src/index.types.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
// We export everything from both the client part of the SDK and from the server part.
// Some of the exports collide, which is not allowed, unless we redifine the colliding
// Some of the exports collide, which is not allowed, unless we redefine the colliding
// exports in this file - which we do below.
export * from './client';
export * from './server';
Expand Down
2 changes: 2 additions & 0 deletions packages/solidstart/src/server/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,3 +126,5 @@ export { withSentryErrorBoundary } from '@sentry/solid';
// -------------------------
// Solid Start SDK exports:
export { init } from './sdk';

export * from './withServerActionInstrumentation';
33 changes: 33 additions & 0 deletions packages/solidstart/src/server/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import { flush } from '@sentry/node';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../common/debug-build';

/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
export async function flushIfServerless(): Promise<void> {
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;

if (isServerless) {
try {
DEBUG_BUILD && logger.log('Flushing events...');
await flush(2000);
DEBUG_BUILD && logger.log('Done flushing events');
} catch (e) {
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
}
}
}

/**
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
* @param error the potential redirect error
*/
export function isRedirect(error: unknown): boolean {
if (error == null || !(error instanceof Response)) {
return false;
}

const hasValidLocation = typeof error.headers.get('location') === 'string';
const hasValidStatus = error.status >= 300 && error.status <= 308;
return hasValidLocation && hasValidStatus;
Comment on lines +30 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity/no action required: the status code makes total sense to me. Why is thelocation header important?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose people might set some weird status codes on errors that aren't redirects and we wouldn't want to ignore those? Not sure tbh, no strong feelings on removing this check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nevermind, I read up on the location header. Makes sense to me!

}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node';
import { flushIfServerless, isRedirect } from './utils';

/**
* Wraps a server action (functions that use the 'use server' directive)
* function body with Sentry Error and Performance instrumentation.
*/
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
serverActionName: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way to infer the name, possibly? That would be ideal... if not, this is fine for now!

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so. Accessing a parent's function name isn't available in strict mode, also it wouldn't work for minifed code either. Unfortunately I think we have to live with this :(

callback: A,
): Promise<ReturnType<A>> {
const activeSpan = getActiveSpan();

if (activeSpan) {
const spanData = spanToJSON(activeSpan).data;

// In solid start, server function calls are made to `/_server` which doesn't tell us
// a lot. We rewrite the span's route to be that of the sever action name but only
// if the target is `/_server`, otherwise we'd overwrite pageloads on routes that use
// server actions (which are more meaningful, e.g. a request to `GET /users/5` is more
// meaningful than overwriting it with `GET doSomeFunctionCall`).
if (spanData && !spanData['http.route'] && spanData['http.target'] === '/_server') {
activeSpan.setAttribute('http.route', serverActionName);
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component');
}
}

try {
return await startSpan(
{
op: 'function.server_action',
name: serverActionName,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (!isRedirect(error)) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
type: 'solidstart',
},
});
}
});

return result;
},
);
} finally {
await flushIfServerless();
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
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 numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
"This is currently not an issue outside of our repo. See: https://github.com/nksaraf/vinxi/issues/177"
],
"preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev",
"start": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start",
"test:prod": "TEST_ENV=production playwright test",
"test:build": "pnpm install && npx playwright install && pnpm build",
"test:assert": "pnpm test:prod"
Expand All@@ -31,7 +32,7 @@
"jsdom": "^24.0.0",
"solid-js": "1.8.17",
"typescript": "^5.4.5",
"vinxi": "^0.3.12",
"vinxi": "^0.4.0",
"vite": "^5.2.8",
"vite-plugin-solid": "^2.10.2",
"vitest": "^1.5.0"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ Sentry.init({
tunnel: 'http://localhost:3031/', // proxy server
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
debug: !!import.meta.env.DEBUG,
});

mount(() => <StartClient />, document.getElementById('app')!);
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,4 +5,5 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0, // Capture 100% of the transactions
tunnel: 'http://localhost:3031/', // proxy server
debug: !!process.env.DEBUG,
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ export default function Home() {
<li>
<A href="/client-error">Client error</A>
</li>
<li>
<A href="/server-error">Server error</A>
</li>
<li>
<A id="navLink" href="/users/5">
User 5
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
throw new Error('Error thrown from Solid Start E2E test app server route');

return { prefecture: 'Kanagawa' };
});
};

export default function ServerErrorPage() {
const data = createAsync(() => getPrefecture());

return <div>Prefecture: {data()?.prefecture}</div>;
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
import { useParams } from '@solidjs/router';
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync, useParams } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
return { prefecture: 'Ehime' };
});
};
export default function User() {
const params = useParams();
return <div>User ID: {params.id}</div>;
const userData = createAsync(() => getPrefecture());

return (
<div>
User ID: {params.id}
<br />
Prefecture: {userData()?.prefecture}
</div>
);
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('server-side errors', () => {
test('captures server action error', async ({ page }) => {
const errorEventPromise = waitForError('solidstart', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route';
});

await page.goto(`/server-error`);

const error = await errorEventPromise;

expect(error.tags).toMatchObject({ runtime: 'node' });
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid Start E2E test app server route',
mechanism: {
type: 'solidstart',
handled: false,
},
},
],
},
transaction: 'GET /server-error',
});
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';

test('sends a server action transaction on pageload', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'GET /users/6';
});

await page.goto('/users/6');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});

test('sends a server action transaction on client navigation', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'POST getPrefecture';
});

await page.goto('/');
await page.locator('#navLink').click();
await page.waitForURL('/users/5');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});
3 changes: 2 additions & 1 deletion packages/solidstart/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"@sentry/solid": "8.20.0",
"@sentry/types": "8.20.0",
"@sentry/utils": "8.20.0",
"@sentry/vite-plugin": "2.19.0"
"@sentry/vite-plugin": "2.19.0",
"@opentelemetry/instrumentation": "^0.52.1"
},
"devDependencies": {
"@solidjs/router": "^0.13.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/rollup.npm.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ export default makeNPMConfigVariants(
// prevent this internal code from ending up in our built package (this doesn't happen automatially because
// the name doesn't match an SDK dependency)
packageSpecificConfig: {
external: ['solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
output: {
dynamicImportInCjs: true,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/src/index.types.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
// We export everything from both the client part of the SDK and from the server part.
// Some of the exports collide, which is not allowed, unless we redifine the colliding
// Some of the exports collide, which is not allowed, unless we redefine the colliding
// exports in this file - which we do below.
export * from './client';
export * from './server';
Expand Down
2 changes: 2 additions & 0 deletions packages/solidstart/src/server/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,3 +126,5 @@ export { withSentryErrorBoundary } from '@sentry/solid';
// -------------------------
// Solid Start SDK exports:
export { init } from './sdk';

export * from './withServerActionInstrumentation';
33 changes: 33 additions & 0 deletions packages/solidstart/src/server/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import { flush } from '@sentry/node';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../common/debug-build';

/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
export async function flushIfServerless(): Promise<void> {
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;

if (isServerless) {
try {
DEBUG_BUILD && logger.log('Flushing events...');
await flush(2000);
DEBUG_BUILD && logger.log('Done flushing events');
} catch (e) {
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
}
}
}

/**
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
* @param error the potential redirect error
*/
export function isRedirect(error: unknown): boolean {
if (error == null || !(error instanceof Response)) {
return false;
}

const hasValidLocation = typeof error.headers.get('location') === 'string';
const hasValidStatus = error.status >= 300 && error.status <= 308;
return hasValidLocation && hasValidStatus;
Comment on lines +30 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity/no action required: the status code makes total sense to me. Why is thelocation header important?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose people might set some weird status codes on errors that aren't redirects and we wouldn't want to ignore those? Not sure tbh, no strong feelings on removing this check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nevermind, I read up on the location header. Makes sense to me!

}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node';
import { flushIfServerless, isRedirect } from './utils';

/**
* Wraps a server action (functions that use the 'use server' directive)
* function body with Sentry Error and Performance instrumentation.
*/
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
serverActionName: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way to infer the name, possibly? That would be ideal... if not, this is fine for now!

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so. Accessing a parent's function name isn't available in strict mode, also it wouldn't work for minifed code either. Unfortunately I think we have to live with this :(

callback: A,
): Promise<ReturnType<A>> {
const activeSpan = getActiveSpan();

if (activeSpan) {
const spanData = spanToJSON(activeSpan).data;

// In solid start, server function calls are made to `/_server` which doesn't tell us
// a lot. We rewrite the span's route to be that of the sever action name but only
// if the target is `/_server`, otherwise we'd overwrite pageloads on routes that use
// server actions (which are more meaningful, e.g. a request to `GET /users/5` is more
// meaningful than overwriting it with `GET doSomeFunctionCall`).
if (spanData && !spanData['http.route'] && spanData['http.target'] === '/_server') {
activeSpan.setAttribute('http.route', serverActionName);
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component');
}
}

try {
return await startSpan(
{
op: 'function.server_action',
name: serverActionName,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (!isRedirect(error)) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
type: 'solidstart',
},
});
}
});

return result;
},
);
} finally {
await flushIfServerless();
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
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 numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@
"This is currently not an issue outside of our repo. See: https://github.com/nksaraf/vinxi/issues/177"
],
"preview": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi dev",
"start": "HOST=localhost PORT=3030 NODE_OPTIONS='--import ./src/instrument.server.mjs' vinxi start",
"test:prod": "TEST_ENV=production playwright test",
"test:build": "pnpm install && npx playwright install && pnpm build",
"test:assert": "pnpm test:prod"
Expand All@@ -31,7 +32,7 @@
"jsdom": "^24.0.0",
"solid-js": "1.8.17",
"typescript": "^5.4.5",
"vinxi": "^0.3.12",
"vinxi": "^0.4.0",
"vite": "^5.2.8",
"vite-plugin-solid": "^2.10.2",
"vitest": "^1.5.0"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ Sentry.init({
tunnel: 'http://localhost:3031/', // proxy server
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
debug: !!import.meta.env.DEBUG,
});

mount(() => <StartClient />, document.getElementById('app')!);
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,4 +5,5 @@ Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0, // Capture 100% of the transactions
tunnel: 'http://localhost:3031/', // proxy server
debug: !!process.env.DEBUG,
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ export default function Home() {
<li>
<A href="/client-error">Client error</A>
</li>
<li>
<A href="/server-error">Server error</A>
</li>
<li>
<A id="navLink" href="/users/5">
User 5
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
throw new Error('Error thrown from Solid Start E2E test app server route');

return { prefecture: 'Kanagawa' };
});
};

export default function ServerErrorPage() {
const data = createAsync(() => getPrefecture());

return <div>Prefecture: {data()?.prefecture}</div>;
}
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
import { useParams } from '@solidjs/router';
import { withServerActionInstrumentation } from '@sentry/solidstart';
import { createAsync, useParams } from '@solidjs/router';

const getPrefecture = async () => {
'use server';
return await withServerActionInstrumentation('getPrefecture', () => {
return { prefecture: 'Ehime' };
});
};
export default function User() {
const params = useParams();
return <div>User ID: {params.id}</div>;
const userData = createAsync(() => getPrefecture());

return (
<div>
User ID: {params.id}
<br />
Prefecture: {userData()?.prefecture}
</div>
);
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('server-side errors', () => {
test('captures server action error', async ({ page }) => {
const errorEventPromise = waitForError('solidstart', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid Start E2E test app server route';
});

await page.goto(`/server-error`);

const error = await errorEventPromise;

expect(error.tags).toMatchObject({ runtime: 'node' });
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid Start E2E test app server route',
mechanism: {
type: 'solidstart',
handled: false,
},
},
],
},
transaction: 'GET /server-error',
});
});
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';

test('sends a server action transaction on pageload', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'GET /users/6';
});

await page.goto('/users/6');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});

test('sends a server action transaction on client navigation', async ({ page }) => {
const transactionPromise = waitForTransaction('solidstart', transactionEvent => {
return transactionEvent?.transaction === 'POST getPrefecture';
});

await page.goto('/');
await page.locator('#navLink').click();
await page.waitForURL('/users/5');

const transaction = await transactionPromise;

expect(transaction.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({
description: 'getPrefecture',
data: {
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function.server_action',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
}),
]),
);
});
3 changes: 2 additions & 1 deletion packages/solidstart/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,7 +72,8 @@
"@sentry/solid": "8.20.0",
"@sentry/types": "8.20.0",
"@sentry/utils": "8.20.0",
"@sentry/vite-plugin": "2.19.0"
"@sentry/vite-plugin": "2.19.0",
"@opentelemetry/instrumentation": "^0.52.1"
},
"devDependencies": {
"@solidjs/router": "^0.13.4",
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/rollup.npm.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ export default makeNPMConfigVariants(
// prevent this internal code from ending up in our built package (this doesn't happen automatially because
// the name doesn't match an SDK dependency)
packageSpecificConfig: {
external: ['solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
external: ['solid-js/web', 'solid-js', '@sentry/solid', '@sentry/solid/solidrouter'],
output: {
dynamicImportInCjs: true,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/solidstart/src/index.types.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
// We export everything from both the client part of the SDK and from the server part.
// Some of the exports collide, which is not allowed, unless we redifine the colliding
// Some of the exports collide, which is not allowed, unless we redefine the colliding
// exports in this file - which we do below.
export * from './client';
export * from './server';
Expand Down
2 changes: 2 additions & 0 deletions packages/solidstart/src/server/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -126,3 +126,5 @@ export { withSentryErrorBoundary } from '@sentry/solid';
// -------------------------
// Solid Start SDK exports:
export { init } from './sdk';

export * from './withServerActionInstrumentation';
33 changes: 33 additions & 0 deletions packages/solidstart/src/server/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
import { flush } from '@sentry/node';
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../common/debug-build';

/** Flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda ends */
export async function flushIfServerless(): Promise<void> {
const isServerless = !!process.env.LAMBDA_TASK_ROOT || !!process.env.VERCEL;

if (isServerless) {
try {
DEBUG_BUILD && logger.log('Flushing events...');
await flush(2000);
DEBUG_BUILD && logger.log('Done flushing events');
} catch (e) {
DEBUG_BUILD && logger.log('Error while flushing events:\n', e);
}
}
}

/**
* Determines if a thrown "error" is a redirect Response which Solid Start users can throw to redirect to another route.
* see: https://docs.solidjs.com/solid-router/reference/data-apis/response-helpers#redirect
* @param error the potential redirect error
*/
export function isRedirect(error: unknown): boolean {
if (error == null || !(error instanceof Response)) {
return false;
}

const hasValidLocation = typeof error.headers.get('location') === 'string';
const hasValidStatus = error.status >= 300 && error.status <= 308;
return hasValidLocation && hasValidStatus;
Comment on lines +30 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out of curiosity/no action required: the status code makes total sense to me. Why is thelocation header important?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose people might set some weird status codes on errors that aren't redirects and we wouldn't want to ignore those? Not sure tbh, no strong feelings on removing this check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nevermind, I read up on the location header. Makes sense to me!

}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, handleCallbackErrors } from '@sentry/core';
import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, captureException, getActiveSpan, spanToJSON, startSpan } from '@sentry/node';
import { flushIfServerless, isRedirect } from './utils';

/**
* Wraps a server action (functions that use the 'use server' directive)
* function body with Sentry Error and Performance instrumentation.
*/
export async function withServerActionInstrumentation<A extends (...args: unknown[]) => unknown>(
serverActionName: string,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some way to infer the name, possibly? That would be ideal... if not, this is fine for now!

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so. Accessing a parent's function name isn't available in strict mode, also it wouldn't work for minifed code either. Unfortunately I think we have to live with this :(

callback: A,
): Promise<ReturnType<A>> {
const activeSpan = getActiveSpan();

if (activeSpan) {
const spanData = spanToJSON(activeSpan).data;

// In solid start, server function calls are made to `/_server` which doesn't tell us
// a lot. We rewrite the span's route to be that of the sever action name but only
// if the target is `/_server`, otherwise we'd overwrite pageloads on routes that use
// server actions (which are more meaningful, e.g. a request to `GET /users/5` is more
// meaningful than overwriting it with `GET doSomeFunctionCall`).
if (spanData && !spanData['http.route'] && spanData['http.target'] === '/_server') {
activeSpan.setAttribute('http.route', serverActionName);
activeSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'component');
}
}

try {
return await startSpan(
{
op: 'function.server_action',
name: serverActionName,
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.solidstart',
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component',
},
},
async span => {
const result = await handleCallbackErrors(callback, error => {
if (!isRedirect(error)) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
captureException(error, {
mechanism: {
handled: false,
type: 'solidstart',
},
});
}
});

return result;
},
);
} finally {
await flushIfServerless();
}
}
Loading