Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/attribution-serializable-reruns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@solidjs/signals": patch
"@solidjs/diagnostics": patch
---

Attribution re-run records are serializable as emitted, and the observe tier's idle cost is a cap.

- `RerunEvent` no longer carries the live `node`. It names its scope by `nodeId` — the engine's per-node id, stable across the scope's runs in the process and distinct between scopes (so runs of unnamed effects still fold to one scope after the record has left the process). In-process consumers that want the node ask `OBSERVE.subjectOf(event)`, which now answers for re-run records as it did for diagnostic events, for as long as the caller holds the record object. `attribution.why(target)` and `subscriptions(target)` are unchanged.
- `@solidjs/diagnostics` artifact format v7: re-runs are stored verbatim (`RerunRecord` is now an alias of `RerunEvent`), and the artifact gains `timeOrigin` — the capturing process's `performance.timeOrigin` — so every relative `at` in it (re-runs, holds, records, diagnostic `data`) is convertible to absolute time after the fact, and a server capture lines up with the browser session it served. The JSONL meta line carries it too; the browser bridge payload includes it.
- New tripwire in the signals suite: the built observe artifact runs a graph-heavy workload within 1.25× of the built prod artifact with no hooks installed (measured 1.03–1.09). The idle wiring cost was informational before; it is capped now.
49 changes: 48 additions & 1 deletion documentation/plans/observe-tier-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ skills and README text `DEV.attribution` → `OBSERVE.attribution`;

## PR B — serializable events, origin, engine diet

The engine entry landed in PR A (D6). What remains is the engine's public
The engine entry landed in PR A (D6). What remained was the engine's public
record shape: `RerunEvent` drops the live `node` (`OBSERVE.subjectOf`-style
lookup for in-process consumers), events gain `ts` and `origin`, and `origin`
unifies client interaction and server request as the external cause of work
Expand All @@ -190,6 +190,53 @@ ride along with `enable()` today). Details in the sketch §4–§5; specified
alongside server-dev-build-plan P1, which supplies the request half of
`origin`.

_Status (2026-09-16)._ Landed, in three pieces:

- **`origin`** came through the Sentry workstream (`ChangeOrigin` /
`withOrigin`, stamped on `ChangeRecord`, `HoldEvent` and the client
`"call"` record; `OBSERVE.attribution.currentOrigin()` for wire layers; the
request half via server-dev-build-plan P1). The runtimes' records
(`OBSERVE.records`: boundary, invocation, call, frame) were designed
serializable from the start and already leave the process.
- **`RerunEvent` is serializable as emitted.** `node` is gone; `nodeId` (the
engine's per-node id — the same one `ChangeOrigin.run` joins and the
cycle/relay checks key on) names the scope, stable across its runs in the
process and distinct between scopes, so unnamed effects still fold to one
scope offline. `OBSERVE.subjectOf` — the lookup diagnostics already had —
now answers for re-run records too, keyed by the record object for as long
as any consumer holds it (the lifetime the node had when the record carried
it). `@solidjs/diagnostics` stores re-runs verbatim (`RerunRecord` is now
an alias of `RerunEvent`).
- **Clocks: no per-record `ts`.** Every `at` the engine and the runtimes emit
is on the `performance.now()` clock, consistently; a second clock per
record would cost bytes on every record and drift against the first. The
anchor travels once instead: `DiagnosticsArtifact.timeOrigin` (format v7,
the process's `performance.timeOrigin`) makes every `at` in an artifact
absolute after the fact and lines a server capture up with the browser
session it served. In-process exporters keep doing `timeOrigin + at`
themselves (RFC 08 documents the contract).

**Engine diet — measured, not done.** Ranking the engine's functions by
minified weight (esbuild, per top-level declaration): the console face
(`formatRerun`/`formatCause`/`logRerun`) is ~1.7 KB minified, ~0.45 KB gz —
4% of the engine's 11.7 KB gz — and `formatOrigin` another ~0.15 KB gz, which
the spike's adapter calls for span names. The premise of the diet (formatters
ride along) is true but small. The weight is spread across the checks (~15 KB
min, a third), hold/interaction/navigation tracking, and the in-process query
and fold surfaces (`costs`, `feedback`, `why`, `history`, ~3.5 KB min /
~1.5 KB gz) — the last being what a production adapter that consumes records
never calls. Shedding those needs the object split into tree-shakable named
exports (`import { feedback } from "@solidjs/signals/attribution"`), an API
change worth ~2 KB gz (17%). Deferred to a decision: it is the same shape
freeze the record types just got, and better taken once than in pieces.

