Build an observability adapter
You maintain an error monitor or a tracing SDK, and a team running Solid asks for an integration.
For most frameworks that means a component wrapper for error boundaries, a router integration for navigation spans, a middleware to inject trace headers into the HTML, and a per-host preload for the server.
Solid's runtime exposes each of those as a hook or a channel, so an adapter is one init() on each platform that subscribes.
This guide is the contract behind those hooks: what fires, when, with what, and what your listener may do.
An app author enabling an existing tool wants Observability. This page is for whoever writes the tool.
What an adapter is made of
| You need | Solid provides | Build |
|---|---|---|
| Errors a boundary caught, in the browser | configureClientErrors from solid-js | every |
| Every failure the server handled or failed on | configureServerErrors from @solidjs/web | every |
| Renders, boundary waits, server-function executions and calls, frame streams | OBSERVE.records.subscribe(type, listener) | observe |
| User interactions, navigations, holds, re-runs | attribution.enable() from solid-js/attribution, then the same OBSERVE.records.subscribe(type, …) | observe |
| The runtime's findings as issues | OBSERVE.diagnostics.subscribe(listener) | observe |
| Your trace ids on the request, carried to the browser | OBSERVE.server.trace.provide(provider) | observe |
| Your own UI kept out of the data | OBSERVE.exclude(owner) | observe |
Import from solid-js and @solidjs/web only.
Both packages resolve to the build the app chose, so your adapter sees the same OBSERVE the app does; @solidjs/signals, the engine underneath, is not a public surface.
Errors: once per object, from the hook alone
Both hooks are called once per error object, the first time the runtime meets it.
The client hook fires when an error boundary renders its fallback; an uncaught error halts the reactive system and reaches reportError, which your global handler already covers, so the hook does not repeat it.
The server hook fires for every failure the server runtime handles or fails on; kind and handling on the context say which, and Observability tabulates them.
Both contexts carry ownerPath, where the error was thrown, and boundaryPath, where it was met, as root-first label arrays.
Join them for grouping; keep them apart for display.
The error hook is the one place an adapter captures an exception. The records below carry the same error object beside a failed invocation or call, and a runtime that hands one object to two listeners invites a double report: whichever listener runs first wins any once-per-object guard your SDK keeps, and that order is the runtime's, not yours. Set a status on the span from the record; capture from the hook.
On the server, a returned value replaces what the client receives. An adapter that offers this — a reference the user can quote — owns the promise that nothing from the original error crosses the wire; an adapter that does not returns nothing.
Records: settled, serializable, delivered inside the runtime
OBSERVE.records delivers the runtime's six record types, described in Observability, and the attribution engine's once it is enabled.
The contract that shapes a span builder:
- A record is complete when you receive it, with
aton theperformance.now()clock and durations in milliseconds. Build the span after the fact with an explicit start and end:epochSeconds = (performance.timeOrigin + at) / 1000. - A record is plain data.
Anything live — the request, the response, the arguments, the result, the error as thrown — is the second argument,
live, and stays in the process. - The listener runs synchronously inside the runtime. It must not write signals; defer span creation to a microtask so the SDK's own bookkeeping never runs inside a Solid flush. A listener that throws is reported to the console and does not stop the others.
- Server records are delivered during the request, so a Node SDK whose active span is the request's parents them without anything passing a parent around.
- A client
"call"and the server"invocation"it caused shareid. An invocation made during a boundary's render pass carriesboundary, theidof that boundary's record. - A
"request"record leaves at the send, before the transport'sfetch, for a call the"call"record cannot show yet: one still in flight, or one that never settles. The two share oneliveobject by identity. - Bodies cost the call a copy, so they are taken only under
subscribe(type, fn, { bodies: true }); without itlive.requestis absent andlive.responseis the transport's own, already consumed.
import { OBSERVE } from "solid-js";
const unsubscribe = OBSERVE?.records.subscribe("invocation", (event, live) => { queueMicrotask(() => { const span = startSpan({ name: event.id, startTime: (performance.timeOrigin + event.at) / 1000, attributes: { direct: event.direct, outcome: event.outcome }, }); if (event.outcome === "error") span.setStatus("error"); span.end((performance.timeOrigin + event.at + event.durationMs) / 1000); }); void live;});The channel exists once per process under a registered symbol.
Subscribe before the app's runtime loads and the subscription still receives its records; subscribe from a second bundled copy of the core and it reaches the same listener set.
Keep the returned unsubscribe: an SDK initialized twice (tests, hot reload) should drop its previous subscriptions rather than stack them.
Attribution: the interaction behind the write
The attribution engine is a separate entry, solid-js/attribution, that installs into OBSERVE.attribution and delivers its records on OBSERVE.records beside the runtime's.
enable() takes a hold on the engine and returns its release; the subscriptions are the channel's and outlive the hold.
import { OBSERVE } from "solid-js";import { attribution } from "solid-js/attribution";
const release = attribution.enable({ values: "none" });
OBSERVE?.records.subscribe("interaction", (event) => { // event.holds and event.navigations are settled; event.origin is the identity below});OBSERVE?.records.subscribe("navigation", (event) => {});OBSERVE?.records.subscribe("hold", (event, heldSignal) => {});OBSERVE?.records.subscribe("rerun", (event, node) => {});Each record arrives when it settles.
An interaction's record already holds its holds and navigations, so a listener building a span tree receives the children before, or inside, the parent; a navigation or hold no interaction performed arrives on its own.
A hold's silent and long flags are the SILENT_HOLD and LONG_HOLD verdicts, computed at settle, so a span can carry them without re-deriving the thresholds.
Fold "rerun" records into a per-interaction hot list rather than shipping one per run; the nodeId on a re-run is a number, and the node itself is the listener's second argument, in-process only.
A "rerun" record is built only while a listener, an imported fold, or the console log wants it; an adapter that never subscribes to "rerun" never pays for one.
Other tools share the engine.
The Performance panel tracks, a diagnostics capture, and your adapter each take a hold, and options combine by the most demanding request per key, except values, where the least permissive level wins: pass values: "none" and your records carry no value previews and no element text whatever another holder asked for.
Release your hold on re-init; attribution.disable() drops everyone's, which is a test harness's reset and not an adapter's.
Records join by identity, not by time.
The origin object on an interaction is the same object the runtime stamps on a client "call" record made under that interaction's handler, and on the holds and navigations it caused.
Keep a WeakMap from origin to span and a call finds its parent without a clock comparison.
The stamp is read at dispatch: a call made synchronously in the handler carries it even when the response lands after the interaction settled, which is the usual shape of onClick={async () => set(await call())}; a call made after an await in the handler carries nothing, the same escape a write there has.
Routers declare their navigations to the engine with OBSERVE.attribution.withOrigin, so navigation records carry the matched route pattern as name; an adapter needs no router code.
The route the document arrived on is declared with the same call around the router's initial match, so the first "navigation" record names it with initial: true, timed from the document's navigation start, and the server "render" record carries the matched route as route; a router that resolves guards or loaders before it writes the location hands the interaction it captured back as interaction, and the write joins the click as if it had been synchronous.
Trace context: answer once, the runtime carries it
On the server, install a provider and the runtime asks it once per request:
import { OBSERVE } from "solid-js";
OBSERVE?.server.trace.provide((request) => { const span = activeSpan(); if (!span) return undefined; return { traceId: span.traceId, spanId: span.spanId, parentId: span.spanId, sampled: true, entries: { "my-trace": `${span.traceId}-${span.spanId}`, "my-baggage": baggageFor(span), }, };});The fields you return replace the runtime's derivation; entries merge by name over the runtime's traceparent; undefined leaves the derivation alone.
The runtime emits every entry on a Server-Timing header on every response and as <meta name content> tags in an HTML shell's head, so your browser SDK's pageload continues the server's trace from the document it already parses, and a frame stream or a server-function response with no <head> is joined through the header.
Set parentId when your SDK continues from a different header than the runtime does; the browser may send two, and the provider is where your view wins.
Diagnostics: issues, not spans
OBSERVE.diagnostics.subscribe(listener) delivers the runtime's findings: a hold nothing acknowledged (SILENT_HOLD), a scope re-running hot (HOT_SCOPE_RERUNS), a waterfall of dependent requests in the browser (ASYNC_WATERFALL) or of sequential waits inside one server boundary (SSR_BOUNDARY_WATERFALL), a render error the server contained (SSR_RENDER_ERROR_CONTAINED), an error replaced by a generic one on the wire (SERVER_ERROR_SANITIZED, with data.source naming the road: "ssr" or "server-function").
A finding has a stable identity and recurs, so it is an issue: fingerprint by code and ownerPath, and let the count grow.
The listener's second argument is the live node the finding is about, when the runtime located one; like live on a record, it stays in the process, and OBSERVE.ownerPath(subject) reads the same labels the event carries.
Severity is the runtime's — info, warn, error — and info findings never reach the console in any build, so an adapter decides whether to forward them.
data.error on the server's error findings is the error as thrown; the error hook has captured it already.
Keep the adapter out of its own data
An adapter that renders inside the app it watches would report its own effects, stores, and holds as findings about the app. Mark the root as the adapter's own as it is created:
import { createRoot, getOwner, OBSERVE } from "solid-js";
createRoot(() => { OBSERVE?.exclude(getOwner()!); // the adapter's own signals, stores, effects});Under an excluded owner, diagnostics about the subtree are neither delivered nor reported, and the engine records no runs for its computations.
An adapter that renders the app inside its own shell excludes the shell and calls OBSERVE.include(owner) at the app's root to re-admit it; the verdict comes from the nearest marked ancestor, and a mark belongs at the owner's creation.
The signals and stores created under it stay excluded wherever their writes come from, so the writes need no runWithOwner and must not use one: a write under an owner is a write in an owned scope, which the dev build flags.
Test against the observe build
Unit tests resolve solid-js and @solidjs/web the way any bundler does.
A test runner that applies the development condition by default gives you the dev build, which is a superset of observe, so a green test there does not prove the adapter runs on observe.
Point the tests at the observe artifacts explicitly, and keep both packages inside one module loader: a copy of @solidjs/signals your runner loads natively beside a copy it transforms is two OBSERVE objects, and the one your adapter subscribed to is not the one the app writes to.
Recap
- Import from
solid-jsand@solidjs/web; the app's build decides whichOBSERVEyou both see. - Capture exceptions from the error hooks only; set span status from records.
- Records are settled and serializable, delivered synchronously inside the runtime; build spans in a microtask from
atanddurationMs, and keeplivein the process. - The engine's records arrive on the same
OBSERVE.records;enable()is a hold you release, andvalues: "none"keeps user data off your records whatever another holder asked for. - Join a call to its interaction by the identity of
origin, not by time; a call dispatched in the handler carries it even when it lands after the interaction settled. - Answer
OBSERVE.server.trace.provideonce per request; the runtime carries the entries to the browser onServer-Timingand<meta>. - Treat diagnostics as issues fingerprinted by
codeandownerPath. OBSERVE.excludethe adapter's own root; keep theunsubscribehandles and drop them on re-init.
Next steps
- Observability: what an app author enables, and the tables of error sites and record types this page relies on.
OBSERVEreference: every record field and channel method.- Attribution reference: the engine's options, including
values, and the interaction, navigation, hold, and re-run record shapes. - Debugging reactivity: the diagnostic codes and what each one means.