From 7226cca745957aa00c34fa72acd8d5f1cdb3efc2 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 11:09:09 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(solid-2):=20add=20@sentry/solid-2=20?= =?UTF-8?q?=E2=80=94=20Solid=202=20SDK=20(client=20+=20server)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One package, both halves: the browser SDK for the client and the Node SDK for the server, resolved by export condition. `init()` on each side adds Solid 2's error hook to the platform defaults — every failure a boundary renders a fallback for (client), and every failure the server runtime handles or fails on (server), reported once with where it was thrown and where it was met. Tracing is opt-in and reads Solid's observe tier: one root span per user interaction with its navigations, holds and server-function calls as children (calls joined by the engine's own interaction frame, not by time); a trace provider on `OBSERVE.server.trace` so the runtime carries Sentry's trace to the browser on its own carriers; spans for server-function executions, waiting `` boundaries and frame streams; the runtime's diagnostics as issues fingerprinted by code and component path. Element text on interaction targets is user data and left out unless `targetText: true`; a finding's `data.error` is not forwarded as an issue extra. Sibling package to `@sentry/solid` (1.x) for the transition; peer `solid-js`/`@solidjs/web` ^2.0.0-rc.8. Local dev dependencies are `link:` to a local Solid checkout until an rc carrying the record shapes and error hooks this builds on is published. Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- package.json | 1 + packages/solid-2/LICENSE | 21 ++ packages/solid-2/README.md | 75 +++++ packages/solid-2/package.json | 98 +++++++ packages/solid-2/rollup.npm.config.mjs | 10 + packages/solid-2/src/client/errors.ts | 32 +++ packages/solid-2/src/client/index.ts | 7 + packages/solid-2/src/client/records.ts | 71 +++++ packages/solid-2/src/client/sdk.ts | 27 ++ packages/solid-2/src/client/tracing.ts | 274 ++++++++++++++++++ packages/solid-2/src/common/diagnostics.ts | 52 ++++ packages/solid-2/src/common/target.ts | 18 ++ packages/solid-2/src/common/time.ts | 11 + packages/solid-2/src/debug-build.ts | 8 + packages/solid-2/src/index.client.ts | 1 + packages/solid-2/src/index.server.ts | 1 + packages/solid-2/src/index.types.ts | 39 +++ packages/solid-2/src/server/errors.ts | 55 ++++ packages/solid-2/src/server/index.ts | 8 + packages/solid-2/src/server/sdk.ts | 26 ++ packages/solid-2/src/server/tracing.ts | 164 +++++++++++ packages/solid-2/test/client/errors.test.ts | 82 ++++++ packages/solid-2/test/client/tracing.test.ts | 277 +++++++++++++++++++ packages/solid-2/test/server/errors.test.ts | 132 +++++++++ packages/solid-2/test/server/tracing.test.ts | 229 +++++++++++++++ packages/solid-2/test/tsconfig.json | 3 + packages/solid-2/tsconfig.json | 9 + packages/solid-2/tsconfig.test.json | 9 + packages/solid-2/tsconfig.types.json | 11 + packages/solid-2/vite.config.ts | 45 +++ yarn.lock | 26 ++ 31 files changed, 1822 insertions(+) create mode 100644 packages/solid-2/LICENSE create mode 100644 packages/solid-2/README.md create mode 100644 packages/solid-2/package.json create mode 100644 packages/solid-2/rollup.npm.config.mjs create mode 100644 packages/solid-2/src/client/errors.ts create mode 100644 packages/solid-2/src/client/index.ts create mode 100644 packages/solid-2/src/client/records.ts create mode 100644 packages/solid-2/src/client/sdk.ts create mode 100644 packages/solid-2/src/client/tracing.ts create mode 100644 packages/solid-2/src/common/diagnostics.ts create mode 100644 packages/solid-2/src/common/target.ts create mode 100644 packages/solid-2/src/common/time.ts create mode 100644 packages/solid-2/src/debug-build.ts create mode 100644 packages/solid-2/src/index.client.ts create mode 100644 packages/solid-2/src/index.server.ts create mode 100644 packages/solid-2/src/index.types.ts create mode 100644 packages/solid-2/src/server/errors.ts create mode 100644 packages/solid-2/src/server/index.ts create mode 100644 packages/solid-2/src/server/sdk.ts create mode 100644 packages/solid-2/src/server/tracing.ts create mode 100644 packages/solid-2/test/client/errors.test.ts create mode 100644 packages/solid-2/test/client/tracing.test.ts create mode 100644 packages/solid-2/test/server/errors.test.ts create mode 100644 packages/solid-2/test/server/tracing.test.ts create mode 100644 packages/solid-2/test/tsconfig.json create mode 100644 packages/solid-2/tsconfig.json create mode 100644 packages/solid-2/tsconfig.test.json create mode 100644 packages/solid-2/tsconfig.types.json create mode 100644 packages/solid-2/vite.config.ts diff --git a/package.json b/package.json index a25b4dd72738..99d40e43d86e 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "packages/server-runtime-injection", "packages/server-utils", "packages/solid", + "packages/solid-2", "packages/solidstart", "packages/svelte", "packages/sveltekit", diff --git a/packages/solid-2/LICENSE b/packages/solid-2/LICENSE new file mode 100644 index 000000000000..63e7eb28e19c --- /dev/null +++ b/packages/solid-2/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/solid-2/README.md b/packages/solid-2/README.md new file mode 100644 index 000000000000..895a2e2e2f33 --- /dev/null +++ b/packages/solid-2/README.md @@ -0,0 +1,75 @@ +

+ + Sentry + +

+ +# Official Sentry SDK for Solid 2 + +[![npm version](https://img.shields.io/npm/v/@sentry/solid-2.svg)](https://www.npmjs.com/package/@sentry/solid-2) +[![npm dm](https://img.shields.io/npm/dm/@sentry/solid-2.svg)](https://www.npmjs.com/package/@sentry/solid-2) +[![npm dt](https://img.shields.io/npm/dt/@sentry/solid-2.svg)](https://www.npmjs.com/package/@sentry/solid-2) + +This SDK is for Solid 2 (`solid-js` ^2). For Solid 1.x use [`@sentry/solid`](https://www.npmjs.com/package/@sentry/solid). + +One package, both halves: the browser SDK (`@sentry/browser`) for the client and the Node SDK (`@sentry/node`) for +the server, resolved by the `browser`/`node` export conditions, or explicitly as `@sentry/solid-2/client` and +`@sentry/solid-2/server`. + +## Errors + +Solid 2's runtime has an error hook on each platform, in every build tier. `init()` installs it: a rendered +`` fallback in the browser, and on the server an `` fallback, a rejected `` fragment, a +server-function throw (HTTP or an in-process call during SSR), a hydration value that would not serialize, and the +failure that fails a request — each once, with where it was met. Nothing to wrap. + +```js +// client +import * as Sentry from '@sentry/solid-2'; +Sentry.init({ dsn: '__DSN__' }); + +// server (before the app loads) +import * as Sentry from '@sentry/solid-2'; +Sentry.init({ + dsn: '__DSN__', + integrations: [ + // Optional: decide what the client receives in an error's place. + Sentry.solidServerErrorsIntegration({ mapError: (error, context) => new Error(`ref ${context.boundary}`) }), + ], +}); +``` + +## Tracing + +Tracing reads Solid's observe tier — the build the `observe` export condition selects — and is opt-in: + +```js +// client +Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1, + integrations: [Sentry.browserTracingIntegration(), Sentry.solidTracingIntegration()], +}); + +// server +Sentry.init({ + dsn: '__DSN__', + tracesSampleRate: 1, + integrations: [Sentry.solidServerTracingIntegration()], +}); +``` + +- **Client** — one root span per user interaction (`ui.interaction.click`), with the navigations, holds and + server-function calls it caused as children; a navigation or hold no interaction claims as its own root span; the + runtime's diagnostics (`SILENT_HOLD`, `HOT_SCOPE_RERUNS`, …) as issues fingerprinted by code and component path. +- **Server** — the runtime carries Sentry's trace to the browser on its own two carriers (`Server-Timing` on every + response, the `` pair in an HTML shell), so a `pageload` parents under the server request with no middleware + and no body rewriting; plus one span per server-function execution, per `` boundary that waited, and per + frame stream produced. + +Element text Solid attaches to an interaction's target (`button#next "Next →"`) is user data and left out of span +names and attributes unless `solidTracingIntegration({ targetText: true })`; the element itself is kept. A finding's +`data.error` — the error as thrown, on the server error findings — is not forwarded as an issue extra (the error hook +already captured it as an exception). + +Without the observe build the tracing integrations are inert and log once in debug mode; errors still report. diff --git a/packages/solid-2/package.json b/packages/solid-2/package.json new file mode 100644 index 000000000000..19b03c5fc418 --- /dev/null +++ b/packages/solid-2/package.json @@ -0,0 +1,98 @@ +{ + "name": "@sentry/solid-2", + "version": "10.67.0", + "description": "Official Sentry SDK for Solid 2 (client and server)", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/solid-2", + "author": "Sentry", + "license": "MIT", + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "files": [ + "/build", + "/*.d.ts", + "/*.d.ts.map" + ], + "main": "build/cjs/index.server.js", + "module": "build/esm/index.server.js", + "browser": "build/esm/index.client.js", + "types": "build/types/index.types.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.types.d.ts", + "browser": { + "import": "./build/esm/index.client.js", + "require": "./build/cjs/index.client.js" + }, + "node": { + "import": "./build/esm/index.server.js", + "require": "./build/cjs/index.server.js" + }, + "default": { + "import": "./build/esm/index.server.js", + "require": "./build/cjs/index.server.js" + } + }, + "./client": { + "types": "./build/types/client/index.d.ts", + "import": "./build/esm/client/index.js", + "require": "./build/cjs/client/index.js" + }, + "./server": { + "types": "./build/types/server/index.d.ts", + "import": "./build/esm/server/index.js", + "require": "./build/cjs/server/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@sentry/browser": "10.67.0", + "@sentry/core": "10.67.0", + "@sentry/node": "10.67.0" + }, + "peerDependencies": { + "@solidjs/web": "^2.0.0-rc.8", + "solid-js": "^2.0.0-rc.8" + }, + "devDependencies": { + "@solidjs/signals": "link:../../../solid-server-dev/packages/signals", + "@solidjs/web": "link:../../../solid-server-dev/packages/web", + "solid-js": "link:../../../solid-server-dev/packages/solid" + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "circularDepCheck": "madge --circular src/index.client.ts && madge --circular src/index.server.ts && madge --circular src/index.types.ts", + "clean": "rimraf build coverage sentry-solid-2-*.tgz", + "lint:fix": "oxlint . --fix --type-aware", + "lint": "oxlint . --type-aware", + "lint:types": "oxlint src --type-aware --type-check", + "lint:es-compatibility": "es-check es2020 ./build/cjs/*.js && es-check es2020 ./build/esm/*.js --module", + "test": "vitest run", + "test:watch": "vitest --watch", + "yalc:publish": "yalc publish --push --sig" + }, + "volta": { + "extends": "../../package.json" + }, + "nx": { + "targets": { + "build:types": { + "outputs": [ + "{projectRoot}/build/types" + ] + } + } + }, + "sideEffects": false +} diff --git a/packages/solid-2/rollup.npm.config.mjs b/packages/solid-2/rollup.npm.config.mjs new file mode 100644 index 000000000000..90938cd1ed59 --- /dev/null +++ b/packages/solid-2/rollup.npm.config.mjs @@ -0,0 +1,10 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.client.ts', 'src/index.server.ts', 'src/client/index.ts', 'src/server/index.ts'], + packageSpecificConfig: { + external: ['solid-js', 'solid-js/attribution', '@solidjs/web'], + }, + }), +); diff --git a/packages/solid-2/src/client/errors.ts b/packages/solid-2/src/client/errors.ts new file mode 100644 index 000000000000..8d8e325e8670 --- /dev/null +++ b/packages/solid-2/src/client/errors.ts @@ -0,0 +1,32 @@ +import { captureException, defineIntegration } from '@sentry/core'; +import { configureClientErrors } from 'solid-js'; + +const INTEGRATION_NAME = 'SolidErrors'; + +/** + * Reports every error a Solid `` boundary catches — the runtime's + * client error hook, available in every build tier. Uncaught errors keep + * reaching the browser SDK's global handlers; this covers the ones a + * fallback swallowed. + */ +export const solidErrorsIntegration = defineIntegration(() => { + return { + name: INTEGRATION_NAME, + setup() { + configureClientErrors({ + onError(error, { ownerPath, boundaryPath }) { + captureException(error, { + mechanism: { type: 'auto.function.solid.error_boundary', handled: true }, + captureContext: { + tags: { + 'solid.owner': ownerPath?.join(' › '), + 'solid.boundary': boundaryPath?.join(' › '), + }, + extra: { 'solid.ownerPath': ownerPath, 'solid.boundaryPath': boundaryPath }, + }, + }); + }, + }); + }, + }; +}); diff --git a/packages/solid-2/src/client/index.ts b/packages/solid-2/src/client/index.ts new file mode 100644 index 000000000000..1fb6e060c43a --- /dev/null +++ b/packages/solid-2/src/client/index.ts @@ -0,0 +1,7 @@ +export * from '@sentry/browser'; + +export { init, getDefaultIntegrations } from './sdk'; +export { solidErrorsIntegration } from './errors'; +export { solidTracingIntegration } from './tracing'; +export type { SolidTracingOptions } from './tracing'; +export type { DiagnosticsOptions } from '../common/diagnostics'; diff --git a/packages/solid-2/src/client/records.ts b/packages/solid-2/src/client/records.ts new file mode 100644 index 000000000000..5b40e5a66b1b --- /dev/null +++ b/packages/solid-2/src/client/records.ts @@ -0,0 +1,71 @@ +import type { Span } from '@sentry/core'; +import { captureException, startInactiveSpan } from '@sentry/core'; +import type { CallEvent, CallLive, FrameEvent, FrameLive } from '@solidjs/web'; +import { describeOrigin } from '../common/target'; +import { epochSeconds, round } from '../common/time'; + +const CALL_ORIGIN = 'auto.http.solid.call'; +const FRAME_ORIGIN = 'auto.ui.solid.frame'; + +/** + * One span per server-function call the page made, as the caller awaited it: + * the twin of the server's `"invocation"` span (same `id`; the difference is + * the wire). The browser SDK's own `http.client` span for the fetch is the + * transport's view — this one is the runtime's, includes decode, and knows + * the function rather than the URL. + */ +export function callSpan(event: CallEvent, live: CallLive, parent: Span | null, keepText: boolean): Span { + const origin = event.origin; + const span = startInactiveSpan({ + name: event.id, + op: 'function.solid.call', + parentSpan: parent, + startTime: epochSeconds(event.at), + attributes: { + 'solid.server_function.id': event.id, + 'solid.server_function.method': event.method, + 'solid.server_function.outcome': event.outcome, + 'solid.server_function.deferred': event.deferred === true, + 'solid.server_function.origin': origin ? describeOrigin(origin, keepText) : undefined, + 'solid.server_function.origin.kind': origin?.kind, + 'http.response.status_code': event.status, + 'sentry.origin': CALL_ORIGIN, + }, + }); + if (event.outcome === 'error') { + span.setStatus({ code: 2, message: event.status !== undefined ? `http ${event.status}` : 'network_error' }); + captureException(live.error, { mechanism: { type: 'auto.function.solid.server_function.call', handled: true } }); + } + span.end(epochSeconds(event.at + event.durationMs)); + return span; +} + +/** One span per frame stream the server-component transport applied, with the chunk census. */ +export function frameSpan(event: FrameEvent, live: FrameLive): void { + if (event.side !== 'client') return; + const span = startInactiveSpan({ + name: event.id || 'frame', + op: 'solid.frame.apply', + startTime: epochSeconds(event.at), + attributes: { + 'solid.frame.id': event.id, + 'solid.frame.address': event.address, + 'solid.frame.version': event.version, + 'solid.frame.outcome': event.outcome, + 'solid.frame.shellMs': event.shellMs === undefined ? undefined : round(event.shellMs), + 'solid.frame.chunks': event.chunks, + 'solid.frame.fragments': event.fragments, + 'solid.frame.slots': event.slots, + 'solid.frame.regions': event.regions, + 'solid.frame.errors': event.errors, + 'sentry.origin': FRAME_ORIGIN, + }, + }); + if (event.outcome !== 'complete') { + span.setStatus({ code: 2, message: event.outcome }); + if (live.error !== undefined) { + captureException(live.error, { mechanism: { type: 'auto.function.solid.frame.apply', handled: true } }); + } + } + span.end(epochSeconds(event.at + event.durationMs)); +} diff --git a/packages/solid-2/src/client/sdk.ts b/packages/solid-2/src/client/sdk.ts new file mode 100644 index 000000000000..d857feb030a6 --- /dev/null +++ b/packages/solid-2/src/client/sdk.ts @@ -0,0 +1,27 @@ +import type { BrowserOptions } from '@sentry/browser'; +import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as browserInit } from '@sentry/browser'; +import type { Client, Integration } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import { solidErrorsIntegration } from './errors'; + +/** Initializes the browser half of the Solid 2 SDK. */ +export function init(options: BrowserOptions): Client | undefined { + const opts = { + defaultIntegrations: getDefaultIntegrations(options), + ...options, + }; + + applySdkMetadata(opts, 'solid-2', ['solid-2', 'browser']); + + return browserInit(opts); +} + +/** + * The browser SDK's defaults plus Solid's error hook: a rendered `` + * fallback reports in every build tier without wrapping anything. Tracing + * (`solidTracingIntegration`) is opt-in, like `browserTracingIntegration`, + * and needs the `observe` build. + */ +export function getDefaultIntegrations(options: BrowserOptions): Integration[] { + return [...getBrowserDefaultIntegrations(options), solidErrorsIntegration()]; +} diff --git a/packages/solid-2/src/client/tracing.ts b/packages/solid-2/src/client/tracing.ts new file mode 100644 index 000000000000..620cc9127268 --- /dev/null +++ b/packages/solid-2/src/client/tracing.ts @@ -0,0 +1,274 @@ +import type { Span, SpanContextData, SpanLink } from '@sentry/core'; +import { debug, defineIntegration, startInactiveSpan } from '@sentry/core'; +import type { CallEvent, CallLive } from '@solidjs/web'; +import { OBSERVE } from 'solid-js'; +import type { + AttributionOptions, + ChangeOrigin, + HoldEvent, + InteractionEvent, + NavigationEvent, + RerunEvent, +} from 'solid-js/attribution'; +import { attribution } from 'solid-js/attribution'; +import type { DiagnosticsOptions } from '../common/diagnostics'; +import { captureDiagnostic } from '../common/diagnostics'; +import { describeOrigin, describeTarget } from '../common/target'; +import { epochSeconds, round } from '../common/time'; +import { DEBUG_BUILD } from '../debug-build'; +import { callSpan, frameSpan } from './records'; + +const INTEGRATION_NAME = 'SolidTracing'; +const ORIGIN = 'auto.ui.solid.attribution'; + +let uninstall: (() => void) | undefined; + +export interface SolidTracingOptions { + /** + * Options for Solid's attribution engine (`attribution.enable`). `log` is + * always off — the SDK is the consumer, not the console. + */ + attribution?: Omit; + /** Report the runtime's diagnostics as issues (default on, `warn` and up). `false` disables. */ + diagnostics?: DiagnosticsOptions | false; + /** + * Spans for the runtime's own records — server-function calls and applied + * frame streams (default on). + */ + records?: boolean; + /** + * Keep the element text Solid puts in an interaction's target + * (`button#next "Next →"`, up to 30 characters) in span names and + * attributes. Off by default — the text of a `` a user clicked is user + * data; the element alone (`button#next`) is kept either way. + */ + targetText?: boolean; +} + +/** + * Traces from Solid 2's observe tier: one root span per user interaction + * with its navigations, holds and server-function calls as children; a + * navigation or hold no interaction claims as a root span of its own; the + * runtime's diagnostics as issues. Records arrive settled, with an absolute + * `at` and durations, so every span is built retroactively with explicit + * start and end times. Inert on a build without `OBSERVE` (production + * without the `observe` condition): errors still report through + * `solidErrorsIntegration`. + */ +export const solidTracingIntegration = defineIntegration((options: SolidTracingOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup() { + if (OBSERVE === undefined) { + DEBUG_BUILD && debug.warn('solidTracingIntegration: solid-js is not an observe build; no traces'); + return; + } + // Solid's channels are process-wide, not per client: a second `init` + // (tests, HMR) replaces the previous subscriptions rather than stacking. + uninstall?.(); + const tracer = new Tracer(options.targetText === true); + attribution.enable({ historyLimit: 200, ...options.attribution, log: false }); + const off = [ + attribution.subscribe('rerun', event => tracer.rerun(event)), + attribution.subscribe('interaction', event => queueMicrotask(() => tracer.interaction(event))), + attribution.subscribe('navigation', event => { + if (event.interaction === undefined) queueMicrotask(() => tracer.orphanNavigation(event)); + }), + attribution.subscribe('hold', event => { + if (event.interaction === undefined && event.origin?.kind !== 'navigation') { + queueMicrotask(() => holdSpan(event, null, tracer.keepText)); + } + }), + ]; + if (options.diagnostics !== false) { + const diagnosticsOptions = options.diagnostics; + off.push( + OBSERVE.diagnostics.subscribe(event => queueMicrotask(() => captureDiagnostic(event, diagnosticsOptions))), + ); + } + if (options.records !== false) { + off.push( + OBSERVE.records.subscribe('call', (event, live) => { + if (tracer.claimCall(event, live)) return; + queueMicrotask(() => callSpan(event, live, null, tracer.keepText)); + }), + OBSERVE.records.subscribe('frame', (event, live) => { + if (event.side === 'client') queueMicrotask(() => frameSpan(event, live)); + }), + ); + } + uninstall = () => { + for (const fn of off) fn(); + uninstall = undefined; + }; + }, + }; +}); + +const isSilent = (hold: HoldEvent): boolean => hold.acknowledgements.length === 0 && hold.paintedDuringHold === 0; + +function holdSpan(hold: HoldEvent, parent: Span | null, keepText: boolean): Span { + const span = startInactiveSpan({ + name: `hold${hold.blockers.length ? ` waiting on ${hold.blockers.join(', ')}` : ''}`, + op: 'solid.hold', + parentSpan: parent, + startTime: epochSeconds(hold.at), + attributes: { + 'solid.hold.ms': round(hold.holdMs), + 'solid.hold.tailMs': round(hold.tailMs), + 'solid.hold.flushes': hold.flushes, + 'solid.hold.silent': isSilent(hold), + 'solid.hold.acknowledgedBy': hold.acknowledgements.map(a => `${a.kind}:${a.source}`), + 'solid.hold.readers': hold.acknowledgements.flatMap(a => (a.reader ? [a.reader.join(' › ')] : [])), + 'solid.hold.blockers': hold.blockers, + 'solid.hold.heldWrites': hold.heldWrites.map(w => w.name), + 'solid.hold.painted': hold.paintedDuringHold, + 'solid.hold.action': hold.action, + 'solid.hold.navigation': hold.origin ? describeOrigin(hold.origin, keepText) : undefined, + 'sentry.origin': ORIGIN, + }, + }); + span.end(epochSeconds(hold.at + hold.holdMs)); + return span; +} + +function navigationSpan(nav: NavigationEvent, parent: Span | null, keepText: boolean, links?: SpanLink[]): Span { + const attributes: Record = { + 'solid.navigation.to': nav.to, + 'solid.navigation.from': nav.from, + 'solid.navigation.outcome': nav.outcome, + 'solid.navigation.writes': nav.writes, + 'solid.navigation.redirects': nav.redirects?.map(h => h.to ?? h.name ?? '?'), + 'solid.navigation.silent': nav.hold !== undefined && isSilent(nav.hold), + 'sentry.origin': ORIGIN, + }; + for (const [key, value] of Object.entries(nav.params ?? {})) { + if (value !== undefined) attributes[`url.path.parameter.${key}`] = value; + } + const span = startInactiveSpan({ + name: nav.name ?? nav.to ?? 'navigation', + op: 'navigation', + parentSpan: parent, + startTime: epochSeconds(nav.at), + attributes, + links, + }); + if (nav.hold !== undefined) holdSpan(nav.hold, span, keepText); + span.end(epochSeconds(nav.at + (nav.settledMs ?? 0))); + return span; +} + +interface RecentInteraction { + at: number; + until: number; + context: SpanContextData; +} +const RECENT_LIMIT = 50; + +class Tracer { + /** Self-time per node name for each open interaction — the record has totals, not the breakdown. */ + private readonly _hot: WeakMap>; + private readonly _settled: WeakSet; + /** Server-function calls dispatched under an interaction still open, awaiting its segment. */ + private readonly _calls: WeakMap>; + /** Settled interactions kept for the time join, newest last. */ + private readonly _recent: RecentInteraction[]; + + public constructor(public readonly keepText: boolean) { + this._hot = new WeakMap(); + this._settled = new WeakSet(); + this._calls = new WeakMap(); + this._recent = []; + } + + public rerun(event: RerunEvent): void { + const origin = event.interaction; + // Runs after settle (an async landing behind a Loading boundary) are the record's, not its wait. + if (origin === undefined || this._settled.has(origin)) return; + let hot = this._hot.get(origin); + if (hot === undefined) this._hot.set(origin, (hot = new Map())); + hot.set(event.nodeName, (hot.get(event.nodeName) ?? 0) + event.selfMs); + } + + /** + * A call whose `origin` runs under an interaction still open is the + * interaction's — held for its span, joined by the engine's object + * identity rather than by time. Anything else is a root span of its own. + */ + public claimCall(event: CallEvent, live: CallLive): boolean { + const origin = event.origin; + const interaction = origin === undefined ? undefined : origin.kind === 'interaction' ? origin : origin.interaction; + if (interaction === undefined || this._settled.has(interaction)) return false; + let calls = this._calls.get(interaction); + if (calls === undefined) this._calls.set(interaction, (calls = [])); + calls.push({ event, live }); + return true; + } + + public interaction(event: InteractionEvent): void { + const { origin } = event; + this._settled.add(origin); + const span = startInactiveSpan({ + name: describeOrigin(origin, this.keepText), + op: `ui.interaction.${event.name}`, + parentSpan: null, + startTime: epochSeconds(event.at), + attributes: { + 'solid.interaction.type': event.name, + 'solid.interaction.target': describeTarget(event.target, this.keepText), + 'solid.interaction.outcome': event.outcome, + 'solid.interaction.handlerMs': round(event.handlerMs), + 'solid.interaction.writes': event.writes, + 'solid.reruns': event.runs, + 'solid.created': event.created, + 'solid.runMs': round(event.runMs), + 'solid.hot': this._hotList(origin), + 'solid.holds': event.holds.length, + 'solid.navigations': event.navigations.length, + 'sentry.origin': ORIGIN, + }, + }); + const underNavigation = new Set(); + for (const nav of event.navigations) { + if (nav.hold !== undefined) underNavigation.add(nav.hold); + navigationSpan(nav, span, this.keepText); + } + for (const hold of event.holds) if (!underNavigation.has(hold)) holdSpan(hold, span, this.keepText); + const calls = this._calls.get(origin); + if (calls !== undefined) { + this._calls.delete(origin); + for (const call of calls) callSpan(call.event, call.live, span, this.keepText); + } + span.end(epochSeconds(event.at + (event.settledMs ?? event.handlerMs))); + this._recent.push({ at: event.at, until: event.at + event.handlerMs, context: span.spanContext() }); + if (this._recent.length > RECENT_LIMIT) this._recent.shift(); + } + + /** + * A navigation the engine could not stamp with an interaction: a router + * that publishes in a later task, or a programmatic `navigate()`. If its + * request time sits inside a settled interaction's handler window, that + * click is its cause — the two traces are linked rather than a parent guessed. + */ + public orphanNavigation(nav: NavigationEvent): void { + let cause: RecentInteraction | undefined; + for (let i = this._recent.length - 1; i >= 0 && cause === undefined; i--) { + const r = this._recent[i]!; + if (nav.at >= r.at && nav.at <= r.until) cause = r; + } + const links: SpanLink[] | undefined = cause + ? [{ context: cause.context, attributes: { 'solid.link': 'interaction-by-time' } }] + : undefined; + navigationSpan(nav, null, this.keepText, links); + } + + private _hotList(origin: ChangeOrigin): string[] { + const hot = this._hot.get(origin); + this._hot.delete(origin); + if (hot === undefined) return []; + return [...hot] + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([name, ms]) => `${name} ${ms.toFixed(2)}ms`); + } +} diff --git a/packages/solid-2/src/common/diagnostics.ts b/packages/solid-2/src/common/diagnostics.ts new file mode 100644 index 000000000000..71c4d7027f81 --- /dev/null +++ b/packages/solid-2/src/common/diagnostics.ts @@ -0,0 +1,52 @@ +import type { SeverityLevel } from '@sentry/core'; +import { captureEvent } from '@sentry/core'; +import type { DiagnosticEvent } from 'solid-js'; + +const LEVEL: Record = { + info: 'info', + warn: 'warning', + error: 'error', +}; + +export interface DiagnosticsOptions { + /** + * Minimum severity to report as an issue. `info` findings are advisory in + * Solid's own tiering (structured channel only, never the console); the + * default reports `warn` and up. + */ + minSeverity?: DiagnosticEvent['severity']; +} + +/** + * A finding's `data`, as the issue's extras. `data.error` — the value as + * thrown, unsanitized, on the server error findings — is left out: the error + * hook already captured it as an exception, and an extras object is not + * where an error's own properties should travel. + */ +function extras(event: DiagnosticEvent): Record { + const { error: _error, ...data } = event.data ?? {}; + return { ...data, ownerPath: event.ownerPath, message: event.message }; +} + +const RANK: Record = { info: 0, warn: 1, error: 2 }; + +/** + * A finding is an issue, not a span: it has a stable identity and recurs. + * Fingerprinted by code + owner path so every occurrence of "the pager holds + * silently" groups into one issue across sessions and minified identifiers. + */ +export function captureDiagnostic(event: DiagnosticEvent, options: DiagnosticsOptions = {}): void { + if (RANK[event.severity] < RANK[options.minSeverity ?? 'warn']) return; + captureEvent({ + message: event.message.split('\n')[0], + level: LEVEL[event.severity], + fingerprint: [event.code, ...(event.ownerPath ?? (event.nodeName ? [event.nodeName] : []))], + tags: { + 'solid.code': event.code, + 'solid.kind': event.kind, + 'solid.node': event.nodeName, + 'solid.owner': event.ownerPath?.join(' › '), + }, + extra: extras(event), + }); +} diff --git a/packages/solid-2/src/common/target.ts b/packages/solid-2/src/common/target.ts new file mode 100644 index 000000000000..b5376e2ad880 --- /dev/null +++ b/packages/solid-2/src/common/target.ts @@ -0,0 +1,18 @@ +import type { ChangeOrigin } from 'solid-js/attribution'; +import { formatOrigin } from 'solid-js/attribution'; + +/** + * Solid describes the element an interaction hit as `tag#id "text"`, with up + * to 30 characters of its text content — a button's label, but also whatever + * a `` said. The text is user data; unless the SDK is told to keep it, + * only the element stays: `button#next`. + */ +export function describeTarget(target: string | undefined, keepText: boolean): string | undefined { + return target === undefined || keepText ? target : target.replace(/ "[^"]*"$/, ''); +} + +/** `formatOrigin`, with target text handled the same way wherever an origin is named. */ +export function describeOrigin(origin: ChangeOrigin, keepText: boolean): string { + const text = formatOrigin(origin); + return keepText ? text : text.replace(/ "[^"]*"(?=[)\s]|$)/g, ''); +} diff --git a/packages/solid-2/src/common/time.ts b/packages/solid-2/src/common/time.ts new file mode 100644 index 000000000000..bd8eb06e98a5 --- /dev/null +++ b/packages/solid-2/src/common/time.ts @@ -0,0 +1,11 @@ +/** + * Every `at` Solid's runtime and attribution engine emit is on the + * `performance.now()` clock; Sentry spans take epoch seconds. + */ +export function epochSeconds(perfNow: number): number { + return (performance.timeOrigin + perfNow) / 1000; +} + +export function round(ms: number): number { + return Math.round(ms * 100) / 100; +} diff --git a/packages/solid-2/src/debug-build.ts b/packages/solid-2/src/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/solid-2/src/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/solid-2/src/index.client.ts b/packages/solid-2/src/index.client.ts new file mode 100644 index 000000000000..4f1cce44fa36 --- /dev/null +++ b/packages/solid-2/src/index.client.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/packages/solid-2/src/index.server.ts b/packages/solid-2/src/index.server.ts new file mode 100644 index 000000000000..0ce5251aa327 --- /dev/null +++ b/packages/solid-2/src/index.server.ts @@ -0,0 +1 @@ +export * from './server'; diff --git a/packages/solid-2/src/index.types.ts b/packages/solid-2/src/index.types.ts new file mode 100644 index 000000000000..55baabff069f --- /dev/null +++ b/packages/solid-2/src/index.types.ts @@ -0,0 +1,39 @@ +// 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 redefine the colliding +// exports in this file - which we do below. +import type { Client, Integration, Options, StackParser } from '@sentry/core'; +import type * as clientSdk from './client'; +import type * as serverSdk from './server'; + +export * from './client'; +export * from './server'; + +/** Initializes Sentry Solid 2 SDK */ +export declare function init(options: Options | clientSdk.BrowserOptions | serverSdk.NodeOptions): Client | undefined; + +export declare const linkedErrorsIntegration: typeof clientSdk.linkedErrorsIntegration; +export declare const consoleIntegration: typeof serverSdk.consoleIntegration; +export declare const contextLinesIntegration: typeof clientSdk.contextLinesIntegration; +export declare const startSpan: typeof clientSdk.startSpan; +export declare const startSpanManual: typeof clientSdk.startSpanManual; +export declare const startInactiveSpan: typeof clientSdk.startInactiveSpan; +export declare const withStaticSpan: typeof clientSdk.withStaticSpan; +// oxlint-disable-next-line typescript/no-deprecated +export declare const withStreamedSpan: typeof clientSdk.withStreamedSpan; + +export declare const getDefaultIntegrations: (options: Options) => Integration[]; +export declare const defaultStackParser: StackParser; + +export declare function close(timeout?: number): PromiseLike; +export declare function flush(timeout?: number): PromiseLike; +export declare function lastEventId(): string | undefined; + +export declare const logger: typeof clientSdk.logger | typeof serverSdk.logger; + +export declare const growthbookIntegration: typeof clientSdk.growthbookIntegration; +export declare const launchDarklyIntegration: typeof clientSdk.launchDarklyIntegration; +export declare const buildLaunchDarklyFlagUsedHandler: typeof clientSdk.buildLaunchDarklyFlagUsedHandler; +export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegration; +export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; +export declare const statsigIntegration: typeof clientSdk.statsigIntegration; +export declare const unleashIntegration: typeof clientSdk.unleashIntegration; diff --git a/packages/solid-2/src/server/errors.ts b/packages/solid-2/src/server/errors.ts new file mode 100644 index 000000000000..2cb68ad7f821 --- /dev/null +++ b/packages/solid-2/src/server/errors.ts @@ -0,0 +1,55 @@ +import { captureException, defineIntegration } from '@sentry/core'; +import type { ServerErrorContext } from '@solidjs/web'; +import { configureServerErrors } from '@solidjs/web'; + +const INTEGRATION_NAME = 'SolidServerErrors'; + +export interface SolidServerErrorsOptions { + /** + * Map an error to what the client receives in its place — rendered into + * the fallback, serialized for hydration, sent as the RPC error. Return + * nothing for Solid's default wire policy (a generic error outside the dev + * build). Solid does not sanitize a returned value again. + */ + mapError?: (error: unknown, context: ServerErrorContext) => unknown | void; +} + +/** + * Reports every failure Solid's server runtime handles or fails on, once per + * error object, with where it was met: an `` fallback rendered, a + * `` fragment rejected, a server-function throw (HTTP dispatch or an + * in-process call during SSR), a hydration value that would not serialize, + * the failure that fails a request. The error as thrown — the wire gets the + * sanitized one. Available in every build tier. + */ +export const solidServerErrorsIntegration = defineIntegration((options: SolidServerErrorsOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup() { + configureServerErrors({ + onError(error, context) { + const { kind, handling, boundary, boundaryPath, functionId, direct, ownerPath } = context; + captureException(error, { + mechanism: { + type: `auto.function.solid.server.${kind}.${handling}`, + handled: handling !== 'failed', + }, + captureContext: { + tags: { + 'solid.kind': kind, + 'solid.handling': handling, + 'solid.boundary': boundary, + 'solid.function': functionId, + 'solid.direct': direct === undefined ? undefined : String(direct), + 'solid.owner': ownerPath?.join(' › '), + 'solid.boundary_path': boundaryPath?.join(' › '), + }, + extra: { 'solid.ownerPath': ownerPath, 'solid.boundaryPath': boundaryPath }, + }, + }); + return options.mapError?.(error, context); + }, + }); + }, + }; +}); diff --git a/packages/solid-2/src/server/index.ts b/packages/solid-2/src/server/index.ts new file mode 100644 index 000000000000..998ae0f638b3 --- /dev/null +++ b/packages/solid-2/src/server/index.ts @@ -0,0 +1,8 @@ +export * from '@sentry/node'; + +export { init, getDefaultIntegrations } from './sdk'; +export { solidServerErrorsIntegration } from './errors'; +export type { SolidServerErrorsOptions } from './errors'; +export { solidServerTracingIntegration } from './tracing'; +export type { SolidServerTracingOptions } from './tracing'; +export type { DiagnosticsOptions } from '../common/diagnostics'; diff --git a/packages/solid-2/src/server/sdk.ts b/packages/solid-2/src/server/sdk.ts new file mode 100644 index 000000000000..e5185a931947 --- /dev/null +++ b/packages/solid-2/src/server/sdk.ts @@ -0,0 +1,26 @@ +import type { Integration } from '@sentry/core'; +import { applySdkMetadata } from '@sentry/core'; +import type { NodeClient, NodeOptions } from '@sentry/node'; +import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node'; +import { solidServerErrorsIntegration } from './errors'; + +/** Initializes the server half of the Solid 2 SDK. */ +export function init(options: NodeOptions): NodeClient | undefined { + const opts = { + defaultIntegrations: getDefaultIntegrations(options), + ...options, + }; + + applySdkMetadata(opts, 'solid-2', ['solid-2', 'node']); + + return initNodeSdk(opts); +} + +/** + * The Node SDK's defaults plus Solid's server error hook: every failure the + * server runtime handles reports in every build tier. Tracing + * (`solidServerTracingIntegration`) is opt-in and needs the `observe` build. + */ +export function getDefaultIntegrations(options: NodeOptions): Integration[] { + return [...getNodeDefaultIntegrations(options), solidServerErrorsIntegration()]; +} diff --git a/packages/solid-2/src/server/tracing.ts b/packages/solid-2/src/server/tracing.ts new file mode 100644 index 000000000000..d6dc3fc822f7 --- /dev/null +++ b/packages/solid-2/src/server/tracing.ts @@ -0,0 +1,164 @@ +import type { Span } from '@sentry/core'; +import { + captureException, + debug, + defineIntegration, + getActiveSpan, + getTraceData, + spanToJSON, + startInactiveSpan, +} from '@sentry/core'; +import type { FrameEvent, FrameLive, InvocationEvent, InvocationLive, TraceContext } from '@solidjs/web'; +import type { BoundaryEvent, BoundaryLive } from 'solid-js'; +import { OBSERVE } from 'solid-js'; +import type { DiagnosticsOptions } from '../common/diagnostics'; +import { captureDiagnostic } from '../common/diagnostics'; +import { epochSeconds } from '../common/time'; +import { DEBUG_BUILD } from '../debug-build'; + +const INTEGRATION_NAME = 'SolidServerTracing'; +const ORIGIN = 'auto.function.solid.server'; + +let uninstall: (() => void) | undefined; + +export interface SolidServerTracingOptions { + /** Report the runtime's server diagnostics as issues (default on, `warn` and up). `false` disables. */ + diagnostics?: DiagnosticsOptions | false; +} + +/** + * The server half of Solid 2 tracing, all through `OBSERVE`: the trace + * provider that lets the runtime carry Sentry's trace to the browser on its + * own two carriers (`Server-Timing` on every response, the `` pair in + * an HTML shell — no middleware, no body rewriting, works for frames and RPC + * responses that have no ``), and one span per server-function + * execution, per `` boundary that waited, and per frame stream + * produced — each delivered inside the request's async context, so they + * parent on the active `http.server` span. Inert without `OBSERVE`. + */ +export const solidServerTracingIntegration = defineIntegration((options: SolidServerTracingOptions = {}) => { + return { + name: INTEGRATION_NAME, + setup() { + if (OBSERVE === undefined) { + DEBUG_BUILD && debug.warn('solidServerTracingIntegration: solid-js is not an observe build; no traces'); + return; + } + // Solid's slots are process-wide, not per client: a second `init` + // replaces the previous provider and subscriptions rather than stacking. + uninstall?.(); + const off = [ + OBSERVE.server.trace.provide(traceProvider), + OBSERVE.records.subscribe('invocation', invocationSpan), + OBSERVE.records.subscribe('boundary', boundarySpan), + OBSERVE.records.subscribe('frame', (event, live) => { + if (event.side === 'server') frameSpan(event, live); + }), + ]; + if (options.diagnostics !== false) { + const diagnosticsOptions = options.diagnostics; + off.push(OBSERVE.diagnostics.subscribe(event => captureDiagnostic(event, diagnosticsOptions))); + } + uninstall = () => { + for (const fn of off) fn(); + uninstall = undefined; + }; + }, + }; +}); + +/** + * Called by the runtime once per request, inside the request's async + * context, where the `http.server` span is active. The parent is overridden + * too: the browser sends `sentry-trace` and `traceparent` with different span + * ids, `@sentry/node` continues from the former while the runtime derives + * its parent from the latter — here Sentry's view wins. + */ +function traceProvider(): Partial | undefined { + const span = getActiveSpan(); + if (!span) return undefined; + const context = span.spanContext(); + const data = getTraceData(); + const entries: Record = {}; + if (data['sentry-trace']) entries['sentry-trace'] = data['sentry-trace']; + if (data.baggage) entries.baggage = data.baggage; + return { + traceId: context.traceId, + spanId: context.spanId, + parentId: spanToJSON(span).parent_span_id, + sampled: context.traceFlags % 2 === 1, + entries, + }; +} + +function invocationSpan(event: InvocationEvent, live: InvocationLive): Span { + const start = epochSeconds(event.at); + const span = startInactiveSpan({ + name: event.id, + op: event.direct ? 'function.solid.direct' : 'function.solid.rpc', + startTime: start, + attributes: { + 'solid.server_function.id': event.id, + 'solid.server_function.direct': event.direct, + 'solid.server_function.deferred': event.deferred === true, + 'solid.server_function.outcome': event.outcome, + 'solid.server_function.boundary': event.boundary, + 'sentry.origin': ORIGIN, + }, + }); + if (event.outcome === 'error') { + span.setStatus({ code: 2, message: 'internal_error' }); + captureException(live.error, { mechanism: { type: 'auto.function.solid.server_function', handled: false } }); + } + span.end(epochSeconds(event.at + event.durationMs)); + return span; +} + +function boundarySpan(event: BoundaryEvent, live: BoundaryLive): Span { + const span = startInactiveSpan({ + name: event.ownerPath ? event.ownerPath.join(' › ') : `boundary ${event.id}`, + op: 'solid.boundary', + startTime: epochSeconds(event.at), + attributes: { + 'solid.boundary.id': event.id, + 'solid.boundary.outcome': event.outcome, + 'solid.boundary.passes': event.passes, + 'solid.boundary.streamed': event.streamed, + 'solid.boundary.heldMs': event.heldMs, + 'solid.boundary.revealGroup': event.revealGroup, + 'sentry.origin': ORIGIN, + }, + }); + if (event.outcome === 'error') { + span.setStatus({ code: 2, message: 'internal_error' }); + captureException(live.error, { mechanism: { type: 'auto.function.solid.boundary', handled: true } }); + } + span.end(epochSeconds(event.at + event.durationMs + event.heldMs)); + return span; +} + +function frameSpan(event: FrameEvent, live: FrameLive): Span { + const span = startInactiveSpan({ + name: event.id || 'frame', + op: 'solid.frame.produce', + startTime: epochSeconds(event.at), + attributes: { + 'solid.frame.id': event.id, + 'solid.frame.version': event.version, + 'solid.frame.outcome': event.outcome, + 'solid.frame.shellMs': event.shellMs, + 'solid.frame.chunks': event.chunks, + 'solid.frame.fragments': event.fragments, + 'solid.frame.slots': event.slots, + 'solid.frame.regions': event.regions, + 'solid.frame.errors': event.errors, + 'sentry.origin': ORIGIN, + }, + }); + if (event.outcome === 'error') { + span.setStatus({ code: 2, message: 'internal_error' }); + captureException(live.error, { mechanism: { type: 'auto.function.solid.frame', handled: true } }); + } + span.end(epochSeconds(event.at + event.durationMs)); + return span; +} diff --git a/packages/solid-2/test/client/errors.test.ts b/packages/solid-2/test/client/errors.test.ts new file mode 100644 index 000000000000..17d040918c18 --- /dev/null +++ b/packages/solid-2/test/client/errors.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment jsdom + */ +import type { Event } from '@sentry/core'; +import { createTransport, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { render } from '@solidjs/web'; +import { createComponent, createMemo, createSignal, flush } from 'solid-js'; +import { Errored } from 'solid-js'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { BrowserClient, solidErrorsIntegration } from '../../src/client'; +import { DEV, OBSERVE } from 'solid-js'; + +function clientWith(events: Event[]): BrowserClient { + const client = new BrowserClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [solidErrorsIntegration()], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + beforeSend: event => { + events.push(event); + return null; + }, + }); + setCurrentClient(client); + client.init(); + return client; +} + +describe('solidErrorsIntegration', () => { + beforeEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('runs against the observe build of solid-js', () => { + expect(OBSERVE).toBeDefined(); + expect(DEV).toBeUndefined(); + }); + + it('reports what an boundary catches, once, with the component labels', async () => { + const events: Event[] = []; + const client = clientWith(events); + const [fail, setFail] = createSignal(false); + const boom = new Error('widget exploded'); + + const Widget = () => { + const view = createMemo( + () => { + if (fail()) throw boom; + return 'ok'; + }, + { name: 'view' }, + ); + return createMemo(() => view()); + }; + const App = () => + createComponent(Errored, { + fallback: () => 'fallback', + get children() { + return createComponent(Widget, {}, 'Widget'); + }, + }); + + const container = document.createElement('div'); + const dispose = render(() => createComponent(App, {}, 'App'), container); + flush(); + setFail(true); + flush(); + await client.flush(100); + + expect(events).toHaveLength(1); + const event = events[0]!; + expect(event.exception?.values?.[0]).toMatchObject({ + value: 'widget exploded', + mechanism: { type: 'auto.function.solid.error_boundary', handled: true }, + }); + // Where it broke, apart from where it was met. + expect(event.tags?.['solid.owner']).toBe(' › computed › › view'); + expect(event.tags?.['solid.boundary']).toBe(''); + expect(event.extra?.['solid.boundaryPath']).toEqual(['', '']); + dispose(); + }); +}); diff --git a/packages/solid-2/test/client/tracing.test.ts b/packages/solid-2/test/client/tracing.test.ts new file mode 100644 index 000000000000..8fb4069f4da8 --- /dev/null +++ b/packages/solid-2/test/client/tracing.test.ts @@ -0,0 +1,277 @@ +/** + * @vitest-environment jsdom + */ +import type { Event, StreamedSpanJSON } from '@sentry/core'; +import { createTransport, getCurrentScope, setCurrentClient, spanStreamingIntegration } from '@sentry/core'; +import { OBSERVE, createEffect, createRoot, createSignal, flush } from 'solid-js'; +import { attribution } from 'solid-js/attribution'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BrowserClient, solidTracingIntegration } from '../../src/client'; +import type { SolidTracingOptions } from '../../src/client'; + +interface Captured { + events: Event[]; + spans: StreamedSpanJSON[]; +} + +function clientWith(options?: SolidTracingOptions): { client: BrowserClient; captured: Captured } { + const captured: Captured = { events: [], spans: [] }; + const client = new BrowserClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [spanStreamingIntegration(), solidTracingIntegration(options)], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + beforeSend: event => { + captured.events.push(event); + return null; + }, + beforeSendSpan: span => { + captured.spans.push(span); + return span; + }, + }); + setCurrentClient(client); + client.init(); + return { client, captured }; +} + +const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + +function readerApp(): { setCount: (n: number) => void; dispose: () => void } { + const [count, setCount] = createSignal(0, { name: 'count' }); + const dispose = createRoot(dispose => { + createEffect(count, () => {}, { name: 'reader' }); + return dispose; + }); + flush(); + return { setCount, dispose }; +} + +describe('solidTracingIntegration', () => { + beforeEach(() => { + getCurrentScope().setClient(undefined); + }); + afterEach(() => { + attribution.disable(); + flush(); + }); + + it('turns a user interaction into a segment with the navigation it performed as a child', async () => { + const { client, captured } = clientWith(); + expect(OBSERVE!.attribution.installed).not.toBeNull(); + const app = readerApp(); + + OBSERVE!.attribution.withInteraction({ type: 'click', target: 'button#next "Next"' }, () => + OBSERVE!.attribution.withOrigin( + { kind: 'navigation', name: '/users/:id', to: '/users/42', from: '/', params: { id: '42' } }, + () => app.setCount(1), + ), + ); + flush(); + await settle(); + await client.flush(100); + + const segment = captured.spans.find(span => span.is_segment); + expect(segment).toBeDefined(); + // Element text is user data: off by default, the element stays. + expect(segment).toMatchObject({ + name: 'click on button#next', + attributes: expect.objectContaining({ + 'sentry.op': 'ui.interaction.click', + 'solid.interaction.type': 'click', + 'solid.interaction.target': 'button#next', + 'solid.interaction.writes': 1, + 'solid.navigations': 1, + 'sentry.origin': 'auto.ui.solid.attribution', + }), + }); + expect(segment!.attributes['solid.reruns']).toBeGreaterThanOrEqual(1); + expect(segment!.attributes['solid.hot']).toEqual(expect.arrayContaining([expect.stringMatching(/^reader /)])); + + const nav = captured.spans.find(span => span.attributes['sentry.op'] === 'navigation'); + expect(nav).toMatchObject({ + name: '/users/:id', + parent_span_id: segment!.span_id, + trace_id: segment!.trace_id, + attributes: expect.objectContaining({ + 'solid.navigation.to': '/users/42', + 'solid.navigation.from': '/', + 'solid.navigation.outcome': 'committed', + 'url.path.parameter.id': '42', + }), + }); + // Backdated to the engine's clock: the segment starts at dispatch, not when the record settled. + expect(segment!.start_timestamp).toBeLessThanOrEqual(nav!.start_timestamp); + expect(nav!.end_timestamp!).toBeLessThanOrEqual(segment!.end_timestamp!); + app.dispose(); + }); + + it('targetText keeps the element text in names and attributes', async () => { + const { client, captured } = clientWith({ targetText: true }); + const app = readerApp(); + OBSERVE!.attribution.withInteraction({ type: 'click', target: 'button#next "Next"' }, () => app.setCount(1)); + flush(); + await settle(); + await client.flush(100); + const segment = captured.spans.find(span => span.is_segment); + expect(segment?.name).toBe('click on button#next "Next"'); + expect(segment?.attributes['solid.interaction.target']).toBe('button#next "Next"'); + app.dispose(); + }); + + it("a server-function call made under an interaction is the interaction's child, joined by identity", async () => { + const { client, captured } = clientWith(); + const app = readerApp(); + const live = { args: [1], response: new Response(''), result: 'ok' }; + + OBSERVE!.attribution.withInteraction({ type: 'click', target: 'button#save' }, () => { + app.setCount(1); + // What the server-function client does at dispatch: the record carries + // the engine's own interaction frame, read while the handler runs. + const origin = OBSERVE!.attribution.currentOrigin(); + OBSERVE!.records.emit( + 'call', + { id: 'saveTodo', method: 'POST', at: performance.now(), durationMs: 12, outcome: 'ok', status: 200, origin }, + live, + ); + }); + flush(); + await settle(); + await client.flush(100); + + const segment = captured.spans.find(span => span.is_segment); + const call = captured.spans.find(span => span.attributes['sentry.op'] === 'function.solid.call'); + expect(segment).toBeDefined(); + expect(call).toMatchObject({ + name: 'saveTodo', + parent_span_id: segment!.span_id, + trace_id: segment!.trace_id, + attributes: expect.objectContaining({ + 'solid.server_function.method': 'POST', + 'solid.server_function.outcome': 'ok', + 'solid.server_function.origin.kind': 'interaction', + 'http.response.status_code': 200, + 'sentry.origin': 'auto.http.solid.call', + }), + }); + app.dispose(); + }); + + it('a call with no interaction, and a failed one, are root spans with the failure captured', async () => { + const { client, captured } = clientWith(); + const boom = new Error('server said no'); + OBSERVE!.records.emit( + 'call', + { id: 'loadFeed', method: 'GET', at: performance.now(), durationMs: 40, outcome: 'ok', status: 200 }, + { args: [], response: new Response(''), result: [] }, + ); + OBSERVE!.records.emit( + 'call', + { id: 'deleteTodo', method: 'POST', at: performance.now(), durationMs: 8, outcome: 'error', status: 500 }, + { args: [7], response: new Response('', { status: 500 }), error: boom }, + ); + await settle(); + await client.flush(100); + + const roots = captured.spans.filter(span => span.is_segment); + expect(roots.map(span => span.name).sort()).toEqual(['deleteTodo', 'loadFeed']); + const failed = roots.find(span => span.name === 'deleteTodo')!; + expect(failed.status).toBe('error'); + expect(captured.events[0]?.exception?.values?.[0]).toMatchObject({ + value: 'server said no', + mechanism: { type: 'auto.function.solid.server_function.call', handled: true }, + }); + }); + + it('an applied frame stream is a span with its chunk census; a truncated one is an error', async () => { + const { client, captured } = clientWith(); + const base = { version: 1, chunks: 5, fragments: 2, slots: 1, regions: 0, errors: 0, durationMs: 30 }; + OBSERVE!.records.emit( + 'frame', + { + ...base, + side: 'client', + id: 'Comments', + address: 'f0', + at: performance.now(), + shellMs: 4, + outcome: 'complete', + }, + { response: new Response('') }, + ); + OBSERVE!.records.emit( + 'frame', + { ...base, side: 'client', id: 'Sidebar', at: performance.now(), outcome: 'truncated' }, + { response: new Response('') }, + ); + await settle(); + await client.flush(100); + + const frames = captured.spans.filter(span => span.attributes['sentry.op'] === 'solid.frame.apply'); + expect(frames.map(span => span.name).sort()).toEqual(['Comments', 'Sidebar']); + const complete = frames.find(span => span.name === 'Comments')!; + expect(complete.attributes).toMatchObject({ + 'solid.frame.address': 'f0', + 'solid.frame.chunks': 5, + 'solid.frame.fragments': 2, + 'solid.frame.shellMs': 4, + 'sentry.origin': 'auto.ui.solid.frame', + }); + expect(frames.find(span => span.name === 'Sidebar')!.status).toBe('error'); + }); + + it('a navigation no interaction claims is its own segment', async () => { + const { client, captured } = clientWith(); + const app = readerApp(); + + OBSERVE!.attribution.withOrigin({ kind: 'navigation', name: '/about', to: '/about' }, () => app.setCount(1)); + flush(); + await settle(); + await client.flush(100); + + const segments = captured.spans.filter(span => span.is_segment); + expect(segments).toHaveLength(1); + expect(segments[0]).toMatchObject({ + name: '/about', + attributes: expect.objectContaining({ 'sentry.op': 'navigation' }), + }); + app.dispose(); + }); + + it('reports the runtime diagnostics as issues fingerprinted by code and owner', async () => { + const { client, captured } = clientWith({ attribution: { hotRuns: { count: 3, windowMs: 10_000 } } }); + const app = readerApp(); + + for (let i = 1; i <= 6; i++) { + app.setCount(i); + flush(); + } + await settle(); + await client.flush(100); + + const issue = captured.events.find(event => event.tags?.['solid.code'] === 'HOT_SCOPE_RERUNS'); + expect(issue).toBeDefined(); + expect(issue!.level).toBe('warning'); + expect(issue!.fingerprint?.[0]).toBe('HOT_SCOPE_RERUNS'); + expect(issue!.message).toContain('HOT_SCOPE_RERUNS'); + expect(issue!.tags?.['solid.node']).toBe('reader'); + app.dispose(); + }); + + it('with diagnostics off, findings stay on the channel', async () => { + const { client, captured } = clientWith({ + diagnostics: false, + attribution: { hotRuns: { count: 3, windowMs: 10_000 } }, + }); + const app = readerApp(); + for (let i = 1; i <= 6; i++) { + app.setCount(i); + flush(); + } + await settle(); + await client.flush(100); + expect(captured.events).toEqual([]); + app.dispose(); + }); +}); diff --git a/packages/solid-2/test/server/errors.test.ts b/packages/solid-2/test/server/errors.test.ts new file mode 100644 index 000000000000..e834473d2ef4 --- /dev/null +++ b/packages/solid-2/test/server/errors.test.ts @@ -0,0 +1,132 @@ +import type { Event } from '@sentry/core'; +import { createTransport, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { NodeClient } from '@sentry/node'; +import { Errored, renderToString } from '@solidjs/web'; +import { createComponent } from 'solid-js'; +import { beforeEach, describe, expect, it } from 'vitest'; +import type { SolidServerErrorsOptions } from '../../src/server'; +import { solidServerErrorsIntegration } from '../../src/server'; + +function clientWith(events: Event[], options?: SolidServerErrorsOptions): NodeClient { + const client = new NodeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [solidServerErrorsIntegration(options)], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + beforeSend: event => { + events.push(event); + return null; + }, + }); + setCurrentClient(client); + client.init(); + return client; +} + +const fallback = (err: () => unknown) => String((err() as Error).message); + +function throwingApp(error: unknown): () => unknown { + return () => + createComponent( + () => + createComponent( + Errored, + { + fallback, + get children() { + throw error; + }, + }, + 'Errored', + ), + {}, + 'App', + ); +} + +describe('solidServerErrorsIntegration', () => { + beforeEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('reports the error as thrown when an renders its fallback, with where it was met', async () => { + const events: Event[] = []; + const client = clientWith(events); + const boom = Object.assign(new Error('connect ECONNREFUSED postgres://app:hunter2@db'), { + connectionString: 'postgres://app:hunter2@db', + }); + + const html = renderToString(throwingApp(boom)); + await client.flush(100); + + // The wire gets Solid's default policy (generic outside dev); Sentry gets the real one. + expect(html).not.toContain('hunter2'); + expect(events).toHaveLength(1); + const event = events[0]!; + expect(event.exception?.values?.[0]).toMatchObject({ + value: 'connect ECONNREFUSED postgres://app:hunter2@db', + mechanism: { type: 'auto.function.solid.server.render.fallback', handled: true }, + }); + // Thrown in the boundary's own children getter here, so the two coincide. + expect(event.tags).toMatchObject({ + 'solid.kind': 'render', + 'solid.handling': 'fallback', + 'solid.owner': '', + 'solid.boundary_path': '', + }); + expect(event.tags?.['solid.boundary']).toEqual(expect.any(String)); + }); + + it('names the component that threw apart from the boundary that met it', async () => { + const events: Event[] = []; + const client = clientWith(events); + const App = () => + createComponent( + Errored, + { + fallback, + get children() { + return createComponent( + () => { + throw new Error('bad render'); + }, + {}, + 'Bad', + ); + }, + }, + 'Errored', + ); + renderToString(() => createComponent(App, {}, 'App')); + await client.flush(100); + + expect(events[0]?.tags).toMatchObject({ + 'solid.owner': '', + 'solid.boundary_path': '', + }); + }); + + it('mapError decides what the client receives in the error’s place', async () => { + const events: Event[] = []; + const client = clientWith(events, { + mapError: (error, { kind }) => new Error(`${kind} failed (ref ${(error as Error).message.length})`), + }); + + const html = renderToString(throwingApp(new Error('secret detail'))); + await client.flush(100); + + expect(html).toContain('render failed (ref 13)'); + expect(html).not.toContain('secret detail'); + expect(events[0]?.exception?.values?.[0]?.value).toBe('secret detail'); + }); + + it('reports once per error object', async () => { + const events: Event[] = []; + const client = clientWith(events); + const boom = new Error('once'); + renderToString(throwingApp(boom)); + renderToString(throwingApp(boom)); + await client.flush(100); + expect(events).toHaveLength(1); + }); +}); diff --git a/packages/solid-2/test/server/tracing.test.ts b/packages/solid-2/test/server/tracing.test.ts new file mode 100644 index 000000000000..2e10671e785d --- /dev/null +++ b/packages/solid-2/test/server/tracing.test.ts @@ -0,0 +1,229 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Event, StreamedSpanJSON } from '@sentry/core'; +import { + createTransport, + getCurrentScope, + setCurrentClient, + spanStreamingIntegration, + spanToJSON, + startSpan, +} from '@sentry/core'; +import { NodeClient } from '@sentry/node'; +import type { RequestEvent } from '@solidjs/web'; +import { createRequestEvent, getTraceContext, Loading, renderToStream } from '@solidjs/web'; +import { OBSERVE, createComponent, createMemo } from 'solid-js'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { solidServerTracingIntegration } from '../../src/server'; + +// The runtime finds the request scope on this storage — what a host wires up. +const RequestContext = Symbol.for('solid.RequestContext'); +let storage: AsyncLocalStorage; +beforeAll(() => { + storage = new AsyncLocalStorage(); + (globalThis as Record)[RequestContext] = storage; +}); +afterAll(() => { + Reflect.deleteProperty(globalThis, RequestContext); +}); + +interface Captured { + events: Event[]; + spans: StreamedSpanJSON[]; +} + +function clientWith(): { client: NodeClient; captured: Captured } { + const captured: Captured = { events: [], spans: [] }; + const client = new NodeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + integrations: [spanStreamingIntegration(), solidServerTracingIntegration()], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + beforeSend: event => { + captured.events.push(event); + return null; + }, + beforeSendSpan: span => { + captured.spans.push(span); + return span; + }, + }); + setCurrentClient(client); + client.init(); + return { client, captured }; +} + +function stream(code: () => unknown): Promise { + return new Promise(resolve => { + const chunks: string[] = []; + renderToStream(code).pipe({ + write(chunk: string) { + chunks.push(chunk); + }, + end() { + resolve(chunks.join('')); + }, + }); + }); +} + +const inRequest = (fn: () => T): T => + storage.run(createRequestEvent(new Request('https://app.example/users/42')), fn); + +describe('solidServerTracingIntegration', () => { + beforeEach(() => { + getCurrentScope().setClient(undefined); + }); + afterEach(() => { + getCurrentScope().setClient(undefined); + }); + + it('runs against the observe build of solid-js', () => { + expect(OBSERVE).toBeDefined(); + }); + + it("answers the runtime's trace provider from the active span, sentry-trace and baggage included", () => { + clientWith(); + const { ctx, span } = inRequest(() => + startSpan({ name: 'GET /users/:id', op: 'http.server' }, span => ({ + ctx: getTraceContext()!, + span: spanToJSON(span), + })), + ); + expect(ctx.traceId).toBe(span.trace_id); + expect(ctx.spanId).toBe(span.span_id); + expect(ctx.sampled).toBe(true); + expect(ctx.entries['sentry-trace']).toBe(`${span.trace_id}-${span.span_id}-1`); + expect(ctx.entries.baggage).toContain(`sentry-trace_id=${span.trace_id}`); + // The runtime's own W3C entry follows Sentry's ids. + expect(ctx.entries.traceparent).toBe(`00-${span.trace_id}-${span.span_id}-01`); + }); + + it('server-function executions are spans; a failed one captures the error as thrown, unhandled', async () => { + const { client, captured } = clientWith(); + const boom = new Error('connect ECONNREFUSED postgres://app:hunter2@db'); + const at = performance.now(); + OBSERVE!.records.emit( + 'invocation', + { id: 'loadFeed', direct: false, at, durationMs: 22, outcome: 'ok' }, + { event: {} as never, args: [] }, + ); + OBSERVE!.records.emit( + 'invocation', + { id: 'saveTodo', direct: true, at, durationMs: 3, outcome: 'error', boundary: '0-1', deferred: true }, + { event: {} as never, args: [1], error: boom }, + ); + await client.flush(100); + + const spans = captured.spans.filter(span => span.name === 'loadFeed' || span.name === 'saveTodo'); + expect(spans).toHaveLength(2); + expect(spans.find(span => span.name === 'loadFeed')?.attributes).toMatchObject({ + 'sentry.op': 'function.solid.rpc', + 'solid.server_function.direct': false, + 'solid.server_function.outcome': 'ok', + 'sentry.origin': 'auto.function.solid.server', + }); + const failed = spans.find(span => span.name === 'saveTodo')!; + expect(failed.status).toBe('error'); + expect(failed.attributes).toMatchObject({ + 'sentry.op': 'function.solid.direct', + 'solid.server_function.boundary': '0-1', + 'solid.server_function.deferred': true, + }); + expect(captured.events[0]?.exception?.values?.[0]).toMatchObject({ + value: 'connect ECONNREFUSED postgres://app:hunter2@db', + mechanism: { type: 'auto.function.solid.server_function', handled: false }, + }); + }); + + it('a produced frame stream is a span with its census; the client half is left to the browser', async () => { + const { client, captured } = clientWith(); + const base = { version: 1, chunks: 7, fragments: 3, slots: 2, regions: 1, errors: 0, durationMs: 18 }; + OBSERVE!.records.emit( + 'frame', + { ...base, side: 'server', id: 'Comments', at: performance.now(), shellMs: 2, outcome: 'complete' }, + {}, + ); + OBSERVE!.records.emit( + 'frame', + { ...base, side: 'client', id: 'Comments', at: performance.now(), outcome: 'complete' }, + { response: new Response('') }, + ); + await client.flush(100); + + const frames = captured.spans.filter(span => span.attributes['sentry.op'] === 'solid.frame.produce'); + expect(frames).toHaveLength(1); + expect(frames[0]!.attributes).toMatchObject({ + 'solid.frame.id': 'Comments', + 'solid.frame.regions': 1, + 'solid.frame.shellMs': 2, + 'sentry.origin': 'auto.function.solid.server', + }); + expect(captured.spans.some(span => span.attributes['sentry.op'] === 'solid.frame.apply')).toBe(false); + }); + + it("a server finding's extras carry its data without the thrown error itself", async () => { + const { client, captured } = clientWith(); + const boom = new Error('secret detail'); + OBSERVE!.diagnostics.emit( + { + code: 'SSR_RENDER_ERROR_CONTAINED', + kind: 'ssr', + severity: 'error', + message: '[SSR_RENDER_ERROR_CONTAINED] Render error caught by : Error: secret detail', + ownerPath: ['', '', ''], + data: { handling: 'fallback', boundary: '0', boundaryPath: ['', ''], error: boom }, + }, + null, + ); + await client.flush(100); + + const issue = captured.events.find(event => event.tags?.['solid.code'] === 'SSR_RENDER_ERROR_CONTAINED'); + expect(issue).toBeDefined(); + expect(issue!.fingerprint).toEqual(['SSR_RENDER_ERROR_CONTAINED', '', '', '']); + expect(issue!.extra).toMatchObject({ handling: 'fallback', boundary: '0', boundaryPath: ['', ''] }); + expect(issue!.extra).not.toHaveProperty('error'); + }); + + it('a boundary that waited during the render becomes a span', async () => { + const { client, captured } = clientWith(); + let release!: (value: string) => void; + const data = new Promise(resolve => (release = resolve)); + + const Slow = () => { + const value = createMemo(() => data); + return createMemo(() => value()); + }; + const App = () => + createComponent( + Loading, + { + fallback: 'loading', + get children() { + return createComponent(Slow, {}, 'Slow'); + }, + }, + 'Loading', + ); + + const html = inRequest(() => stream(() => createComponent(App, {}, 'App'))); + await new Promise(resolve => setTimeout(resolve, 5)); + release('ready'); + expect(await html).toContain('ready'); + await client.flush(100); + + const boundary = captured.spans.find(span => span.attributes['sentry.op'] === 'solid.boundary'); + expect(boundary).toBeDefined(); + expect(boundary).toMatchObject({ + name: '', + attributes: expect.objectContaining({ + 'solid.boundary.outcome': 'settled', + 'solid.boundary.streamed': true, + 'sentry.origin': 'auto.function.solid.server', + }), + }); + expect(boundary!.attributes['solid.boundary.passes']).toBeGreaterThanOrEqual(1); + // Backdated: the span covers the wait, on the record's clock. + expect(boundary!.end_timestamp! - boundary!.start_timestamp).toBeGreaterThan(0.004); + }); +}); diff --git a/packages/solid-2/test/tsconfig.json b/packages/solid-2/test/tsconfig.json new file mode 100644 index 000000000000..38ca0b13bcdd --- /dev/null +++ b/packages/solid-2/test/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.test.json" +} diff --git a/packages/solid-2/tsconfig.json b/packages/solid-2/tsconfig.json new file mode 100644 index 000000000000..fd54f069790c --- /dev/null +++ b/packages/solid-2/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + + "include": ["src/**/*"], + + "compilerOptions": { + "lib": ["DOM", "es2020"] + } +} diff --git a/packages/solid-2/tsconfig.test.json b/packages/solid-2/tsconfig.test.json new file mode 100644 index 000000000000..9c723e1802ac --- /dev/null +++ b/packages/solid-2/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + + "include": ["test/**/*", "vite.config.ts"], + + "compilerOptions": { + "types": ["vite/client"] + } +} diff --git a/packages/solid-2/tsconfig.types.json b/packages/solid-2/tsconfig.types.json new file mode 100644 index 000000000000..5e5cc814cbec --- /dev/null +++ b/packages/solid-2/tsconfig.types.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types", + "rootDir": "./src" + } +} diff --git a/packages/solid-2/vite.config.ts b/packages/solid-2/vite.config.ts new file mode 100644 index 000000000000..34df6565ddec --- /dev/null +++ b/packages/solid-2/vite.config.ts @@ -0,0 +1,45 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; +import baseConfig from '../../vite/vite.config'; + +// Solid ships three build tiers behind export conditions (`development`, +// `observe`, default = production). The SDK's tracing integrations need the +// `observe` tier; Vite's resolver always prefers `development` in test mode, +// so the tier is pinned by aliasing each package to its observe artifact — +// the same files for the SDK and for Solid's own internal imports, so every +// module sees one instance. Two projects: the browser and server halves are +// different artifacts of the same packages. +const solid = (pkg: string) => resolve(__dirname, '../../node_modules/@solidjs', pkg); +const solidJs = resolve(__dirname, 'node_modules/solid-js'); + +function project(name: string, platform: 'browser' | 'server', environment: string) { + const alias = [ + { find: /^solid-js$/, replacement: `${solidJs}/dist/${platform === 'browser' ? 'solid' : 'server'}.observe.js` }, + { find: /^solid-js\/attribution$/, replacement: `${solidJs}/dist/attribution.js` }, + { find: /^solid-js\/internal$/, replacement: `${solidJs}/dist/internal.js` }, + { + find: /^@solidjs\/web$/, + replacement: `${solid('web')}/dist/${platform === 'browser' ? 'web' : 'server'}.observe.js`, + }, + { find: /^@solidjs\/signals$/, replacement: `${solid('signals')}/dist/observe/index.js` }, + { find: /^@solidjs\/signals\/attribution$/, replacement: `${solid('signals')}/dist/observe/attribution.js` }, + ]; + return { + extends: true as const, + resolve: { alias }, + server: { deps: { inline: [/solid-server-dev/, /solid-js/, /@solidjs/] } }, + test: { + name, + environment, + include: [`test/${name}/**/*.test.ts`], + }, + }; +} + +export default defineConfig({ + ...baseConfig, + test: { + ...baseConfig.test, + projects: [project('client', 'browser', 'jsdom'), project('server', 'server', 'node')], + }, +}); diff --git a/yarn.lock b/yarn.lock index e66adedac544..c011d3892684 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8731,6 +8731,14 @@ resolved "https://registry.yarnpkg.com/@solidjs/router/-/router-1.0.0.tgz#9e4e5d6dbdeb725e8e4a9b5a3c7158c39fff096f" integrity sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q== +"@solidjs/signals@^2.0.0-rc.8": + version "0.0.0" + uid "" + +"@solidjs/signals@link:../solid-server-dev/packages/signals": + version "0.0.0" + uid "" + "@solidjs/start@^1.3.2": version "1.3.2" resolved "https://registry.yarnpkg.com/@solidjs/start/-/start-1.3.2.tgz#438cf350fde2d4aa03c179fea224f7231c3d8489" @@ -8759,6 +8767,10 @@ dependencies: "@testing-library/dom" "^9.3.1" +"@solidjs/web@link:../solid-server-dev/packages/web": + version "0.0.0" + uid "" + "@speed-highlight/core@^1.2.14", "@speed-highlight/core@^1.2.7": version "1.2.15" resolved "https://registry.yarnpkg.com/@speed-highlight/core/-/core-1.2.15.tgz#88c45609a2b5c2293a2e1935417c507f98f39d0b" @@ -24920,6 +24932,11 @@ seroval-plugins@~1.5.0: resolved "https://registry.yarnpkg.com/seroval-plugins/-/seroval-plugins-1.5.4.tgz#3e7d1910b5a516684046770d201b993c81b1b95a" integrity sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw== +seroval-plugins@~1.6.7: + version "1.6.7" + resolved "https://registry.yarnpkg.com/seroval-plugins/-/seroval-plugins-1.6.7.tgz#4aba839a2fdafa9c115d965a71861d4b288b90c1" + integrity sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw== + seroval@^1.4.0, seroval@^1.5.0, seroval@^1.5.4, seroval@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/seroval/-/seroval-1.6.2.tgz#93ecff62ca1312a565e37146b2a332b9d625194d" @@ -24930,6 +24947,11 @@ seroval@~1.5.0: resolved "https://registry.yarnpkg.com/seroval/-/seroval-1.5.4.tgz#9d0cedae244f8213bbbbbcc99c497eb7c945d961" integrity sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw== +seroval@~1.6.7: + version "1.6.7" + resolved "https://registry.yarnpkg.com/seroval/-/seroval-1.6.7.tgz#b33953ac59aec5bde14f99e010bdaa7a507ae2db" + integrity sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ== + serve-index@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" @@ -25368,6 +25390,10 @@ solid-js@^1.9.11: seroval "~1.5.0" seroval-plugins "~1.5.0" +"solid-js@link:../solid-server-dev/packages/solid": + version "0.0.0" + uid "" + solid-refresh@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/solid-refresh/-/solid-refresh-0.6.3.tgz#d23ef80f04e177619c9234a809c573cb16360627" From 9d2c60071a3b96f3ebc545de4a7b175aad627c55 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 18 Sep 2026 09:51:47 -0700 Subject: [PATCH 2/6] feat(solid-2): depend on the published solid 2.0.0-rc.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the local `link:` development dependencies for the published rc.9 — the first release carrying the record shapes, the error hooks and the observe server build the package relies on — and rebuild the lockfile. The tests' Solid inlining moved from Vite's `server.deps` to Vitest's `test.server.deps`, where it belongs: the linked checkout had masked the mistake (paths outside node_modules inline by default), and against the installed packages Node loaded `@solidjs/signals` natively beside the copy Vite inlined — two `OBSERVE`s, and a server half that looked like the wrong tier. The package roots are now resolved from this package rather than assumed hoisted. Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- packages/solid-2/package.json | 10 +++++----- packages/solid-2/vite.config.ts | 27 ++++++++++++++++++++------- yarn.lock | 33 ++++++++++++++++++++------------- 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/packages/solid-2/package.json b/packages/solid-2/package.json index 19b03c5fc418..a9a891c0559e 100644 --- a/packages/solid-2/package.json +++ b/packages/solid-2/package.json @@ -55,13 +55,13 @@ "@sentry/node": "10.67.0" }, "peerDependencies": { - "@solidjs/web": "^2.0.0-rc.8", - "solid-js": "^2.0.0-rc.8" + "@solidjs/web": "^2.0.0-rc.9", + "solid-js": "^2.0.0-rc.9" }, "devDependencies": { - "@solidjs/signals": "link:../../../solid-server-dev/packages/signals", - "@solidjs/web": "link:../../../solid-server-dev/packages/web", - "solid-js": "link:../../../solid-server-dev/packages/solid" + "@solidjs/signals": "^2.0.0-rc.9", + "@solidjs/web": "^2.0.0-rc.9", + "solid-js": "^2.0.0-rc.9" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/solid-2/vite.config.ts b/packages/solid-2/vite.config.ts index 34df6565ddec..d43b0c8c7219 100644 --- a/packages/solid-2/vite.config.ts +++ b/packages/solid-2/vite.config.ts @@ -1,4 +1,6 @@ -import { resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; import { defineConfig } from 'vitest/config'; import baseConfig from '../../vite/vite.config'; @@ -9,8 +11,16 @@ import baseConfig from '../../vite/vite.config'; // the same files for the SDK and for Solid's own internal imports, so every // module sees one instance. Two projects: the browser and server halves are // different artifacts of the same packages. -const solid = (pkg: string) => resolve(__dirname, '../../node_modules/@solidjs', pkg); -const solidJs = resolve(__dirname, 'node_modules/solid-js'); +const require = createRequire(join(__dirname, 'package.json')); +/** The install root of a package as this package resolves it (Solid 1.x lives at the workspace root for @sentry/solid). */ +function packageRoot(pkg: string): string { + let dir = dirname(require.resolve(pkg)); + while (!existsSync(join(dir, 'package.json'))) dir = dirname(dir); + return dir; +} +const solidJs = packageRoot('solid-js'); +const web = packageRoot('@solidjs/web'); +const signals = packageRoot('@solidjs/signals'); function project(name: string, platform: 'browser' | 'server', environment: string) { const alias = [ @@ -19,19 +29,22 @@ function project(name: string, platform: 'browser' | 'server', environment: stri { find: /^solid-js\/internal$/, replacement: `${solidJs}/dist/internal.js` }, { find: /^@solidjs\/web$/, - replacement: `${solid('web')}/dist/${platform === 'browser' ? 'web' : 'server'}.observe.js`, + replacement: `${web}/dist/${platform === 'browser' ? 'web' : 'server'}.observe.js`, }, - { find: /^@solidjs\/signals$/, replacement: `${solid('signals')}/dist/observe/index.js` }, - { find: /^@solidjs\/signals\/attribution$/, replacement: `${solid('signals')}/dist/observe/attribution.js` }, + { find: /^@solidjs\/signals$/, replacement: `${signals}/dist/observe/index.js` }, + { find: /^@solidjs\/signals\/attribution$/, replacement: `${signals}/dist/observe/attribution.js` }, ]; return { extends: true as const, resolve: { alias }, - server: { deps: { inline: [/solid-server-dev/, /solid-js/, /@solidjs/] } }, test: { name, environment, include: [`test/${name}/**/*.test.ts`], + // Keep Solid inside Vite's pipeline (where the aliases apply) rather + // than Node's loader: a package Node loads natively beside one Vite + // inlines is two module instances, two `OBSERVE`s. + server: { deps: { inline: [/solid-js/, /@solidjs/] } }, }, }; } diff --git a/yarn.lock b/yarn.lock index c011d3892684..99d4464a2be7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8731,13 +8731,10 @@ resolved "https://registry.yarnpkg.com/@solidjs/router/-/router-1.0.0.tgz#9e4e5d6dbdeb725e8e4a9b5a3c7158c39fff096f" integrity sha512-cCSk1hvgCowiMa9bzzYWHiLu1U4E22+DfJe6/rOwAyECKrxc3jrd5QnoW3sDDJtW+e077cz/M67bPl3DqOBw1Q== -"@solidjs/signals@^2.0.0-rc.8": - version "0.0.0" - uid "" - -"@solidjs/signals@link:../solid-server-dev/packages/signals": - version "0.0.0" - uid "" +"@solidjs/signals@^2.0.0-rc.9": + version "2.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@solidjs/signals/-/signals-2.0.0-rc.9.tgz#2140db06574f917ec35ce590f3420bd37a05c9b5" + integrity sha512-o3pqiTgpH5NR2DstiKrt9s/6+0YOFtv+MfvLONwLsS247I+EWMMyTu9BkRcgd35UR5Pa1DM16lI1/5uaIMY6Gw== "@solidjs/start@^1.3.2": version "1.3.2" @@ -8767,9 +8764,13 @@ dependencies: "@testing-library/dom" "^9.3.1" -"@solidjs/web@link:../solid-server-dev/packages/web": - version "0.0.0" - uid "" +"@solidjs/web@^2.0.0-rc.9": + version "2.0.0-rc.9" + resolved "https://registry.yarnpkg.com/@solidjs/web/-/web-2.0.0-rc.9.tgz#a056d25115dcefd9aed178716c8bf7e6c031400f" + integrity sha512-pfiWoLDnLc+QYWc7UyLqO+5QrPEf3oTiNmmRC+C+uM6AZ5VH0bZMNPtLM5rJ29LKPiTwQitKV843IQDf/oeyhQ== + dependencies: + seroval "~1.6.7" + seroval-plugins "~1.6.7" "@speed-highlight/core@^1.2.14", "@speed-highlight/core@^1.2.7": version "1.2.15" @@ -25390,9 +25391,15 @@ solid-js@^1.9.11: seroval "~1.5.0" seroval-plugins "~1.5.0" -"solid-js@link:../solid-server-dev/packages/solid": - version "0.0.0" - uid "" +solid-js@^2.0.0-rc.9: + version "2.0.0-rc.9" + resolved "https://registry.yarnpkg.com/solid-js/-/solid-js-2.0.0-rc.9.tgz#f07125cee1149da629326beed94afdf71c6188b9" + integrity sha512-J/oHWnWqe7S0FeIEdIRKDvyyo+HY/TYKr2PrIB8VlePMWuErDg78QHqdsAV7f6HKa9qhWR/23eqzR/ZRV9ep0g== + dependencies: + "@solidjs/signals" "^2.0.0-rc.9" + csstype "^3.1.0" + seroval "~1.6.7" + seroval-plugins "~1.6.7" solid-refresh@^0.6.3: version "0.6.3" From d9ff63c53a81a03d3426a417b7c9e2fb3f2cabb9 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 18 Sep 2026 10:01:05 -0700 Subject: [PATCH 3/6] docs(solid-2): reviewer's brief on Solid 2's observe tier Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- docs/solid-2-observe.md | 176 +++++++++++++++++++++++++++++++++++++ packages/solid-2/README.md | 1 + 2 files changed, 177 insertions(+) create mode 100644 docs/solid-2-observe.md diff --git a/docs/solid-2-observe.md b/docs/solid-2-observe.md new file mode 100644 index 000000000000..eaca02931802 --- /dev/null +++ b/docs/solid-2-observe.md @@ -0,0 +1,176 @@ +# Solid 2's observe tier — what `@sentry/solid-2` builds on + +A reviewer's brief for the Solid 2 SDK (`packages/solid-2`): what the runtime exposes, what the integration relies +on, and where the trust boundaries are. The normative text lives in the Solid repo +([RFC 08, dev diagnostics](https://github.com/solidjs/solid/blob/next/documentation/solid-2.0/08-dev-diagnostics.md), +[RFC 12, SSR/HTTP](https://github.com/solidjs/solid/blob/next/documentation/solid-2.0/12-ssr-http.md), +[RFC 10, server functions](https://github.com/solidjs/solid/blob/next/documentation/solid-2.0/10-server-functions.md)); +this is the map. + +## Three build tiers + +Solid 2 ships three builds of every runtime package, selected by export condition: + +| Tier | Condition | Contains | Size (brotli, CSR app) | +| --------- | ------------- | -------------------------------------------------------------------------------- | ------------------------ | +| `prod` | default | The runtime. `OBSERVE` and `DEV` are `undefined`. | 15.2 KB | +| `observe` | `observe` | prod + the structured channels and hook slots: `OBSERVE`. No console, no checks. | 16.7 KB (+1.3 KB wiring) | +| `dev` | `development` | observe + the dev checks and the console face: `DEV`. Unminified. | — | + +The tiers nest: whatever works on `observe` works on `dev`. An app opts in with `solid({ observe: true })` in +`@solidjs/vite-plugin`, which sets the condition for every environment and turns on the compiler's `componentNames` +(component labels survive minification — the `` paths below). In `vite dev` the `development` +condition still wins, by design: the dev build is a superset. + +What this means for the SDK: + +- **Error reporting works in every tier.** The error hooks are part of the runtime, not of `OBSERVE`. A production + app with no observability tooling gets error events from `init()` alone. +- **Tracing needs the observe build.** Without it `OBSERVE` is `undefined`, the tracing integrations log once in + debug mode and do nothing. There is no partial mode. +- The attribution engine (`solid-js/attribution`) is a separate 10.5 KB entry the observe build carries only when + imported — `solidTracingIntegration` imports it, so enabling tracing costs that plus the 1.3 KB wiring. Idle + wiring cost with no hooks installed is capped by Solid's suite at 1.25× the prod build on a graph-heavy workload + (measured 1.03–1.09). + +## The surface the integration touches + +Everything is imported from `solid-js` and `@solidjs/web`. The SDK never imports `@solidjs/signals` (the engine +package underneath), and never touches a `_`-prefixed field. + +### Error hooks (every tier) + +```ts +// solid-js +configureClientErrors({ onError(error, { ownerPath, boundaryPath }) {} }); +// @solidjs/web (server) +configureServerErrors({ + onError(error, { kind, handling, boundary, boundaryPath, functionId, direct, ownerPath, event }) {}, +}); +``` + +- Called **once per error object**, at first sight. A boundary re-collecting the same failure after `reset()` says + nothing new. +- **Client**: fires when an `` renders its fallback — the one road a rendered failure took that no global + handler saw. Uncaught errors keep reaching `window.onerror` / `unhandledrejection`, which the browser SDK already + handles; the hook does not duplicate them. +- **Server**: fires for every failure the runtime handles or fails on. `kind`/`handling` say which: + `render/fallback` (an `` rendered its fallback), `render/client` (a `` fragment rejected; the + client re-renders the subtree), `render/failed` (nothing contained it; the request fails), `render/serialize` (a + hydration value would not serialize), `server-function/thrown`, `server-function/channel` (a rejection escaping + through a returned promise/iterable/stream after the head committed). `direct: true` marks an in-process + server-function call made during SSR. +- The hook receives the error **as thrown**. The wire — the fallback's serialized error, the RPC error body — gets + Solid's sanitized value (a generic `Error` outside dev) unless the hook **returns** a replacement. That return is + what `solidServerErrorsIntegration({ mapError })` exposes; the SDK itself never returns one. +- `ownerPath` is where the error was **thrown** (labels up the owner chain of the computation that threw); + `boundaryPath` is where it was **met** (the boundary's own chain). Both only where the runtime keeps owner names — + observe and dev; production owners carry none, so in prod these are `undefined` and grouping falls back to the + stack. +- Per-root hooks exist (`render(fn, el, { onError })`, `renderToStream(fn, { onError })`) and win over the ambient + one. The SDK uses the ambient one. + +### `OBSERVE.records` (observe tier) + +One process-wide channel, on both platforms, delivering **completed** records by type: + +| Type | Platform | One per | Joins by | +| -------------- | -------- | ------------------------------------------------- | ----------------------------------------------- | +| `"call"` | client | server-function call the page made | `id` with the server's `"invocation"`; `origin` | +| `"invocation"` | server | server-function execution (HTTP or direct) | `id`; `boundary` with the boundary record | +| `"boundary"` | server | `` that waited during a render | `id` (hydration id); `revealGroup` | +| `"frame"` | both | frame stream produced (server) / applied (client) | `id` + `version` across the two sides | + +The contract that shapes the integration: + +- A record is **plain data**: ids, names, `outcome`, `at`, `durationMs`, counts. Anything live — the request, the + response, the arguments, the result, the error as thrown — travels in a second `live` argument to the listener, + never on the record. That is why `captureException(live.error)` and why record attributes never carry values. +- Records arrive **settled**, with `at` on the `performance.now()` clock and durations from it, so spans are built + after the fact with explicit `startTime`/`end`. `epochSeconds(at) = (performance.timeOrigin + at) / 1000`. +- A `"call"` carries `origin`: the engine's interaction/navigation frame, read at dispatch. It is the **same object** + the attribution engine puts on its `InteractionEvent`, so a call is joined to the click that made it by identity, + not by time (`Tracer.claimCall`). +- Listeners run **inside the runtime**, synchronously. They must not throw (a throwing listener is reported and does + not stop others) and must not write signals — hence every span is built in `queueMicrotask`. +- Server records are delivered inside the request's async context, so under `@sentry/node` they parent on the active + `http.server` span without anything passing a parent around. + +### The attribution engine (observe tier, `solid-js/attribution`) + +`attribution.enable(options)` installs the engine into the core's single hook slot; `attribution.subscribe(type, fn)` +delivers `InteractionEvent`, `NavigationEvent`, `HoldEvent` and `RerunEvent` as they settle, bottom-up (a hold before +the navigation it held, before the interaction that performed it). The SDK uses only those two calls. The engine's +folds (`costs()`, `feedback()`, `why()`, `subscriptions()`) are named exports the SDK never imports, so they +tree-shake out of an app that only ships the integration. + +- `InteractionEvent`: one per user event the web runtime stamped (`click`, `keydown`, …), with the handler's + duration, the writes it made, the re-runs and creations it caused, `settledMs` (dispatch → last effect that traces + back to it), and its `holds` and `navigations`. The integration's root span. +- `NavigationEvent`: declared by the router via `withOrigin` — route pattern as `name`, concrete `to`/`from`, + `params`, redirect hops, `outcome`. Router-agnostic: any router that wraps its location write gets these; the SDK + has no router code. +- `HoldEvent`: a write that landed behind async work, with what blocked it, how long, and which affordance + acknowledged the wait (`isPending`, `latest`, an optimistic value) or none — Solid's INP-shaped fact. +- `RerunEvent`: per re-run, `nodeId` (no live node), causes, self-time. The integration folds these into a per- + interaction hot list; it never sends one per run. +- The engine also emits **diagnostics** (`OBSERVE.diagnostics`): `SILENT_HOLD`, `LONG_HOLD`, `HOT_SCOPE_RERUNS`, + `ASYNC_WATERFALL`, and the server's `SSR_RENDER_ERROR_CONTAINED`, `SSR_ERROR_SANITIZED`, … A finding is an + **issue**, not a span: it has a stable identity and recurs, so the SDK fingerprints it by `[code, ...ownerPath]`. + Severity is Solid's: `info` is advisory and not reported by default; `error` findings do not exist in the observe + build (they are dev-only checks) except the server's contained-render-error family. + +### `OBSERVE.server.trace` (observe tier, server) + +`OBSERVE.server.trace.provide(request => TraceContext)` installs one provider, called once per request inside the +request's async context. Whatever it answers — trace/span ids, `sampled`, and named `entries` — the runtime emits on +**two carriers** it already owns: a `Server-Timing: traceparent;desc="…"` header on **every** response, and the +`` / `` pair in an HTML shell. So the browser `pageload` parents under +the server request with no middleware and no body rewriting, and it works for frame streams and RPC responses that +have no `` at all (the 1.x `sentryBeforeResponseMiddleware` rewrite was silently a no-op for those). + +The provider answers from `getActiveSpan()` / `getTraceData()` and **overrides `parentId`**: the browser sends +`sentry-trace` and `traceparent` with different span ids, `@sentry/node` continues from the former while the runtime +derives its parent from the latter; the provider is where Sentry's view wins. + +## What the integration decides, and what it does not + +- **Sampling** is the SDK's (`tracesSampleRate` / `tracesSampler`). An unsampled session still pays Solid's 1.3 KB + wiring, but not the engine's work: `attribution.enable()` is called regardless today — a follow-up can gate it on + the sampling decision. +- **Span topology.** A user interaction is a **root** span (`parentSpan: null`); its navigations, holds and calls are + children; a navigation or hold no interaction claims is a root of its own; an orphan navigation whose request time + falls inside a settled interaction's handler window gets a span **link** to it rather than a guessed parent. Whether + an interaction should instead parent under an active `pageload`/`navigation` idle span is an open product question + (`forceTransaction` is deprecated; span streaming makes "root or child" the only distinction). +- **Mechanism types** follow the `auto.function.solid.*` family; `sentry.origin` is `auto.ui.solid.attribution`, + `auto.http.solid.call`, `auto.ui.solid.frame`, `auto.function.solid.server`. +- **Process-wide channels vs. per-client integrations.** Solid's channels are singletons; the integrations keep an + `uninstall` so a second `init()` (tests, HMR) replaces subscriptions instead of stacking them. The `Integration` + interface has no teardown hook, so this is module state. + +## PII + +Solid's records name things — owner paths, `name` options, store paths, route patterns, function ids — and are +otherwise numbers, kinds and outcomes. The complete list of fields that carry user data is in RFC 08 ("Values in +records — the PII surface"); what the integration does with each: + +| Field | Content | Integration | +| ----------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------- | +| `InteractionRef.target` / `ChangeOrigin.target` | `tag#id "text"` with up to 30 chars of text content | Text stripped from names and attributes unless `targetText: true` | +| `ChangeRecord.prev`/`value`, `HeldWrite.prev`/`value` | Value previews (strings cut at 40 chars) | Never sent — re-runs are folded to names and counts | +| `ChangeOrigin`/`NavigationEvent` `to`/`from`/`params` | Concrete paths and bound params | Sent as span attributes (URLs are already in the trace) | +| `DiagnosticEvent.data.error` (server error findings) | The error as thrown, unsanitized | Not forwarded as an extra; the error hook captured it as an exception | +| `DiagnosticEvent.data`, `.message` (responsiveness) | Interaction target, navigation paths | Forwarded as extras / issue title, target text subject to the same gate | + +No `dataCollection` category fits UI text today; `targetText` is the integration's own switch until one exists. + +## Where the proofs are + +- Unit tests in `packages/solid-2/test` run against the **built observe artifacts** of the published Solid (aliased + explicitly — Vitest's resolver always prefers `development`), in jsdom for the client and Node for the server, + through the real `BrowserClient`/`NodeClient` under span streaming. +- The join-by-identity of a call to its interaction, the trace provider answering from a real span, a waiting + `` becoming a span with its component path, and the thrown-vs-met split are each pinned there. +- Child spans parenting under OTel's `http.server` span and the browser `pageload` continuation are the e2e app's to + prove (they were proven manually in the Solid-side spike against a real Sentry project). diff --git a/packages/solid-2/README.md b/packages/solid-2/README.md index 895a2e2e2f33..dfbb807650cb 100644 --- a/packages/solid-2/README.md +++ b/packages/solid-2/README.md @@ -11,6 +11,7 @@ [![npm dt](https://img.shields.io/npm/dt/@sentry/solid-2.svg)](https://www.npmjs.com/package/@sentry/solid-2) This SDK is for Solid 2 (`solid-js` ^2). For Solid 1.x use [`@sentry/solid`](https://www.npmjs.com/package/@sentry/solid). +What the runtime exposes and what this package relies on: [docs/solid-2-observe.md](../../docs/solid-2-observe.md). One package, both halves: the browser SDK (`@sentry/browser`) for the client and the Node SDK (`@sentry/node`) for the server, resolved by the `browser`/`node` export conditions, or explicitly as `@sentry/solid-2/client` and From 479060805328ae9cd479233595cd3b6c4e1f5904 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 18 Sep 2026 11:07:47 -0700 Subject: [PATCH 4/6] fix(solid-2): records set span status only; a call after its interaction settled is still its child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the e2e app surfaced against the published rc.9. The record spans captured the errors they carried (`live.error`), which duplicated the error hooks: the same error object reaches both, and whichever runs first wins Sentry's once-per-object guard — the invocation record beat the server hook and reported a handled boundary catch as an unhandled server-function crash with the wrong mechanism. The hooks are the one error path; records set the span's status. `onClick={async () => set(await call())}` makes no synchronous write, so the interaction settles as `idle` before the call it dispatched lands; the call carries the interaction's frame but arrived after the claim window and became a root span. It is now a child of the interaction's span by the same identity, marked `solid.server_function.after_settle`. Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- packages/solid-2/src/client/records.ts | 21 ++++++---- packages/solid-2/src/client/tracing.ts | 22 ++++++++-- packages/solid-2/src/server/tracing.ts | 33 +++++---------- packages/solid-2/test/client/tracing.test.ts | 42 +++++++++++++++++--- packages/solid-2/test/server/tracing.test.ts | 8 ++-- 5 files changed, 80 insertions(+), 46 deletions(-) diff --git a/packages/solid-2/src/client/records.ts b/packages/solid-2/src/client/records.ts index 5b40e5a66b1b..2d4ce8a82b54 100644 --- a/packages/solid-2/src/client/records.ts +++ b/packages/solid-2/src/client/records.ts @@ -1,5 +1,5 @@ import type { Span } from '@sentry/core'; -import { captureException, startInactiveSpan } from '@sentry/core'; +import { startInactiveSpan } from '@sentry/core'; import type { CallEvent, CallLive, FrameEvent, FrameLive } from '@solidjs/web'; import { describeOrigin } from '../common/target'; import { epochSeconds, round } from '../common/time'; @@ -12,9 +12,17 @@ const FRAME_ORIGIN = 'auto.ui.solid.frame'; * the twin of the server's `"invocation"` span (same `id`; the difference is * the wire). The browser SDK's own `http.client` span for the fetch is the * transport's view — this one is the runtime's, includes decode, and knows - * the function rather than the URL. + * the function rather than the URL. A failed call sets the span's status + * only: the error itself reaches the caller, and whatever catches it there + * (an ``, the server error hook on the other side) reports it once. */ -export function callSpan(event: CallEvent, live: CallLive, parent: Span | null, keepText: boolean): Span { +export function callSpan( + event: CallEvent, + _live: CallLive, + parent: Span | null, + keepText: boolean, + afterSettle = false, +): Span { const origin = event.origin; const span = startInactiveSpan({ name: event.id, @@ -29,19 +37,19 @@ export function callSpan(event: CallEvent, live: CallLive, parent: Span | null, 'solid.server_function.origin': origin ? describeOrigin(origin, keepText) : undefined, 'solid.server_function.origin.kind': origin?.kind, 'http.response.status_code': event.status, + 'solid.server_function.after_settle': afterSettle ? true : undefined, 'sentry.origin': CALL_ORIGIN, }, }); if (event.outcome === 'error') { span.setStatus({ code: 2, message: event.status !== undefined ? `http ${event.status}` : 'network_error' }); - captureException(live.error, { mechanism: { type: 'auto.function.solid.server_function.call', handled: true } }); } span.end(epochSeconds(event.at + event.durationMs)); return span; } /** One span per frame stream the server-component transport applied, with the chunk census. */ -export function frameSpan(event: FrameEvent, live: FrameLive): void { +export function frameSpan(event: FrameEvent, _live: FrameLive): void { if (event.side !== 'client') return; const span = startInactiveSpan({ name: event.id || 'frame', @@ -63,9 +71,6 @@ export function frameSpan(event: FrameEvent, live: FrameLive): void { }); if (event.outcome !== 'complete') { span.setStatus({ code: 2, message: event.outcome }); - if (live.error !== undefined) { - captureException(live.error, { mechanism: { type: 'auto.function.solid.frame.apply', handled: true } }); - } } span.end(epochSeconds(event.at + event.durationMs)); } diff --git a/packages/solid-2/src/client/tracing.ts b/packages/solid-2/src/client/tracing.ts index 620cc9127268..4ac3392a2b0f 100644 --- a/packages/solid-2/src/client/tracing.ts +++ b/packages/solid-2/src/client/tracing.ts @@ -169,6 +169,8 @@ class Tracer { /** Self-time per node name for each open interaction — the record has totals, not the breakdown. */ private readonly _hot: WeakMap>; private readonly _settled: WeakSet; + /** The root span each settled interaction became — the parent for work it caused after its window closed. */ + private readonly _spans: WeakMap; /** Server-function calls dispatched under an interaction still open, awaiting its segment. */ private readonly _calls: WeakMap>; /** Settled interactions kept for the time join, newest last. */ @@ -177,6 +179,7 @@ class Tracer { public constructor(public readonly keepText: boolean) { this._hot = new WeakMap(); this._settled = new WeakSet(); + this._spans = new WeakMap(); this._calls = new WeakMap(); this._recent = []; } @@ -191,14 +194,24 @@ class Tracer { } /** - * A call whose `origin` runs under an interaction still open is the - * interaction's — held for its span, joined by the engine's object - * identity rather than by time. Anything else is a root span of its own. + * A call whose `origin` is an interaction is the interaction's, joined by + * the engine's object identity rather than by time. Made while the + * interaction is still open, it is held for the interaction's span; made + * after the interaction settled — the usual shape of `onClick={async () => + * set(await call())}`, where the handler's synchronous window closes long + * before the call lands — it becomes a child of that span at once, marked + * `after_settle`. Only a call with no interaction at all is a root span. */ public claimCall(event: CallEvent, live: CallLive): boolean { const origin = event.origin; const interaction = origin === undefined ? undefined : origin.kind === 'interaction' ? origin : origin.interaction; - if (interaction === undefined || this._settled.has(interaction)) return false; + if (interaction === undefined) return false; + if (this._settled.has(interaction)) { + const parent = this._spans.get(interaction); + if (parent === undefined) return false; + queueMicrotask(() => callSpan(event, live, parent, this.keepText, true)); + return true; + } let calls = this._calls.get(interaction); if (calls === undefined) this._calls.set(interaction, (calls = [])); calls.push({ event, live }); @@ -240,6 +253,7 @@ class Tracer { for (const call of calls) callSpan(call.event, call.live, span, this.keepText); } span.end(epochSeconds(event.at + (event.settledMs ?? event.handlerMs))); + this._spans.set(origin, span); this._recent.push({ at: event.at, until: event.at + event.handlerMs, context: span.spanContext() }); if (this._recent.length > RECENT_LIMIT) this._recent.shift(); } diff --git a/packages/solid-2/src/server/tracing.ts b/packages/solid-2/src/server/tracing.ts index d6dc3fc822f7..91a992ca26d8 100644 --- a/packages/solid-2/src/server/tracing.ts +++ b/packages/solid-2/src/server/tracing.ts @@ -1,13 +1,5 @@ import type { Span } from '@sentry/core'; -import { - captureException, - debug, - defineIntegration, - getActiveSpan, - getTraceData, - spanToJSON, - startInactiveSpan, -} from '@sentry/core'; +import { debug, defineIntegration, getActiveSpan, getTraceData, spanToJSON, startInactiveSpan } from '@sentry/core'; import type { FrameEvent, FrameLive, InvocationEvent, InvocationLive, TraceContext } from '@solidjs/web'; import type { BoundaryEvent, BoundaryLive } from 'solid-js'; import { OBSERVE } from 'solid-js'; @@ -91,7 +83,7 @@ function traceProvider(): Partial | undefined { }; } -function invocationSpan(event: InvocationEvent, live: InvocationLive): Span { +function invocationSpan(event: InvocationEvent, _live: InvocationLive): Span { const start = epochSeconds(event.at); const span = startInactiveSpan({ name: event.id, @@ -106,15 +98,14 @@ function invocationSpan(event: InvocationEvent, live: InvocationLive): Span { 'sentry.origin': ORIGIN, }, }); - if (event.outcome === 'error') { - span.setStatus({ code: 2, message: 'internal_error' }); - captureException(live.error, { mechanism: { type: 'auto.function.solid.server_function', handled: false } }); - } + // Status only: the server error hook already captured the throw, once, + // with where it was met (`solidServerErrorsIntegration`). + if (event.outcome === 'error') span.setStatus({ code: 2, message: 'internal_error' }); span.end(epochSeconds(event.at + event.durationMs)); return span; } -function boundarySpan(event: BoundaryEvent, live: BoundaryLive): Span { +function boundarySpan(event: BoundaryEvent, _live: BoundaryLive): Span { const span = startInactiveSpan({ name: event.ownerPath ? event.ownerPath.join(' › ') : `boundary ${event.id}`, op: 'solid.boundary', @@ -129,15 +120,12 @@ function boundarySpan(event: BoundaryEvent, live: BoundaryLive): Span { 'sentry.origin': ORIGIN, }, }); - if (event.outcome === 'error') { - span.setStatus({ code: 2, message: 'internal_error' }); - captureException(live.error, { mechanism: { type: 'auto.function.solid.boundary', handled: true } }); - } + if (event.outcome === 'error') span.setStatus({ code: 2, message: 'internal_error' }); span.end(epochSeconds(event.at + event.durationMs + event.heldMs)); return span; } -function frameSpan(event: FrameEvent, live: FrameLive): Span { +function frameSpan(event: FrameEvent, _live: FrameLive): Span { const span = startInactiveSpan({ name: event.id || 'frame', op: 'solid.frame.produce', @@ -155,10 +143,7 @@ function frameSpan(event: FrameEvent, live: FrameLive): Span { 'sentry.origin': ORIGIN, }, }); - if (event.outcome === 'error') { - span.setStatus({ code: 2, message: 'internal_error' }); - captureException(live.error, { mechanism: { type: 'auto.function.solid.frame', handled: true } }); - } + if (event.outcome === 'error') span.setStatus({ code: 2, message: 'internal_error' }); span.end(epochSeconds(event.at + event.durationMs)); return span; } diff --git a/packages/solid-2/test/client/tracing.test.ts b/packages/solid-2/test/client/tracing.test.ts index 8fb4069f4da8..abc9d7a4e39c 100644 --- a/packages/solid-2/test/client/tracing.test.ts +++ b/packages/solid-2/test/client/tracing.test.ts @@ -158,7 +158,41 @@ describe('solidTracingIntegration', () => { app.dispose(); }); - it('a call with no interaction, and a failed one, are root spans with the failure captured', async () => { + it("a call that lands after its interaction settled is still the interaction's child, marked after_settle", async () => { + const { client, captured } = clientWith(); + const app = readerApp(); + let origin: ReturnType; + + // `onClick={async () => set(await call())}`: the handler makes no + // synchronous write, so the interaction settles as `idle` at once… + OBSERVE!.attribution.withInteraction({ type: 'click', target: 'button#save' }, () => { + origin = OBSERVE!.attribution.currentOrigin(); + }); + flush(); + await settle(); + // …and the call it dispatched lands later, carrying that frame. + OBSERVE!.records.emit( + 'call', + { id: 'saveTodo', method: 'POST', at: performance.now(), durationMs: 12, outcome: 'ok', status: 200, origin }, + { args: [1], response: new Response(''), result: 'ok' }, + ); + await settle(); + await client.flush(100); + + const segment = captured.spans.find( + span => span.is_segment && span.attributes['sentry.op'] === 'ui.interaction.click', + ); + const call = captured.spans.find(span => span.attributes['sentry.op'] === 'function.solid.call'); + expect(segment).toBeDefined(); + expect(call).toMatchObject({ + parent_span_id: segment!.span_id, + attributes: expect.objectContaining({ 'solid.server_function.after_settle': true }), + }); + expect(call!.start_timestamp).toBeGreaterThanOrEqual(segment!.end_timestamp!); + app.dispose(); + }); + + it('a call with no interaction, and a failed one, are root spans; a failure is status only', async () => { const { client, captured } = clientWith(); const boom = new Error('server said no'); OBSERVE!.records.emit( @@ -178,10 +212,8 @@ describe('solidTracingIntegration', () => { expect(roots.map(span => span.name).sort()).toEqual(['deleteTodo', 'loadFeed']); const failed = roots.find(span => span.name === 'deleteTodo')!; expect(failed.status).toBe('error'); - expect(captured.events[0]?.exception?.values?.[0]).toMatchObject({ - value: 'server said no', - mechanism: { type: 'auto.function.solid.server_function.call', handled: true }, - }); + // The error reached the caller; whatever catches it there reports it. Not here. + expect(captured.events).toEqual([]); }); it('an applied frame stream is a span with its chunk census; a truncated one is an error', async () => { diff --git a/packages/solid-2/test/server/tracing.test.ts b/packages/solid-2/test/server/tracing.test.ts index 2e10671e785d..fb9aebbdcd47 100644 --- a/packages/solid-2/test/server/tracing.test.ts +++ b/packages/solid-2/test/server/tracing.test.ts @@ -99,7 +99,7 @@ describe('solidServerTracingIntegration', () => { expect(ctx.entries.traceparent).toBe(`00-${span.trace_id}-${span.span_id}-01`); }); - it('server-function executions are spans; a failed one captures the error as thrown, unhandled', async () => { + it('server-function executions are spans; a failed one is status only — the error hook reports it', async () => { const { client, captured } = clientWith(); const boom = new Error('connect ECONNREFUSED postgres://app:hunter2@db'); const at = performance.now(); @@ -130,10 +130,8 @@ describe('solidServerTracingIntegration', () => { 'solid.server_function.boundary': '0-1', 'solid.server_function.deferred': true, }); - expect(captured.events[0]?.exception?.values?.[0]).toMatchObject({ - value: 'connect ECONNREFUSED postgres://app:hunter2@db', - mechanism: { type: 'auto.function.solid.server_function', handled: false }, - }); + // Status only: the server error hook is the one path an error takes to Sentry. + expect(captured.events).toEqual([]); }); it('a produced frame stream is a span with its census; the client half is left to the browser', async () => { From 6b6f55e0296964c0a6313a36309f43791ceecc5b Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 18 Sep 2026 12:02:34 -0700 Subject: [PATCH 5/6] =?UTF-8?q?test(solid-2):=20e2e=20app=20=E2=80=94=20st?= =?UTF-8?q?art-mode=20observe=20build,=20both=20SDK=20halves,=20through=20?= =?UTF-8?q?Playwright?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Solid 2 app on @solidjs/vite-plugin's start mode with `observe: true`, the server SDK through `start.instrument`, the browser SDK from the app's client module, and a bare node:http host. Against the packed tarballs it proves what the units cannot: boundary and invocation spans under OTel's http.server span, the pageload continuing the server trace with no middleware, a click's server-function call becoming its child through a real fetch, and both error hooks firing once with component paths. Two findings folded into the README and the reviewer brief: a module both graphs reach must import the explicit client/server entry, and the app should declare @sentry/node so the plugin's inlining of this package does not bundle it too (import-in-the-middle cannot find itself from a bundle). Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- .../test-applications/solid-2/README.md | 26 ++++++ .../test-applications/solid-2/package.json | 37 ++++++++ .../solid-2/playwright.config.mjs | 8 ++ .../test-applications/solid-2/server.mjs | 87 +++++++++++++++++++ .../test-applications/solid-2/src/App.tsx | 78 +++++++++++++++++ .../test-applications/solid-2/src/data.ts | 10 +++ .../solid-2/src/instrument.ts | 13 +++ .../solid-2/src/sentry.client.ts | 17 ++++ .../solid-2/start-event-proxy.mjs | 6 ++ .../solid-2/tests/errors.client.test.ts | 32 +++++++ .../solid-2/tests/errors.server.test.ts | 56 ++++++++++++ .../solid-2/tests/performance.client.test.ts | 57 ++++++++++++ .../solid-2/tests/performance.server.test.ts | 43 +++++++++ .../test-applications/solid-2/tsconfig.json | 13 +++ .../test-applications/solid-2/vite.config.ts | 19 ++++ docs/solid-2-observe.md | 26 +++++- packages/solid-2/README.md | 25 +++++- 17 files changed, 549 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/README.md create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/package.json create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/src/App.tsx create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/src/data.ts create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/src/sentry.client.ts create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/tests/errors.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/tests/errors.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/tests/performance.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/tests/performance.server.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/solid-2/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/solid-2/README.md b/dev-packages/e2e-tests/test-applications/solid-2/README.md new file mode 100644 index 000000000000..6c97875be876 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/README.md @@ -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 +``, `/server-error` throws during SSR inside an ``, `/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 `` 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 `` 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 +``` diff --git a/dev-packages/e2e-tests/test-applications/solid-2/package.json b/dev-packages/e2e-tests/test-applications/solid-2/package.json new file mode 100644 index 000000000000..ba1c9720648b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/package.json @@ -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 + } +} diff --git a/dev-packages/e2e-tests/test-applications/solid-2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/solid-2/playwright.config.mjs new file mode 100644 index 000000000000..b52ff06a5105 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: 'pnpm start', + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/solid-2/server.mjs b/dev-packages/e2e-tests/test-applications/solid-2/server.mjs new file mode 100644 index 000000000000..20cb480cfc6b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/server.mjs @@ -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}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-2/src/App.tsx b/dev-packages/e2e-tests/test-applications/solid-2/src/App.tsx new file mode 100644 index 000000000000..6968528c2dda --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/src/App.tsx @@ -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 ( + <> + +

{view()}

+ + ); +} + +function Home() { + const [result, setResult] = createSignal(''); + return ( + <> +

Solid 2 E2E

+ + +

{result()}

+ + server error + + + ); +} + +function UserPage() { + const user = createMemo(() => getPrefecture(6)); + return ( + loading…

}> +

{JSON.stringify(user())}

+
+ ); +} + +export default function App() { + const path = pathname(); + return ( +
+ {path === '/server-error' ? ( +

fallback: {String((err() as Error).message)}

}> + +
+ ) : path === '/client-error' ? ( +

fallback: {String((err() as Error).message)}

}> + +
+ ) : path === '/users/6' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/solid-2/src/data.ts b/dev-packages/e2e-tests/test-applications/solid-2/src/data.ts new file mode 100644 index 000000000000..39997adc8649 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/src/data.ts @@ -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 { + throw new Error('Error thrown from Solid 2 E2E test app server function'); +} diff --git a/dev-packages/e2e-tests/test-applications/solid-2/src/instrument.ts b/dev-packages/e2e-tests/test-applications/solid-2/src/instrument.ts new file mode 100644 index 000000000000..b5c25d3035f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/src/instrument.ts @@ -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, +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-2/src/sentry.client.ts b/dev-packages/e2e-tests/test-applications/solid-2/src/sentry.client.ts new file mode 100644 index 000000000000..3a3529b4287b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/src/sentry.client.ts @@ -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, +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-2/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/solid-2/start-event-proxy.mjs new file mode 100644 index 000000000000..c98eeb1883c4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'solid-2', +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-2/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/solid-2/tests/errors.client.test.ts new file mode 100644 index 000000000000..6029b56545f5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/tests/errors.client.test.ts @@ -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 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(''), + 'solid.boundary': expect.stringContaining(''), + }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-2/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/solid-2/tests/errors.server.test.ts new file mode 100644 index 000000000000..b75479dc6f07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/tests/errors.server.test.ts @@ -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 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(''), + 'solid.boundary_path': expect.stringContaining(''), + }, + 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) }, + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-2/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solid-2/tests/performance.client.test.ts new file mode 100644 index 000000000000..7f95f4d6cf6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/tests/performance.client.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +test('the pageload continues the server trace through the runtime’s carriers', async ({ page }) => { + const isServer = (span: SerializedStreamedSpan): boolean => + span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/'; + // No router in this app, so the pageload keeps the SDK's default name; the path is an attribute. + const isPageload = (span: SerializedStreamedSpan): boolean => + span.is_segment && getSpanOp(span) === 'pageload' && span.attributes['url.path']?.value === '/'; + // Grouped by trace: the two only satisfy this together if the browser + // continued the server's trace (other tests load '/' too). + const spansPromise = collectStreamedSpans('solid-2', spans => spans.some(isServer) && spans.some(isPageload)); + + await page.goto('/'); + + const spans = await spansPromise; + const server = spans.find(isServer)!; + const pageload = spans.find(isPageload)!; + // No middleware rewrote the document: the runtime emitted the pair + // from the trace provider's answer, and Server-Timing on the response. + expect(pageload.trace_id).toBe(server.trace_id); + expect(pageload.parent_span_id).toBe(server.span_id); +}); + +test('a click is a root span with the server-function call it made as a child, joined by identity', async ({ + page, +}) => { + const spansPromise = collectStreamedSpans( + 'solid-2', + spans => + spans.some(span => span.is_segment && getSpanOp(span) === 'ui.interaction.click') && + spans.some(span => getSpanOp(span) === 'function.solid.call'), + ); + + await page.goto('/'); + await page.locator('#callBtn').click(); + await expect(page.locator('#callResult')).toContainText('Kagoshima'); + + const spans = await spansPromise; + const interaction = spans.find(span => span.is_segment && getSpanOp(span) === 'ui.interaction.click')!; + const call = spans.find(span => getSpanOp(span) === 'function.solid.call')!; + + expect(interaction.name).toBe('click on button#callBtn'); + expect(interaction.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.ui.solid.attribution', type: 'string' }, + 'solid.interaction.type': { value: 'click', type: 'string' }, + }); + expect(call.parent_span_id).toBe(interaction.span_id); + expect(call.trace_id).toBe(interaction.trace_id); + expect(call.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.http.solid.call', type: 'string' }, + 'solid.server_function.method': { value: 'POST', type: 'string' }, + 'solid.server_function.outcome': { value: 'ok', type: 'string' }, + 'solid.server_function.origin.kind': { value: 'interaction', type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-2/tests/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/solid-2/tests/performance.server.test.ts new file mode 100644 index 000000000000..70c0440f0af1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/tests/performance.server.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; + +test('a waiting boundary and the server function it awaited are spans under the request', async ({ + page, +}) => { + const spansPromise = collectStreamedSpans( + 'solid-2', + spans => + spans.some( + span => + span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/users/6', + ) && + spans.some(span => getSpanOp(span) === 'solid.boundary') && + spans.some(span => getSpanOp(span) === 'function.solid.direct'), + ); + + await page.goto('/users/6'); + await expect(page.locator('#user')).toContainText('Kagoshima'); + + const spans = await spansPromise; + const request = spans.find( + span => span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/users/6', + )!; + const boundary = spans.find(span => getSpanOp(span) === 'solid.boundary')!; + const invocation = spans.find(span => getSpanOp(span) === 'function.solid.direct')!; + + // Both parent on the request: the records are delivered inside its async context. + expect(boundary.parent_span_id).toBe(request.span_id); + expect(invocation.parent_span_id).toBe(request.span_id); + expect(boundary.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.function.solid.server', type: 'string' }, + 'solid.boundary.outcome': { value: 'settled', type: 'string' }, + }); + expect(boundary.name).toContain(''); + expect(invocation.attributes).toMatchObject({ + 'solid.server_function.direct': { value: true, type: 'boolean' }, + 'solid.server_function.outcome': { value: 'ok', type: 'string' }, + }); + // Backdated from the record's clock, inside the request's window. + expect(boundary.start_timestamp).toBeGreaterThanOrEqual(request.start_timestamp - 0.001); + expect(boundary.end_timestamp).toBeLessThanOrEqual(request.end_timestamp + 0.001); +}); diff --git a/dev-packages/e2e-tests/test-applications/solid-2/tsconfig.json b/dev-packages/e2e-tests/test-applications/solid-2/tsconfig.json new file mode 100644 index 000000000000..a14ff4542e48 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "@solidjs/web", + "strict": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/solid-2/vite.config.ts b/dev-packages/e2e-tests/test-applications/solid-2/vite.config.ts new file mode 100644 index 000000000000..451e6c83c785 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/solid-2/vite.config.ts @@ -0,0 +1,19 @@ +import solid from '@solidjs/vite-plugin'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + solid({ + ssr: true, + // The production-speed runtime that keeps `OBSERVE` alive — what the + // tracing integrations read. Errors report in every tier. + observe: true, + serverFunctions: true, + start: { + // Awaited to completion before the server graph loads: Sentry.init() + // runs before @solidjs/web or the app are imported. + instrument: './src/instrument.ts', + }, + }), + ], +}); diff --git a/docs/solid-2-observe.md b/docs/solid-2-observe.md index eaca02931802..5734244b6a34 100644 --- a/docs/solid-2-observe.md +++ b/docs/solid-2-observe.md @@ -85,7 +85,10 @@ The contract that shapes the integration: - A record is **plain data**: ids, names, `outcome`, `at`, `durationMs`, counts. Anything live — the request, the response, the arguments, the result, the error as thrown — travels in a second `live` argument to the listener, - never on the record. That is why `captureException(live.error)` and why record attributes never carry values. + never on the record. Record attributes never carry values. The integration does not capture `live.error` either: + the same error object reaches an error hook (an `` fallback, the server hook), and Sentry's once-per-object + guard means whichever ran first would win — the record beat the hook and misreported a handled boundary catch as + an unhandled crash before the e2e app caught it. Records set span status; hooks report errors. - Records arrive **settled**, with `at` on the `performance.now()` clock and durations from it, so spans are built after the fact with explicit `startTime`/`end`. `epochSeconds(at) = (performance.timeOrigin + at) / 1000`. - A `"call"` carries `origin`: the engine's interaction/navigation frame, read at dispatch. It is the **same object** @@ -143,6 +146,11 @@ derives its parent from the latter; the provider is where Sentry's view wins. falls inside a settled interaction's handler window gets a span **link** to it rather than a guessed parent. Whether an interaction should instead parent under an active `pageload`/`navigation` idle span is an open product question (`forceTransaction` is deprecated; span streaming makes "root or child" the only distinction). +- **Calls after the interaction settled.** `onClick={async () => set(await call())}` makes no synchronous write, so + the engine settles the interaction as `idle` at once and the call it dispatched lands afterwards — carrying the + interaction's frame. It becomes a child of the interaction's (already ended) span by that identity, marked + `solid.server_function.after_settle`, rather than a root: the causal tree is right, the timing tells the truth. + Whether the engine should keep an interaction open across the handler's returned promise is a Solid-side question. - **Mechanism types** follow the `auto.function.solid.*` family; `sentry.origin` is `auto.ui.solid.attribution`, `auto.http.solid.call`, `auto.ui.solid.frame`, `auto.function.solid.server`. - **Process-wide channels vs. per-client integrations.** Solid's channels are singletons; the integrations keep an @@ -172,5 +180,17 @@ No `dataCollection` category fits UI text today; `targetText` is the integration through the real `BrowserClient`/`NodeClient` under span streaming. - The join-by-identity of a call to its interaction, the trace provider answering from a real span, a waiting `` becoming a span with its component path, and the thrown-vs-met split are each pinned there. -- Child spans parenting under OTel's `http.server` span and the browser `pageload` continuation are the e2e app's to - prove (they were proven manually in the Solid-side spike against a real Sentry project). +- The e2e app (`dev-packages/e2e-tests/test-applications/solid-2`) runs a built `@solidjs/vite-plugin` start-mode + app — observe build, server SDK through `start.instrument`, a bare `node:http` host — through Playwright against the + packed tarballs and proves what the units cannot: boundary and invocation spans parenting under OTel's + `http.server` span, the browser `pageload` continuing the server trace with no middleware, a click's call becoming + its child through a real fetch, and both error hooks firing once with component paths. + +Two things to know when reading the e2e app's config: the client module imports `@sentry/solid-2/client` explicitly +because the module is reachable from the server graph (behind an `isServer` guard), where the bare specifier resolves +to the server half; and the app declares `@sentry/node` as a direct dependency. The plugin inlines this package into +the server bundle (it consumes the Solid runtime; an externalized copy would load Solid's prod build through Node and +see no `OBSERVE`), and under pnpm's isolated layout a transitive `@sentry/node` is not resolvable from the app root, +so Vite bundles it too — where `import-in-the-middle` cannot find itself and logs a registration failure (core-module +instrumentation still works; the `http.server` spans show it). Declared by the app, `@sentry/node` resolves, is +externalized, and the warning is gone. Worth a line in the SDK's install docs. diff --git a/packages/solid-2/README.md b/packages/solid-2/README.md index dfbb807650cb..5e37d4ad9b64 100644 --- a/packages/solid-2/README.md +++ b/packages/solid-2/README.md @@ -15,7 +15,30 @@ What the runtime exposes and what this package relies on: [docs/solid-2-observe. One package, both halves: the browser SDK (`@sentry/browser`) for the client and the Node SDK (`@sentry/node`) for the server, resolved by the `browser`/`node` export conditions, or explicitly as `@sentry/solid-2/client` and -`@sentry/solid-2/server`. +`@sentry/solid-2/server`. Use the explicit entries in any module both graphs can reach — a client `Sentry.init` behind +an `isServer` guard is still resolved by the server build, to the server half. + +## Setup with `@solidjs/vite-plugin` + +```js +// vite.config.js +solid({ + ssr: true, + observe: true, // tracing reads the observe build; errors report in every tier + start: { instrument: './src/instrument.js' }, // awaited before the server graph loads +}); +``` + +```js +// src/instrument.js — the server's Sentry.init(); nothing else +import * as Sentry from '@sentry/solid-2/server'; +Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1, integrations: [Sentry.solidServerTracingIntegration()] }); +``` + +Add `@sentry/node` to the app's own dependencies. The plugin bundles this package into the server build (it consumes +the Solid runtime, and must see the same copy the app does); with a package manager that isolates dependencies, a +transitive `@sentry/node` is bundled along with it, where `import-in-the-middle` cannot find itself. Declared by the +app, it stays external. ## Errors From 0f4dc75c806e5c5fa1851c832fd93fbec7b46661 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 18 Sep 2026 12:48:41 -0700 Subject: [PATCH 6/6] docs(solid-2): the server half is host-agnostic; start.instrument is one host's preload Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- packages/solid-2/README.md | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/solid-2/README.md b/packages/solid-2/README.md index 5e37d4ad9b64..4a9a027ed790 100644 --- a/packages/solid-2/README.md +++ b/packages/solid-2/README.md @@ -18,27 +18,39 @@ the server, resolved by the `browser`/`node` export conditions, or explicitly as `@sentry/solid-2/server`. Use the explicit entries in any module both graphs can reach — a client `Sentry.init` behind an `isServer` guard is still resolved by the server build, to the server half. -## Setup with `@solidjs/vite-plugin` +## Server setup + +Nothing here depends on how the app is hosted: the integrations read `@solidjs/web` and `solid-js`, which every +server that renders Solid — the `@solidjs/vite-plugin` handler, an Express or Hono server calling `renderToStream`, a +worker — already loads. The one requirement is `@sentry/node`'s: `init()` must run before the app's server graph +loads, so OpenTelemetry can patch `node:http` and friends. Put it in a module of its own and load that first the way +your host does — `node --import ./instrument.mjs`, or for an app served by the plugin's handler, its +`start.instrument` option, which awaits the module before anything else in the server graph (a plain `import` at the +top of an ESM entry is hoisted below the entry's own dependencies and does not work): + +```js +// src/instrument.js — the server's Sentry.init(); nothing else +import * as Sentry from '@sentry/solid-2/server'; +Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1, integrations: [Sentry.solidServerTracingIntegration()] }); +``` ```js // vite.config.js solid({ ssr: true, observe: true, // tracing reads the observe build; errors report in every tier - start: { instrument: './src/instrument.js' }, // awaited before the server graph loads + start: { instrument: './src/instrument.js' }, }); ``` -```js -// src/instrument.js — the server's Sentry.init(); nothing else -import * as Sentry from '@sentry/solid-2/server'; -Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1, integrations: [Sentry.solidServerTracingIntegration()] }); -``` +Tracing needs Solid's observe build: the `observe` export condition on every environment, plus the compiler's +`componentNames` so component labels survive minification. `solid({ observe: true })` sets both; without the plugin, +set `resolve.conditions` (or `node --conditions=observe` for an unbundled server) and the compiler option yourself. -Add `@sentry/node` to the app's own dependencies. The plugin bundles this package into the server build (it consumes -the Solid runtime, and must see the same copy the app does); with a package manager that isolates dependencies, a -transitive `@sentry/node` is bundled along with it, where `import-in-the-middle` cannot find itself. Declared by the -app, it stays external. +When the plugin bundles the server, add `@sentry/node` to the app's own dependencies. The plugin inlines this package +(it consumes the Solid runtime and must see the same copy the app does); with a package manager that isolates +dependencies, a transitive `@sentry/node` is bundled along with it, where `import-in-the-middle` cannot find itself. +Declared by the app, it stays external. ## Errors