Skip to content
Draft
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
26 changes: 26 additions & 0 deletions dev-packages/e2e-tests/test-applications/solid-2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# solid-2

A Solid 2 app on `@solidjs/vite-plugin`'s start mode, built with `observe: true`, with `@sentry/solid-2` on both halves:

- the server SDK through `start.instrument` (`src/instrument.ts`), which the plugin awaits before the server graph loads;
- the browser SDK from `src/sentry.client.ts`, imported by the app behind an `isServer` guard;
- `server.mjs`, a bare `node:http` host around the built `handleRequest`.

No router: `App.tsx` switches on the pathname. `/` calls a server function on click, `/users/6` awaits one under a
`<Loading>`, `/server-error` throws during SSR inside an `<Errored>`, `/client-error` throws in the browser inside one.

What the tests pin, against the packed tarballs:

- errors: both hooks fire once, with the component that threw and the boundary that met it; the wire carries the
sanitized message while Sentry gets the real one; a server-function throw arrives with the function id;
- performance (server): a waiting `<Loading>` and the server function it awaited are spans under OTel's `http.server`
span, backdated from the runtime's clock;
- performance (client): the `pageload` continues the server trace with no middleware (the runtime's `<meta>` pair and
`Server-Timing`); a click is a root span and the server-function call it made is its child, joined by identity even
though the call landed after the interaction settled.

```bash
pnpm install
pnpm build
pnpm test:prod
```
37 changes: 37 additions & 0 deletions dev-packages/e2e-tests/test-applications/solid-2/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "solid-2",
"version": "0.0.0",
"//": "Solid 2 on @solidjs/vite-plugin start mode: an observe build (`observe: true`), the server SDK initialized through `start.instrument` (awaited before the server graph loads — no `--import`), the browser SDK from the app's client module. server.mjs is the whole production host. @sentry/node is a direct dependency so the server build externalizes it: the plugin inlines @sentry/solid-2 (a consumer of the Solid runtime), and under pnpm a transitive @sentry/node would be bundled with it, where import-in-the-middle cannot find itself.",
"scripts": {
"clean": "pnpx rimraf node_modules pnpm-lock.yaml dist",
"build": "vite build",
"start": "PORT=3030 node server.mjs",
"test:prod": "TEST_ENV=production playwright test",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm test:prod"
},
"type": "module",
"dependencies": {
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"@sentry/solid-2": "file:../../packed/sentry-solid-2-packed.tgz",
"@solidjs/web": "^2.0.0-rc.9",
"solid-js": "^2.0.0-rc.9"
},
"devDependencies": {
"@playwright/test": "~1.63.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@solidjs/vite-plugin": "^3.0.0-next.44",
"typescript": "^5.4.5",
"vite": "^8.1.5"
},
"volta": {
"extends": "../../package.json",
"node": "24.15.0"
},
"engines": {
"node": ">=24"
},
"sentryTest": {
"optional": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

const config = getPlaywrightConfig({
startCommand: 'pnpm start',
port: 3030,
});

export default config;
87 changes: 87 additions & 0 deletions dev-packages/e2e-tests/test-applications/solid-2/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// The whole production host for a Solid 2 start-mode app: static client assets
// plus the built server bundle's `handleRequest`, an adapter-agnostic web
// `Request -> Response` handler. Copied from @solidjs/vite-plugin's start-ssr
// example, with one change: the handler is imported FIRST, and awaited. Its
// entry runs `start.instrument` (Sentry.init, OpenTelemetry) to completion
// before the rest of the server graph loads — so `node:http` is imported only
// after the instrumentation that patches it is in place.
const { handleRequest } = await import('./dist/server/server.js');
const { createServer } = await import('node:http');
const { readFileSync } = await import('node:fs');
const { Readable } = await import('node:stream');
const { fileURLToPath } = await import('node:url');
const path = (await import('node:path')).default;

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const port = process.env.PORT || 3000;

const MIME = {
'.js': 'application/javascript',
'.css': 'text/css',
'.html': 'text/html',
'.json': 'application/json',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml',
};

function webRequest(req) {
const url = new URL(req.url || '/', `http://${req.headers.host || `localhost:${port}`}`);
const method = req.method || 'GET';
// Attach a body only when the request carries one (Content-Length or
// Transfer-Encoding, RFC 9112 §6): the runtime treats a present body that
// decodes to nothing as malformed since @solidjs/web 2.0.0-rc.5.
const hasBody =
method !== 'GET' &&
method !== 'HEAD' &&
(req.headers['transfer-encoding'] !== undefined ||
(req.headers['content-length'] !== undefined && req.headers['content-length'] !== '0'));
const body = hasBody ? Readable.toWeb(req) : undefined;
return new Request(url, {
method,
headers: req.headers,
body,
...(body ? { duplex: 'half' } : {}),
});
}

const server = createServer(async (req, res) => {
const url = req.url || '/';

// Static client assets first.
if (url !== '/' && !url.includes('..')) {
try {
const content = readFileSync(path.resolve(__dirname, 'dist/client' + url.split('?')[0]));
res.setHeader('Content-Type', MIME[path.extname(url)] || 'application/octet-stream');
res.end(content);
return;
} catch {
// Fall through to the handler (SSR routes, /_server, ...).
}
}

try {
// The `options.event` seam: extra fields spread into the request event,
// conventionally the platform's raw request as `nativeEvent` — app code
// reads it back via getRequestEvent() (e.g. the client IP from
// event.nativeEvent.socket.remoteAddress on bare Node).
const response = await handleRequest(webRequest(req), { event: { nativeEvent: req } });
res.statusCode = response.status;
const cookies = response.headers.getSetCookie?.();
response.headers.forEach((value, key) => {
if (key !== 'set-cookie') res.setHeader(key, value);
});
if (cookies?.length) res.setHeader('set-cookie', cookies);
if (response.body) {
for await (const chunk of response.body) res.write(chunk);
}
res.end();
} catch (e) {
console.error(e);
res.statusCode = 500;
res.end(e.message);
}
});

server.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
78 changes: 78 additions & 0 deletions dev-packages/e2e-tests/test-applications/solid-2/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Errored, Loading, getRequestEvent, isServer } from '@solidjs/web';
import { createMemo, createSignal } from 'solid-js';
import { explode, getPrefecture } from './data';
if (!isServer) await import('./sentry.client');

