From 7226cca745957aa00c34fa72acd8d5f1cdb3efc2 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 17 Sep 2026 11:09:09 -0700 Subject: [PATCH 01/10] =?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 02/10] 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 03/10] 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 04/10] 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 05/10] =?UTF-8?q?test(solid-2):=20e2e=20app=20=E2=80=94=20?= =?UTF-8?q?start-mode=20observe=20build,=20both=20SDK=20halves,=20through?= =?UTF-8?q?=20Playwright?= 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 06/10] 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 From 21509bfa7b7f84c2aecfdd86db0d1584a9e62708 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 22 Sep 2026 22:52:07 -0700 Subject: [PATCH 07/10] fix(solid-2): hold the attribution engine rather than switch it; start interactions at the event's timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit solidjs/solid#3580 made `attribution.enable()` a hold on a shared engine that returns its release — Solid's own Performance-panel tracks, a diagnostics capture and this SDK coexist, and `disable()` tears the engine down for everyone. The tracer keeps its release and calls it on re-init; before, every re-`init` stacked another hold. A client with tracing off takes no hold at all. `InteractionEvent.at` is now the browser event's own timestamp (the same instant Chrome's INP entry starts at), with the queueing before the handler as `inputDelayMs`; the interaction span starts there, ends at `at + inputDelayMs + handlerMs` when nothing settled later, and carries the delay as an attribute. `solid.hold.silent` uses the engine's own `isSilentHold` so it agrees with the SILENT_HOLD finding by construction. Needs the rc that carries #3580; the dependency bump follows its publish. Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- docs/solid-2-observe.md | 48 +++++++++++------ packages/solid-2/src/client/tracing.ts | 37 +++++++++---- packages/solid-2/test/client/errors.test.ts | 3 +- packages/solid-2/test/client/tracing.test.ts | 57 ++++++++++++++++++++ 4 files changed, 119 insertions(+), 26 deletions(-) diff --git a/docs/solid-2-observe.md b/docs/solid-2-observe.md index 5734244b6a34..9b2ce2b8ec2a 100644 --- a/docs/solid-2-observe.md +++ b/docs/solid-2-observe.md @@ -18,9 +18,11 @@ Solid 2 ships three builds of every runtime package, selected by export conditio | `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. +`@solidjs/vite-plugin`, which sets the condition for every environment and turns on the compiler's `sourceNames` +(component labels survive minification — the ` › ` paths below; a follow-up plugin release extends it to +primitives and binding effects, so owner-path labels read `count` and `span.textContent` rather than `signal` and +`effect`, and issues fingerprinted by `[code, ...ownerPath]` re-fingerprint once when an app upgrades). In `vite dev` +the `development` condition still wins, by design: the dev build is a superset. What this means for the SDK: @@ -101,15 +103,29 @@ The contract that shapes the integration: ### 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. +`attribution.enable(options)` takes a **hold** on a shared engine and returns its release. The engine is installed +into the core's single hook slot while any hold remains — Solid's own Performance-panel tracks +(`@solidjs/web/performance-tracks`), a diagnostics capture and this SDK coexist and see the same records — and +options combine across holds by the most demanding request per key (booleans OR, `historyLimit` max, the threshold +that fires sooner wins), so `log: false` asks for nothing and silences no one, and a hold can add to what another +asked for but never take it away. The SDK keeps the release and calls it on re-`init` (HMR, tests); it never calls +`attribution.disable()`, which tears the engine down for every consumer and is the console's and a test harness's +reset. `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); subscriptions are dropped only when the last hold is released. 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; `isSilentHold()`/`isLongHold()` are the engine's own verdicts, and the SDK uses them so its +`solid.hold.silent` agrees with the `SILENT_HOLD` finding by construction. Five further record types exist — +`create`, `effect`, `flush`, `flight`, `fallback` — built only while a listener for them exists; they are +profiler-grain and the SDK does not subscribe (`flight`, an async span per kickoff joined to its interaction, is the +one worth a product look). + +- `InteractionEvent`: one per user event the web runtime stamped (`click`, `keydown`, …). `at` is the browser + event's own `timeStamp` — the same instant Chrome's INP entry (`PerformanceEventTiming.startTime`) starts at — and + `inputDelayMs` the queueing before the handler ran; the SDK's span starts at `at` and carries the delay as + `solid.interaction.inputDelayMs`. The record carries the handler's own duration (`handlerMs`, from handler entry), + the writes it made, the re-runs and creations it caused, `settledMs` (from `at` to the 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. @@ -138,9 +154,11 @@ 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. +- **Sampling** is the SDK's (`tracesSampleRate` / `tracesSampler`). A client with tracing off takes no hold on the + engine (`hasSpansEnabled`), so it pays Solid's 1.3 KB wiring and nothing of the engine's work; per-trace sampling + happens after the fact on records the engine produced anyway. `checks: false` in `attribution` options would fold + the five cost checks' bookkeeping off for a records-only posture; the SDK leaves them on because it reports their + findings as issues. - **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 diff --git a/packages/solid-2/src/client/tracing.ts b/packages/solid-2/src/client/tracing.ts index 4ac3392a2b0f..9f835916a8b3 100644 --- a/packages/solid-2/src/client/tracing.ts +++ b/packages/solid-2/src/client/tracing.ts @@ -1,5 +1,5 @@ import type { Span, SpanContextData, SpanLink } from '@sentry/core'; -import { debug, defineIntegration, startInactiveSpan } from '@sentry/core'; +import { debug, defineIntegration, hasSpansEnabled, startInactiveSpan } from '@sentry/core'; import type { CallEvent, CallLive } from '@solidjs/web'; import { OBSERVE } from 'solid-js'; import type { @@ -10,7 +10,7 @@ import type { NavigationEvent, RerunEvent, } from 'solid-js/attribution'; -import { attribution } from 'solid-js/attribution'; +import { attribution, isSilentHold } from 'solid-js/attribution'; import type { DiagnosticsOptions } from '../common/diagnostics'; import { captureDiagnostic } from '../common/diagnostics'; import { describeOrigin, describeTarget } from '../common/target'; @@ -58,16 +58,28 @@ export interface SolidTracingOptions { export const solidTracingIntegration = defineIntegration((options: SolidTracingOptions = {}) => { return { name: INTEGRATION_NAME, - setup() { + setup(client) { if (OBSERVE === undefined) { DEBUG_BUILD && debug.warn('solidTracingIntegration: solid-js is not an observe build; no traces'); return; } + // No tracing, no hold: a client that will never start a span should + // not keep the engine recording on its behalf. + if (!hasSpansEnabled(client.getOptions())) { + DEBUG_BUILD && debug.log('solidTracingIntegration: tracing is not enabled; the attribution engine is not held'); + 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 }); + // `enable()` is a hold on a shared engine, not a switch: Solid's own + // Performance-panel tracks, a diagnostics capture and this SDK coexist, + // options combine by the most demanding request per key (`log: false` + // asks for nothing; it silences no one), and the engine stays up while + // any hold remains. The returned release is this SDK's — `disable()` + // would tear the engine down for every consumer. + const release = 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))), @@ -99,14 +111,13 @@ export const solidTracingIntegration = defineIntegration((options: SolidTracingO } uninstall = () => { for (const fn of off) fn(); + release(); 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(', ')}` : ''}`, @@ -117,7 +128,7 @@ function holdSpan(hold: HoldEvent, parent: Span | null, keepText: boolean): Span 'solid.hold.ms': round(hold.holdMs), 'solid.hold.tailMs': round(hold.tailMs), 'solid.hold.flushes': hold.flushes, - 'solid.hold.silent': isSilent(hold), + 'solid.hold.silent': isSilentHold(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, @@ -139,7 +150,7 @@ function navigationSpan(nav: NavigationEvent, parent: Span | null, keepText: boo '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), + 'solid.navigation.silent': nav.hold !== undefined && isSilentHold(nav.hold), 'sentry.origin': ORIGIN, }; for (const [key, value] of Object.entries(nav.params ?? {})) { @@ -221,6 +232,11 @@ class Tracer { public interaction(event: InteractionEvent): void { const { origin } = event; this._settled.add(origin); + // `at` is the browser event's own timestamp (the same instant Chrome's + // INP entry starts at), so the span covers the input delay the browser + // counts first; the handler itself ran from `at + inputDelayMs`. + const inputDelayMs = event.inputDelayMs ?? 0; + const handlerEnd = event.at + inputDelayMs + event.handlerMs; const span = startInactiveSpan({ name: describeOrigin(origin, this.keepText), op: `ui.interaction.${event.name}`, @@ -230,6 +246,7 @@ class Tracer { 'solid.interaction.type': event.name, 'solid.interaction.target': describeTarget(event.target, this.keepText), 'solid.interaction.outcome': event.outcome, + 'solid.interaction.inputDelayMs': event.inputDelayMs === undefined ? undefined : round(event.inputDelayMs), 'solid.interaction.handlerMs': round(event.handlerMs), 'solid.interaction.writes': event.writes, 'solid.reruns': event.runs, @@ -252,9 +269,9 @@ class Tracer { 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))); + span.end(epochSeconds(event.settledMs === undefined ? handlerEnd : event.at + event.settledMs)); this._spans.set(origin, span); - this._recent.push({ at: event.at, until: event.at + event.handlerMs, context: span.spanContext() }); + this._recent.push({ at: event.at, until: handlerEnd, context: span.spanContext() }); if (this._recent.length > RECENT_LIMIT) this._recent.shift(); } diff --git a/packages/solid-2/test/client/errors.test.ts b/packages/solid-2/test/client/errors.test.ts index 17d040918c18..4c16c2462179 100644 --- a/packages/solid-2/test/client/errors.test.ts +++ b/packages/solid-2/test/client/errors.test.ts @@ -74,7 +74,8 @@ describe('solidErrorsIntegration', () => { 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'); + // The boundary's `children` memo is labelled by the primitive that made it (rc.10+). + expect(event.tags?.['solid.owner']).toBe(' › › children › › 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 index abc9d7a4e39c..8a50b84338f3 100644 --- a/packages/solid-2/test/client/tracing.test.ts +++ b/packages/solid-2/test/client/tracing.test.ts @@ -57,6 +57,63 @@ describe('solidTracingIntegration', () => { flush(); }); + it('holds the engine beside other consumers and lets go of only its own hold', async () => { + // A co-holder (Solid's own Performance-panel tracks, a diagnostics capture) first. + const releaseOther = attribution.enable({ log: false }); + const { client, captured } = clientWith(); + expect(OBSERVE!.attribution.installed).not.toBeNull(); + + // The other consumer leaves; the SDK's hold keeps the engine up and recording. + releaseOther(); + expect(OBSERVE!.attribution.installed).not.toBeNull(); + const app = readerApp(); + OBSERVE!.attribution.withInteraction({ type: 'click', target: 'button#save' }, () => app.setCount(1)); + flush(); + await settle(); + await client.flush(100); + expect(captured.spans.some(span => span.attributes['sentry.op'] === 'ui.interaction.click')).toBe(true); + + // A second init replaces the first hold rather than stacking a new one: + // after a co-holder's release the engine is still up (the SDK's hold), + // and a global disable() — the test harness's reset — takes it down. + clientWith(); + const releaseAgain = attribution.enable(); + releaseAgain(); + expect(OBSERVE!.attribution.installed).not.toBeNull(); + app.dispose(); + }); + + it('takes no hold on a client that will never start a span', () => { + const client = new BrowserClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [solidTracingIntegration()], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + }); + setCurrentClient(client); + client.init(); + expect(OBSERVE!.attribution.installed).toBeNull(); + }); + + it("an interaction span starts at the browser event's own timestamp and carries the input delay", async () => { + const { client, captured } = clientWith(); + const app = readerApp(); + // The web runtime dates the frame from `event.timeStamp`; 40ms of queueing before the handler ran. + const at = performance.now() - 40; + OBSERVE!.attribution.withInteraction({ type: 'click', target: 'button#save', at }, () => app.setCount(1)); + flush(); + await settle(); + await client.flush(100); + + const segment = captured.spans.find( + span => span.is_segment && span.attributes['sentry.op'] === 'ui.interaction.click', + )!; + expect(segment.start_timestamp).toBeCloseTo((performance.timeOrigin + at) / 1000, 3); + expect(segment.attributes['solid.interaction.inputDelayMs']).toBeGreaterThanOrEqual(39); + expect(segment.end_timestamp).toBeGreaterThan(segment.start_timestamp + 0.039); + app.dispose(); + }); + 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(); From e5cbd1c99085a2516aa792cded97fe7d6938899f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 00:01:11 -0700 Subject: [PATCH 08/10] =?UTF-8?q?feat(solid-2):=20ride=20Solid=202.0.0-rc.?= =?UTF-8?q?10=20=E2=80=94=20one=20records=20channel,=20engine-side=20scrub?= =?UTF-8?q?bing,=20a=20lean=20consumer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rc.10 moved the attribution engine's records onto `OBSERVE.records` (channel listeners outlive any hold), replaced the `isSilentHold`/ `isLongHold` exports with `HoldEvent.silent`/`long`, and added `AttributionOptions.values` — what user data a record is built with, the one option where the least permissive holder wins. The tracer subscribes to `interaction`/`navigation`/`hold` on the channel and no longer to `rerun`: the engine builds re-run records only for an audience, and the interaction record's `runs`/`runMs` are what the span needs, so the SDK is a lean consumer (the `solid.hot` attribute goes with it). `targetText` maps onto `values` — `"none"` by default, `"labels"` (the caption of a button or a link) when on — so records are built without the text rather than scrubbed after; the local scrub in `common/target.ts` is gone. `solid.hold.silent`/`long` read the engine's verdicts. The interaction wait for an async handler's promise landed in rc.10, so `after_settle` now covers only the non-async handler that dispatches a call and returns; the brief says so. Verified against the published rc.10: units 24/24; the e2e app 6/6 with `@solidjs/vite-plugin` built from its `source-names` branch (#371) — the released plugin still passes `componentNames`, which the rc.10 compiler rejects, so the app pins rc.10 and waits on that plugin release. Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- .../test-applications/solid-2/package.json | 4 +- docs/solid-2-observe.md | 57 +++++++----- packages/solid-2/README.md | 6 +- packages/solid-2/package.json | 10 +- packages/solid-2/src/client/records.ts | 10 +- packages/solid-2/src/client/tracing.ts | 93 ++++++++----------- packages/solid-2/src/common/target.ts | 19 ++-- packages/solid-2/test/client/tracing.test.ts | 5 +- yarn.lock | 26 +++--- 9 files changed, 113 insertions(+), 117 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/solid-2/package.json b/dev-packages/e2e-tests/test-applications/solid-2/package.json index ba1c9720648b..7d627400ccdd 100644 --- a/dev-packages/e2e-tests/test-applications/solid-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solid-2/package.json @@ -14,8 +14,8 @@ "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" + "@solidjs/web": "^2.0.0-rc.10", + "solid-js": "^2.0.0-rc.10" }, "devDependencies": { "@playwright/test": "~1.63.0", diff --git a/docs/solid-2-observe.md b/docs/solid-2-observe.md index 9b2ce2b8ec2a..2ebe12cc3358 100644 --- a/docs/solid-2-observe.md +++ b/docs/solid-2-observe.md @@ -110,15 +110,17 @@ options combine across holds by the most demanding request per key (booleans OR, that fires sooner wins), so `log: false` asks for nothing and silences no one, and a hold can add to what another asked for but never take it away. The SDK keeps the release and calls it on re-`init` (HMR, tests); it never calls `attribution.disable()`, which tears the engine down for every consumer and is the console's and a test harness's -reset. `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); subscriptions are dropped only when the last hold is released. 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; `isSilentHold()`/`isLongHold()` are the engine's own verdicts, and the SDK uses them so its -`solid.hold.silent` agrees with the `SILENT_HOLD` finding by construction. Five further record types exist — -`create`, `effect`, `flush`, `flight`, `fallback` — built only while a listener for them exists; they are -profiler-grain and the SDK does not subscribe (`flight`, an async span per kickoff joined to its interaction, is the -one worth a product look). +reset. The engine's records arrive on the one channel, `OBSERVE.records.subscribe(type, (event, live) => …)` — +`interaction`, `navigation`, `hold`, `rerun`, `graph`, and the profiler-grain `create`/`effect`/`flush`/`flight`/ +`fallback` — delivered as they settle, bottom-up (a hold before the navigation it held, before the interaction that +performed it). Channel listeners outlive any hold: subscribe before or after `enable()`, unsubscribe with the +returned function. The engine builds `rerun` records **only for an audience** (a `rerun` listener, an imported fold, +or `log: true`); the SDK subscribes to none of those, so it is a lean consumer — the interaction record's +`runs`/`runMs` are what its span needs. `HoldEvent.silent`/`long` carry the engine's own verdicts (computed at +settle with the thresholds in effect), so `solid.hold.silent` agrees with the `SILENT_HOLD` finding by construction. +The 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. (`flight`, an async span per kickoff joined to its +interaction, is the record worth a product look.) - `InteractionEvent`: one per user event the web runtime stamped (`click`, `keydown`, …). `at` is the browser event's own `timeStamp` — the same instant Chrome's INP entry (`PerformanceEventTiming.startTime`) starts at — and @@ -134,7 +136,9 @@ one worth a product look). - `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 + `ASYNC_WATERFALL`, `UNTRACKED_ASYNC_HANDLER`, `OPTIMISTIC_REVERTED`, `ABANDONED_FLIGHTS`, `FALLBACK_FLASH`, + `STACKED_HOLDS`, `WASTED_RECOMPUTE`, `GRAPH_GROWTH`, and the server's `SSR_RENDER_ERROR_CONTAINED`, + `SERVER_ERROR_SANITIZED`, `SSR_BOUNDARY_WATERFALL`, … 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. @@ -164,11 +168,13 @@ 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. +- **Calls after the interaction settled.** Since rc.10 an interaction whose handler returns a promise stays open + until it settles (`InteractionEvent.continuationMs`; `UNTRACKED_ASYNC_HANDLER` when nothing acknowledged the + wait), so `onClick={async () => set(await call())}` no longer settles early and its call arrives while the + interaction is open. The `after_settle` path remains for the shape that still escapes — a non-async handler that + dispatches a call and returns (`onClick={() => { save().then(set) }}`): the call lands after the `idle` settle, + carrying the interaction's frame, and becomes a child of the ended span by that identity, marked + `solid.server_function.after_settle`. - **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 @@ -181,15 +187,18 @@ Solid's records name things — owner paths, `name` options, store paths, route 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. +| Field | Content | Integration | +| ----------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `InteractionRef.target` / `ChangeOrigin.target` | `tag#id "text"` with up to 30 chars of text content | Engine `values: "none"` (default) — no text is ever built; `targetText: true` asks for `"labels"`, the caption of a `button`/`a` only | +| `ChangeRecord.prev`/`value`, `HeldWrite.prev`/`value` | Value previews (strings cut at 40 chars) | Never built under `"none"`/`"labels"`; never sent either way | +| `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 | + +Scrubbing happens in the engine where the record is built (`AttributionOptions.values`, the one key where the +least permissive holder wins across holds), so nothing in the SDK holds text it then drops; `targetText` maps onto +that option. No `dataCollection` category fits UI text today; `targetText` is the integration's switch until one +exists. ## Where the proofs are diff --git a/packages/solid-2/README.md b/packages/solid-2/README.md index 4a9a027ed790..e39115c0ef8f 100644 --- a/packages/solid-2/README.md +++ b/packages/solid-2/README.md @@ -103,8 +103,10 @@ Sentry.init({ 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 +Element text Solid attaches to an interaction's target (`button#next "Next →"`) is user data: the SDK asks the +engine for `values: "none"`, so records are built without it (the element alone, `button#next`, stays) and without +value previews. `solidTracingIntegration({ targetText: true })` asks for `"labels"` instead — the caption of a +`button` or an `a` is kept, the text of anything else is not. 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). diff --git a/packages/solid-2/package.json b/packages/solid-2/package.json index a9a891c0559e..d6eea9825c3d 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.9", - "solid-js": "^2.0.0-rc.9" + "@solidjs/web": "^2.0.0-rc.10", + "solid-js": "^2.0.0-rc.10" }, "devDependencies": { - "@solidjs/signals": "^2.0.0-rc.9", - "@solidjs/web": "^2.0.0-rc.9", - "solid-js": "^2.0.0-rc.9" + "@solidjs/signals": "^2.0.0-rc.10", + "@solidjs/web": "^2.0.0-rc.10", + "solid-js": "^2.0.0-rc.10" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/solid-2/src/client/records.ts b/packages/solid-2/src/client/records.ts index 2d4ce8a82b54..38724f7db61c 100644 --- a/packages/solid-2/src/client/records.ts +++ b/packages/solid-2/src/client/records.ts @@ -16,13 +16,7 @@ const FRAME_ORIGIN = 'auto.ui.solid.frame'; * 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, - afterSettle = false, -): Span { +export function callSpan(event: CallEvent, _live: CallLive, parent: Span | null, afterSettle = false): Span { const origin = event.origin; const span = startInactiveSpan({ name: event.id, @@ -34,7 +28,7 @@ export function callSpan( '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': origin ? describeOrigin(origin) : undefined, 'solid.server_function.origin.kind': origin?.kind, 'http.response.status_code': event.status, 'solid.server_function.after_settle': afterSettle ? true : undefined, diff --git a/packages/solid-2/src/client/tracing.ts b/packages/solid-2/src/client/tracing.ts index 9f835916a8b3..ba9fbe896413 100644 --- a/packages/solid-2/src/client/tracing.ts +++ b/packages/solid-2/src/client/tracing.ts @@ -8,9 +8,8 @@ import type { HoldEvent, InteractionEvent, NavigationEvent, - RerunEvent, } from 'solid-js/attribution'; -import { attribution, isSilentHold } 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'; @@ -37,10 +36,13 @@ export interface SolidTracingOptions { */ 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. + * Keep the caption of the control an interaction hit (`button#next "Next + * →"`) in span names and attributes. Off by default: the engine is asked + * for `values: "none"`, so records carry the element alone (`button#next`) + * and no value previews. On, the engine's `"labels"` level applies — the + * caption of a `button` or an `a` is kept, the text of anything else (a + * `td`, a `div`) is still dropped. Scrubbed where the record is built, so + * nothing in the SDK ever holds the text. */ targetText?: boolean; } @@ -72,23 +74,33 @@ export const solidTracingIntegration = defineIntegration((options: SolidTracingO // 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); + const tracer = new Tracer(); // `enable()` is a hold on a shared engine, not a switch: Solid's own // Performance-panel tracks, a diagnostics capture and this SDK coexist, // options combine by the most demanding request per key (`log: false` // asks for nothing; it silences no one), and the engine stays up while // any hold remains. The returned release is this SDK's — `disable()` // would tear the engine down for every consumer. - const release = attribution.enable({ historyLimit: 200, ...options.attribution, log: false }); + // `values` is the least-permissive-wins key: asking for "none" here + // holds even beside a dev console that asked for "full". No `rerun` + // subscription — the engine builds re-run records only for an + // audience, and the interaction record's `runs`/`runMs` are what the + // span needs; a records-only consumer stays a lean one. + const release = attribution.enable({ + historyLimit: 200, + ...options.attribution, + values: options.targetText === true ? 'labels' : 'none', + log: false, + }); const off = [ - attribution.subscribe('rerun', event => tracer.rerun(event)), - attribution.subscribe('interaction', event => queueMicrotask(() => tracer.interaction(event))), - attribution.subscribe('navigation', event => { + release, + OBSERVE.records.subscribe('interaction', event => queueMicrotask(() => tracer.interaction(event))), + OBSERVE.records.subscribe('navigation', event => { if (event.interaction === undefined) queueMicrotask(() => tracer.orphanNavigation(event)); }), - attribution.subscribe('hold', event => { + OBSERVE.records.subscribe('hold', event => { if (event.interaction === undefined && event.origin?.kind !== 'navigation') { - queueMicrotask(() => holdSpan(event, null, tracer.keepText)); + queueMicrotask(() => holdSpan(event, null)); } }), ]; @@ -102,7 +114,7 @@ export const solidTracingIntegration = defineIntegration((options: SolidTracingO off.push( OBSERVE.records.subscribe('call', (event, live) => { if (tracer.claimCall(event, live)) return; - queueMicrotask(() => callSpan(event, live, null, tracer.keepText)); + queueMicrotask(() => callSpan(event, live, null)); }), OBSERVE.records.subscribe('frame', (event, live) => { if (event.side === 'client') queueMicrotask(() => frameSpan(event, live)); @@ -111,14 +123,13 @@ export const solidTracingIntegration = defineIntegration((options: SolidTracingO } uninstall = () => { for (const fn of off) fn(); - release(); uninstall = undefined; }; }, }; }); -function holdSpan(hold: HoldEvent, parent: Span | null, keepText: boolean): Span { +function holdSpan(hold: HoldEvent, parent: Span | null): Span { const span = startInactiveSpan({ name: `hold${hold.blockers.length ? ` waiting on ${hold.blockers.join(', ')}` : ''}`, op: 'solid.hold', @@ -128,14 +139,15 @@ function holdSpan(hold: HoldEvent, parent: Span | null, keepText: boolean): Span 'solid.hold.ms': round(hold.holdMs), 'solid.hold.tailMs': round(hold.tailMs), 'solid.hold.flushes': hold.flushes, - 'solid.hold.silent': isSilentHold(hold), + 'solid.hold.silent': hold.silent, + 'solid.hold.long': hold.long, '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, + 'solid.hold.navigation': hold.origin ? describeOrigin(hold.origin) : undefined, 'sentry.origin': ORIGIN, }, }); @@ -143,14 +155,14 @@ function holdSpan(hold: HoldEvent, parent: Span | null, keepText: boolean): Span return span; } -function navigationSpan(nav: NavigationEvent, parent: Span | null, keepText: boolean, links?: SpanLink[]): Span { +function navigationSpan(nav: NavigationEvent, parent: Span | null, 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 && isSilentHold(nav.hold), + 'solid.navigation.silent': nav.hold?.silent ?? false, 'sentry.origin': ORIGIN, }; for (const [key, value] of Object.entries(nav.params ?? {})) { @@ -164,7 +176,7 @@ function navigationSpan(nav: NavigationEvent, parent: Span | null, keepText: boo attributes, links, }); - if (nav.hold !== undefined) holdSpan(nav.hold, span, keepText); + if (nav.hold !== undefined) holdSpan(nav.hold, span); span.end(epochSeconds(nav.at + (nav.settledMs ?? 0))); return span; } @@ -177,8 +189,6 @@ interface RecentInteraction { 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; /** The root span each settled interaction became — the parent for work it caused after its window closed. */ private readonly _spans: WeakMap; @@ -187,23 +197,13 @@ class Tracer { /** Settled interactions kept for the time join, newest last. */ private readonly _recent: RecentInteraction[]; - public constructor(public readonly keepText: boolean) { - this._hot = new WeakMap(); + public constructor() { this._settled = new WeakSet(); this._spans = new WeakMap(); 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` is an interaction is the interaction's, joined by * the engine's object identity rather than by time. Made while the @@ -220,7 +220,7 @@ class Tracer { if (this._settled.has(interaction)) { const parent = this._spans.get(interaction); if (parent === undefined) return false; - queueMicrotask(() => callSpan(event, live, parent, this.keepText, true)); + queueMicrotask(() => callSpan(event, live, parent, true)); return true; } let calls = this._calls.get(interaction); @@ -238,13 +238,13 @@ class Tracer { const inputDelayMs = event.inputDelayMs ?? 0; const handlerEnd = event.at + inputDelayMs + event.handlerMs; const span = startInactiveSpan({ - name: describeOrigin(origin, this.keepText), + name: describeOrigin(origin), 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.target': describeTarget(event.target), 'solid.interaction.outcome': event.outcome, 'solid.interaction.inputDelayMs': event.inputDelayMs === undefined ? undefined : round(event.inputDelayMs), 'solid.interaction.handlerMs': round(event.handlerMs), @@ -252,7 +252,6 @@ class Tracer { '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, @@ -261,13 +260,13 @@ class Tracer { const underNavigation = new Set(); for (const nav of event.navigations) { if (nav.hold !== undefined) underNavigation.add(nav.hold); - navigationSpan(nav, span, this.keepText); + navigationSpan(nav, span); } - for (const hold of event.holds) if (!underNavigation.has(hold)) holdSpan(hold, span, this.keepText); + for (const hold of event.holds) if (!underNavigation.has(hold)) holdSpan(hold, span); 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); + for (const call of calls) callSpan(call.event, call.live, span); } span.end(epochSeconds(event.settledMs === undefined ? handlerEnd : event.at + event.settledMs)); this._spans.set(origin, span); @@ -290,16 +289,6 @@ class Tracer { 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`); + navigationSpan(nav, null, links); } } diff --git a/packages/solid-2/src/common/target.ts b/packages/solid-2/src/common/target.ts index b5376e2ad880..e094aa52e5b4 100644 --- a/packages/solid-2/src/common/target.ts +++ b/packages/solid-2/src/common/target.ts @@ -2,17 +2,16 @@ 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`. + * The element an interaction hit, as Solid recorded it. What text it carries + * is the engine's decision, made where the record is built + * (`AttributionOptions.values`): the SDK asks for `"none"` unless + * `targetText` is on, so nothing here has to strip anything. */ -export function describeTarget(target: string | undefined, keepText: boolean): string | undefined { - return target === undefined || keepText ? target : target.replace(/ "[^"]*"$/, ''); +export function describeTarget(target: string | undefined): string | undefined { + return target; } -/** `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, ''); +/** `formatOrigin`, named here so every span that names an origin uses one sentence shape. */ +export function describeOrigin(origin: ChangeOrigin): string { + return formatOrigin(origin); } diff --git a/packages/solid-2/test/client/tracing.test.ts b/packages/solid-2/test/client/tracing.test.ts index 8a50b84338f3..908b78d6f40e 100644 --- a/packages/solid-2/test/client/tracing.test.ts +++ b/packages/solid-2/test/client/tracing.test.ts @@ -143,8 +143,11 @@ describe('solidTracingIntegration', () => { 'sentry.origin': 'auto.ui.solid.attribution', }), }); + // The record's totals are the span's; no per-run subscription (the engine + // builds re-run records only for an audience, and the SDK is not one). expect(segment!.attributes['solid.reruns']).toBeGreaterThanOrEqual(1); - expect(segment!.attributes['solid.hot']).toEqual(expect.arrayContaining([expect.stringMatching(/^reader /)])); + expect(segment!.attributes['solid.runMs']).toBeGreaterThanOrEqual(0); + expect(segment!.attributes['solid.hot']).toBeUndefined(); const nav = captured.spans.find(span => span.attributes['sentry.op'] === 'navigation'); expect(nav).toMatchObject({ diff --git a/yarn.lock b/yarn.lock index 99d4464a2be7..be317a4fcff0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8731,10 +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.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/signals@^2.0.0-rc.10": + version "2.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@solidjs/signals/-/signals-2.0.0-rc.10.tgz#d0395b8a7df151672c7e1a1c7942a4f6788d9c89" + integrity sha512-+5JN0259Scjqft7kTCv1pc3rMRniAxNr1We748UU2zz7MrE8MVn65IPt8VS3rA/7DubRCxoiBdDl+aUlJY9v5g== "@solidjs/start@^1.3.2": version "1.3.2" @@ -8764,10 +8764,10 @@ dependencies: "@testing-library/dom" "^9.3.1" -"@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== +"@solidjs/web@^2.0.0-rc.10": + version "2.0.0-rc.10" + resolved "https://registry.yarnpkg.com/@solidjs/web/-/web-2.0.0-rc.10.tgz#ce78347769b50a8ea40e3394240920b223d5079a" + integrity sha512-CHXXV5BtqUkuxz/AM45WUKCYF/OiBhC6fOe9gyiktES2bpKj6SUL2xxIciM7q7G+ewYXAN/SGy1/SNFSrxjrOQ== dependencies: seroval "~1.6.7" seroval-plugins "~1.6.7" @@ -25391,12 +25391,12 @@ solid-js@^1.9.11: seroval "~1.5.0" seroval-plugins "~1.5.0" -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== +solid-js@^2.0.0-rc.10: + version "2.0.0-rc.10" + resolved "https://registry.yarnpkg.com/solid-js/-/solid-js-2.0.0-rc.10.tgz#e473575d9be4b252cf0da791d535576d48ebf2e2" + integrity sha512-eh25kmg7yXuJCVN2VpS03bUuTJ0rvb7MnJyIw+8tcSRwnINaugyI5RLwPMy9Rji94CqsMbDrD9Ixh9bRuBMwwQ== dependencies: - "@solidjs/signals" "^2.0.0-rc.9" + "@solidjs/signals" "^2.0.0-rc.10" csstype "^3.1.0" seroval "~1.6.7" seroval-plugins "~1.6.7" From 5d3710ec45804405272136c87024be0d66b9ea79 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 27 Sep 2026 01:37:21 -0700 Subject: [PATCH 09/10] =?UTF-8?q?test(solid-2):=20e2e=20app=20on=20@solidj?= =?UTF-8?q?s/vite-plugin=20next.45=20=E2=80=94=20the=20release=20that=20sp?= =?UTF-8?q?eaks=20rc.10's=20sourceNames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude via Cursor Co-authored-by: Cursor --- dev-packages/e2e-tests/test-applications/solid-2/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/solid-2/package.json b/dev-packages/e2e-tests/test-applications/solid-2/package.json index 7d627400ccdd..b4707dabf99f 100644 --- a/dev-packages/e2e-tests/test-applications/solid-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solid-2/package.json @@ -20,7 +20,7 @@ "devDependencies": { "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils", - "@solidjs/vite-plugin": "^3.0.0-next.44", + "@solidjs/vite-plugin": "^3.0.0-next.45", "typescript": "^5.4.5", "vite": "^8.1.5" }, From f011d4a8d384ad58b59524ea867b84acbf554051 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 30 Sep 2026 08:23:33 -0700 Subject: [PATCH 10/10] feat(solid-2): route-named pageload, navigation and request spans from Solid's records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solid 2 rc.13 and @solidjs/router next.32 ship the router's declaration to the observe tier: the route the document arrived on (NavigationEvent with `initial`, RenderEvent.route on the server) and every navigation after it, `name` being the route pattern. The SDK names its spans from those records and has no router code. - `solidBrowserTracingIntegration()` — browserTracingIntegration with `instrumentNavigation` handed to the records. The `navigation` span is the SDK's own (`startBrowserTracingNavigationSpan`, origin `auto.navigation.solid`), started at the router's write and ended at the transition's commit rather than at `history.pushState`, named by the route pattern with `url.template` / `url.path.parameter.*`. - The initial record renames the `pageload` (source `route`) and sets the scope's transaction name; the server `"render"` record renames the `http.server` span to `GET /users/:id` with `http.route` and the isolation scope's transaction name. - Topology: an interaction is a root in the navigation's trace, linked to it (`solid.link: navigation`) instead of containing it. A call whose origin is a navigation is that span's child, held until the navigation settles when needed; a call no frame claims parents under the active span (the pageload) instead of being forced to a root. - Peers `solid-js` / `@solidjs/web` `^2.0.0-rc.13`; `@sentry/conventions` for the attribute keys. - E2E app on `@solidjs/router` with a parameterised route; tests for the route-named pageload, request, `navigate()` click and anchor navigation. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .../test-applications/solid-2/README.md | 15 +- .../test-applications/solid-2/package.json | 5 +- .../test-applications/solid-2/src/App.tsx | 54 +++-- .../solid-2/src/sentry.client.ts | 2 +- .../solid-2/tests/performance.client.test.ts | 96 +++++++- .../solid-2/tests/performance.server.test.ts | 8 + docs/solid-2-observe.md | 25 ++- packages/solid-2/README.md | 16 +- packages/solid-2/package.json | 13 +- .../solid-2/src/client/browser-tracing.ts | 45 ++++ packages/solid-2/src/client/index.ts | 1 + packages/solid-2/src/client/navigation.ts | 172 ++++++++++++++ packages/solid-2/src/client/records.ts | 8 +- packages/solid-2/src/client/tracing.ts | 209 +++++++++++------- packages/solid-2/src/server/tracing.ts | 68 +++++- packages/solid-2/test/client/tracing.test.ts | 185 +++++++++++++++- packages/solid-2/test/server/tracing.test.ts | 35 +++ yarn.lock | 26 +-- 18 files changed, 838 insertions(+), 145 deletions(-) create mode 100644 packages/solid-2/src/client/browser-tracing.ts create mode 100644 packages/solid-2/src/client/navigation.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 index 6c97875be876..3a80fa3813bd 100644 --- a/dev-packages/e2e-tests/test-applications/solid-2/README.md +++ b/dev-packages/e2e-tests/test-applications/solid-2/README.md @@ -6,18 +6,23 @@ A Solid 2 app on `@solidjs/vite-plugin`'s start mode, built with `observe: true` - 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. +`@solidjs/router`'s `createRouter` declares the routes to the runtime's observe tier; the SDK has no router code. `/` +calls a server function on click and offers `/users/6` as an anchor and as a `navigate()` button, `/users/:id` +awaits one under a ``, `/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; + span, backdated from the runtime's clock; the request is named `GET /users/:id` by the route the runtime rendered; - performance (client): the `pageload` continues the server trace with no middleware (the runtime's `` 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. + `Server-Timing`) and is named `/users/:id` by the route it arrived on; a navigation is the browser's `navigation` + span named by the route, ended where the transition committed, with the route's server-function call as its child; + the `navigate()` click links to it, the anchor click records no interaction; a click is a root span and the + server-function call it made is its child, joined by identity even though the call landed after the interaction + settled. ```bash pnpm install diff --git a/dev-packages/e2e-tests/test-applications/solid-2/package.json b/dev-packages/e2e-tests/test-applications/solid-2/package.json index b4707dabf99f..ccff0fc5075a 100644 --- a/dev-packages/e2e-tests/test-applications/solid-2/package.json +++ b/dev-packages/e2e-tests/test-applications/solid-2/package.json @@ -14,8 +14,9 @@ "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.10", - "solid-js": "^2.0.0-rc.10" + "@solidjs/router": "^2.0.0-next.32", + "@solidjs/web": "^2.0.0-rc.13", + "solid-js": "^2.0.0-rc.13" }, "devDependencies": { "@playwright/test": "~1.63.0", 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 index 6968528c2dda..088d29289273 100644 --- a/dev-packages/e2e-tests/test-applications/solid-2/src/App.tsx +++ b/dev-packages/e2e-tests/test-applications/solid-2/src/App.tsx @@ -1,13 +1,9 @@ -import { Errored, Loading, getRequestEvent, isServer } from '@solidjs/web'; +import { createRouter, useNavigate, useParams } from '@solidjs/router'; +import { Errored, Loading, isServer } from '@solidjs/web'; import { createMemo, createSignal } from 'solid-js'; import { explode, getPrefecture } from './data'; if (!isServer) await import('./sentry.client'); -function 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'); } @@ -30,6 +26,7 @@ function ClientBoundary() { function Home() { const [result, setResult] = createSignal(''); + const navigate = useNavigate(); return ( <>

