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
31 changes: 31 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,31 @@
# 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`.

`@solidjs/router`'s `createRouter` declares the routes to the runtime's observe tier; the SDK has no router code. `/`
calls a server function on click and offers `/users/6` as an anchor and as a `navigate()` button, `/users/:id`
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; the request is named `GET /users/:id` by the route the runtime rendered;
- performance (client): the `pageload` continues the server trace with no middleware (the runtime's `<meta>` pair and
`Server-Timing`) and is named `/users/:id` by the route it arrived on; a navigation is the browser's `navigation`
span named by the route, ended where the transition committed, with the route's server-function call as its child;
the `navigate()` click links to it, the anchor click records no interaction; 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
```
38 changes: 38 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,38 @@
{
"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/router": "^2.0.0-next.32",
"@solidjs/web": "^2.0.0-rc.13",
"solid-js": "^2.0.0-rc.13"
},
"devDependencies": {
"@playwright/test": "~1.63.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@solidjs/vite-plugin": "^3.0.0-next.45",
"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}`);
});
92 changes: 92 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,92 @@
import { createRouter, useNavigate, useParams } from '@solidjs/router';
import { Errored, Loading, isServer } from '@solidjs/web';
import { createMemo, createSignal } from 'solid-js';
import { explode, getPrefecture } from './data';
if (!isServer) await import('./sentry.client');

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('');
const navigate = useNavigate();
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>
<a id="userLink" href="/users/6">
user 6
</a>
<button id="userBtn" onClick={() => navigate('/users/6')}>
go to user 6
</button>
</>
);
}

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

// The router declares its routes to the runtime's observe tier: the route the
// document arrived on (both sides) and every navigation after it. The SDK has
// no router code; it names the pageload, navigation and request spans from
// those records.
const Router = createRouter({
routes: [
{ path: '/', component: Home },
{ path: '/users/:id', component: UserPage },
{
path: '/server-error',
component: () => (
<Errored fallback={err => <p id="serverErrorFallback">fallback: {String((err() as Error).message)}</p>}>
<ServerError />
</Errored>
),
},
{
path: '/client-error',
component: () => (
<Errored fallback={err => <p id="clientErrorFallback">fallback: {String((err() as Error).message)}</p>}>
<ClientBoundary />
</Errored>
),
},
] as const,
});

export default function App() {
return <Router>{props => <main>{props.children}</main>}</Router>;
}
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.solidBrowserTracingIntegration(), 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>'),
},
});
});
});
Loading