function pathname(): string {
if (isServer) return new URL(getRequestEvent()!.request.url).pathname;
return window.location.pathname;
}

function ServerError(): never {
throw new Error('Error thrown from Solid 2 E2E test app server render');
}

function ClientBoundary() {
const [fail, setFail] = createSignal(false);
const view = createMemo(() => {
if (fail()) throw new Error('Error thrown from Solid 2 E2E test app client render');
return 'client content';
});
return (
<>
<button id="clientErrorBtn" onClick={() => setFail(true)}>
break the client
</button>
<p id="clientContent">{view()}</p>
</>
);
}

function Home() {
const [result, setResult] = createSignal('');
return (
<>
<h1>Solid 2 E2E</h1>
<button id="callBtn" onClick={async () => setResult(JSON.stringify(await getPrefecture(6)))}>
call the server
</button>
<button id="explodeBtn" onClick={() => explode().catch(e => setResult(`caught: ${e.message}`))}>
call a failing server function
</button>
<p id="callResult">{result()}</p>
<a id="serverErrorLink" href="/server-error">
server error
</a>
</>
);
}

function UserPage() {
const user = createMemo(() => getPrefecture(6));
return (
<Loading fallback={<p id="loading">loading…</p>}>
<p id="user">{JSON.stringify(user())}</p>
</Loading>
);
}

export default function App() {
const path = pathname();
return (
<main>
{path === '/server-error' ? (
<Errored fallback={err => <p id="serverErrorFallback">fallback: {String((err() as Error).message)}</p>}>
<ServerError />
</Errored>
) : path === '/client-error' ? (
<Errored fallback={err => <p id="clientErrorFallback">fallback: {String((err() as Error).message)}</p>}>
<ClientBoundary />
</Errored>
) : path === '/users/6' ? (
<UserPage />
) : (
<Home />
)}
</main>
);
}
10 changes: 10 additions & 0 deletions dev-packages/e2e-tests/test-applications/solid-2/src/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use server';

