Observability

This guide covers what Solid tells you about a running production app: errors your boundaries caught, traces that follow a request from the server into the browser, and what a slow interaction was waiting on.

Error reporting works on every build. When an Errored boundary catches an error, the browser's global error handler never sees it, so Solid gives you a hook on each platform that reports every failure the runtime handled, along with the component that threw and the boundary that caught it.

Everything else needs the observe build. It publishes what the runtime did as plain records (a Loading boundary that waited on the server, a server-function call, a click and the writes it held) and carries the request's trace into the browser for you.


Three builds

Solid ships three builds of every runtime package, selected by export condition.

BuildConditionCarriesOBSERVEDEV
proddefaultThe runtime and the error hooks.undefinedundefined
observeobserveprod, plus the records channel, the diagnostics channel, the attribution slot, and the server's trace slot. No console output, no checks.objectundefined
devdevelopmentobserve, plus the development checks and the console reporter. Unminified.objectobject

The builds nest, so anything that works on the observe build also works on the dev build. The observe build is a little larger than prod. The attribution engine is much larger, but it is a separate import and only ships if you import it.

Opt into the observe build in the Vite plugin:

vite.config.ts
import solid from "@solidjs/vite-plugin";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [solid({ ssr: true, observe: true })],
});

observe: true adds the observe condition to every environment and turns on sourceNames. That keeps names like <Checkout>, span.textContent and count through minification, so the paths below name your components instead of saying computed. Source locations (the at line in a dev console report) stay off unless you set sourceNames: { locations: true }.

Under vite dev you still get the dev build, which is a superset, so anything you see there holds in production. Without the plugin, set resolve.conditions in your bundler (or node --conditions=observe for an unbundled server) and pass sourceNames: true to the compiler yourself.

Errors report on every build

The two error hooks are part of the runtime, not of OBSERVE. A production app with no observability tooling at all still gets them; the observe build is for the records and the traces.


Hear the errors your boundaries catch

In the browser, an uncaught error halts the reactive system and goes to reportError, which window.onerror and every error monitor already listen to. An error a boundary catches does not: the fallback renders, the app keeps running, and no global handler hears about it. configureClientErrors is how you hear about those:

src/monitor.ts
import { configureClientErrors } from "solid-js";
configureClientErrors({
onError(error, { ownerPath, boundaryPath }) {
report(error, {
thrownIn: ownerPath?.join(" › "),
caughtBy: boundaryPath?.join(" › "),
});
},
});

Click "Place order" with a broken price and the Errored fallback appears; the hook fires once, with ownerPath reading <App> › <Checkout> › <OrderSummary> › computed and boundaryPath reading <App> › <Checkout> › <Errored>. The first path is where the code broke; the second is what the shopper saw instead. A boundary's reset() recomputing the same failing node collects the same error object again and does not report it again. The paths are present where the runtime keeps owner names, which is the observe and dev builds; on the production build the hook still fires and the paths are undefined.

A root can carry its own hook ahead of the ambient one: render(App, el, { onError }) and hydrate(App, el, { onError }) take the same function, and the nearest root wins.


Hear every failure the server handles

On the server, a failure can be handled in several ways that no global handler sees: an Errored fallback rendered into the stream, a Loading fragment that rejected and was handed to the client, a server function that threw, a hydration value that would not serialize, or a failure that fails the whole request. configureServerErrors hears all of them, once per error object:

src/instrument.ts
import { configureServerErrors } from "@solidjs/web";
configureServerErrors({
onError(
error,
{ kind, handling, ownerPath, boundaryPath, functionId, direct }
) {
report(error, {
site: `${kind}/${handling}`,
thrownIn: ownerPath?.join(" › "),
caughtBy: boundaryPath?.join(" › "),
serverFunction: functionId,
duringRender: direct,
});
},
});

kind and handling tell you what happened:

kindhandlingWhat happened
renderfallbackAn Errored boundary rendered its fallback.
renderclientA Loading fragment rejected; the client re-renders that subtree.
renderfailedNothing contained it; the request fails.
renderserializeA value written to the hydration stream would not serialize.
server-functionthrownThe function threw. direct is true for an in-process call during a render.
server-functionchannelA rejection escaped through a returned stream or iterable after the head committed.
requestfailedCode outside the runtime, such as middleware, passed a failure to reportRequestFailure.

A request failure the runtime never met, such as a throw a middleware catches before any render starts, reaches the hook only when that code calls reportRequestFailure(error, event) from @solidjs/web; Catching errors shows the call. The context then carries the request's event, and the hook's return value is ignored, as it is for every failed handling.

The hook runs inside the request scope, so getRequestEvent() works in it. renderToStream(App, { onError }) and renderToString(App, { onError }) take a per-request hook that wins over the ambient one for that request. A monitoring SDK's init() has to run before the modules it patches load; the plugin's start.serverSetup option is where a plugin-hosted app puts it, and the module above is the shape it takes.

The return value goes on the wire

The hook receives the error as thrown. What the client receives (the serialized error in an Errored fallback, or the body of a failed server-function call) is a sanitized value, a generic Error outside the dev build. Return a value from the hook and that value replaces it. Return the error itself and its message, stack, and any secret they carry reach the browser. Return a reference the shopper can quote back to you, or nothing.