Solid 2 E2E

@@ -43,12 +40,19 @@ function Home() { server error + + user 6 + + ); } function UserPage() { - const user = createMemo(() => getPrefecture(6)); + const params = useParams<{ id: string }>(); + const user = createMemo(() => getPrefecture(Number(params.id))); return ( loading…

}>

{JSON.stringify(user())}

@@ -56,23 +60,33 @@ function UserPage() { ); } -export default function App() { - const path = pathname(); - return ( -
- {path === '/server-error' ? ( +// The router declares its routes to the runtime's observe tier: the route the +// document arrived on (both sides) and every navigation after it. The SDK has +// no router code; it names the pageload, navigation and request spans from +// those records. +const Router = createRouter({ + routes: [ + { path: '/', component: Home }, + { path: '/users/:id', component: UserPage }, + { + path: '/server-error', + component: () => (

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

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

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

}>
- ) : path === '/users/6' ? ( - - ) : ( - - )} -
- ); + ), + }, + ] as const, +}); + +export default function App() { + return {props =>
{props.children}
}
; } 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 index 3a3529b4287b..a6f464614e86 100644 --- 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 @@ -12,6 +12,6 @@ Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions tunnel: 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, - integrations: [Sentry.browserTracingIntegration(), Sentry.solidTracingIntegration()], + integrations: [Sentry.solidBrowserTracingIntegration(), Sentry.solidTracingIntegration()], debug: !!import.meta.env.DEBUG, }); 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 index 7f95f4d6cf6d..3167c4a6ef5d 100644 --- 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 @@ -5,7 +5,6 @@ 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 @@ -23,6 +22,101 @@ test('the pageload continues the server trace through the runtime’s carriers', expect(pageload.parent_span_id).toBe(server.span_id); }); +test('the pageload is named by the route the document arrived on, from the router’s declaration', async ({ page }) => { + const isPageload = (span: SerializedStreamedSpan): boolean => + span.is_segment && getSpanOp(span) === 'pageload' && span.attributes['url.path']?.value === '/users/6'; + const spansPromise = collectStreamedSpans('solid-2', spans => spans.some(isPageload)); + + await page.goto('/users/6'); + await expect(page.locator('#user')).toContainText('Kagoshima'); + + const pageload = (await spansPromise).find(isPageload)!; + // The route pattern, not the URL: one name per route in Performance. + expect(pageload.name).toBe('/users/:id'); + expect(pageload.attributes).toMatchObject({ + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/users/:id', type: 'string' }, + 'url.path.parameter.id': { value: '6', type: 'string' }, + }); +}); + +const isNavigation = (span: SerializedStreamedSpan): boolean => span.is_segment && getSpanOp(span) === 'navigation'; +const isCall = (span: SerializedStreamedSpan): boolean => getSpanOp(span) === 'function.solid.call'; + +/** What every navigation to `/users/6` from `/` paints, however it was performed. */ +function expectRouteNavigation(spans: SerializedStreamedSpan[]): SerializedStreamedSpan { + const navigation = spans.find(isNavigation)!; + expect(navigation.name).toBe('/users/:id'); + expect(navigation.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.navigation.solid', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/users/:id', type: 'string' }, + 'url.path': { value: '/users/6', type: 'string' }, + 'url.path.parameter.id': { value: '6', type: 'string' }, + 'solid.navigation.to': { value: '/users/6', type: 'string' }, + 'solid.navigation.from': { value: '/', type: 'string' }, + 'solid.navigation.outcome': { value: 'committed', type: 'string' }, + }); + // Ended where the runtime settled it (the data landed, the transition + // committed), not at the browser SDK's idle timeout. + expect(navigation.end_timestamp - navigation.start_timestamp).toBeLessThan(1); + // A root in the trace the navigation opened. + expect(navigation.parent_span_id).toBeUndefined(); + // The one navigation span: nothing painted twice. + expect(spans.filter(span => getSpanOp(span) === 'navigation')).toHaveLength(1); + // The server-function call the route's data made is the navigation's + // child — joined by the frame the engine recorded on the call, not by time. + const call = spans.find(isCall)!; + expect(call.parent_span_id).toBe(navigation.span_id); + expect(call.trace_id).toBe(navigation.trace_id); + expect(call.attributes['solid.server_function.origin.kind']).toEqual({ value: 'navigation', type: 'string' }); + return navigation; +} + +test('a click that navigates: the navigation span is the browser’s, named by the route; the click links to it', async ({ + page, +}) => { + const isClick = (span: SerializedStreamedSpan): boolean => + span.is_segment && getSpanOp(span) === 'ui.interaction.click' && span.name === 'click on button#userBtn'; + const spansPromise = collectStreamedSpans( + 'solid-2', + spans => spans.some(isNavigation) && spans.some(isClick) && spans.some(isCall), + ); + + await page.goto('/'); + await page.locator('#userBtn').click(); + await expect(page.locator('#user')).toContainText('Kagoshima'); + + const spans = await spansPromise; + const navigation = expectRouteNavigation(spans); + const click = spans.find(isClick)!; + + // The click that performed the navigation is in the trace it opened and + // links to it — the causal fact, no guessed parent. + expect(click.trace_id).toBe(navigation.trace_id); + expect(click.links).toEqual([ + expect.objectContaining({ + span_id: navigation.span_id, + attributes: expect.objectContaining({ 'solid.link': { value: 'navigation', type: 'string' } }), + }), + ]); +}); + +test('an anchor that navigates: the same navigation span, with the route’s call as its child', async ({ page }) => { + const spansPromise = collectStreamedSpans('solid-2', spans => spans.some(isNavigation) && spans.some(isCall)); + + await page.goto('/'); + await page.locator('#userLink').click(); + await expect(page.locator('#user')).toContainText('Kagoshima'); + + // The router handles anchors in a document-level listener, outside the + // runtime's interaction frame: the navigation records no interaction, so + // there is no click span to link — the navigation and its call stand alone. + const spans = await spansPromise; + expectRouteNavigation(spans); + expect(spans.filter(span => getSpanOp(span) === 'ui.interaction.click')).toHaveLength(0); +}); + test('a click is a root span with the server-function call it made as a child, joined by identity', async ({ page, }) => { 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 index 70c0440f0af1..5024abd6f2ca 100644 --- 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 @@ -25,6 +25,14 @@ test('a waiting boundary and the server function it awaited are spans const boundary = spans.find(span => getSpanOp(span) === 'solid.boundary')!; const invocation = spans.find(span => getSpanOp(span) === 'function.solid.direct')!; + // The request is named by the route the router matched, from the render + // record — `GET /users/:id`, not one name per user. + expect(request.name).toBe('GET /users/:id'); + expect(request.attributes).toMatchObject({ + 'http.route': { value: '/users/:id', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.path.parameter.id': { value: '6', type: 'string' }, + }); // 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); diff --git a/docs/solid-2-observe.md b/docs/solid-2-observe.md index 2ebe12cc3358..b67227777639 100644 --- a/docs/solid-2-observe.md +++ b/docs/solid-2-observe.md @@ -163,11 +163,26 @@ derives its parent from the latter; the provider is where Sentry's view wins. happens after the fact on records the engine produced anyway. `checks: false` in `attribution` options would fold the five cost checks' bookkeeping off for a records-only posture; the SDK leaves them on because it reports their findings as issues. -- **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). +- **Span topology.** A `navigation` is the **browser transaction**: `solidBrowserTracingIntegration` is + `browserTracingIntegration` with `instrumentNavigation` handed to the records, so the SDK's own navigation span + (`startBrowserTracingNavigationSpan`, `sentry.origin: auto.navigation.solid`) starts at the router's write and + ends at the transition's commit, in a trace of its own, instead of at `history.pushState` — which the router only + performs on commit, after the work. A user interaction is a **root** span (`parentSpan: null`) in that trace, + **linked** to the navigation it performed (`solid.link: navigation`; it cannot be a parent across the trace + boundary), with its holds and calls as children; a navigation no interaction claims stands alone, an orphan hold + is a root of its own. Without the wrapper (or with `instrumentNavigation: false` on it) the record becomes a span of + the SDK's own, a child of the interaction. A server-function call whose `origin` is a navigation is that navigation + span's child, held until the navigation settles when needed; a call no frame claims parents under the **active** + span — the `pageload`, for the calls a load makes — as the browser SDK parents a fetch, and is a root only when + nothing is active. +- **Route names.** `NavigationEvent.name` (the route pattern the router declared) names the `navigation` span and, + on the initial record, renames the `pageload` (`updateName`, source `route`, `url.template`, + `url.path.parameter.*`); the server's `"render"` record's `route` renames the `http.server` span to + `GET /users/:id` with `http.route`, and the isolation scope's transaction name so errors in the request carry it. + With no declaration, every name stays the URL, as the browser SDK left it. +- **Anchor navigations are unattributed.** `@solidjs/router` handles `` clicks in a `document`-level listener, + outside the web runtime's interaction frame, so the navigation records no `interaction` and no click span exists to + link; `navigate()` from a handler is attributed. A Solid-side follow-up, not the SDK's to guess. - **Calls after the interaction settled.** Since rc.10 an interaction whose handler returns a promise stays open until it settles (`InteractionEvent.continuationMs`; `UNTRACKED_ASYNC_HANDLER` when nothing acknowledged the wait), so `onClick={async () => set(await call())}` no longer settles early and its call arrives while the diff --git a/packages/solid-2/README.md b/packages/solid-2/README.md index e39115c0ef8f..16c857bba1ff 100644 --- a/packages/solid-2/README.md +++ b/packages/solid-2/README.md @@ -84,7 +84,7 @@ Tracing reads Solid's observe tier — the build the `observe` export condition Sentry.init({ dsn: '__DSN__', tracesSampleRate: 1, - integrations: [Sentry.browserTracingIntegration(), Sentry.solidTracingIntegration()], + integrations: [Sentry.solidBrowserTracingIntegration(), Sentry.solidTracingIntegration()], }); // server @@ -95,9 +95,17 @@ Sentry.init({ }); ``` -- **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. +`solidBrowserTracingIntegration` is `browserTracingIntegration` with its navigation instrumentation handed to +Solid's records: a `navigation` span begins when the router writes the location and ends when the transition +commits, named by the **route pattern** (`/users/:id`) the router declared, with the parameters as +`url.path.parameter.*`. The `pageload` and the server's `http.server` span are renamed by the same declaration. It +takes the same options as `browserTracingIntegration`. + +- **Client** — the `navigation` span is the browser transaction, a trace of its own; the server-function calls the + route's data made are its children. A user interaction is a root span (`ui.interaction.click`) in that trace, + linked to the navigation it performed (`solid.link: navigation`), with the holds and calls it caused as children. A + call no frame claims parents under the active span — the `pageload`, while it runs. The runtime's diagnostics + (`SILENT_HOLD`, `HOT_SCOPE_RERUNS`, …) become 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 diff --git a/packages/solid-2/package.json b/packages/solid-2/package.json index d6eea9825c3d..a13a4e53402d 100644 --- a/packages/solid-2/package.json +++ b/packages/solid-2/package.json @@ -52,16 +52,17 @@ "dependencies": { "@sentry/browser": "10.67.0", "@sentry/core": "10.67.0", - "@sentry/node": "10.67.0" + "@sentry/node": "10.67.0", + "@sentry/conventions": "^0.23.0" }, "peerDependencies": { - "@solidjs/web": "^2.0.0-rc.10", - "solid-js": "^2.0.0-rc.10" + "@solidjs/web": "^2.0.0-rc.13", + "solid-js": "^2.0.0-rc.13" }, "devDependencies": { - "@solidjs/signals": "^2.0.0-rc.10", - "@solidjs/web": "^2.0.0-rc.10", - "solid-js": "^2.0.0-rc.10" + "@solidjs/signals": "^2.0.0-rc.13", + "@solidjs/web": "^2.0.0-rc.13", + "solid-js": "^2.0.0-rc.13" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/solid-2/src/client/browser-tracing.ts b/packages/solid-2/src/client/browser-tracing.ts new file mode 100644 index 000000000000..c1d7a00fce6d --- /dev/null +++ b/packages/solid-2/src/client/browser-tracing.ts @@ -0,0 +1,45 @@ +import { browserTracingIntegration } from '@sentry/browser'; +import type { Client, Integration } from '@sentry/core'; + +/** Clients whose navigation spans `solidTracingIntegration` starts from the runtime's records. */ +const NAVIGATION_CLIENTS = new WeakSet(); + +/** + * `browserTracingIntegration` with its navigation instrumentation handed to + * Solid's records: the browser SDK stops starting a navigation span on + * `history.pushState`, and `solidTracingIntegration` starts it from the + * router's `"navigation"` record instead — named by the route pattern + * (`/users/:id`, source `route`), starting at the write that requested it + * and ending when the runtime settled it. + * + * The history hook is the wrong instant for a Solid router: the URL is + * committed with the transition, after the destination's data landed, so a + * span started there begins when the navigation is effectively over and + * misses the work. The record has the request time, the route, the params, + * the outcome and the holds, so the span is built from it retroactively with + * explicit timestamps. Page loads keep the browser SDK's own span; the + * record of the route the document arrived on renames it. + * + * `instrumentNavigation: false` here means what it means on the browser + * integration: no navigation transactions. `solidTracingIntegration` then + * paints the record as a span of its own, as it does with no browser tracing + * at all. + */ +export function solidBrowserTracingIntegration( + options: Parameters[0] = {}, +): Integration { + const integration = browserTracingIntegration({ ...options, instrumentNavigation: false }); + const { instrumentNavigation = true } = options; + return { + ...integration, + afterAllSetup(client) { + integration.afterAllSetup?.(client); + if (instrumentNavigation) NAVIGATION_CLIENTS.add(client); + }, + }; +} + +/** Whether `solidBrowserTracingIntegration` handed this client's navigation spans to the records. */ +export function drivesNavigationSpans(client: Client): boolean { + return NAVIGATION_CLIENTS.has(client); +} diff --git a/packages/solid-2/src/client/index.ts b/packages/solid-2/src/client/index.ts index 1fb6e060c43a..4515f59b988d 100644 --- a/packages/solid-2/src/client/index.ts +++ b/packages/solid-2/src/client/index.ts @@ -3,5 +3,6 @@ export * from '@sentry/browser'; export { init, getDefaultIntegrations } from './sdk'; export { solidErrorsIntegration } from './errors'; export { solidTracingIntegration } from './tracing'; +export { solidBrowserTracingIntegration } from './browser-tracing'; export type { SolidTracingOptions } from './tracing'; export type { DiagnosticsOptions } from '../common/diagnostics'; diff --git a/packages/solid-2/src/client/navigation.ts b/packages/solid-2/src/client/navigation.ts new file mode 100644 index 000000000000..bde3a5ded00e --- /dev/null +++ b/packages/solid-2/src/client/navigation.ts @@ -0,0 +1,172 @@ +import { getAbsoluteUrl, startBrowserTracingNavigationSpan } from '@sentry/browser'; +import { PARAMS_KEY_BASE, URL_PATH_PARAMETER_KEY_BASE, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import type { Client, Span, SpanAttributes, SpanLink } from '@sentry/core'; +import { + getActiveSpan, + getCurrentScope, + getRootSpan, + hasSpanStreamingEnabled, + NAVIGATION_SPAN_NAME_FALLBACK, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SENTRY_SEGMENT_NAME_SOURCE, + spanToJSON, + startInactiveSpan, +} from '@sentry/core'; +import type { HoldEvent, NavigationEvent } from 'solid-js/attribution'; +import { describeOrigin } from '../common/target'; +import { epochSeconds, round } from '../common/time'; + +export const ORIGIN = 'auto.ui.solid.attribution'; +const NAVIGATION_ORIGIN = 'auto.navigation.solid'; + +export function holdSpan(hold: HoldEvent, parent: Span | null): 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': hold.silent, + 'solid.hold.long': hold.long, + '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) : undefined, + 'sentry.origin': ORIGIN, + }, + }); + span.end(epochSeconds(hold.at + hold.holdMs)); + return span; +} + +/** + * The route's parameters as span attributes, under both keys the browser + * SDKs' router integrations write (`url.path.parameter.id`, `params.id`). + */ +function routeParamAttributes(params: NavigationEvent['params']): SpanAttributes { + const attributes: SpanAttributes = {}; + for (const [key, value] of Object.entries(params ?? {})) { + if (value !== undefined) { + attributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = value; + attributes[`${PARAMS_KEY_BASE}.${key}`] = value; + } + } + return attributes; +} + +function navigationAttributes(nav: NavigationEvent): SpanAttributes { + return { + '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?.silent ?? false, + ...routeParamAttributes(nav.params), + }; +} + +/** What a navigation span gets before it ends: its hold, and whatever the caller has for it (its calls). */ +function fill(span: Span, nav: NavigationEvent, children: ((span: Span) => void) | undefined): Span { + if (nav.hold !== undefined) holdSpan(nav.hold, span); + children?.(span); + span.end(epochSeconds(nav.at + (nav.settledMs ?? 0))); + return span; +} + +/** The record as a span of its own — no browser navigation span to be. */ +export function navigationSpan( + nav: NavigationEvent, + parent: Span | null, + links?: SpanLink[], + children?: (span: Span) => void, +): Span { + const span = startInactiveSpan({ + name: nav.name ?? nav.to ?? 'navigation', + op: 'navigation', + parentSpan: parent, + startTime: epochSeconds(nav.at), + attributes: { ...navigationAttributes(nav), 'sentry.origin': ORIGIN }, + links, + }); + return fill(span, nav, children); +} + +/** + * The record as the browser SDK's `navigation` span — the transaction + * Sentry's Performance product names pages by. Started through the same + * door the history hook would have used (`startBrowserTracingNavigationSpan`: + * a new trace, the previous idle span ended, `beforeStartSpan` and the + * client hooks honoured), dated from the write that requested it, and ended + * where the runtime settled it: the transition committed and its holds + * landed. The browser SDK's idle timeout exists for frameworks that cannot + * say when a navigation is over; this one can. Without a route pattern the + * name is the path — or the fallback under span streaming, where names must + * be low-cardinality. `undefined` when no span was started for this client + * (another client is current; tracing disabled), so the caller falls back. + */ +export function browserNavigationSpan( + client: Client, + nav: NavigationEvent, + links?: SpanLink[], + children?: (span: Span) => void, +): Span | undefined { + const active = getActiveSpan(); + const before = active === undefined ? undefined : getRootSpan(active); + const name = nav.name ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : nav.to); + const span = startBrowserTracingNavigationSpan( + client, + { + name: name ?? NAVIGATION_SPAN_NAME_FALLBACK, + startTime: epochSeconds(nav.at), + attributes: { + ...navigationAttributes(nav), + [SENTRY_SEGMENT_NAME_SOURCE]: nav.name !== undefined ? 'route' : 'url', + [URL_TEMPLATE]: nav.name, + 'sentry.origin': NAVIGATION_ORIGIN, + }, + links, + }, + nav.to !== undefined ? { url: getAbsoluteUrl(nav.to) } : undefined, + ); + // `startBrowserTracingNavigationSpan` answers with whatever idle span is + // active; only one that appeared for this call is ours to fill and end. + if ( + span === undefined || + span === before || + spanToJSON(span).attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] !== 'navigation' + ) { + return undefined; + } + return fill(span, nav, children); +} + +/** + * The route the document arrived on names the `pageload` span: the same + * rename every router integration performs, from the record instead of the + * router. Nothing without a route pattern — the URL the span already has is + * the best name there is — or without an active `pageload` (tracing without + * the browser integration, a record after the span ended). + */ +export function nameInitialRoute(nav: NavigationEvent): void { + const name = nav.name; + if (name === undefined) return; + const active = getActiveSpan(); + if (active === undefined) return; + const root = getRootSpan(active); + if (spanToJSON(root).attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] !== 'pageload') return; + root.updateName(name); + root.setAttributes({ + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [URL_TEMPLATE]: name, + ...routeParamAttributes(nav.params), + }); + // The scope's transaction name is what error events on this page carry. + getCurrentScope().setTransactionName(name); +} diff --git a/packages/solid-2/src/client/records.ts b/packages/solid-2/src/client/records.ts index 38724f7db61c..a545ffa4258f 100644 --- a/packages/solid-2/src/client/records.ts +++ b/packages/solid-2/src/client/records.ts @@ -15,8 +15,14 @@ const FRAME_ORIGIN = 'auto.ui.solid.frame'; * 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. + * `parent` `null` makes a root; `undefined` leaves it to the active span. */ -export function callSpan(event: CallEvent, _live: CallLive, parent: Span | null, afterSettle = false): Span { +export function callSpan( + event: CallEvent, + _live: CallLive, + parent: Span | null | undefined, + afterSettle = false, +): Span { const origin = event.origin; const span = startInactiveSpan({ name: event.id, diff --git a/packages/solid-2/src/client/tracing.ts b/packages/solid-2/src/client/tracing.ts index ba9fbe896413..ca2eeb26ea94 100644 --- a/packages/solid-2/src/client/tracing.ts +++ b/packages/solid-2/src/client/tracing.ts @@ -1,4 +1,4 @@ -import type { Span, SpanContextData, SpanLink } from '@sentry/core'; +import type { Client, Span, SpanContextData, SpanLink } from '@sentry/core'; import { debug, defineIntegration, hasSpansEnabled, startInactiveSpan } from '@sentry/core'; import type { CallEvent, CallLive } from '@solidjs/web'; import { OBSERVE } from 'solid-js'; @@ -15,10 +15,11 @@ import { captureDiagnostic } from '../common/diagnostics'; import { describeOrigin, describeTarget } from '../common/target'; import { epochSeconds, round } from '../common/time'; import { DEBUG_BUILD } from '../debug-build'; +import { drivesNavigationSpans } from './browser-tracing'; +import { browserNavigationSpan, holdSpan, nameInitialRoute, navigationSpan, ORIGIN } from './navigation'; import { callSpan, frameSpan } from './records'; const INTEGRATION_NAME = 'SolidTracing'; -const ORIGIN = 'auto.ui.solid.attribution'; let uninstall: (() => void) | undefined; @@ -49,13 +50,25 @@ export interface SolidTracingOptions { /** * 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`. + * with its holds and server-function calls as children; the runtime's + * diagnostics as issues; and the router's navigation records as the route + * names of the browser SDK's `pageload` and `navigation` spans. Records + * arrive settled, with an absolute `at` and durations, so every span is + * built retroactively with explicit start and end times. + * + * Navigations: the record of the route the document arrived on + * (`NavigationEvent.initial`) renames the active `pageload` span to the + * route pattern (source `route`, params as attributes). Beside + * `solidBrowserTracingIntegration`, every later record starts the + * `navigation` span the browser SDK would otherwise have started on + * `pushState` — named by the route, from the write that requested it to the + * settle — with the navigation's holds as children; the interaction that + * performed it links to it rather than containing it. With the plain + * `browserTracingIntegration`, or none, a navigation record is a span of its + * own: a child of its interaction, or a root when no interaction claims it. + * + * Inert on a build without `OBSERVE` (production without the `observe` + * condition): errors still report through `solidErrorsIntegration`. */ export const solidTracingIntegration = defineIntegration((options: SolidTracingOptions = {}) => { return { @@ -74,7 +87,7 @@ export const solidTracingIntegration = defineIntegration((options: SolidTracingO // 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(); + const tracer = new Tracer(client); // `enable()` is a hold on a shared engine, not a switch: Solid's own // Performance-panel tracks, a diagnostics capture and this SDK coexist, // options combine by the most demanding request per key (`log: false` @@ -96,7 +109,11 @@ export const solidTracingIntegration = defineIntegration((options: SolidTracingO release, OBSERVE.records.subscribe('interaction', event => queueMicrotask(() => tracer.interaction(event))), OBSERVE.records.subscribe('navigation', event => { - if (event.interaction === undefined) queueMicrotask(() => tracer.orphanNavigation(event)); + // The document's own arrival is nobody's interaction; a later + // record an interaction claims arrives again inside that + // interaction's record, and is handled there. + if (event.initial === true) queueMicrotask(() => tracer.initialNavigation(event)); + else if (event.interaction === undefined) queueMicrotask(() => tracer.orphanNavigation(event)); }), OBSERVE.records.subscribe('hold', event => { if (event.interaction === undefined && event.origin?.kind !== 'navigation') { @@ -114,7 +131,9 @@ export const solidTracingIntegration = defineIntegration((options: SolidTracingO off.push( OBSERVE.records.subscribe('call', (event, live) => { if (tracer.claimCall(event, live)) return; - queueMicrotask(() => callSpan(event, live, null)); + // No frame claims it: a child of the active span — the `pageload` + // while it runs, as the browser SDK parents a fetch — else a root. + queueMicrotask(() => callSpan(event, live, undefined)); }), OBSERVE.records.subscribe('frame', (event, live) => { if (event.side === 'client') queueMicrotask(() => frameSpan(event, live)); @@ -129,58 +148,6 @@ export const solidTracingIntegration = defineIntegration((options: SolidTracingO }; }); -function holdSpan(hold: HoldEvent, parent: Span | null): 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': hold.silent, - 'solid.hold.long': hold.long, - '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) : undefined, - 'sentry.origin': ORIGIN, - }, - }); - span.end(epochSeconds(hold.at + hold.holdMs)); - return span; -} - -function navigationSpan(nav: NavigationEvent, parent: Span | null, 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?.silent ?? false, - '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); - span.end(epochSeconds(nav.at + (nav.settledMs ?? 0))); - return span; -} - interface RecentInteraction { at: number; until: number; @@ -189,6 +156,7 @@ interface RecentInteraction { const RECENT_LIMIT = 50; class Tracer { + private readonly _client: Client; 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; @@ -196,26 +164,97 @@ class Tracer { private readonly _calls: WeakMap>; /** Settled interactions kept for the time join, newest last. */ private readonly _recent: RecentInteraction[]; + /** Navigations whose record arrived — with the span each became, when it became one. */ + private readonly _navigationsSettled: WeakSet; + private readonly _navigationSpans: WeakMap; + /** Server-function calls dispatched under a navigation still open, awaiting its span. */ + private readonly _navigationCalls: WeakMap>; - public constructor() { + public constructor(client: Client) { + this._client = client; this._settled = new WeakSet(); this._spans = new WeakMap(); this._calls = new WeakMap(); this._recent = []; + this._navigationsSettled = new WeakSet(); + this._navigationSpans = new WeakMap(); + this._navigationCalls = new WeakMap(); } /** - * 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. + * A navigation record as the browser `navigation` span, when + * `solidBrowserTracingIntegration` handed those to the records and the + * span was started for this client; `undefined` otherwise. + */ + private _browserNavigation(nav: NavigationEvent, links?: SpanLink[]): Span | undefined { + if (!drivesNavigationSpans(this._client)) return undefined; + const span = browserNavigationSpan(this._client, nav, links, s => this._paintNavigationCalls(nav, s)); + if (span !== undefined) this._settleNavigation(nav, span); + return span; + } + + /** A navigation record as a span of its own under `parent` — no browser span to be. */ + private _ownNavigation(nav: NavigationEvent, parent: Span | null, links?: SpanLink[]): Span { + const span = navigationSpan(nav, parent, links, s => this._paintNavigationCalls(nav, s)); + this._settleNavigation(nav, span); + return span; + } + + /** `links` join a navigation to an interaction it cannot be a child of — the browser span starts a trace of its own. */ + private _navigation(nav: NavigationEvent, parent: Span | null, links?: SpanLink[]): Span { + return this._browserNavigation(nav, links) ?? this._ownNavigation(nav, parent, links); + } + + private _paintNavigationCalls(nav: NavigationEvent, span: Span): void { + const calls = this._navigationCalls.get(nav.origin); + if (calls === undefined) return; + this._navigationCalls.delete(nav.origin); + for (const call of calls) callSpan(call.event, call.live, span); + } + + private _settleNavigation(nav: NavigationEvent, span: Span | undefined): void { + this._navigationsSettled.add(nav.origin); + if (span !== undefined) this._navigationSpans.set(nav.origin, span); + } + + /** The route the document arrived on: names the `pageload`, is no navigation. */ + public initialNavigation(nav: NavigationEvent): void { + this._settleNavigation(nav, undefined); + nameInitialRoute(nav); + } + + /** + * A call is claimed by the frame its `origin` names, joined by the + * engine's object identity rather than by time. A call a navigation + * caused — a `createAsync` calling the server on the route's write — is + * the navigation's: held for its span while the navigation is open, a + * child of that span at once when it has settled. A call under an + * interaction is the interaction's the same way; made after the + * interaction settled — the shape of `onClick={() => { save().then(set) }}`, + * where the handler's synchronous window closes long before the call + * lands — it becomes a child of that span, marked `after_settle`. A call + * with no frame at all is left to the caller: a child of whatever span is + * active (the `pageload`, for the calls a load makes), else a root. */ public claimCall(event: CallEvent, live: CallLive): boolean { const origin = event.origin; - const interaction = origin === undefined ? undefined : origin.kind === 'interaction' ? origin : origin.interaction; + if (origin === undefined) return false; + if (origin.kind === 'navigation') { + const navigation = this._navigationSpans.get(origin); + if (navigation !== undefined) { + queueMicrotask(() => callSpan(event, live, navigation, true)); + return true; + } + if (!this._navigationsSettled.has(origin)) { + let calls = this._navigationCalls.get(origin); + if (calls === undefined) this._navigationCalls.set(origin, (calls = [])); + calls.push({ event, live }); + return true; + } + // A settled navigation that became no span — the document's arrival — + // leaves the call to its interaction, if any, else to the pageload. + } + const interaction = origin.kind === 'interaction' ? origin : origin.interaction; if (interaction === undefined) return false; if (this._settled.has(interaction)) { const parent = this._spans.get(interaction); @@ -237,6 +276,19 @@ class Tracer { // counts first; the handler itself ran from `at + inputDelayMs`. const inputDelayMs = event.inputDelayMs ?? 0; const handlerEnd = event.at + inputDelayMs + event.handlerMs; + // The navigations first, when they are browser navigation spans: each + // starts a new trace, and the interaction's own span then opens in the + // trace of the navigation it performed rather than in one of its own. + // Nothing in `navigations` is the document's arrival (`initial`). + const browserNavigations: Span[] = []; + const ownNavigations: NavigationEvent[] = []; + const underNavigation = new Set(); + for (const nav of event.navigations) { + if (nav.hold !== undefined) underNavigation.add(nav.hold); + const browserSpan = this._browserNavigation(nav); + if (browserSpan !== undefined) browserNavigations.push(browserSpan); + else ownNavigations.push(nav); + } const span = startInactiveSpan({ name: describeOrigin(origin), op: `ui.interaction.${event.name}`, @@ -257,11 +309,12 @@ class Tracer { 'sentry.origin': ORIGIN, }, }); - const underNavigation = new Set(); - for (const nav of event.navigations) { - if (nav.hold !== undefined) underNavigation.add(nav.hold); - navigationSpan(nav, span); + // A navigation that became the browser's span is a root of its own: the + // click links to it — the causal fact, kept without a guessed parent. + for (const browserSpan of browserNavigations) { + span.addLink({ context: browserSpan.spanContext(), attributes: { 'solid.link': 'navigation' } }); } + for (const nav of ownNavigations) this._ownNavigation(nav, span); for (const hold of event.holds) if (!underNavigation.has(hold)) holdSpan(hold, span); const calls = this._calls.get(origin); if (calls !== undefined) { @@ -289,6 +342,6 @@ class Tracer { const links: SpanLink[] | undefined = cause ? [{ context: cause.context, attributes: { 'solid.link': 'interaction-by-time' } }] : undefined; - navigationSpan(nav, null, links); + this._navigation(nav, null, links); } } diff --git a/packages/solid-2/src/server/tracing.ts b/packages/solid-2/src/server/tracing.ts index 91a992ca26d8..48c5f5793190 100644 --- a/packages/solid-2/src/server/tracing.ts +++ b/packages/solid-2/src/server/tracing.ts @@ -1,6 +1,28 @@ -import type { Span } from '@sentry/core'; -import { debug, defineIntegration, getActiveSpan, getTraceData, spanToJSON, startInactiveSpan } from '@sentry/core'; -import type { FrameEvent, FrameLive, InvocationEvent, InvocationLive, TraceContext } from '@solidjs/web'; +import { HTTP_ROUTE, URL_PATH_PARAMETER_KEY_BASE } from '@sentry/conventions/attributes'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { + debug, + defineIntegration, + getActiveSpan, + getDefaultIsolationScope, + getIsolationScope, + getRootSpan, + getTraceData, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SENTRY_SEGMENT_NAME_SOURCE, + spanToJSON, + startInactiveSpan, + updateSpanName, +} from '@sentry/core'; +import type { + FrameEvent, + FrameLive, + InvocationEvent, + InvocationLive, + RenderEvent, + RenderLive, + TraceContext, +} from '@solidjs/web'; import type { BoundaryEvent, BoundaryLive } from 'solid-js'; import { OBSERVE } from 'solid-js'; import type { DiagnosticsOptions } from '../common/diagnostics'; @@ -26,7 +48,11 @@ export interface SolidServerTracingOptions { * 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`. + * parent on the active `http.server` span. The `"render"` record names that + * span by the route the router matched (`GET /users/:id`, `http.route`, + * source `route`) — the name Sentry's Performance product groups requests + * by, from the runtime rather than from router code in the SDK. Inert + * without `OBSERVE`. */ export const solidServerTracingIntegration = defineIntegration((options: SolidServerTracingOptions = {}) => { return { @@ -46,6 +72,7 @@ export const solidServerTracingIntegration = defineIntegration((options: SolidSe OBSERVE.records.subscribe('frame', (event, live) => { if (event.side === 'server') frameSpan(event, live); }), + OBSERVE.records.subscribe('render', nameRequestByRoute), ]; if (options.diagnostics !== false) { const diagnosticsOptions = options.diagnostics; @@ -83,6 +110,39 @@ function traceProvider(): Partial | undefined { }; } +/** + * The route the router matched for a document render names the request's + * `http.server` span — the rename every server framework integration makes + * from its router (`GET /users/:id`, `http.route`, source `route`), here from + * `RenderEvent.route`, which the router declared to the runtime and the + * runtime read at settle (a lazy subtree that resolved during the render + * names the exact route). The record settles as the stream is handed over, + * inside the request's async context and before the response finishes, so + * the active span's root is the request. Nothing without a pattern: the + * URL the span already has is the best name there is. A route the HTTP + * layer set (a host framework's catch-all for the SSR handler) is replaced — + * the document's own route is the more specific fact. + */ +function nameRequestByRoute(event: RenderEvent, live: RenderLive): void { + const name = event.route?.name; + if (name === undefined) return; + const active = getActiveSpan(); + if (active === undefined) return; + const root = getRootSpan(active); + if (spanToJSON(root).attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] !== 'http.server') return; + const method = live.event?.request.method ?? 'GET'; + const transactionName = `${method} ${name}`; + updateSpanName(root, transactionName); + const attributes: SpanAttributes = { [HTTP_ROUTE]: name, [SENTRY_SEGMENT_NAME_SOURCE]: 'route' }; + for (const [key, value] of Object.entries(event.route?.params ?? {})) { + if (value !== undefined) attributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = value; + } + root.setAttributes(attributes); + // The request's isolation scope carries the name onto its error events. + const isolation = getIsolationScope(); + if (isolation !== getDefaultIsolationScope()) isolation.setTransactionName(transactionName); +} + function invocationSpan(event: InvocationEvent, _live: InvocationLive): Span { const start = epochSeconds(event.at); const span = startInactiveSpan({ diff --git a/packages/solid-2/test/client/tracing.test.ts b/packages/solid-2/test/client/tracing.test.ts index 908b78d6f40e..0fed07179150 100644 --- a/packages/solid-2/test/client/tracing.test.ts +++ b/packages/solid-2/test/client/tracing.test.ts @@ -1,12 +1,20 @@ /** * @vitest-environment jsdom */ -import type { Event, StreamedSpanJSON } from '@sentry/core'; -import { createTransport, getCurrentScope, setCurrentClient, spanStreamingIntegration } from '@sentry/core'; +import type { Event, Integration, StreamedSpanJSON } from '@sentry/core'; +import { + createTransport, + getActiveSpan, + getCurrentScope, + getRootSpan, + setCurrentClient, + spanStreamingIntegration, + spanToJSON, +} 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 { BrowserClient, solidBrowserTracingIntegration, solidTracingIntegration } from '../../src/client'; import type { SolidTracingOptions } from '../../src/client'; interface Captured { @@ -14,12 +22,15 @@ interface Captured { spans: StreamedSpanJSON[]; } -function clientWith(options?: SolidTracingOptions): { client: BrowserClient; captured: Captured } { +function clientWith( + options?: SolidTracingOptions, + browserTracing: Integration[] = [], +): { 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)], + integrations: [spanStreamingIntegration(), ...browserTracing, solidTracingIntegration(options)], transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), stackParser: () => [], beforeSend: event => { @@ -331,6 +342,170 @@ describe('solidTracingIntegration', () => { app.dispose(); }); + describe('route names, from the records', () => { + const routeRef = (initial: boolean) => + ({ + kind: 'navigation', + ...(initial ? { initial: true } : { from: '/' }), + name: '/users/:id', + to: '/users/42', + params: { id: '42' }, + }) as const; + + it('the route the document arrived on renames the pageload span, source route', async () => { + const { client, captured } = clientWith(undefined, [solidBrowserTracingIntegration()]); + const pageload = getRootSpan(getActiveSpan()!); + expect(spanToJSON(pageload).attributes['sentry.op']).toBe('pageload'); + + // What the router does as it builds its context: no write, settles at frame close. + OBSERVE!.attribution.withOrigin(routeRef(true), () => {}); + await settle(); + + expect(spanToJSON(pageload).name).toBe('/users/:id'); + expect(getCurrentScope().getScopeData().transactionName).toBe('/users/:id'); + pageload.end(); + await client.flush(100); + + const segment = captured.spans.find(span => span.is_segment && span.attributes['sentry.op'] === 'pageload')!; + expect(segment).toMatchObject({ + name: '/users/:id', + attributes: expect.objectContaining({ + 'sentry.segment.name.source': 'route', + 'url.template': '/users/:id', + 'url.path.parameter.id': '42', + 'params.id': '42', + }), + }); + // The arrival is no navigation: nothing else was painted for it. + expect(captured.spans.filter(span => span.attributes['sentry.op'] === 'navigation')).toEqual([]); + }); + + it('an arrival without a route pattern leaves the pageload as the browser SDK named it', async () => { + const { client, captured } = clientWith(undefined, [solidBrowserTracingIntegration()]); + const pageload = getRootSpan(getActiveSpan()!); + OBSERVE!.attribution.withOrigin({ kind: 'navigation', initial: true, to: '/somewhere' }, () => {}); + await settle(); + pageload.end(); + await client.flush(100); + const segment = captured.spans.find(span => span.is_segment && span.attributes['sentry.op'] === 'pageload')!; + expect(segment.name).not.toBe('/somewhere'); + expect(segment.attributes['sentry.segment.name.source']).not.toBe('route'); + }); + + it("beside solidBrowserTracingIntegration a click's navigation is the browser navigation span, and the click links to it", async () => { + const { client, captured } = clientWith(undefined, [solidBrowserTracingIntegration()]); + const app = readerApp(); + const at = performance.now(); + OBSERVE!.attribution.withInteraction({ type: 'click', target: 'a#user' }, () => + OBSERVE!.attribution.withOrigin(routeRef(false), () => app.setCount(1)), + ); + flush(); + await settle(); + await client.flush(100); + + const nav = captured.spans.find(span => span.is_segment && span.attributes['sentry.op'] === 'navigation')!; + const click = captured.spans.find( + span => span.is_segment && span.attributes['sentry.op'] === 'ui.interaction.click', + )!; + expect(nav).toMatchObject({ + name: '/users/:id', + attributes: expect.objectContaining({ + 'sentry.segment.name.source': 'route', + 'url.template': '/users/:id', + 'url.path': '/users/42', + 'url.path.parameter.id': '42', + 'solid.navigation.from': '/', + 'solid.navigation.outcome': 'committed', + 'sentry.origin': 'auto.navigation.solid', + }), + }); + // Dated from the write that requested it, ended where the runtime settled it — not the idle timeout. + expect(nav.start_timestamp).toBeGreaterThanOrEqual((performance.timeOrigin + at) / 1000 - 0.001); + expect(nav.end_timestamp! - nav.start_timestamp).toBeLessThan(0.5); + // A root in the trace the navigation opened; the click joins that trace and links to it. + expect(nav.parent_span_id).toBeUndefined(); + expect(click.trace_id).toBe(nav.trace_id); + expect(click.links).toEqual([ + expect.objectContaining({ + span_id: nav.span_id, + attributes: expect.objectContaining({ 'solid.link': 'navigation' }), + }), + ]); + // Not painted twice: the only navigation span is the browser's. + expect(captured.spans.filter(span => span.attributes['sentry.op'] === 'navigation')).toHaveLength(1); + app.dispose(); + }); + + it('a navigation no interaction claims is the browser navigation span too; the earlier idle span ends', async () => { + const { client, captured } = clientWith(undefined, [solidBrowserTracingIntegration()]); + const app = readerApp(); + OBSERVE!.attribution.withOrigin(routeRef(false), () => app.setCount(1)); + flush(); + await settle(); + await client.flush(100); + const segments = captured.spans.filter(span => span.is_segment); + expect(segments.map(span => span.attributes['sentry.op']).sort()).toEqual(['navigation', 'pageload']); + expect(segments.find(span => span.attributes['sentry.op'] === 'navigation')!.name).toBe('/users/:id'); + app.dispose(); + }); + + it("a server-function call a navigation caused is the navigation span's child, joined by identity", async () => { + const { client, captured } = clientWith(undefined, [solidBrowserTracingIntegration()]); + const app = readerApp(); + OBSERVE!.attribution.withOrigin(routeRef(false), () => { + app.setCount(1); + // What a `createAsync` calling the server on the route's write records: the navigation frame. + const origin = OBSERVE!.attribution.currentOrigin(); + expect(origin?.kind).toBe('navigation'); + OBSERVE!.records.emit( + 'call', + { id: 'loadUser', method: 'POST', at: performance.now(), durationMs: 9, outcome: 'ok', status: 200, origin }, + { args: [42], response: new Response(''), result: {} }, + ); + }); + flush(); + await settle(); + await client.flush(100); + const nav = captured.spans.find(span => span.is_segment && span.attributes['sentry.op'] === 'navigation')!; + const call = captured.spans.find(span => span.attributes['sentry.op'] === 'function.solid.call')!; + expect(call.parent_span_id).toBe(nav.span_id); + expect(call.trace_id).toBe(nav.trace_id); + expect(call.attributes['solid.server_function.origin.kind']).toBe('navigation'); + app.dispose(); + }); + + it('a call no frame claims during the load is a child of the pageload span', async () => { + const { client, captured } = clientWith(undefined, [solidBrowserTracingIntegration()]); + const pageload = getRootSpan(getActiveSpan()!); + OBSERVE!.records.emit( + 'call', + { id: 'loadFeed', method: 'GET', at: performance.now(), durationMs: 4, outcome: 'ok', status: 200 }, + { args: [], response: new Response(''), result: [] }, + ); + await settle(); + pageload.end(); + await client.flush(100); + const call = captured.spans.find(span => span.attributes['sentry.op'] === 'function.solid.call')!; + expect(call.is_segment).toBe(false); + expect(call.parent_span_id).toBe(pageload.spanContext().spanId); + }); + + it('instrumentNavigation: false on the wrapper means no browser navigation span; the record is a span of its own', async () => { + const { client, captured } = clientWith(undefined, [ + solidBrowserTracingIntegration({ instrumentNavigation: false }), + ]); + const app = readerApp(); + OBSERVE!.attribution.withOrigin(routeRef(false), () => app.setCount(1)); + flush(); + await settle(); + await client.flush(100); + const nav = captured.spans.find(span => span.attributes['sentry.op'] === 'navigation')!; + expect(nav.attributes['sentry.origin']).toBe('auto.ui.solid.attribution'); + expect(nav.attributes['url.template']).toBeUndefined(); + 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(); diff --git a/packages/solid-2/test/server/tracing.test.ts b/packages/solid-2/test/server/tracing.test.ts index fb9aebbdcd47..9218064984ea 100644 --- a/packages/solid-2/test/server/tracing.test.ts +++ b/packages/solid-2/test/server/tracing.test.ts @@ -183,6 +183,41 @@ describe('solidServerTracingIntegration', () => { expect(issue!.extra).not.toHaveProperty('error'); }); + it("the render's route names the request span: method + pattern, http.route, source route", async () => { + const { client, captured } = clientWith(); + const event = createRequestEvent(new Request('https://app.example/users/42')); + const render = (route: object | undefined) => + startSpan({ name: 'GET /users/42', op: 'http.server', attributes: { 'url.path': '/users/42' } }, span => { + // What the runtime delivers as the stream is handed over, inside the request's async context. + OBSERVE!.records.emit( + 'render', + { mode: 'stream', at: performance.now(), durationMs: 3, boundaries: 0, outcome: 'complete', ...route }, + { trace: getTraceContext()!, event }, + ); + return spanToJSON(span); + }); + + const named = render({ route: { name: '/users/:id', to: '/users/42', params: { id: '42' } } }); + expect(named.name).toBe('GET /users/:id'); + expect(named.attributes).toMatchObject({ + 'http.route': '/users/:id', + 'sentry.segment.name.source': 'route', + 'url.path.parameter.id': '42', + 'url.path': '/users/42', + }); + + // No pattern (no router, a route file the router could not match): the URL name stands. + const unnamed = render({ route: { to: '/users/42' } }); + expect(unnamed.name).toBe('GET /users/42'); + expect(unnamed.attributes['http.route']).toBeUndefined(); + const noRoute = render(undefined); + expect(noRoute.name).toBe('GET /users/42'); + + await client.flush(100); + const segment = captured.spans.find(span => span.is_segment && span.name === 'GET /users/:id'); + expect(segment).toBeDefined(); + }); + it('a boundary that waited during the render becomes a span', async () => { const { client, captured } = clientWith(); let release!: (value: string) => void; diff --git a/yarn.lock b/yarn.lock index be317a4fcff0..ff6e5a682a62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8731,10 +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.10": - version "2.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@solidjs/signals/-/signals-2.0.0-rc.10.tgz#d0395b8a7df151672c7e1a1c7942a4f6788d9c89" - integrity sha512-+5JN0259Scjqft7kTCv1pc3rMRniAxNr1We748UU2zz7MrE8MVn65IPt8VS3rA/7DubRCxoiBdDl+aUlJY9v5g== +"@solidjs/signals@^2.0.0-rc.13": + version "2.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@solidjs/signals/-/signals-2.0.0-rc.13.tgz#0a796fe2281c8523bbe67879cd14435af5cb7e19" + integrity sha512-4+pRdrAHtfyE9BUJWBup3TpzJojJjgW2mV1vm/Jik4tWa5epxXB/YrLkwqP1v8+S9XjyKKZu5BSLqmcpswMYeQ== "@solidjs/start@^1.3.2": version "1.3.2" @@ -8764,10 +8764,10 @@ dependencies: "@testing-library/dom" "^9.3.1" -"@solidjs/web@^2.0.0-rc.10": - version "2.0.0-rc.10" - resolved "https://registry.yarnpkg.com/@solidjs/web/-/web-2.0.0-rc.10.tgz#ce78347769b50a8ea40e3394240920b223d5079a" - integrity sha512-CHXXV5BtqUkuxz/AM45WUKCYF/OiBhC6fOe9gyiktES2bpKj6SUL2xxIciM7q7G+ewYXAN/SGy1/SNFSrxjrOQ== +"@solidjs/web@^2.0.0-rc.13": + version "2.0.0-rc.13" + resolved "https://registry.yarnpkg.com/@solidjs/web/-/web-2.0.0-rc.13.tgz#2859d797294d7ca1ab09e82157f567699be37e58" + integrity sha512-vI/7v/XM8B/3U/oKAzCW2VjTLFkM5IArB2DFnrjxyPRYD3FX9KAXg9QuAnPIPQHC/xvg/LgxmvoVfZUfJ96gOQ== dependencies: seroval "~1.6.7" seroval-plugins "~1.6.7" @@ -25391,12 +25391,12 @@ solid-js@^1.9.11: seroval "~1.5.0" seroval-plugins "~1.5.0" -solid-js@^2.0.0-rc.10: - version "2.0.0-rc.10" - resolved "https://registry.yarnpkg.com/solid-js/-/solid-js-2.0.0-rc.10.tgz#e473575d9be4b252cf0da791d535576d48ebf2e2" - integrity sha512-eh25kmg7yXuJCVN2VpS03bUuTJ0rvb7MnJyIw+8tcSRwnINaugyI5RLwPMy9Rji94CqsMbDrD9Ixh9bRuBMwwQ== +solid-js@^2.0.0-rc.13: + version "2.0.0-rc.13" + resolved "https://registry.yarnpkg.com/solid-js/-/solid-js-2.0.0-rc.13.tgz#dab58df9435bb422e3a991674b88fc6992e74955" + integrity sha512-62bYOI4JZ15KOqL5eReKyWSwAXrGb0fbX8SDnHsbJk2UX+SzSyy1gobas2cxW/0BXcobGfQ1V6ffPyQkMIBdoQ== dependencies: - "@solidjs/signals" "^2.0.0-rc.10" + "@solidjs/signals" "^2.0.0-rc.13" csstype "^3.1.0" seroval "~1.6.7" seroval-plugins "~1.6.7"