export async function getPrefecture(id: number): Promise<{ prefecture: string; id: number }> {
await new Promise(resolve => setTimeout(resolve, 20));
return { prefecture: 'Kagoshima', id };
}

export async function explode(): Promise<never> {
throw new Error('Error thrown from Solid 2 E2E test app server function');
}
13 changes: 13 additions & 0 deletions dev-packages/e2e-tests/test-applications/solid-2/src/instrument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// `start.instrument`: awaited to completion before anything else in the
// server graph loads, so the Node SDK's OpenTelemetry setup lands before the
// modules it patches.
import * as Sentry from '@sentry/solid-2/server';

Sentry.init({
dsn: process.env.E2E_TEST_DSN,
environment: 'qa', // dynamic sampling bias to keep transactions
tracesSampleRate: 1.0,
tunnel: 'http://localhost:3031/', // proxy server
integrations: [Sentry.solidServerTracingIntegration()],
debug: !!process.env.DEBUG,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// The explicit client entry: this module is reachable from the server graph
// (App.tsx imports it behind an `isServer` guard), where the bare package
// would resolve to the server half and the client integrations would not
// exist.
import * as Sentry from '@sentry/solid-2/client';

// Module-level so it runs when the app module is evaluated, before hydration.
Sentry.init({
// We can't use env variables here, seems like they are stripped
// out in production builds.
dsn: 'https://public@dsn.ingest.sentry.io/1337',
environment: 'qa', // dynamic sampling bias to keep transactions
tunnel: 'http://localhost:3031/', // proxy server
tracesSampleRate: 1.0,
integrations: [Sentry.browserTracingIntegration(), Sentry.solidTracingIntegration()],
debug: !!import.meta.env.DEBUG,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'solid-2',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('client-side errors', () => {
test('captures what an <Errored> boundary caught, without wrapping anything', async ({ page }) => {
const errorEventPromise = waitForError('solid-2', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid 2 E2E test app client render';
});

await page.goto('/client-error');
await expect(page.locator('#clientContent')).toHaveText('client content');
await page.locator('#clientErrorBtn').click();
await expect(page.locator('#clientErrorFallback')).toHaveText(/fallback: Error thrown/);

const error = await errorEventPromise;
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid 2 E2E test app client render',
mechanism: { type: 'auto.function.solid.error_boundary', handled: true },
},
],
},
tags: {
'solid.owner': expect.stringContaining('<ClientBoundary>'),
'solid.boundary': expect.stringContaining('<Errored>'),
},
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test } from '@playwright/test';
import { waitForError } from '@sentry-internal/test-utils';

test.describe('server-side errors', () => {
test('captures a render error an <Errored> contained, as thrown, located by component', async ({ page }) => {
const errorEventPromise = waitForError('solid-2', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid 2 E2E test app server render';
});

await page.goto('/server-error');
// The wire got the sanitized message; Sentry got the real one.
await expect(page.locator('#serverErrorFallback')).toHaveText(/fallback: Internal Server Error/);

const error = await errorEventPromise;
expect(error).toMatchObject({
exception: {
values: [
{
type: 'Error',
value: 'Error thrown from Solid 2 E2E test app server render',
mechanism: { type: 'auto.function.solid.server.render.fallback', handled: true },
},
],
},
tags: {
'solid.kind': 'render',
'solid.handling': 'fallback',
'solid.owner': expect.stringContaining('<ServerError>'),
'solid.boundary_path': expect.stringContaining('<Errored>'),
},
transaction: 'GET /server-error',
});
});

test('captures a server function throw, unhandled, with the function id', async ({ page }) => {
const errorEventPromise = waitForError('solid-2', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Solid 2 E2E test app server function';
});

await page.goto('/');
await page.locator('#explodeBtn').click();
await expect(page.locator('#callResult')).toHaveText(/caught:/);

const error = await errorEventPromise;
expect(error).toMatchObject({
exception: {
values: [
{
mechanism: { type: 'auto.function.solid.server.server-function.thrown', handled: true },
},
],
},
tags: { 'solid.kind': 'server-function', 'solid.handling': 'thrown', 'solid.function': expect.any(String) },
});
});
});
Loading