configureServerErrors({
onError(error, context) {
const ref = report(error, context);
// The shopper sees "Something went wrong (ref 4f2a)"; the log has the rest.
return new Error(`Something went wrong (ref ${ref})`);
},
});

Follow a request into the browser

A request arrives with a W3C traceparent header, or without one. The server continues the trace it was given or originates one, and every getTraceContext() call during the request, in-process server-function calls included, reads the same context. Forward it from a server function to the services it calls:

import { getTraceContext } from "@solidjs/web";
export async function chargeCard(orderId: string) {
"use server";
return fetch(paymentsUrl, {
method: "POST",
headers: { ...getTraceContext()?.entries },
body: JSON.stringify({ orderId }),
});
}

The runtime also tells the browser which trace the page belongs to, through a Server-Timing header on the response and <meta> tags in the HTML head. Frame streams and server-function responses have no <head>, so they rely on the header. You do not need middleware for any of this. The trace only reaches the browser when the incoming traceparent was sampled or a provider answered, so a page with no tracing tool sees no change to its responses.

On the observe build, a tracing tool provides the trace instead of the header:

import { OBSERVE } from "solid-js";
OBSERVE?.server.trace.provide((request) => {
const span = activeSpan();
if (!span) return undefined;
return {
traceId: span.traceId,
spanId: span.spanId,
entries: { "my-trace": `${span.traceId}-${span.spanId}` },
};
});

The provider is called once per request, during the request, at the shell flush or the first getTraceContext() read, whichever comes first. The fields it returns replace the runtime's derivation; its entries merge by name over the runtime's traceparent; undefined leaves the derivation alone.


See what the runtime did

OBSERVE.records delivers a plain record each time the runtime finishes something worth attributing, on both platforms:

TypePlatformOne record per
"render"serverA renderToString or renderToStream render: how long the shell took and how many boundaries it waited on.
"boundary"serverA Loading boundary that waited during a render; one that rendered on its first pass emits nothing.
"invocation"serverA server-function execution, from HTTP dispatch or an in-process call.
"request"clientA server-function request the page sent, delivered at the send, so a call still in flight has a row.
"call"clientA server-function call the page made, as the caller awaited it.
"frame"bothA frame stream produced (server) or applied (client).
"recovery"clientA Loading boundary the server gave up on, re-rendered as fresh DOM on the client; joins its "boundary" by id.

Once the attribution engine is enabled, its records ("interaction", "hold", "navigation", "rerun", and the timeline records behind the Performance panel tracks) arrive on the same channel.

Log the boundaries that kept a shopper waiting:

import { OBSERVE } from "solid-js";
OBSERVE?.records.subscribe("boundary", (event) => {
if (event.durationMs < 200) return;
console.log(
`<Loading> at ${event.ownerPath?.join(" › ")} waited ${Math.round(event.durationMs)}ms`,
event.outcome
);
});

Render /orders with a slow database and the line reads <Loading> at <App> › <Orders> › <Loading> waited 640ms settled. A record is data: ids, names, an outcome, at on the performance.now() clock, durations, counts. Anything live (the request, the response, the arguments, the error as thrown) comes in a second argument to the listener, never on the record, so you can send a record off the device as it is. A client "call" and the server "invocation" it caused share an id; the difference between their durations is the wire. The "request" and "call" records of one call share their live object by identity, filled in as the call proceeds, so a listener holding it from the send reads the outcome off it at settle. Request and response bodies are taken only for a listener that asked, subscribe("call", fn, { bodies: true }); a listener that reads ids, statuses, and timings never pays for them. An invocation made during a boundary's render pass names that boundary, so a wait can be read as the calls it consisted of.

Listeners run synchronously inside the runtime, the moment the record is complete. A listener must not write signals; one that throws is reported to the console and the others still run. Subscribe from a module that loads before the app; the channel exists once per process, so the subscription reaches records from every copy of the runtime a host bundles.


What each interaction cost

When a click feels slow, the attribution engine tells you what it waited on. It is the same engine Debugging reactivity uses in development, and on the observe build it runs in production when you enable it:

import { OBSERVE } from "solid-js";
import { attribution } from "solid-js/attribution";
const release = attribution.enable({ values: "labels" });
OBSERVE?.records.subscribe("interaction", (event) => {
if (event.settledMs === undefined || event.settledMs < 300) return;
console.log(
`${event.name} on ${event.target} took ${Math.round(event.settledMs)}ms to settle:`,
event.holds.map(
(hold) => `${hold.blockers.join(", ")} held ${Math.round(hold.holdMs)}ms`
)
);
});

Click "Place order" and the line reads click on button#place-order "Place order" took 840ms to settle: ["placeOrder held 812ms"]. An interaction record carries the handler's own time, the writes it made, the re-runs and creations they caused, when the last effect that traces back to it ran (settledMs), and the holds and navigations it performed, each settled before the interaction is. A hold names what blocked the write, how long, and whether the screen acknowledged the wait with isPending, latest, or an optimistic value; silent is true on a hold nothing acknowledged, which is what the shopper experiences as a dead click, and long on one whose wait ran past the point a fallback should have taken over. A router that wraps its location write in OBSERVE.attribution.withOrigin gives its navigations the matched route pattern as their name, so /orders/:id folds together across shoppers; the route the document arrived on is declared the same way, so the first navigation has a name too, and a server "render" record carries it as route.