**Idle wiring is now a cap.** `tests/observe-idle-cost.test.ts` runs one
graph-heavy workload against the built prod and observe artifacts in one
process, interleaved, best-of-k, and caps the observe/prod ratio at 1.25
(measured 1.03–1.09 with no hooks installed; re-measured on a round over the
cap so worker-thread contention does not fail it, a real regression does).
`SIGNALS_TIER=observe pnpm bench` stays for the absolute number.

## Open questions

- **CJS.** _Resolved: removed, across the board._ Every entry used to ship a
Expand Down
5 changes: 3 additions & 2 deletions documentation/proposals/production-observability-sketch.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,9 @@ build.

### 4.4 Rerun record (from `RerunEvent`)

Serialized as `Omit<RerunEvent, "node">` — `@solidjs/diagnostics` already
defines exactly this projection (`RerunRecord`). Attached to the interaction
Serialized as-is: since observe-tier-plan PR B the event carries `nodeId`
instead of the live `node` (`OBSERVE.subjectOf(event)` for in-process
consumers), so `@solidjs/diagnostics`'s `RerunRecord` is the same shape. Attached to the interaction
span only above thresholds (4.1); otherwise folded into the span's aggregates.

### 4.5 Cause chain (from `ChangeRecord`)
Expand Down
6 changes: 4 additions & 2 deletions documentation/solid-2.0/08-dev-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,8 @@ Each `DiagnosticEvent` has:
| `nodeName` | `string?` | Debug name of the signal/node involved |
| `data` | `object?` | Additional context |

An event is a serializable record and never carries the node it is about. `OBSERVE.subjectOf(record)` hands the live node back to a consumer that runs in-process — the console reporter uses it to print the DOM element a binding effect writes; devtools use it to go from a record to the scope. It answers for `DiagnosticEvent`s and the attribution engine's `RerunEvent`s, for as long as the caller holds the record object; a copy that left the process and came back has no subject.

### `OBSERVE.records` — the runtimes' records channel

Beside diagnostics (findings) and attribution (re-runs and holds), `OBSERVE` carries **records**: a record is a completed, serializable summary of one thing a runtime did — a boundary that waited, a server-function call, a frame stream — delivered synchronously the moment it is complete, with the live handles an in-process observer may want (the request, the response, the value as thrown) passed **beside** it rather than on it. One channel, `OBSERVE.records`, on both platforms; subscribe by record type, and the types available are whatever the loaded runtimes declared:
Expand Down Expand Up @@ -683,7 +685,7 @@ One record per stream, delivered at `complete`. `id` is the frame's on the wire:

The **server half** (`renderServerComponent`, `renderToFrameStream`, the handler path): `version` is what the producer stamped; `outcome` is `"complete"` when the render ran to the end, fragment failures included (those are counted in `errors`, each having revealed its fallback and ridden a keyed `error` chunk), and `"error"` when the render threw synchronously — the stream then carried the failure as its only content and completed anyway, and `live.error` is the value as thrown. `<Loading>` boundaries inside the frame emit their own `"boundary"` records. The **client half** (`applyFrameResponse`): `version` is the consumer's restamp — the number the frame's stale-guard saw — and `address` is the local id the chunks were applied under when the consumer remapped the wire id onto its own boundary (the call's address, for the server-component transport), absent when applied under the wire id; `outcome` is `"complete"` when the `complete` chunk arrived, `"truncated"` when the body ended before it (the connection dropped, the producer abandoned the stream), `"error"` when the read failed (a malformed chunk, a body error) with the failure in `live.error`; `live.response` is the response the stream was read from. A single-flight response carries one stream per frame it refreshed; each is its own record, on both sides. Emitted by the observe and dev artifacts of the frames entry on either platform (`frames/dist/server.observe.js`, `client.observe.js`, and the dev pair).

`@solidjs/diagnostics` folds every record type into the artifact it captures — `artifact.records.{boundary, invocation, frame, call}`, format v6, one table per type — on either platform: `captureArtifact(() => renderToStream(…))` on the server, the browser bridge in the page; so a render's waits and calls and a page's requests are evidence a test or an agent can hold beside the findings. See the package README.
`@solidjs/diagnostics` folds every record type into the artifact it captures — `artifact.records.{boundary, invocation, frame, call}`, format v7, one table per type, with `artifact.timeOrigin` anchoring every record's `at` — on either platform: `captureArtifact(() => renderToStream(…))` on the server, the browser bridge in the page; so a render's waits and calls and a page's requests are evidence a test or an agent can hold beside the findings. See the package README.

### `OBSERVE.server` — the trace-provider slot

Expand Down Expand Up @@ -847,7 +849,7 @@ createRoot(() => {
});
```

**Records and clocks.** Everything the engine hands out — `RerunEvent`, `InteractionEvent`, `HoldEvent`, `NavigationEvent` — is a record with an absolute `at` on the `performance.now()` clock (`RerunEvent.at` the run's start, `HoldEvent.at` the start of the wait, `NavigationEvent.at`/`InteractionEvent.at` the request/dispatch) plus durations from it (`holdMs`, `settledMs`, `selfMs`). Epoch time for an exporter is `performance.timeOrigin + at` (milliseconds). Without cross-origin isolation the browser quantizes `performance.now()` to 100µs, so a single run's `selfMs` is often `0`; the per-interaction `settledMs` is the wall-clock number to report. Records carry live graph references (`RerunEvent.node`) and the frame objects that join them (`origin`, `interaction`) — the same object across records, so join by identity, not by name. `subscribe(type, listener)` delivers each record synchronously at the moment it is complete (a re-run at recompute end; an interaction, hold or navigation when it settles), bottom-up: a hold before the navigation it held, before the interaction that performed it. A listener runs inside the engine and must not write signals; hand work off to a microtask.
**Records and clocks.** Everything the engine hands out — `RerunEvent`, `InteractionEvent`, `HoldEvent`, `NavigationEvent` — is a record with an absolute `at` on the `performance.now()` clock (`RerunEvent.at` the run's start, `HoldEvent.at` the start of the wait, `NavigationEvent.at`/`InteractionEvent.at` the request/dispatch) plus durations from it (`holdMs`, `settledMs`, `selfMs`). Epoch time for an exporter is `performance.timeOrigin + at` (milliseconds). Without cross-origin isolation the browser quantizes `performance.now()` to 100µs, so a single run's `selfMs` is often `0`; the per-interaction `settledMs` is the wall-clock number to report. Records are serializable as emitted: none carries a live graph reference — a re-run names its scope by `nodeId` (the engine's per-node id, stable across the scope's runs in the process, distinct between scopes; in-process consumers get the node back through `OBSERVE.subjectOf(event)`) — while the frame objects that join records (`origin`, `interaction`) are the same object across records in-process, so join by identity there and by `ChangeOrigin.run`/`at`/`name` once they have left it. `subscribe(type, listener)` delivers each record synchronously at the moment it is complete (a re-run at recompute end; an interaction, hold or navigation when it settles), bottom-up: a hold before the navigation it held, before the interaction that performed it. A listener runs inside the engine and must not write signals; hand work off to a microtask.

**Excluding the observer.** `OBSERVE.exclude(owner)` marks an owner subtree as the observer's own: diagnostics whose subject sits under it are built (a throwing site still throws) but never delivered or printed, and the attribution engine records no run for its computations, charges none of them to an interaction, counts no write to its signals or stores toward an interaction, and does not spend a once-per-key slot (`IMMUTABLE_UPDATE_IN_STORE`'s per-path memory) on them. An interaction whose writes all went to excluded subjects, with none of the app's work run — a click on the observer's own panel — is not recorded at all. Mark the root as it is created (a store's nodes take the owner the store was created under, recorded only once the engine is enabled — enable before creating the panel's stores), and make writes from outside the graph under it (`runWithOwner(owner, () => setPanel(…))`) so the writer's context is excluded too. `OBSERVE.isExcluded(subject)` answers the question for any owner or node.

Expand Down
2 changes: 2 additions & 0 deletions packages/diagnostics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ artifact.attribution; // { reruns, costs, holds, feedback } — who re-ran, why,

Options: `scenario` labels the artifact, `attribution: false` captures diagnostics only, and an options object is passed through to the engine's `enable()` (`@solidjs/signals/attribution`). `artifactToJSONL(artifact)` emits line-oriented output for offline or agent-side analysis.

**Clocks.** Every `at` in the artifact — a re-run's start, a hold's, a record's — is on the capturing process's `performance.now()` clock; `artifact.timeOrigin` (epoch milliseconds, the process's `performance.timeOrigin`) anchors it, so `timeOrigin + at` is the absolute time of anything in the artifact and two captures from one process line up. Durations (`selfMs`, `holdMs`, `durationMs`) are already relative. Re-runs are stored as the engine emits them: `nodeId` names the scope (stable across its runs in the process, distinct between scopes), so runs of unnamed effects still fold to one scope offline; the live node never leaves the process (in-process, `OBSERVE.subjectOf(rerun)` hands it back).

### Records: server renders and browser requests

Beside findings and attribution the artifact carries **records** — `artifact.records`, the runtimes' `OBSERVE.records` channel folded into one table per record type, on either platform. Over a server render the evidence is **waits and calls**: run `renderToStream` (or `renderToString`) as the scenario:
Expand Down
3 changes: 2 additions & 1 deletion packages/diagnostics/src/artifact.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RECORD_TYPES } from "./records.js";
import type { DiagnosticsArtifact } from "./types.js";

export const ARTIFACT_FORMAT_VERSION = 6 as const;
export const ARTIFACT_FORMAT_VERSION = 7 as const;

/** Pretty JSON for humans and for checked-in golden files. */
export function serializeArtifact(artifact: DiagnosticsArtifact): string {
Expand All @@ -21,6 +21,7 @@ export function artifactToJSONL(artifact: DiagnosticsArtifact): string {
formatVersion: artifact.formatVersion,
scenario: artifact.scenario,
capturedAt: artifact.capturedAt,
timeOrigin: artifact.timeOrigin,
durationMs: artifact.durationMs,
diagnosticCount: artifact.diagnostics.length,
rerunCount: artifact.attribution?.reruns.length ?? null,
Expand Down
13 changes: 5 additions & 8 deletions packages/diagnostics/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import type {
AttributionOptions,
DiagnosticsArtifact,
HoldEvent,
RerunEvent,
RerunRecord
} from "./types.js";

Expand All @@ -30,6 +29,8 @@ export interface BridgeBeginOptions {
/** The serializable half of an artifact — assembled into a full one Node-side. */
export interface BridgePayload {
capturedAt: string;
/** The page's `performance.timeOrigin` — see `DiagnosticsArtifact.timeOrigin`. */
timeOrigin: number;
durationMs: number;
diagnostics: DiagnosticsArtifact["diagnostics"];
attribution: DiagnosticsArtifact["attribution"];
Expand Down Expand Up @@ -123,7 +124,7 @@ export function installDiagnosticsBridge(
let attribution: DiagnosticsArtifact["attribution"] = null;
if (active.useAttribution) {
attribution = {
reruns: engine.history().map(({ node: _node, ...record }: RerunEvent) => record),
reruns: [...engine.history()],
costs: engine.costs(),
holds: [...engine.holds()],
feedback: engine.feedback()
Expand All @@ -134,6 +135,7 @@ export function installDiagnosticsBridge(
const records = active.records.stop();
return toSerializable({
capturedAt: active.startedAt.toISOString(),
timeOrigin: performance.timeOrigin,
durationMs: performance.now() - active.start,
diagnostics: events,
attribution,
Expand All @@ -145,12 +147,7 @@ export function installDiagnosticsBridge(
},
whyDidRun(name) {
requireAttributionSession("whyDidRun");
return toSerializable(
engine
.history()
.filter(event => event.nodeName === name)
.map(({ node: _node, ...record }: RerunEvent) => record)
);
return toSerializable(engine.history().filter(event => event.nodeName === name));
},
costs() {
requireAttributionSession("costs");
Expand Down
10 changes: 3 additions & 7 deletions packages/diagnostics/src/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { OBSERVE, flush } from "@solidjs/signals";
import { attribution as engine } from "@solidjs/signals/attribution";
import { ARTIFACT_FORMAT_VERSION } from "./artifact.js";
import { captureRecords } from "./records.js";
import type { AttributionOptions, DiagnosticsArtifact, RerunEvent, RerunRecord } from "./types.js";
import type { AttributionOptions, DiagnosticsArtifact } from "./types.js";

export interface CaptureOptions {
/** Label stamped into the artifact meta. */
Expand All @@ -26,11 +26,6 @@ export interface CaptureResult<T> {
artifact: DiagnosticsArtifact;
}

function toRerunRecord(event: RerunEvent): RerunRecord {
const { node: _node, ...record } = event;
return record;
}

/**
* Run a scenario with the dev channels open — diagnostics, attribution, the
* records — and fold what they saw into a single serializable artifact. This is the fixture everything else in this
Expand Down Expand Up @@ -74,7 +69,7 @@ export async function captureArtifact<T>(
// Read every table before disable(): aggregates reset on disable.
if (useAttribution) {
attribution = {
reruns: engine.history().map(toRerunRecord),
reruns: [...engine.history()],
costs: engine.costs(),
holds: [...engine.holds()],
feedback: engine.feedback()
Expand All @@ -92,6 +87,7 @@ export async function captureArtifact<T>(
formatVersion: ARTIFACT_FORMAT_VERSION,
scenario: options.scenario,
capturedAt: startedAt.toISOString(),
timeOrigin: performance.timeOrigin,
durationMs,
diagnostics: events,
attribution,
Expand Down
1 change: 1 addition & 0 deletions packages/diagnostics/src/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export async function captureBrowserArtifact<T>(
formatVersion: ARTIFACT_FORMAT_VERSION,
scenario: options.scenario,
capturedAt: payload.capturedAt,
timeOrigin: payload.timeOrigin,
durationMs: payload.durationMs,
diagnostics: payload.diagnostics,
attribution: payload.attribution,
Expand Down
Loading
Loading