;
+ /** 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;
+ /** 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. */
+ private readonly _recent: RecentInteraction[];
+
+ public constructor(public readonly keepText: boolean) {
+ this._hot = new WeakMap();
+ 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
+ * 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) 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 });
+ 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._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();
+ }
+
+ /**
+ * 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..91a992ca26d8
--- /dev/null
+++ b/packages/solid-2/src/server/tracing.ts
@@ -0,0 +1,149 @@
+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 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,
+ },
+ });
+ // 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 {
+ 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' });
+ 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' });
+ 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..abc9d7a4e39c
--- /dev/null
+++ b/packages/solid-2/test/client/tracing.test.ts
@@ -0,0 +1,309 @@
+/**
+ * @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 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(
+ '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');
+ // 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 () => {
+ 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..fb9aebbdcd47
--- /dev/null
+++ b/packages/solid-2/test/server/tracing.test.ts
@@ -0,0 +1,227 @@
+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 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();
+ 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,
+ });
+ // 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 () => {
+ 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..d43b0c8c7219
--- /dev/null
+++ b/packages/solid-2/vite.config.ts
@@ -0,0 +1,58 @@
+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';
+
+// 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 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 = [
+ { 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: `${web}/dist/${platform === 'browser' ? 'web' : 'server'}.observe.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 },
+ 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/] } },
+ },
+ };
+}
+
+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..99d4464a2be7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8731,6 +8731,11 @@
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/start@^1.3.2":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@solidjs/start/-/start-1.3.2.tgz#438cf350fde2d4aa03c179fea224f7231c3d8489"
@@ -8759,6 +8764,14 @@
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==
+ 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"
resolved "https://registry.yarnpkg.com/@speed-highlight/core/-/core-1.2.15.tgz#88c45609a2b5c2293a2e1935417c507f98f39d0b"
@@ -24920,6 +24933,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 +24948,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 +25391,16 @@ 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==
+ 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"
resolved "https://registry.yarnpkg.com/solid-refresh/-/solid-refresh-0.6.3.tgz#d23ef80f04e177619c9234a809c573cb16360627"
| |