enable() takes a hold on the engine and returns its release. Every tool on the page (an adapter, the Performance panel tracks, a diagnostics capture) shares one engine. Each takes its own hold, options combine by the most demanding request, and the engine stays on until the last hold is released. attribution.disable() tears everything down regardless. Record subscriptions belong to OBSERVE.records, not to the engine, so they survive a release and are dropped only by the function subscribe returned. attribution.history("interaction"), history("hold"), history("navigation"), and history("rerun") return the same records from a ring buffer for a console session that did not subscribe ahead of time.

feedback(), costs(), why(), and subscriptions() are their own exports of solid-js/attribution, so a build that only subscribes to records ships none of them. A "rerun" record is only built while something wants it (a "rerun" listener, an imported fold, or the console log), so history("rerun") and why() are empty for runs before one appeared; subscriptions() reads the live graph and is unaffected.


What leaves the process

Records name things: component labels, the name option you gave a scope, store paths, route patterns, server-function ids. Beyond names, four fields carry data from the page, and a tool that ships records off the device decides what to do with each:

  • target on an interaction and on a call's origin: the element as tag#id "text", with up to 30 characters of its text content.
  • prev and value on a change record and on a held write: previews of the values, strings cut at 40 characters.
  • to, from, and params on a navigation: the concrete URL and the bound parameters.
  • data.error on the server's error findings (SSR_RENDER_ERROR_CONTAINED, SERVER_ERROR_SANITIZED): the error as thrown.

The first two are governed at the source by the engine's values option, so a record never carries what the level excludes and nothing downstream has to scrub:

valuesprev / valuetargetDefault in
"full"previewselement and text: button#save "Save"dev build
"labels"absenttext kept on a button or an a only
"none"absentelement only: button#saveobserve build

Across holds the least permissive level wins, so a production adapter that passes values: "none" keeps it beside a panel asking for "full". If your tool exports records, pass the level explicitly instead of relying on the default. to, from, params, and data.error are outside the level: a tool that exports them treats them as it treats any URL or captured exception.

A tool that renders inside the app it watches, like a diagnostics panel or devtools, marks its root as its own, so its effects and stores never appear as findings about the app:

import { createRoot, getOwner, OBSERVE } from "solid-js";
createRoot(() => {
OBSERVE?.exclude(getOwner()!);
// the panel's signals, stores, and effects
});

A tool that renders the app inside its own shell, <DevToolbar><App /></DevToolbar>, excludes the shell and re-admits the app with OBSERVE.include(owner) at the app's root; each owner answers by its nearest marked ancestor.


Common problems

OBSERVE is undefined in production

The production build was resolved. Check that the bundler applied the observe condition to the environment that is undefined; solid({ observe: true }) applies it to every environment, and a custom server that runs unbundled needs node --conditions=observe.

The paths say computed and effect but never a component

The compiler's sourceNames option is off, so components have no labels to record. solid({ observe: true }) turns it on; with another setup, pass sourceNames: true to the compiler. Under vite dev the labels are present by default.

No Server-Timing entry and no <meta> on the page

Nothing told the runtime the trace was recorded: the incoming traceparent had no sampled flag, or there was none and no provider answered. Install a provider, or send a sampled traceparent from the edge.

My tool's own effects show up as findings

Mark the tool's root with OBSERVE.exclude(getOwner()!) as it is created. Writes into the excluded subtree stay excluded wherever they come from; do not route them through runWithOwner, which makes them writes in an owned scope.


Recap

  • Errors report on every build through configureClientErrors and configureServerErrors, once per error object, with where the error was thrown (ownerPath) and where it was met (boundaryPath).
  • The server hook's return value is what the client receives; return a reference, not the error.
  • Records, traces, and attribution need the observe build: solid({ observe: true }), or the observe condition and sourceNames by hand.
  • OBSERVE.records delivers settled, serializable records, the runtime's and the attribution engine's alike; live handles travel beside them, and a listener must not write signals.
  • The runtime carries a sampled or provided trace into the browser on Server-Timing and <meta>; no middleware.
  • attribution.enable() then OBSERVE.records.subscribe("interaction", …) says what each click waited on; the fold tables are separate exports you pay for only when you import them.
  • values on enable() decides whether records carry value previews and element text; an exporter passes it explicitly.
  • Mark a tool's own root with OBSERVE.exclude so it never reports on itself.

Next steps

  • Build an observability adapter: the contracts a tool author relies on, for an error monitor or a tracing SDK.
  • Debugging reactivity: the same records read at a console in development, and the diagnostics they feed.
  • Arguments and security: what a server function can trust from the request, including the trace it forwards.
  • Boundaries: what Errored and Loading do with a failure before the hook hears about it.
Last updated: 11/3/26, 8:16 AMEdit this pageReport an issue with this page